fix: require signed execution security evidence
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from 'node:crypto';
|
||||
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 = [
|
||||
@@ -13,6 +15,7 @@ const reportNames = [
|
||||
'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',
|
||||
@@ -113,6 +116,75 @@ function verifyToolProvenance(toolVersions, packageJson, packageLock) {
|
||||
}
|
||||
}
|
||||
|
||||
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.');
|
||||
@@ -164,7 +236,10 @@ export async function verifySupplyChainEvidence(options) {
|
||||
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')) };
|
||||
}
|
||||
|
||||
@@ -176,7 +251,7 @@ function runTool(command, args, cwd, options = {}) {
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
async function collectEvidence(projectRoot, evidenceDir, triagePath) {
|
||||
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');
|
||||
@@ -193,9 +268,11 @@ async function collectEvidence(projectRoot, evidenceDir, triagePath) {
|
||||
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');
|
||||
@@ -223,9 +300,11 @@ async function main() {
|
||||
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));
|
||||
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>.');
|
||||
process.stdout.write(`${JSON.stringify(await verifySupplyChainEvidence({ projectRoot, evidenceDir, today: values.today }))}\n`);
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user