fix: require signed execution security evidence
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,6 +7,7 @@ playwright-report/
|
|||||||
test-results/
|
test-results/
|
||||||
.env*
|
.env*
|
||||||
config/site.production.json
|
config/site.production.json
|
||||||
|
config/release-execution-trust.json
|
||||||
release-evidence/
|
release-evidence/
|
||||||
*.release.tar
|
*.release.tar
|
||||||
*.release.tar.partial-*
|
*.release.tar.partial-*
|
||||||
|
|||||||
@@ -24,11 +24,15 @@ node scripts/supply-chain-check.mjs collect \
|
|||||||
--project . \
|
--project . \
|
||||||
--evidence /external/write-once/release-evidence/supply-chain \
|
--evidence /external/write-once/release-evidence/supply-chain \
|
||||||
--triage APPROVED_COMMITTED_MEDIUM_TRIAGE.json \
|
--triage APPROVED_COMMITTED_MEDIUM_TRIAGE.json \
|
||||||
|
--execution-security SIGNED_EXECUTION_SECURITY_ENVELOPE.json \
|
||||||
|
--tool-trust-policy config/release-execution-trust.json \
|
||||||
--allow-registry-network yes \
|
--allow-registry-network yes \
|
||||||
--today YYYY-MM-DD
|
--now YYYY-MM-DDTHH:MM:SSZ
|
||||||
```
|
```
|
||||||
|
|
||||||
The collector runs `npm ci --ignore-scripts`, production and full development audits, the pinned license checker, and the pinned CycloneDX generator. It strips the environment to a minimal credential-free set. The verifier fails when package metadata or the lockfile changes, evidence digests do not match, a license is not allowlisted, an SBOM is malformed, or a Critical/High finding exists. Every Medium finding must have one current, exact, committed triage entry; stale extras also fail.
|
The collector runs `npm ci --ignore-scripts`, production and full development audits, the pinned license checker, and the pinned CycloneDX generator. It strips the environment to a minimal credential-free set. It also requires a separately signed execution-security envelope: all four privacy image roles must be digest-pinned with verified provenance and zero Critical/High findings; native tools must be integrity/signature verified and clean; repository secret/history scans, pinned CI actions, and distribution notices must all pass. The verifier checks that envelope against the protected public-key policy at `config/release-execution-trust.json`. Until Luca supplies and approves that public trust policy and the signed evidence, the supply-chain gate intentionally fails closed.
|
||||||
|
|
||||||
|
The verifier also fails when package metadata or the lockfile changes, evidence digests do not match, a license is not allowlisted, an SBOM is malformed, or a Critical/High finding exists. Every Medium finding must have one current, exact, committed triage entry; stale extras also fail.
|
||||||
|
|
||||||
- [ ] `npm run verify:supply-chain` passes against the selected immutable evidence directory.
|
- [ ] `npm run verify:supply-chain` passes against the selected immutable evidence directory.
|
||||||
- [ ] `npm audit` evidence covers production dependencies and the complete development tree.
|
- [ ] `npm audit` evidence covers production dependencies and the complete development tree.
|
||||||
@@ -36,6 +40,8 @@ The collector runs `npm ci --ignore-scripts`, production and full development au
|
|||||||
- [ ] Every Medium finding has a current committed owner, rationale, and review date.
|
- [ ] Every Medium finding has a current committed owner, rationale, and review date.
|
||||||
- [ ] CycloneDX SBOM is bound by the evidence manifest and packaged with the release.
|
- [ ] CycloneDX SBOM is bound by the evidence manifest and packaged with the release.
|
||||||
- [ ] License inventory contains no unknown, copyleft, or unapproved expression.
|
- [ ] License inventory contains no unknown, copyleft, or unapproved expression.
|
||||||
|
- [ ] Signed execution image/native-tool evidence is current, complete, digest-pinned, and has zero Critical/High findings.
|
||||||
|
- [ ] Repository secret/history, CI action pinning, and distribution notice checks pass.
|
||||||
- [ ] `npm run build:preview` passes.
|
- [ ] `npm run build:preview` passes.
|
||||||
- [ ] Complete production build, unit/browser/accessibility/performance gates pass using the approved production config.
|
- [ ] Complete production build, unit/browser/accessibility/performance gates pass using the approved production config.
|
||||||
- [ ] Privacy reconciliation and signed-release admission pass against external trust state.
|
- [ ] Privacy reconciliation and signed-release admission pass against external trust state.
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
"capture:runtime-budgets": "playwright test tests/performance/runtime-budget-trace.spec.ts --project=runtime-budget-desktop --workers=1",
|
"capture:runtime-budgets": "playwright test tests/performance/runtime-budget-trace.spec.ts --project=runtime-budget-desktop --workers=1",
|
||||||
"verify:runtime-budgets": "node scripts/verify-runtime-budgets.mjs",
|
"verify:runtime-budgets": "node scripts/verify-runtime-budgets.mjs",
|
||||||
"verify:phase3": "npm run verify:runtime-budgets && npm run test:browser",
|
"verify:phase3": "npm run verify:runtime-budgets && npm run test:browser",
|
||||||
"verify:supply-chain": "node scripts/supply-chain-check.mjs verify --project . --evidence release-evidence/supply-chain",
|
"verify:supply-chain": "node scripts/supply-chain-check.mjs verify --project . --evidence release-evidence/supply-chain --tool-trust-policy config/release-execution-trust.json",
|
||||||
"smoke:production": "node scripts/smoke-production.mjs",
|
"smoke:production": "node scripts/smoke-production.mjs",
|
||||||
"aggregate:performance": "node scripts/aggregate-drive-traces.mjs",
|
"aggregate:performance": "node scripts/aggregate-drive-traces.mjs",
|
||||||
"sign:performance": "node scripts/sign-drive-trace.mjs",
|
"sign:performance": "node scripts/sign-drive-trace.mjs",
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import { createHash } from 'node:crypto';
|
import { createHash, createPublicKey, verify } from 'node:crypto';
|
||||||
import { execFileSync, spawnSync } from 'node:child_process';
|
import { execFileSync, spawnSync } from 'node:child_process';
|
||||||
import { lstat, mkdir, readFile, writeFile } from 'node:fs/promises';
|
import { lstat, mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
||||||
import { pathToFileURL } from 'node:url';
|
import { pathToFileURL } from 'node:url';
|
||||||
|
|
||||||
|
import { canonicalValueBytes } from '../src/security/canonical-digest.ts';
|
||||||
|
|
||||||
const sha256Pattern = /^[a-f0-9]{64}$/u;
|
const sha256Pattern = /^[a-f0-9]{64}$/u;
|
||||||
const datePattern = /^\d{4}-\d{2}-\d{2}$/u;
|
const datePattern = /^\d{4}-\d{2}-\d{2}$/u;
|
||||||
const reportNames = [
|
const reportNames = [
|
||||||
@@ -13,6 +15,7 @@ const reportNames = [
|
|||||||
'licenses.json',
|
'licenses.json',
|
||||||
'sbom.cdx.json',
|
'sbom.cdx.json',
|
||||||
'medium-triage.json',
|
'medium-triage.json',
|
||||||
|
'execution-security.json',
|
||||||
];
|
];
|
||||||
const allowedLicenses = new Set([
|
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',
|
'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) {
|
function assertTracked(projectRoot, triagePath) {
|
||||||
const rel = relative(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.');
|
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'));
|
if (options.requireTrackedTriage !== false && mediumFindings.length > 0) assertTracked(projectRoot, resolve(evidenceDir, 'medium-triage.json'));
|
||||||
const licenseCount = verifyLicenses(reports['licenses.json'], options.allowedLicenses ?? allowedLicenses);
|
const licenseCount = verifyLicenses(reports['licenses.json'], options.allowedLicenses ?? allowedLicenses);
|
||||||
const componentCount = verifySbom(reports['sbom.cdx.json'], packageJson);
|
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,
|
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')) };
|
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;
|
return result.stdout;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function collectEvidence(projectRoot, evidenceDir, triagePath) {
|
async function collectEvidence(projectRoot, evidenceDir, triagePath, executionSecurityPath) {
|
||||||
await mkdir(evidenceDir, { recursive: false });
|
await mkdir(evidenceDir, { recursive: false });
|
||||||
const beforePackage = await safeRead(resolve(projectRoot, 'package.json'), 'package.json');
|
const beforePackage = await safeRead(resolve(projectRoot, 'package.json'), 'package.json');
|
||||||
const beforeLock = await safeRead(resolve(projectRoot, 'package-lock.json'), 'package-lock.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');
|
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.');
|
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 triage = await safeRead(triagePath, 'Committed Medium triage');
|
||||||
|
const executionSecurity = await safeRead(executionSecurityPath, 'Signed execution security evidence');
|
||||||
const output = {
|
const output = {
|
||||||
'npm-audit-production.json': Buffer.from(productionAudit), 'npm-audit-development.json': Buffer.from(developmentAudit),
|
'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,
|
'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 });
|
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 lock = parseJson(beforeLock, 'package-lock.json');
|
||||||
@@ -223,9 +300,11 @@ async function main() {
|
|||||||
if (command === 'collect') {
|
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['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.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>.');
|
} 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) {
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { createHash } from 'node:crypto';
|
import { createHash, generateKeyPairSync, sign } from 'node:crypto';
|
||||||
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
|
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { canonicalValueBytes } from '../../src/security/canonical-digest.ts';
|
||||||
|
|
||||||
// @ts-expect-error The production release checker is an executable ESM module.
|
// @ts-expect-error The production release checker is an executable ESM module.
|
||||||
import { verifySupplyChainEvidence } from '../../scripts/supply-chain-check.mjs';
|
import { verifySupplyChainEvidence } from '../../scripts/supply-chain-check.mjs';
|
||||||
|
|
||||||
@@ -21,12 +23,56 @@ async function evidenceFixture(overrides: Record<string, unknown> = {}) {
|
|||||||
} });
|
} });
|
||||||
await writeFile(join(root, 'package.json'), packageJson);
|
await writeFile(join(root, 'package.json'), packageJson);
|
||||||
await writeFile(join(root, 'package-lock.json'), packageLock);
|
await writeFile(join(root, 'package-lock.json'), packageLock);
|
||||||
|
const signingKeys = generateKeyPairSync('ed25519');
|
||||||
|
const trustPolicyPath = join(root, 'execution-trust.json');
|
||||||
|
await writeFile(trustPolicyPath, JSON.stringify({
|
||||||
|
schema: 'portfolio-execution-evidence-trust/v1',
|
||||||
|
keyId: 'supply-chain-test-key',
|
||||||
|
issuerEpoch: 1,
|
||||||
|
scope: 'portfolio-execution-security-evidence/v1',
|
||||||
|
publicKeyPem: signingKeys.publicKey.export({ type: 'spki', format: 'pem' }),
|
||||||
|
revoked: false,
|
||||||
|
expectedImageRoles: ['coordinator', 'matcher', 'worker-a', 'worker-b'],
|
||||||
|
}));
|
||||||
|
const executionPayload = overrides.executionPayload ?? {
|
||||||
|
schema: 'portfolio-execution-security-evidence/v1',
|
||||||
|
keyId: 'supply-chain-test-key',
|
||||||
|
issuerEpoch: 1,
|
||||||
|
scope: 'portfolio-execution-security-evidence/v1',
|
||||||
|
generatedAt: '2026-09-02T00:00:00Z',
|
||||||
|
expiresAt: '2026-09-03T00:00:00Z',
|
||||||
|
images: ['coordinator', 'matcher', 'worker-a', 'worker-b'].map((role, index) => ({
|
||||||
|
role,
|
||||||
|
image: `registry.invalid/portfolio-${role}@sha256:${String(index + 1).repeat(64)}`,
|
||||||
|
provenanceVerified: true,
|
||||||
|
critical: 0,
|
||||||
|
high: 0,
|
||||||
|
})),
|
||||||
|
nativeTools: [{ name: 'nginx', version: '1.29.1', sha256: 'a'.repeat(64), signatureVerified: true, critical: 0, high: 0 }],
|
||||||
|
repository: {
|
||||||
|
commitSha: 'b'.repeat(40),
|
||||||
|
secretScanPassed: true,
|
||||||
|
historyScanPassed: true,
|
||||||
|
ciActionsPinned: true,
|
||||||
|
distributionNoticesComplete: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const executionEnvelope = overrides.executionEnvelope ?? {
|
||||||
|
schema: 'portfolio-execution-security-evidence-envelope/v1',
|
||||||
|
payload: executionPayload,
|
||||||
|
signature: {
|
||||||
|
algorithm: 'Ed25519',
|
||||||
|
encoding: 'base64url-nopad',
|
||||||
|
value: sign(null, canonicalValueBytes(executionPayload), signingKeys.privateKey).toString('base64url'),
|
||||||
|
},
|
||||||
|
};
|
||||||
const reports = {
|
const reports = {
|
||||||
'npm-audit-production.json': JSON.stringify(overrides.productionAudit ?? { auditReportVersion: 2, vulnerabilities: {}, metadata: { vulnerabilities: { info: 0, low: 0, moderate: 0, high: 0, critical: 0, total: 0 } } }),
|
'npm-audit-production.json': JSON.stringify(overrides.productionAudit ?? { auditReportVersion: 2, vulnerabilities: {}, metadata: { vulnerabilities: { info: 0, low: 0, moderate: 0, high: 0, critical: 0, total: 0 } } }),
|
||||||
'npm-audit-development.json': JSON.stringify(overrides.developmentAudit ?? { auditReportVersion: 2, vulnerabilities: {}, metadata: { vulnerabilities: { info: 0, low: 0, moderate: 0, high: 0, critical: 0, total: 0 } } }),
|
'npm-audit-development.json': JSON.stringify(overrides.developmentAudit ?? { auditReportVersion: 2, vulnerabilities: {}, metadata: { vulnerabilities: { info: 0, low: 0, moderate: 0, high: 0, critical: 0, total: 0 } } }),
|
||||||
'licenses.json': JSON.stringify(overrides.licenses ?? { 'portfolio@1.0.0': { licenses: 'ISC' } }),
|
'licenses.json': JSON.stringify(overrides.licenses ?? { 'portfolio@1.0.0': { licenses: 'ISC' } }),
|
||||||
'sbom.cdx.json': JSON.stringify(overrides.sbom ?? { bomFormat: 'CycloneDX', specVersion: '1.6', version: 1, metadata: { component: { type: 'application', name: 'portfolio', version: '1.0.0' } }, components: [] }),
|
'sbom.cdx.json': JSON.stringify(overrides.sbom ?? { bomFormat: 'CycloneDX', specVersion: '1.6', version: 1, metadata: { component: { type: 'application', name: 'portfolio', version: '1.0.0' } }, components: [] }),
|
||||||
'medium-triage.json': JSON.stringify(overrides.triage ?? { schema: 'portfolio-medium-vulnerability-triage/v1', findings: [] }),
|
'medium-triage.json': JSON.stringify(overrides.triage ?? { schema: 'portfolio-medium-vulnerability-triage/v1', findings: [] }),
|
||||||
|
'execution-security.json': JSON.stringify(executionEnvelope),
|
||||||
};
|
};
|
||||||
for (const [name, contents] of Object.entries(reports)) await writeFile(join(evidence, name), contents);
|
for (const [name, contents] of Object.entries(reports)) await writeFile(join(evidence, name), contents);
|
||||||
const manifest = {
|
const manifest = {
|
||||||
@@ -37,40 +83,72 @@ async function evidenceFixture(overrides: Record<string, unknown> = {}) {
|
|||||||
toolVersions: { npm: '11.0.0', cyclonedxNpm: '6.0.1', licenseChecker: '4.4.2' },
|
toolVersions: { npm: '11.0.0', cyclonedxNpm: '6.0.1', licenseChecker: '4.4.2' },
|
||||||
};
|
};
|
||||||
await writeFile(join(evidence, 'evidence-manifest.json'), JSON.stringify(manifest));
|
await writeFile(join(evidence, 'evidence-manifest.json'), JSON.stringify(manifest));
|
||||||
return { root, evidence };
|
return { root, evidence, trustPolicyPath };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const verifyFixture = (fixture: Awaited<ReturnType<typeof evidenceFixture>>, extra: Record<string, unknown> = {}) =>
|
||||||
|
verifySupplyChainEvidence({
|
||||||
|
projectRoot: fixture.root,
|
||||||
|
evidenceDir: fixture.evidence,
|
||||||
|
toolTrustPolicyPath: fixture.trustPolicyPath,
|
||||||
|
requireTrackedTriage: false,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
|
||||||
describe('supply-chain evidence', () => {
|
describe('supply-chain evidence', () => {
|
||||||
it('accepts a digest-bound clean audit, allowlisted licenses, and CycloneDX SBOM', async () => {
|
it('accepts a digest-bound clean audit, allowlisted licenses, and CycloneDX SBOM', async () => {
|
||||||
const fixture = await evidenceFixture();
|
const fixture = await evidenceFixture();
|
||||||
await expect(verifySupplyChainEvidence({ projectRoot: fixture.root, evidenceDir: fixture.evidence, requireTrackedTriage: false })).resolves.toMatchObject({
|
await expect(verifyFixture(fixture, { now: '2026-09-02T12:00:00Z' })).resolves.toMatchObject({
|
||||||
critical: 0, high: 0, medium: 0, componentCount: 0,
|
critical: 0, high: 0, medium: 0, componentCount: 0, executionImages: 4,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fails closed on altered evidence, Critical/High findings, or forbidden licenses', async () => {
|
it('fails closed on altered evidence, Critical/High findings, or forbidden licenses', async () => {
|
||||||
const fixture = await evidenceFixture();
|
const fixture = await evidenceFixture();
|
||||||
await writeFile(join(fixture.evidence, 'licenses.json'), JSON.stringify({ 'portfolio@1.0.0': { licenses: 'AGPL-3.0' } }));
|
await writeFile(join(fixture.evidence, 'licenses.json'), JSON.stringify({ 'portfolio@1.0.0': { licenses: 'AGPL-3.0' } }));
|
||||||
await expect(verifySupplyChainEvidence({ projectRoot: fixture.root, evidenceDir: fixture.evidence, requireTrackedTriage: false })).rejects.toThrow(/digest/u);
|
await expect(verifyFixture(fixture)).rejects.toThrow(/digest/u);
|
||||||
|
|
||||||
const high = await evidenceFixture({
|
const high = await evidenceFixture({
|
||||||
productionAudit: { auditReportVersion: 2, vulnerabilities: { unsafe: { name: 'unsafe', severity: 'high', via: [], effects: [], range: '*', nodes: [] } }, metadata: { vulnerabilities: { info: 0, low: 0, moderate: 0, high: 1, critical: 0, total: 1 } } },
|
productionAudit: { auditReportVersion: 2, vulnerabilities: { unsafe: { name: 'unsafe', severity: 'high', via: [], effects: [], range: '*', nodes: [] } }, metadata: { vulnerabilities: { info: 0, low: 0, moderate: 0, high: 1, critical: 0, total: 1 } } },
|
||||||
});
|
});
|
||||||
await expect(verifySupplyChainEvidence({ projectRoot: high.root, evidenceDir: high.evidence, requireTrackedTriage: false })).rejects.toThrow(/High|high/u);
|
await expect(verifyFixture(high)).rejects.toThrow(/High|high/u);
|
||||||
|
|
||||||
const license = await evidenceFixture({ licenses: { 'portfolio@1.0.0': { licenses: 'AGPL-3.0-only' } } });
|
const license = await evidenceFixture({ licenses: { 'portfolio@1.0.0': { licenses: 'AGPL-3.0-only' } } });
|
||||||
await expect(verifySupplyChainEvidence({ projectRoot: license.root, evidenceDir: license.evidence, requireTrackedTriage: false })).rejects.toThrow(/license/u);
|
await expect(verifyFixture(license)).rejects.toThrow(/license/u);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('requires an exact, current triage record for every Medium finding', async () => {
|
it('requires an exact, current triage record for every Medium finding', async () => {
|
||||||
const audit = { auditReportVersion: 2, vulnerabilities: { packageA: { name: 'packageA', severity: 'moderate', via: [], effects: [], range: '<2', nodes: ['node_modules/packageA'] } }, metadata: { vulnerabilities: { info: 0, low: 0, moderate: 1, high: 0, critical: 0, total: 1 } } };
|
const audit = { auditReportVersion: 2, vulnerabilities: { packageA: { name: 'packageA', severity: 'moderate', via: [], effects: [], range: '<2', nodes: ['node_modules/packageA'] } }, metadata: { vulnerabilities: { info: 0, low: 0, moderate: 1, high: 0, critical: 0, total: 1 } } };
|
||||||
const missing = await evidenceFixture({ developmentAudit: audit });
|
const missing = await evidenceFixture({ developmentAudit: audit });
|
||||||
await expect(verifySupplyChainEvidence({ projectRoot: missing.root, evidenceDir: missing.evidence, requireTrackedTriage: false })).rejects.toThrow(/triage/u);
|
await expect(verifyFixture(missing)).rejects.toThrow(/triage/u);
|
||||||
|
|
||||||
const accepted = await evidenceFixture({
|
const accepted = await evidenceFixture({
|
||||||
developmentAudit: audit,
|
developmentAudit: audit,
|
||||||
triage: { schema: 'portfolio-medium-vulnerability-triage/v1', findings: [{ scope: 'development', package: 'packageA', severity: 'moderate', range: '<2', disposition: 'accepted', rationale: 'Build-only tool; no deployed runtime exposure.', owner: 'Luca Pîslaru', reviewBy: '2027-01-01' }] },
|
triage: { schema: 'portfolio-medium-vulnerability-triage/v1', findings: [{ scope: 'development', package: 'packageA', severity: 'moderate', range: '<2', disposition: 'accepted', rationale: 'Build-only tool; no deployed runtime exposure.', owner: 'Luca Pîslaru', reviewBy: '2027-01-01' }] },
|
||||||
});
|
});
|
||||||
await expect(verifySupplyChainEvidence({ projectRoot: accepted.root, evidenceDir: accepted.evidence, requireTrackedTriage: false, today: '2026-09-02' })).resolves.toMatchObject({ medium: 1 });
|
await expect(verifyFixture(accepted, { today: '2026-09-02' })).resolves.toMatchObject({ medium: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed on unsigned tooling, vulnerable images, or incomplete repository security evidence', async () => {
|
||||||
|
const unsigned = await evidenceFixture({ executionEnvelope: { schema: 'portfolio-execution-security-evidence-envelope/v1', payload: {}, signature: { algorithm: 'Ed25519', encoding: 'base64url-nopad', value: '' } } });
|
||||||
|
await expect(verifyFixture(unsigned)).rejects.toThrow(/execution|signature|payload/u);
|
||||||
|
|
||||||
|
const vulnerable = await evidenceFixture({ executionPayload: {
|
||||||
|
schema: 'portfolio-execution-security-evidence/v1', keyId: 'supply-chain-test-key', issuerEpoch: 1,
|
||||||
|
scope: 'portfolio-execution-security-evidence/v1', generatedAt: '2026-09-02T00:00:00Z', expiresAt: '2026-09-03T00:00:00Z',
|
||||||
|
images: ['coordinator', 'matcher', 'worker-a', 'worker-b'].map((role, index) => ({ role, image: `registry.invalid/${role}@sha256:${String(index + 1).repeat(64)}`, provenanceVerified: true, critical: 0, high: role === 'worker-a' ? 1 : 0 })),
|
||||||
|
nativeTools: [{ name: 'nginx', version: '1.29.1', sha256: 'a'.repeat(64), signatureVerified: true, critical: 0, high: 0 }],
|
||||||
|
repository: { commitSha: 'b'.repeat(40), secretScanPassed: true, historyScanPassed: true, ciActionsPinned: true, distributionNoticesComplete: true },
|
||||||
|
} });
|
||||||
|
await expect(verifyFixture(vulnerable)).rejects.toThrow(/Critical|High|vulnerab/u);
|
||||||
|
|
||||||
|
const unscanned = await evidenceFixture({ executionPayload: {
|
||||||
|
schema: 'portfolio-execution-security-evidence/v1', keyId: 'supply-chain-test-key', issuerEpoch: 1,
|
||||||
|
scope: 'portfolio-execution-security-evidence/v1', generatedAt: '2026-09-02T00:00:00Z', expiresAt: '2026-09-03T00:00:00Z',
|
||||||
|
images: ['coordinator', 'matcher', 'worker-a', 'worker-b'].map((role, index) => ({ role, image: `registry.invalid/${role}@sha256:${String(index + 1).repeat(64)}`, provenanceVerified: true, critical: 0, high: 0 })),
|
||||||
|
nativeTools: [{ name: 'nginx', version: '1.29.1', sha256: 'a'.repeat(64), signatureVerified: true, critical: 0, high: 0 }],
|
||||||
|
repository: { commitSha: 'b'.repeat(40), secretScanPassed: false, historyScanPassed: true, ciActionsPinned: true, distributionNoticesComplete: true },
|
||||||
|
} });
|
||||||
|
await expect(verifyFixture(unscanned)).rejects.toThrow(/repository|secret/u);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user