release: verify production portfolio deployment
This commit is contained in:
164
scripts/smoke-production.mjs
Normal file
164
scripts/smoke-production.mjs
Normal file
@@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env node
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
const sha256Pattern = /^[a-f0-9]{64}$/u;
|
||||
const privateIdentifiers = /(?:simpastil|simpasteel|neon[ -]?debugger|yourhealth)/iu;
|
||||
const sourceMapReference = /(?:sourceMappingURL\s*=|["'(]\/?[^"')\s]+\.map(?:[?"')\s]|$))/iu;
|
||||
|
||||
function rootHttpsUrl(value, label) {
|
||||
let url;
|
||||
try { url = new URL(value); } catch { throw new Error(`${label} must be a valid URL.`); }
|
||||
if (url.protocol !== 'https:' || url.username || url.password || url.port || url.pathname !== '/' || url.search || url.hash) {
|
||||
throw new Error(`${label} must be one canonical root HTTPS origin without credentials, port, query, or fragment.`);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
function requireHeader(response, name, expected) {
|
||||
const value = response.headers.get(name);
|
||||
if (value === null || (expected instanceof RegExp ? !expected.test(value) : value !== expected)) throw new Error(`${name} response header is missing or invalid.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function allMatches(source, expression) {
|
||||
return [...source.matchAll(expression)].map((match) => match[1]);
|
||||
}
|
||||
|
||||
function decodedText(value) {
|
||||
return value.replace(/&/gu, '&').replace(/</gu, '<').replace(/>/gu, '>').replace(/"/gu, '"').replace(/&#(?:39|x27);/giu, "'");
|
||||
}
|
||||
|
||||
function visibleText(html) {
|
||||
return decodedText(html.replace(/<script\b[\s\S]*?<\/script>/giu, ' ').replace(/<style\b[\s\S]*?<\/style>/giu, ' ').replace(/<[^>]+>/gu, ' ').replace(/\s+/gu, ' ').trim());
|
||||
}
|
||||
|
||||
export function verifyNoJavaScriptDocument(html) {
|
||||
if (typeof html !== 'string' || sourceMapReference.test(html)) throw new Error('JavaScript-disabled document contains a source map reference.');
|
||||
if (privateIdentifiers.test(html)) throw new Error('JavaScript-disabled document contains a private identifier.');
|
||||
const text = visibleText(html);
|
||||
const headings = allMatches(html, /<h[1-6]\b[^>]*>([\s\S]*?)<\/h[1-6]>/giu).length;
|
||||
const links = allMatches(html, /<a\b[^>]*href=["'][^"']+["'][^>]*>([\s\S]*?)<\/a>/giu).length;
|
||||
if (!/Luca Pîslaru/u.test(text) || !/System Administrator\s*&\s*Full-Stack Developer/u.test(text) ||
|
||||
!/Selected builds/iu.test(text) || !/Contact/iu.test(text) || !/mailto:/iu.test(html) || headings < 3 || links < 1 || text.length < 120) {
|
||||
throw new Error('JavaScript-disabled document lacks substantive semantic identity, work evidence, or contact content.');
|
||||
}
|
||||
return { headings, links, visibleCharacters: text.length };
|
||||
}
|
||||
|
||||
function verifySecurityHeaders(response, expectedDigest) {
|
||||
requireHeader(response, 'x-portfolio-manifest-digest', expectedDigest);
|
||||
const csp = requireHeader(response, 'content-security-policy', /default-src 'self'/u);
|
||||
for (const directive of ["connect-src 'none'", "object-src 'none'", "base-uri 'none'", "frame-ancestors 'none'", "form-action 'self'", 'upgrade-insecure-requests']) {
|
||||
if (!csp.includes(directive)) throw new Error(`Content-Security-Policy lacks ${directive}.`);
|
||||
}
|
||||
if (/unsafe-inline|unsafe-eval|\*/u.test(csp)) throw new Error('Content-Security-Policy contains an unsafe source expression.');
|
||||
requireHeader(response, 'strict-transport-security', /^max-age=31536000(?:; preload)?$/u);
|
||||
requireHeader(response, 'x-content-type-options', 'nosniff');
|
||||
requireHeader(response, 'referrer-policy', 'no-referrer');
|
||||
requireHeader(response, 'permissions-policy', /camera=\(\).*microphone=\(\).*geolocation=\(\)/u);
|
||||
requireHeader(response, 'x-frame-options', 'DENY');
|
||||
}
|
||||
|
||||
function exactTagContent(html, expression, label) {
|
||||
const values = allMatches(html, expression);
|
||||
if (values.length !== 1) throw new Error(`Expected exactly one ${label}.`);
|
||||
return decodedText(values[0]);
|
||||
}
|
||||
|
||||
function verifyMetadata(html, canonicalUrl) {
|
||||
const canonical = exactTagContent(html, /<link\b(?=[^>]*\brel=["']canonical["'])[^>]*\bhref=["']([^"']+)["'][^>]*>/giu, 'canonical link');
|
||||
const ogUrl = exactTagContent(html, /<meta\b(?=[^>]*\bproperty=["']og:url["'])[^>]*\bcontent=["']([^"']+)["'][^>]*>/giu, 'og:url');
|
||||
const robots = exactTagContent(html, /<meta\b(?=[^>]*\bname=["']robots["'])[^>]*\bcontent=["']([^"']+)["'][^>]*>/giu, 'robots meta');
|
||||
exactTagContent(html, /<meta\b(?=[^>]*\bname=["']description["'])[^>]*\bcontent=["']([^"']+)["'][^>]*>/giu, 'description meta');
|
||||
exactTagContent(html, /<meta\b(?=[^>]*\bproperty=["']og:title["'])[^>]*\bcontent=["']([^"']+)["'][^>]*>/giu, 'og:title');
|
||||
exactTagContent(html, /<meta\b(?=[^>]*\bproperty=["']og:description["'])[^>]*\bcontent=["']([^"']+)["'][^>]*>/giu, 'og:description');
|
||||
if (canonical !== canonicalUrl || ogUrl !== canonicalUrl || !/^index\s*,\s*follow$/iu.test(robots)) throw new Error('Canonical, Open Graph, or robots metadata is inconsistent.');
|
||||
const jsonLdText = exactTagContent(html, /<script\b(?=[^>]*\btype=["']application\/ld\+json["'])[^>]*>([\s\S]*?)<\/script>/giu, 'JSON-LD block');
|
||||
let jsonLd;
|
||||
try { jsonLd = JSON.parse(jsonLdText); } catch { throw new Error('JSON-LD is not valid JSON.'); }
|
||||
if (jsonLd?.['@context'] !== 'https://schema.org' || jsonLd?.name !== 'Luca Pîslaru' || jsonLd?.url !== canonicalUrl) throw new Error('JSON-LD identity or canonical URL is inconsistent.');
|
||||
return allMatches(html, /<(?:script|link)\b(?=[^>]*(?:src|href)=["'](\/assets\/[^"']+)["'])[^>]*>/giu);
|
||||
}
|
||||
|
||||
async function request(fetchImpl, url, init = {}) {
|
||||
const response = await fetchImpl(url, { ...init, redirect: 'manual', signal: init.signal ?? AbortSignal.timeout(15_000) });
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function verifyProductionSmoke(options) {
|
||||
const canonical = rootHttpsUrl(options.canonicalUrl, 'Canonical URL');
|
||||
if (!sha256Pattern.test(options.expectedManifestDigest)) throw new Error('Expected signed manifest digest must be lowercase SHA-256.');
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
for (const alternateValue of options.alternateUrls ?? []) {
|
||||
let alternate;
|
||||
try { alternate = new URL(alternateValue); } catch { throw new Error('Alternate URL is invalid.'); }
|
||||
if (alternate.username || alternate.password || alternate.search || alternate.hash || alternate.pathname !== '/' || alternate.origin === canonical.origin) throw new Error('Alternate URL must be a distinct credential-free root origin.');
|
||||
const response = await request(fetchImpl, alternate);
|
||||
if (response.status !== 308 || response.headers.get('location') !== canonical.href) throw new Error('Alternate host must redirect to the canonical HTTPS URL in one permanent hop.');
|
||||
}
|
||||
const rootResponse = await request(fetchImpl, canonical, { headers: { 'accept-encoding': 'br, gzip' } });
|
||||
if (rootResponse.status !== 200) throw new Error('Canonical HTTPS URL did not return 200.');
|
||||
verifySecurityHeaders(rootResponse, options.expectedManifestDigest);
|
||||
requireHeader(rootResponse, 'content-type', /^text\/html(?:;|$)/u);
|
||||
requireHeader(rootResponse, 'cache-control', /max-age=0.*must-revalidate/u);
|
||||
requireHeader(rootResponse, 'content-encoding', /^(?:br|gzip)$/u);
|
||||
const html = await rootResponse.text();
|
||||
if (privateIdentifiers.test(html) || sourceMapReference.test(html)) throw new Error('Production HTML exposes a private identifier or source map.');
|
||||
const noJs = verifyNoJavaScriptDocument(html);
|
||||
const assetPaths = verifyMetadata(html, canonical.href);
|
||||
if (assetPaths.length === 0 || assetPaths.some((path) => path.endsWith('.map'))) throw new Error('Production HTML lacks a safe hashed asset reference.');
|
||||
const assetResponse = await request(fetchImpl, new URL(assetPaths[0], canonical), { headers: { 'accept-encoding': 'br, gzip' } });
|
||||
if (assetResponse.status !== 200) throw new Error('Production asset did not return 200.');
|
||||
verifySecurityHeaders(assetResponse, options.expectedManifestDigest);
|
||||
requireHeader(assetResponse, 'cache-control', /max-age=31536000.*immutable/u);
|
||||
requireHeader(assetResponse, 'content-type', /^(?:text\/javascript|application\/javascript|text\/css)(?:;|$)/u);
|
||||
requireHeader(assetResponse, 'content-encoding', /^(?:br|gzip)$/u);
|
||||
const assetText = await assetResponse.text();
|
||||
if (privateIdentifiers.test(assetText) || sourceMapReference.test(assetText)) throw new Error('Production asset exposes a private identifier or source map.');
|
||||
for (const path of ['/robots.txt', '/sitemap.xml']) {
|
||||
const response = await request(fetchImpl, new URL(path, canonical));
|
||||
if (response.status !== 200) throw new Error(`${path} did not return 200.`);
|
||||
verifySecurityHeaders(response, options.expectedManifestDigest);
|
||||
requireHeader(response, 'cache-control', /max-age=0.*must-revalidate/u);
|
||||
const text = await response.text();
|
||||
if (path === '/robots.txt' && (!/Allow:\s*\//iu.test(text) || !text.includes(`${canonical.href}sitemap.xml`))) throw new Error('robots.txt is inconsistent with the canonical sitemap.');
|
||||
if (path === '/sitemap.xml' && !text.includes(`<loc>${canonical.href}</loc>`)) throw new Error('sitemap.xml is inconsistent with the canonical URL.');
|
||||
}
|
||||
let videoRangeVerified = false;
|
||||
if (options.videoPath !== undefined) {
|
||||
if (!/^\/assets\/[a-z0-9][a-z0-9._-]*\.(?:mp4|webm)$/u.test(options.videoPath)) throw new Error('Video path must be one safe public MP4/WebM asset.');
|
||||
const response = await request(fetchImpl, new URL(options.videoPath, canonical), { headers: { range: 'bytes=0-1023' } });
|
||||
if (response.status !== 206) throw new Error('Production video range request did not return 206.');
|
||||
verifySecurityHeaders(response, options.expectedManifestDigest);
|
||||
requireHeader(response, 'accept-ranges', 'bytes'); requireHeader(response, 'content-range', /^bytes 0-1023\/\d+$/u);
|
||||
requireHeader(response, 'content-type', /^video\/(?:mp4|webm)$/u); requireHeader(response, 'cache-control', /max-age=31536000.*immutable/u);
|
||||
videoRangeVerified = true;
|
||||
}
|
||||
return { schema: 'portfolio-production-smoke/v1', canonicalUrl: canonical.href, manifestDigest: options.expectedManifestDigest,
|
||||
jsDisabledContentVerified: true, headings: noJs.headings, assetCompressionVerified: true, videoRangeVerified };
|
||||
}
|
||||
|
||||
function parseArguments(args) {
|
||||
const values = { alternateUrls: [] };
|
||||
for (let index = 0; index < args.length; index += 2) {
|
||||
const key = args[index]; const value = args[index + 1];
|
||||
if (!key?.startsWith('--') || !value) throw new Error('Smoke arguments must be complete name/value pairs.');
|
||||
const name = key.slice(2);
|
||||
if (name === 'alternate-url') values.alternateUrls.push(value);
|
||||
else if (Object.hasOwn(values, name)) throw new Error(`Duplicate smoke argument: ${key}`);
|
||||
else values[name] = value;
|
||||
}
|
||||
if (!values['canonical-url'] || !values['manifest-digest']) throw new Error('Usage: smoke-production.mjs --canonical-url <https-root> --manifest-digest <sha256> [--alternate-url <url>] [--video-path <path>].');
|
||||
return values;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const values = parseArguments(process.argv.slice(2));
|
||||
const result = await verifyProductionSmoke({ canonicalUrl: values['canonical-url'], expectedManifestDigest: values['manifest-digest'],
|
||||
alternateUrls: values.alternateUrls, videoPath: values['video-path'] });
|
||||
process.stdout.write(`${JSON.stringify(result)}\n`);
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main().catch((error) => { process.stderr.write(`Production smoke failed: ${error instanceof Error ? error.message : 'unknown error'}\n`); process.exitCode = 1; });
|
||||
}
|
||||
233
scripts/supply-chain-check.mjs
Normal file
233
scripts/supply-chain-check.mjs
Normal file
@@ -0,0 +1,233 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from 'node:crypto';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import { lstat, mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
const sha256Pattern = /^[a-f0-9]{64}$/u;
|
||||
const datePattern = /^\d{4}-\d{2}-\d{2}$/u;
|
||||
const reportNames = [
|
||||
'npm-audit-production.json',
|
||||
'npm-audit-development.json',
|
||||
'licenses.json',
|
||||
'sbom.cdx.json',
|
||||
'medium-triage.json',
|
||||
];
|
||||
const allowedLicenses = new Set([
|
||||
'0BSD', 'Apache-2.0', 'BlueOak-1.0.0', 'BSD-2-Clause', 'BSD-3-Clause', 'CC0-1.0', 'ISC', 'MIT', 'Python-2.0', 'Unlicense',
|
||||
]);
|
||||
|
||||
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
||||
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
const exactKeys = (value, keys) => isObject(value) && JSON.stringify(Object.keys(value).sort()) === JSON.stringify([...keys].sort());
|
||||
|
||||
async function safeRead(path, label) {
|
||||
const stats = await lstat(path);
|
||||
if (!stats.isFile() || stats.isSymbolicLink() || stats.nlink !== 1) throw new Error(`${label} must be one regular non-linked file.`);
|
||||
return readFile(path);
|
||||
}
|
||||
|
||||
function parseJson(bytes, label) {
|
||||
try { return JSON.parse(bytes.toString('utf8')); } catch { throw new Error(`${label} is not valid JSON.`); }
|
||||
}
|
||||
|
||||
function parseAudit(report, scope) {
|
||||
if (!isObject(report) || report.auditReportVersion !== 2 || !isObject(report.vulnerabilities)) {
|
||||
throw new Error(`${scope} npm audit evidence has an unsupported schema.`);
|
||||
}
|
||||
const findings = [];
|
||||
for (const [key, raw] of Object.entries(report.vulnerabilities)) {
|
||||
if (!isObject(raw) || raw.name !== key || !['info', 'low', 'moderate', 'high', 'critical'].includes(raw.severity) || typeof raw.range !== 'string') {
|
||||
throw new Error(`${scope} npm audit evidence contains an invalid finding.`);
|
||||
}
|
||||
findings.push({ scope, package: key, severity: raw.severity, range: raw.range });
|
||||
}
|
||||
return findings.sort((left, right) => `${left.scope}\0${left.package}`.localeCompare(`${right.scope}\0${right.package}`, 'en'));
|
||||
}
|
||||
|
||||
function parseTriage(value, today) {
|
||||
if (!exactKeys(value, ['schema', 'findings']) || value.schema !== 'portfolio-medium-vulnerability-triage/v1' || !Array.isArray(value.findings)) {
|
||||
throw new Error('Medium vulnerability triage has an invalid envelope.');
|
||||
}
|
||||
const records = new Map();
|
||||
for (const finding of value.findings) {
|
||||
if (!exactKeys(finding, ['scope', 'package', 'severity', 'range', 'disposition', 'rationale', 'owner', 'reviewBy']) ||
|
||||
!['production', 'development'].includes(finding.scope) || finding.severity !== 'moderate' || finding.disposition !== 'accepted' ||
|
||||
typeof finding.package !== 'string' || finding.package.length === 0 || typeof finding.range !== 'string' || finding.range.length === 0 ||
|
||||
typeof finding.rationale !== 'string' || finding.rationale.trim().length < 20 || typeof finding.owner !== 'string' || finding.owner.trim().length === 0 ||
|
||||
typeof finding.reviewBy !== 'string' || !datePattern.test(finding.reviewBy) || finding.reviewBy < today) {
|
||||
throw new Error('Medium vulnerability triage contains an invalid, expired, or incomplete record.');
|
||||
}
|
||||
const key = `${finding.scope}\0${finding.package}\0${finding.range}`;
|
||||
if (records.has(key)) throw new Error('Medium vulnerability triage contains duplicate records.');
|
||||
records.set(key, finding);
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
function verifyLicenses(value, allowlist) {
|
||||
if (!isObject(value) || Object.keys(value).length === 0) throw new Error('License evidence is empty or invalid.');
|
||||
for (const [dependency, record] of Object.entries(value)) {
|
||||
if (!isObject(record) || typeof record.licenses !== 'string' || !allowlist.has(record.licenses)) {
|
||||
throw new Error(`Dependency ${dependency} has a missing or forbidden license.`);
|
||||
}
|
||||
}
|
||||
return Object.keys(value).length;
|
||||
}
|
||||
|
||||
function verifySbom(value, packageJson) {
|
||||
if (!isObject(value) || value.bomFormat !== 'CycloneDX' || !/^1\.(?:5|6)$/u.test(value.specVersion) || value.version !== 1 ||
|
||||
!isObject(value.metadata) || !isObject(value.metadata.component) || value.metadata.component.type !== 'application' ||
|
||||
value.metadata.component.name !== packageJson.name || value.metadata.component.version !== packageJson.version ||
|
||||
(value.components !== undefined && !Array.isArray(value.components))) {
|
||||
throw new Error('CycloneDX SBOM is invalid or does not describe this package.');
|
||||
}
|
||||
const identities = new Set();
|
||||
for (const component of value.components ?? []) {
|
||||
if (!isObject(component) || typeof component.type !== 'string' || typeof component.name !== 'string' || typeof component.version !== 'string') {
|
||||
throw new Error('CycloneDX SBOM contains an invalid component.');
|
||||
}
|
||||
const identity = typeof component['bom-ref'] === 'string' ? component['bom-ref'] : `${component.type}\0${component.name}\0${component.version}`;
|
||||
if (identities.has(identity)) throw new Error('CycloneDX SBOM contains duplicate component identities.');
|
||||
identities.add(identity);
|
||||
}
|
||||
return identities.size;
|
||||
}
|
||||
|
||||
function verifyToolProvenance(toolVersions, packageJson, packageLock) {
|
||||
if (!exactKeys(toolVersions, ['npm', 'cyclonedxNpm', 'licenseChecker']) ||
|
||||
typeof toolVersions.npm !== 'string' || !/^\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?$/u.test(toolVersions.npm)) {
|
||||
throw new Error('Supply-chain tool provenance is incomplete.');
|
||||
}
|
||||
const tools = [
|
||||
['cyclonedxNpm', '@cyclonedx/cyclonedx-npm'],
|
||||
['licenseChecker', 'license-checker-rseidelsohn'],
|
||||
];
|
||||
for (const [field, dependency] of tools) {
|
||||
const locked = packageLock.packages?.[`node_modules/${dependency}`];
|
||||
if (typeof toolVersions[field] !== 'string' || toolVersions[field] !== locked?.version ||
|
||||
packageJson.devDependencies?.[dependency] !== locked.version || typeof locked.integrity !== 'string' || !locked.integrity.startsWith('sha512-')) {
|
||||
throw new Error(`Pinned ${dependency} tool provenance does not match the integrity-bound lockfile.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertTracked(projectRoot, triagePath) {
|
||||
const rel = relative(projectRoot, triagePath);
|
||||
if (rel === '' || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) throw new Error('Committed Medium triage must be inside the project root.');
|
||||
try {
|
||||
execFileSync('git', ['ls-files', '--error-unmatch', '--', rel], { cwd: projectRoot, stdio: 'ignore' });
|
||||
} catch {
|
||||
throw new Error('Medium vulnerability triage must be committed to Git before release.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifySupplyChainEvidence(options) {
|
||||
const projectRoot = resolve(options.projectRoot);
|
||||
const evidenceDir = resolve(options.evidenceDir);
|
||||
const today = options.today ?? new Date().toISOString().slice(0, 10);
|
||||
if (!datePattern.test(today)) throw new Error('Trusted supply-chain date must be YYYY-MM-DD.');
|
||||
const packageJsonBytes = await safeRead(resolve(projectRoot, 'package.json'), 'package.json');
|
||||
const packageLockBytes = await safeRead(resolve(projectRoot, 'package-lock.json'), 'package-lock.json');
|
||||
const packageJson = parseJson(packageJsonBytes, 'package.json');
|
||||
const packageLock = parseJson(packageLockBytes, 'package-lock.json');
|
||||
if (!isObject(packageJson) || !isObject(packageLock) || packageLock.lockfileVersion !== 3 || packageLock.name !== packageJson.name || packageLock.version !== packageJson.version) {
|
||||
throw new Error('package.json and package-lock.json are not a clean matching npm lock pair.');
|
||||
}
|
||||
const manifest = parseJson(await safeRead(resolve(evidenceDir, 'evidence-manifest.json'), 'Supply-chain evidence manifest'), 'Supply-chain evidence manifest');
|
||||
if (!exactKeys(manifest, ['schema', 'packageJsonSha256', 'packageLockSha256', 'reports', 'toolVersions']) ||
|
||||
manifest.schema !== 'portfolio-supply-chain-evidence/v1' || manifest.packageJsonSha256 !== sha256(packageJsonBytes) ||
|
||||
manifest.packageLockSha256 !== sha256(packageLockBytes) || !isObject(manifest.reports) || !isObject(manifest.toolVersions) ||
|
||||
JSON.stringify(Object.keys(manifest.reports).sort()) !== JSON.stringify([...reportNames].sort())) {
|
||||
throw new Error('Supply-chain evidence manifest or lockfile digest is invalid.');
|
||||
}
|
||||
verifyToolProvenance(manifest.toolVersions, packageJson, packageLock);
|
||||
const reports = {};
|
||||
for (const name of reportNames) {
|
||||
const bytes = await safeRead(resolve(evidenceDir, name), `Supply-chain report ${name}`);
|
||||
if (!sha256Pattern.test(manifest.reports[name]) || manifest.reports[name] !== sha256(bytes)) throw new Error(`Supply-chain report digest mismatch: ${name}`);
|
||||
reports[name] = parseJson(bytes, name);
|
||||
}
|
||||
const production = parseAudit(reports['npm-audit-production.json'], 'production');
|
||||
const development = parseAudit(reports['npm-audit-development.json'], 'development');
|
||||
const all = [...production, ...development];
|
||||
const critical = all.filter((finding) => finding.severity === 'critical').length;
|
||||
const high = all.filter((finding) => finding.severity === 'high').length;
|
||||
if (critical > 0 || high > 0) throw new Error(`Supply chain is blocked by ${critical} Critical and ${high} High finding(s).`);
|
||||
const mediumFindings = all.filter((finding) => finding.severity === 'moderate');
|
||||
const triage = parseTriage(reports['medium-triage.json'], today);
|
||||
const expectedTriage = new Set(mediumFindings.map((finding) => `${finding.scope}\0${finding.package}\0${finding.range}`));
|
||||
if (triage.size !== expectedTriage.size || [...expectedTriage].some((key) => !triage.has(key))) {
|
||||
throw new Error('Every Medium finding requires one exact current triage record, with no stale extras.');
|
||||
}
|
||||
if (options.requireTrackedTriage !== false && mediumFindings.length > 0) assertTracked(projectRoot, resolve(evidenceDir, 'medium-triage.json'));
|
||||
const licenseCount = verifyLicenses(reports['licenses.json'], options.allowedLicenses ?? allowedLicenses);
|
||||
const componentCount = verifySbom(reports['sbom.cdx.json'], packageJson);
|
||||
return { schema: 'portfolio-supply-chain-verification/v1', critical, high, medium: mediumFindings.length, licenseCount, componentCount,
|
||||
packageLockSha256: manifest.packageLockSha256, evidenceManifestSha256: sha256(await safeRead(resolve(evidenceDir, 'evidence-manifest.json'), 'Supply-chain evidence manifest')) };
|
||||
}
|
||||
|
||||
function runTool(command, args, cwd, options = {}) {
|
||||
const result = spawnSync(command, args, { cwd, encoding: 'utf8', env: { PATH: process.env.PATH ?? '', LANG: 'C.UTF-8', LC_ALL: 'C.UTF-8', NO_COLOR: '1' }, maxBuffer: 64 * 1024 * 1024 });
|
||||
if (result.error || (!options.allowAuditExit && result.status !== 0) || (options.allowAuditExit && ![0, 1].includes(result.status))) {
|
||||
throw new Error(`${command} ${args[0] ?? ''} failed without producing trusted evidence.`);
|
||||
}
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
async function collectEvidence(projectRoot, evidenceDir, triagePath) {
|
||||
await mkdir(evidenceDir, { recursive: false });
|
||||
const beforePackage = await safeRead(resolve(projectRoot, 'package.json'), 'package.json');
|
||||
const beforeLock = await safeRead(resolve(projectRoot, 'package-lock.json'), 'package-lock.json');
|
||||
const npmVersion = runTool('npm', ['--version'], projectRoot).trim();
|
||||
runTool('npm', ['ci', '--ignore-scripts'], projectRoot);
|
||||
const productionAudit = runTool('npm', ['audit', '--omit=dev', '--json'], projectRoot, { allowAuditExit: true });
|
||||
const developmentAudit = runTool('npm', ['audit', '--json'], projectRoot, { allowAuditExit: true });
|
||||
const licenseBin = resolve(projectRoot, 'node_modules/.bin/license-checker-rseidelsohn');
|
||||
const cyclonedxBin = resolve(projectRoot, 'node_modules/.bin/cyclonedx-npm');
|
||||
const licenses = runTool(licenseBin, ['--json'], projectRoot);
|
||||
const sbomPath = resolve(evidenceDir, 'sbom.cdx.json');
|
||||
runTool(cyclonedxBin, ['--output-file', sbomPath, '--output-format', 'JSON', '--spec-version', '1.6'], projectRoot);
|
||||
const afterPackage = await safeRead(resolve(projectRoot, 'package.json'), 'package.json');
|
||||
const afterLock = await safeRead(resolve(projectRoot, 'package-lock.json'), 'package-lock.json');
|
||||
if (sha256(beforePackage) !== sha256(afterPackage) || sha256(beforeLock) !== sha256(afterLock)) throw new Error('npm ci changed package metadata or lockfile.');
|
||||
const triage = await safeRead(triagePath, 'Committed Medium triage');
|
||||
const output = {
|
||||
'npm-audit-production.json': Buffer.from(productionAudit), 'npm-audit-development.json': Buffer.from(developmentAudit),
|
||||
'licenses.json': Buffer.from(licenses), 'sbom.cdx.json': await safeRead(sbomPath, 'Generated CycloneDX SBOM'), 'medium-triage.json': triage,
|
||||
};
|
||||
for (const [name, bytes] of Object.entries(output)) if (name !== 'sbom.cdx.json') await writeFile(resolve(evidenceDir, name), bytes, { flag: 'wx', mode: 0o400 });
|
||||
const lock = parseJson(beforeLock, 'package-lock.json');
|
||||
const manifest = { schema: 'portfolio-supply-chain-evidence/v1', packageJsonSha256: sha256(beforePackage), packageLockSha256: sha256(beforeLock),
|
||||
reports: Object.fromEntries(reportNames.map((name) => [name, sha256(output[name])])), toolVersions: { npm: npmVersion,
|
||||
cyclonedxNpm: lock.packages?.['node_modules/@cyclonedx/cyclonedx-npm']?.version, licenseChecker: lock.packages?.['node_modules/license-checker-rseidelsohn']?.version } };
|
||||
await writeFile(resolve(evidenceDir, 'evidence-manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, { flag: 'wx', mode: 0o400 });
|
||||
}
|
||||
|
||||
function parseArguments(args) {
|
||||
const command = args.shift();
|
||||
const values = { alternate: [] };
|
||||
for (let index = 0; index < args.length; index += 2) {
|
||||
const key = args[index]; const value = args[index + 1];
|
||||
if (!key?.startsWith('--') || !value || Object.hasOwn(values, key.slice(2))) throw new Error('Supply-chain arguments must be unique complete name/value pairs.');
|
||||
values[key.slice(2)] = value;
|
||||
}
|
||||
return { command, values };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { command, values } = parseArguments(process.argv.slice(2));
|
||||
const projectRoot = resolve(values.project ?? '.');
|
||||
const evidenceDir = resolve(values.evidence ?? 'release-evidence/supply-chain');
|
||||
if (command === 'collect') {
|
||||
if (values['allow-registry-network'] !== 'yes') throw new Error('Collection requires --allow-registry-network yes in a credentialless registry-only sandbox.');
|
||||
if (!values.triage) throw new Error('Collection requires --triage <committed-record>.');
|
||||
await collectEvidence(projectRoot, evidenceDir, resolve(values.triage));
|
||||
} else if (command !== 'verify') throw new Error('Usage: supply-chain-check.mjs <collect|verify> --project <root> --evidence <dir>.');
|
||||
process.stdout.write(`${JSON.stringify(await verifySupplyChainEvidence({ projectRoot, evidenceDir, today: values.today }))}\n`);
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main().catch((error) => { process.stderr.write(`Supply-chain verification failed: ${error instanceof Error ? error.message : 'unknown error'}\n`); process.exitCode = 1; });
|
||||
}
|
||||
Reference in New Issue
Block a user