Files
Portfolio/scripts/supply-chain-check.mjs

313 lines
21 KiB
JavaScript

#!/usr/bin/env node
import { createHash, createPublicKey, verify } 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';
import { canonicalValueBytes } from '../src/security/canonical-digest.ts';
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',
'execution-security.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 timestamp(value, label) {
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/u.test(value) || Number.isNaN(Date.parse(value))) {
throw new Error(`${label} must be a UTC RFC 3339 timestamp.`);
}
return value;
}
async function verifyExecutionSecurity(envelope, trustPath, now) {
const trust = parseJson(await safeRead(resolve(trustPath), 'Execution evidence trust policy'), 'Execution evidence trust policy');
if (!exactKeys(trust, ['schema', 'keyId', 'issuerEpoch', 'scope', 'publicKeyPem', 'revoked', 'expectedImageRoles']) ||
trust.schema !== 'portfolio-execution-evidence-trust/v1' || trust.scope !== 'portfolio-execution-security-evidence/v1' ||
typeof trust.keyId !== 'string' || !Number.isSafeInteger(trust.issuerEpoch) || trust.issuerEpoch < 1 ||
typeof trust.publicKeyPem !== 'string' || trust.revoked !== false || !Array.isArray(trust.expectedImageRoles) ||
trust.expectedImageRoles.length === 0 || trust.expectedImageRoles.some((role) => typeof role !== 'string') ||
new Set(trust.expectedImageRoles).size !== trust.expectedImageRoles.length) {
throw new Error('Execution evidence trust policy is invalid or revoked.');
}
if (!exactKeys(envelope, ['schema', 'payload', 'signature']) || envelope.schema !== 'portfolio-execution-security-evidence-envelope/v1' ||
!isObject(envelope.payload) || !isObject(envelope.signature)) throw new Error('Execution security evidence envelope is invalid.');
const payload = envelope.payload;
if (!exactKeys(payload, ['schema', 'keyId', 'issuerEpoch', 'scope', 'generatedAt', 'expiresAt', 'images', 'nativeTools', 'repository']) ||
payload.schema !== 'portfolio-execution-security-evidence/v1' || payload.scope !== trust.scope || payload.keyId !== trust.keyId ||
payload.issuerEpoch !== trust.issuerEpoch || !Array.isArray(payload.images) || !Array.isArray(payload.nativeTools) || !isObject(payload.repository)) {
throw new Error('Execution security evidence payload is invalid.');
}
const generatedAt = timestamp(payload.generatedAt, 'Execution evidence generatedAt');
const expiresAt = timestamp(payload.expiresAt, 'Execution evidence expiresAt');
const current = timestamp(now, 'Execution evidence current time');
if (Date.parse(generatedAt) > Date.parse(current) || Date.parse(current) >= Date.parse(expiresAt)) throw new Error('Execution security evidence is expired or not yet valid.');
const expectedRoles = [...trust.expectedImageRoles].sort();
const roles = [];
for (const image of payload.images) {
if (!exactKeys(image, ['role', 'image', 'provenanceVerified', 'critical', 'high']) || typeof image.role !== 'string' ||
typeof image.image !== 'string' || !/@sha256:[a-f0-9]{64}$/u.test(image.image) || image.provenanceVerified !== true ||
!Number.isSafeInteger(image.critical) || !Number.isSafeInteger(image.high) || image.critical < 0 || image.high < 0) {
throw new Error('Execution image provenance evidence is invalid or unsigned.');
}
if (image.critical > 0 || image.high > 0) throw new Error('Execution image is blocked by Critical/High vulnerabilities.');
roles.push(image.role);
}
if (JSON.stringify(roles.sort()) !== JSON.stringify(expectedRoles)) throw new Error('Execution image role closure is incomplete or duplicated.');
if (payload.nativeTools.length === 0) throw new Error('Native tool security evidence is empty.');
const nativeNames = new Set();
for (const tool of payload.nativeTools) {
if (!exactKeys(tool, ['name', 'version', 'sha256', 'signatureVerified', 'critical', 'high']) || typeof tool.name !== 'string' ||
typeof tool.version !== 'string' || !sha256Pattern.test(tool.sha256) || tool.signatureVerified !== true ||
!Number.isSafeInteger(tool.critical) || !Number.isSafeInteger(tool.high) || tool.critical < 0 || tool.high < 0 || nativeNames.has(tool.name)) {
throw new Error('Native tool provenance evidence is invalid or unsigned.');
}
if (tool.critical > 0 || tool.high > 0) throw new Error('Native tool is blocked by Critical/High vulnerabilities.');
nativeNames.add(tool.name);
}
const repository = payload.repository;
if (!exactKeys(repository, ['commitSha', 'secretScanPassed', 'historyScanPassed', 'ciActionsPinned', 'distributionNoticesComplete']) ||
typeof repository.commitSha !== 'string' || !/^[a-f0-9]{40}$/u.test(repository.commitSha) || repository.secretScanPassed !== true ||
repository.historyScanPassed !== true || repository.ciActionsPinned !== true || repository.distributionNoticesComplete !== true) {
throw new Error('Repository secret/history/CI/distribution security evidence is incomplete.');
}
const signature = envelope.signature;
if (!exactKeys(signature, ['algorithm', 'encoding', 'value']) || signature.algorithm !== 'Ed25519' || signature.encoding !== 'base64url-nopad' ||
typeof signature.value !== 'string' || !/^[A-Za-z0-9_-]+$/u.test(signature.value)) throw new Error('Execution security evidence signature is invalid.');
let publicKey;
try { publicKey = createPublicKey(trust.publicKeyPem); } catch { throw new Error('Execution evidence public key is invalid.'); }
if (!verify(null, canonicalValueBytes(payload), publicKey, Buffer.from(signature.value, 'base64url'))) {
throw new Error('Execution security evidence signature verification failed.');
}
return { imageCount: roles.length, nativeToolCount: nativeNames.size };
}
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);
if (typeof options.toolTrustPolicyPath !== 'string') throw new Error('Execution evidence trust policy is required.');
const execution = await verifyExecutionSecurity(reports['execution-security.json'], options.toolTrustPolicyPath, options.now ?? new Date().toISOString());
return { schema: 'portfolio-supply-chain-verification/v1', critical, high, medium: mediumFindings.length, licenseCount, componentCount,
executionImages: execution.imageCount, nativeTools: execution.nativeToolCount,
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, executionSecurityPath) {
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 executionSecurity = await safeRead(executionSecurityPath, 'Signed execution security evidence');
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,
'execution-security.json': executionSecurity,
};
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>.');
if (!values['execution-security']) throw new Error('Collection requires --execution-security <signed-evidence>.');
await collectEvidence(projectRoot, evidenceDir, resolve(values.triage), resolve(values['execution-security']));
} else if (command !== 'verify') throw new Error('Usage: supply-chain-check.mjs <collect|verify> --project <root> --evidence <dir>.');
if (!values['tool-trust-policy']) throw new Error('Verification requires --tool-trust-policy <protected-policy>.');
process.stdout.write(`${JSON.stringify(await verifySupplyChainEvidence({ projectRoot, evidenceDir, today: values.today, now: values.now, toolTrustPolicyPath: resolve(values['tool-trust-policy']) }))}\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; });
}