fix: promote immutable verified release bundles
This commit is contained in:
@@ -88,7 +88,6 @@ jobs:
|
||||
include:
|
||||
- { gate: FE-GATE-012, name: bundle, browser: false }
|
||||
- { gate: FE-GATE-014, name: config-compatibility, browser: false }
|
||||
- { gate: FE-GATE-015, name: release-coherence, browser: false }
|
||||
- { gate: FE-GATE-019, name: hosting-header, browser: false }
|
||||
- { gate: FE-GATE-026, name: lab-performance, browser: true }
|
||||
steps:
|
||||
@@ -113,9 +112,171 @@ jobs:
|
||||
path: artifacts/
|
||||
if-no-files-found: error
|
||||
|
||||
immutable_build:
|
||||
name: FE-GATE-015 / immutable-release-candidate
|
||||
needs: release_gate
|
||||
if: ${{ startsWith(gitea.ref, 'refs/tags/v') || (gitea.event_name == 'workflow_dispatch' && (inputs.stage == 'release' || inputs.stage == 'production' || inputs.stage == 'field')) }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
outputs:
|
||||
dist_sha256: ${{ steps.candidate.outputs.dist_sha256 }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- name: Frozen install
|
||||
run: |
|
||||
corepack enable
|
||||
corepack pnpm install --frozen-lockfile
|
||||
- name: Build candidate once and verify local evidence
|
||||
run: corepack pnpm ci:gate -- FE-GATE-015
|
||||
- name: Archive the exact candidate file set
|
||||
id: candidate
|
||||
run: |
|
||||
mkdir -p .release
|
||||
tar --sort=name --mtime="@0" --owner=0 --group=0 --numeric-owner -czf ".release/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" \
|
||||
dist \
|
||||
artifacts/performance/bundle.json \
|
||||
artifacts/quality/vite-module-inventory.json \
|
||||
artifacts/release/build-manifest.json \
|
||||
artifacts/release/checksums.txt \
|
||||
artifacts/release/dependency-inventory.json \
|
||||
artifacts/release/provenance.json \
|
||||
artifacts/release/release-candidate.json \
|
||||
artifacts/release/verification.json \
|
||||
artifacts/release/sbom.cdx.json \
|
||||
artifacts/security/dependency-diff.json \
|
||||
artifacts/security/license-report.json \
|
||||
artifacts/security/scan.sarif \
|
||||
artifacts/security/supply-chain-coherence.json \
|
||||
artifacts/security/supply-chain-verification.json \
|
||||
artifacts/security/vulnerability-report.json
|
||||
node -e "const m=require('./artifacts/release/release-candidate.json'); process.stdout.write('dist_sha256='+m.distSha256+'\\n')" >> "$GITHUB_OUTPUT"
|
||||
- name: Upload immutable candidate archive
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}
|
||||
path: .release/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz
|
||||
if-no-files-found: error
|
||||
|
||||
vulnerability_provider:
|
||||
name: external-vulnerability-provider
|
||||
needs: immutable_build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
CANDIDATE_DIST_SHA256: ${{ needs.immutable_build.outputs.dist_sha256 }}
|
||||
VULNERABILITY_PROVIDER_COMMAND: ${{ vars.VULNERABILITY_PROVIDER_COMMAND }}
|
||||
VULNERABILITY_REPORT_PATH: provider-evidence/vulnerability-report.json
|
||||
steps:
|
||||
- name: Download immutable candidate archive
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}
|
||||
path: .release/vulnerability-candidate
|
||||
- name: Extract the provider input candidate
|
||||
run: tar -xzf ".release/vulnerability-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz"
|
||||
- name: Run configured external vulnerability provider
|
||||
run: |
|
||||
test -n "$VULNERABILITY_PROVIDER_COMMAND"
|
||||
mkdir -p provider-evidence
|
||||
sh -eu -c "$VULNERABILITY_PROVIDER_COMMAND"
|
||||
test -s "$VULNERABILITY_REPORT_PATH"
|
||||
- name: Upload external vulnerability report
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: vulnerability-provider-${{ gitea.run_id }}-${{ gitea.run_attempt }}
|
||||
path: provider-evidence/vulnerability-report.json
|
||||
if-no-files-found: error
|
||||
|
||||
provenance_provider:
|
||||
name: external-provenance-provider
|
||||
needs: immutable_build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
CANDIDATE_DIST_SHA256: ${{ needs.immutable_build.outputs.dist_sha256 }}
|
||||
PROVENANCE_PROVIDER_COMMAND: ${{ vars.PROVENANCE_PROVIDER_COMMAND }}
|
||||
PROVENANCE_ATTESTATION_PATH: provider-evidence/provenance-attestation.json
|
||||
steps:
|
||||
- name: Download immutable candidate archive
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}
|
||||
path: .release/provenance-candidate
|
||||
- name: Extract the provider input candidate
|
||||
run: tar -xzf ".release/provenance-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz"
|
||||
- name: Run configured external provenance provider
|
||||
run: |
|
||||
test -n "$PROVENANCE_PROVIDER_COMMAND"
|
||||
mkdir -p provider-evidence
|
||||
sh -eu -c "$PROVENANCE_PROVIDER_COMMAND"
|
||||
test -s "$PROVENANCE_ATTESTATION_PATH"
|
||||
- name: Upload external provenance attestation
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: provenance-provider-${{ gitea.run_id }}-${{ gitea.run_attempt }}
|
||||
path: provider-evidence/provenance-attestation.json
|
||||
if-no-files-found: error
|
||||
|
||||
promotion:
|
||||
name: promote-verified-immutable-candidate
|
||||
needs: [immutable_build, vulnerability_provider, provenance_provider]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
VULNERABILITY_REPORT_PATH: .release/vulnerability/vulnerability-report.json
|
||||
PROVENANCE_ATTESTATION_PATH: .release/provenance/provenance-attestation.json
|
||||
VULNERABILITY_PUBLIC_KEY_PATH: ${{ vars.VULNERABILITY_PUBLIC_KEY_PATH }}
|
||||
VULNERABILITY_KEY_ID: ${{ vars.VULNERABILITY_KEY_ID }}
|
||||
PROVENANCE_PUBLIC_KEY_PATH: ${{ vars.PROVENANCE_PUBLIC_KEY_PATH }}
|
||||
PROVENANCE_KEY_ID: ${{ vars.PROVENANCE_KEY_ID }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- name: Frozen install
|
||||
run: |
|
||||
corepack enable
|
||||
corepack pnpm install --frozen-lockfile
|
||||
- name: Download immutable candidate archive
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}
|
||||
path: .release/candidate
|
||||
- name: Download vulnerability provider evidence
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: vulnerability-provider-${{ gitea.run_id }}-${{ gitea.run_attempt }}
|
||||
path: .release/vulnerability
|
||||
- name: Download provenance provider evidence
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: provenance-provider-${{ gitea.run_id }}-${{ gitea.run_attempt }}
|
||||
path: .release/provenance
|
||||
- name: Extract unchanged candidate
|
||||
run: tar -xzf ".release/candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz"
|
||||
- name: Verify provider signatures and candidate digests
|
||||
run: |
|
||||
corepack pnpm verify:provider-evidence
|
||||
corepack pnpm verify:promotion
|
||||
- name: Upload promoted unchanged candidate
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: promoted-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}
|
||||
path: |
|
||||
.release/candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz
|
||||
.release/vulnerability/vulnerability-report.json
|
||||
.release/provenance/provenance-attestation.json
|
||||
artifacts/security/provider-verification.json
|
||||
artifacts/security/promotion-verification.json
|
||||
if-no-files-found: error
|
||||
|
||||
production_gate:
|
||||
name: ${{ matrix.gate }} / ${{ matrix.name }}
|
||||
needs: release_gate
|
||||
needs: promotion
|
||||
if: ${{ gitea.event_name == 'workflow_dispatch' && (inputs.stage == 'production' || inputs.stage == 'field') }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
|
||||
@@ -268,8 +268,8 @@
|
||||
"name": "security",
|
||||
"steps": [
|
||||
{ "script": "verify:reproducible-build", "expect": "pass" },
|
||||
{ "script": "build:release", "expect": "pass" },
|
||||
{ "script": "verify:supply-chain", "expect": "pass" },
|
||||
{ "script": "build:release-candidate", "expect": "pass" },
|
||||
{ "script": "verify:local-evidence", "expect": "pass" },
|
||||
{ "script": "check:supply-chain:fixtures", "expect": "pass" },
|
||||
{
|
||||
"script": "check:supply-chain:provider-fixtures",
|
||||
@@ -305,14 +305,14 @@
|
||||
"FE-GATE-015": {
|
||||
"name": "release-coherence",
|
||||
"steps": [
|
||||
{ "script": "build:release", "expect": "pass" },
|
||||
{ "script": "verify:release", "expect": "pass" },
|
||||
{ "script": "verify:supply-chain:promotion", "expect": "pass" }
|
||||
{ "script": "build:release-candidate", "expect": "pass" },
|
||||
{ "script": "verify:local-evidence", "expect": "pass" }
|
||||
],
|
||||
"logPath": "artifacts/quality/gates/FE-GATE-015.txt",
|
||||
"evidence": [
|
||||
"artifacts/release/release-candidate.json",
|
||||
"artifacts/release/verification.json",
|
||||
"artifacts/security/promotion-verification.json"
|
||||
"artifacts/security/supply-chain-coherence.json"
|
||||
],
|
||||
"retentionClass": "release-coherence"
|
||||
},
|
||||
|
||||
@@ -19,8 +19,8 @@ The canonical commands are:
|
||||
```bash
|
||||
corepack pnpm verify:lockfile
|
||||
corepack pnpm verify:reproducible-build
|
||||
corepack pnpm build:release
|
||||
corepack pnpm verify:supply-chain
|
||||
corepack pnpm build:release-candidate
|
||||
corepack pnpm verify:local-evidence
|
||||
corepack pnpm check:supply-chain:fixtures
|
||||
```
|
||||
|
||||
@@ -31,16 +31,19 @@ rejected.
|
||||
|
||||
## External promotion controls
|
||||
|
||||
The vulnerability adapter reads the file named by
|
||||
`VULNERABILITY_REPORT_PATH`. It requires a provider, the exact lockfile digest,
|
||||
severity findings and valid independent, unexpired exception evidence.
|
||||
`PROVENANCE_ATTESTATION_PATH` must name a provider, signer and the exact built
|
||||
dist subject digest.
|
||||
Promotion reads the provider files named by `VULNERABILITY_REPORT_PATH` and
|
||||
`PROVENANCE_ATTESTATION_PATH`. The vulnerability report must bind both the
|
||||
exact lockfile digest and candidate `distSha256`; the provenance attestation
|
||||
must name `dist` with that same digest. Both documents use strict schemas and
|
||||
Ed25519 signatures verified with separately configured trusted public keys and
|
||||
key IDs.
|
||||
|
||||
If either provider input is absent, local verification remains meaningful but
|
||||
`artifacts/security/supply-chain-verification.json` records
|
||||
`promotionStatus: FAIL_UNVERIFIED`. `verify:supply-chain:promotion` then exits
|
||||
non-zero. Scanner or signing outages are not converted to an empty PASS.
|
||||
`promotionStatus: FAIL_UNVERIFIED`. `verify:provider-evidence` and
|
||||
`verify:promotion` then exit non-zero. Promotion recomputes the candidate file
|
||||
set and digests and never rebuilds it. Scanner or signing outages are not
|
||||
converted to an empty PASS.
|
||||
|
||||
Approved vulnerability exceptions require vulnerability/package identity,
|
||||
owner, a different reviewer, reason and expiry. Expired or self-approved
|
||||
|
||||
+4
-3
@@ -11,7 +11,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "node scripts/build-frontend.ts",
|
||||
"build:release": "corepack pnpm build && corepack pnpm generate:supply-chain && corepack pnpm scan:security",
|
||||
"build:release-candidate": "corepack pnpm build && corepack pnpm generate:supply-chain && corepack pnpm scan:security && corepack pnpm verify:release && node scripts/verify-supply-chain-artifacts.ts && node scripts/create-release-candidate.ts",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint src scripts tests recipes .storybook vite.config.ts vitest.config.ts playwright*.config.ts --max-warnings=0",
|
||||
"check:architecture": "node scripts/check-architecture.ts",
|
||||
@@ -73,11 +73,12 @@
|
||||
"generate:artifact-schemas": "node scripts/generate-artifact-schemas.ts",
|
||||
"check:artifact-schemas": "node scripts/generate-artifact-schemas.ts --check",
|
||||
"generate:supply-chain": "node scripts/generate-supply-chain.ts",
|
||||
"verify:supply-chain": "node scripts/verify-supply-chain-artifacts.ts",
|
||||
"verify:local-evidence": "node scripts/verify-release-candidate.ts && node scripts/verify-release.ts && node scripts/verify-supply-chain-artifacts.ts && node scripts/verify-release-candidate.ts",
|
||||
"verify:provider-evidence": "node scripts/verify-provider-evidence.ts",
|
||||
"verify:promotion": "node scripts/verify-supply-chain-promotion.ts",
|
||||
"update:dependency-baseline": "node scripts/update-dependency-baseline.ts",
|
||||
"check:supply-chain:fixtures": "node scripts/check-supply-chain-fixtures.ts",
|
||||
"check:supply-chain:provider-fixtures": "node scripts/check-supply-chain-provider-fixtures.ts",
|
||||
"verify:supply-chain:promotion": "node scripts/verify-supply-chain-promotion.ts",
|
||||
"verify:reproducible-build": "node scripts/verify-reproducible-build.ts",
|
||||
"scan:security": "node scripts/security-scan.ts",
|
||||
"scan:security:fixture": "node scripts/security-scan.ts --policy tests/fixtures/security/secret-detection/forbidden-policy.json --artifact artifacts/security/scan-fixture.sarif",
|
||||
|
||||
@@ -22,6 +22,10 @@ const document = parseCiContractDocument(
|
||||
JSON.parse(await readFile("config/ci/gates.json", "utf8")),
|
||||
);
|
||||
const workflow = await readFile(document.providerAdapter, "utf8");
|
||||
const packageDocument = JSON.parse(await readFile("package.json", "utf8")) as {
|
||||
scripts?: Record<string, string>;
|
||||
};
|
||||
const packageScripts = packageDocument.scripts ?? {};
|
||||
const nodeVersion = (await readFile(".nvmrc", "utf8")).trim();
|
||||
const gateRunner = await readFile("scripts/run-ci-gate.ts", "utf8");
|
||||
const drillRunner = await readFile("scripts/drill-runbook.ts", "utf8");
|
||||
@@ -154,7 +158,7 @@ for (const pattern of forbiddenWorkflowPatterns) {
|
||||
}
|
||||
}
|
||||
const jobTimeoutCount = workflow.match(/timeout-minutes:\s*45/g)?.length ?? 0;
|
||||
if (jobTimeoutCount !== 5) {
|
||||
if (jobTimeoutCount !== 9) {
|
||||
failures.push("every CI gate job must declare timeout-minutes: 45");
|
||||
}
|
||||
if (/if-no-files-found:\s*warn/.test(workflow)) {
|
||||
@@ -166,9 +170,16 @@ for (const requiredToken of [
|
||||
"production_gate:",
|
||||
"field_gate:",
|
||||
"documentation_gate:",
|
||||
"immutable_build:",
|
||||
"vulnerability_provider:",
|
||||
"provenance_provider:",
|
||||
"promotion:",
|
||||
"needs: merge_gate",
|
||||
"needs: release_gate",
|
||||
"needs: production_gate",
|
||||
"needs: immutable_build",
|
||||
"needs: [immutable_build, vulnerability_provider, provenance_provider]",
|
||||
"actions/download-artifact@v4",
|
||||
"actions/upload-artifact@v4",
|
||||
"if: always()",
|
||||
"permissions:",
|
||||
@@ -178,11 +189,60 @@ for (const requiredToken of [
|
||||
'VITE_COMMIT_SHA: "${{ gitea.sha }}"',
|
||||
'RELEASE_ID: "${{ gitea.ref }}-${{ gitea.run_id }}-${{ gitea.run_attempt }}"',
|
||||
'CI_RUNNER_IMAGE: "${{ vars.RUNNER_IMAGE_DIGEST }}"',
|
||||
"VULNERABILITY_REPORT_PATH:",
|
||||
"PROVENANCE_ATTESTATION_PATH:",
|
||||
"VULNERABILITY_PUBLIC_KEY_PATH:",
|
||||
"PROVENANCE_PUBLIC_KEY_PATH:",
|
||||
"release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}",
|
||||
"corepack pnpm verify:provider-evidence",
|
||||
"corepack pnpm verify:promotion",
|
||||
]) {
|
||||
if (!workflow.includes(requiredToken)) {
|
||||
failures.push(`workflow missing ${requiredToken}`);
|
||||
}
|
||||
}
|
||||
for (const script of [
|
||||
"build:release-candidate",
|
||||
"verify:local-evidence",
|
||||
"verify:provider-evidence",
|
||||
"verify:promotion",
|
||||
]) {
|
||||
if (!packageScripts[script]) failures.push(`package script missing ${script}`);
|
||||
}
|
||||
for (const removedScript of [
|
||||
"build:release",
|
||||
"verify:supply-chain",
|
||||
"verify:supply-chain:promotion",
|
||||
]) {
|
||||
if (packageScripts[removedScript]) {
|
||||
failures.push(`legacy package script remains ${removedScript}`);
|
||||
}
|
||||
}
|
||||
if (/\b(?:build|rebuild)(?::[\w-]+)?\b/u.test(packageScripts["verify:promotion"] ?? "")) {
|
||||
failures.push("verify:promotion must not build or rebuild candidate bytes");
|
||||
}
|
||||
const promotionWorkflow = workflow.match(
|
||||
/\n {2}promotion:\n(?<body>[\s\S]*?)\n {2}production_gate:/u,
|
||||
)?.groups?.body;
|
||||
if (!promotionWorkflow) {
|
||||
failures.push("workflow promotion job is missing or misplaced");
|
||||
} else if (
|
||||
/\b(?:build|build:[\w-]+|rebuild)\b/u.test(
|
||||
promotionWorkflow.replaceAll("immutable_build", ""),
|
||||
)
|
||||
) {
|
||||
failures.push("workflow promotion job must not build or rebuild candidate bytes");
|
||||
}
|
||||
const immutableGateSteps = document.gates["FE-GATE-015"]?.steps;
|
||||
if (
|
||||
JSON.stringify(immutableGateSteps) !==
|
||||
JSON.stringify([
|
||||
{ script: "build:release-candidate", expect: "pass" },
|
||||
{ script: "verify:local-evidence", expect: "pass" },
|
||||
])
|
||||
) {
|
||||
failures.push("FE-GATE-015 must build the candidate once and verify local evidence only");
|
||||
}
|
||||
for (const requiredToken of [
|
||||
"ciCheckoutIdentityFailures",
|
||||
"ciBuildEnvironmentFailures",
|
||||
|
||||
@@ -1,103 +1,118 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { generateKeyPairSync, sign } from "node:crypto";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
|
||||
type Document = Record<string, unknown>;
|
||||
import {
|
||||
evaluatePromotionEvidence,
|
||||
providerEvidenceSignaturePayload,
|
||||
} from "./lib/provider-evidence.ts";
|
||||
|
||||
function isRecord(value: unknown): value is Document {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
async function readDocument(file: string): Promise<Document> {
|
||||
const parsed: unknown = JSON.parse(await readFile(file, "utf8"));
|
||||
if (!isRecord(parsed)) throw new Error(`${file} must be a JSON object`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const fixtureDirectory = path.resolve(".tmp/supply-chain-provider-fixture");
|
||||
await rm(fixtureDirectory, { recursive: true, force: true });
|
||||
await mkdir(fixtureDirectory, { recursive: true });
|
||||
const inventory = await readDocument(
|
||||
"artifacts/release/dependency-inventory.json",
|
||||
);
|
||||
const verification = await readDocument(
|
||||
"artifacts/security/supply-chain-verification.json",
|
||||
);
|
||||
const vulnerabilityPath = path.join(
|
||||
fixtureDirectory,
|
||||
"vulnerability-report.json",
|
||||
);
|
||||
const attestationPath = path.join(fixtureDirectory, "attestation.json");
|
||||
await writeFile(
|
||||
vulnerabilityPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
provider: "fixture-scanner",
|
||||
scannedLockfileSha256: inventory.lockfileSha256,
|
||||
generatedAt: "2026-07-26T00:00:00.000Z",
|
||||
findings: [],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
await writeFile(
|
||||
attestationPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
provider: "fixture-attestor",
|
||||
signer: "fixture-workload-identity",
|
||||
subject: {
|
||||
name: "dist",
|
||||
digest: { sha256: verification.distSha256 },
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
const providerRun = spawnSync(
|
||||
"node",
|
||||
["scripts/generate-supply-chain.ts"],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
VULNERABILITY_REPORT_PATH: vulnerabilityPath,
|
||||
PROVENANCE_ATTESTATION_PATH: attestationPath,
|
||||
},
|
||||
encoding: "utf8",
|
||||
const candidateDistSha256 = "1".repeat(64);
|
||||
const lockfileSha256 = "2".repeat(64);
|
||||
const vulnerabilityKeys = generateKeyPairSync("ed25519");
|
||||
const provenanceKeys = generateKeyPairSync("ed25519");
|
||||
const trust = {
|
||||
vulnerabilityTrust: {
|
||||
keyId: "fixture-vulnerability-key",
|
||||
publicKey: vulnerabilityKeys.publicKey,
|
||||
},
|
||||
);
|
||||
let promotionStatus = "MISSING";
|
||||
if (providerRun.status === 0) {
|
||||
const providerVerification = await readDocument(
|
||||
"artifacts/security/supply-chain-verification.json",
|
||||
);
|
||||
promotionStatus =
|
||||
typeof providerVerification.promotionStatus === "string"
|
||||
? providerVerification.promotionStatus
|
||||
: "MISSING";
|
||||
provenanceTrust: {
|
||||
keyId: "fixture-provenance-key",
|
||||
publicKey: provenanceKeys.publicKey,
|
||||
},
|
||||
};
|
||||
|
||||
function signedEvidence(
|
||||
value: Record<string, unknown>,
|
||||
keyId: string,
|
||||
privateKey: typeof vulnerabilityKeys.privateKey,
|
||||
) {
|
||||
return {
|
||||
...value,
|
||||
signature: {
|
||||
algorithm: "Ed25519",
|
||||
keyId,
|
||||
value: sign(
|
||||
null,
|
||||
providerEvidenceSignaturePayload(value),
|
||||
privateKey,
|
||||
).toString("base64"),
|
||||
},
|
||||
};
|
||||
}
|
||||
const restore = spawnSync(
|
||||
"node",
|
||||
["scripts/generate-supply-chain.ts"],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
await rm(fixtureDirectory, { recursive: true, force: true });
|
||||
|
||||
function evidenceFor(distDigest: string) {
|
||||
return {
|
||||
vulnerabilityReport: signedEvidence(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
provider: "fixture-vulnerability-provider",
|
||||
generatedAt: "2026-08-01T00:00:00.000Z",
|
||||
scannedLockfileSha256: lockfileSha256,
|
||||
scannedDistSha256: distDigest,
|
||||
findings: [],
|
||||
},
|
||||
"fixture-vulnerability-key",
|
||||
vulnerabilityKeys.privateKey,
|
||||
),
|
||||
provenanceAttestation: signedEvidence(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
provider: "fixture-provenance-provider",
|
||||
signer: "fixture-workload-identity",
|
||||
generatedAt: "2026-08-01T00:00:00.000Z",
|
||||
subject: { name: "dist", digest: { sha256: distDigest } },
|
||||
},
|
||||
"fixture-provenance-key",
|
||||
provenanceKeys.privateKey,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const base = {
|
||||
candidate: { distSha256: candidateDistSha256, lockfileSha256 },
|
||||
currentDistSha256: candidateDistSha256,
|
||||
localStatus: "PASS",
|
||||
...trust,
|
||||
};
|
||||
const validEvidence = evidenceFor(candidateDistSha256);
|
||||
const fixtures = {
|
||||
absent: evaluatePromotionEvidence({
|
||||
...base,
|
||||
vulnerabilityReport: null,
|
||||
provenanceAttestation: null,
|
||||
}),
|
||||
validImmutable: evaluatePromotionEvidence({ ...base, ...validEvidence }),
|
||||
wrongDigest: evaluatePromotionEvidence({
|
||||
...base,
|
||||
...evidenceFor("3".repeat(64)),
|
||||
}),
|
||||
postAttestationMutation: evaluatePromotionEvidence({
|
||||
...base,
|
||||
...validEvidence,
|
||||
currentDistSha256: "4".repeat(64),
|
||||
}),
|
||||
};
|
||||
const passed =
|
||||
providerRun.status === 0 &&
|
||||
promotionStatus === "PASS" &&
|
||||
restore.status === 0;
|
||||
fixtures.validImmutable.status === "PASS" &&
|
||||
fixtures.absent.status === "FAIL_UNVERIFIED" &&
|
||||
fixtures.wrongDigest.status === "FAIL_UNVERIFIED" &&
|
||||
fixtures.postAttestationMutation.status === "FAIL_UNVERIFIED";
|
||||
|
||||
await mkdir("artifacts/security", { recursive: true });
|
||||
await writeFile(
|
||||
"artifacts/security/supply-chain-provider-fixtures.json",
|
||||
`${JSON.stringify(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
providerAccepted: providerRun.status === 0,
|
||||
promotionStatus,
|
||||
unverifiedDefaultRestored: restore.status === 0,
|
||||
fixtures: Object.fromEntries(
|
||||
Object.entries(fixtures).map(([name, result]) => [
|
||||
name,
|
||||
{ status: result.status, failures: result.failures },
|
||||
]),
|
||||
),
|
||||
passingFixtureCount: Object.values(fixtures).filter(
|
||||
(result) => result.status === "PASS",
|
||||
).length,
|
||||
status: passed ? "PASS" : "FAIL",
|
||||
},
|
||||
null,
|
||||
@@ -105,17 +120,9 @@ await writeFile(
|
||||
)}\n`,
|
||||
);
|
||||
if (!passed) {
|
||||
const detail =
|
||||
providerRun.stderr ||
|
||||
restore.stderr ||
|
||||
providerRun.stdout ||
|
||||
restore.stdout ||
|
||||
`providerStatus=${String(providerRun.status)}, promotionStatus=${promotionStatus}, restoreStatus=${String(restore.status)}`;
|
||||
process.stderr.write(
|
||||
`Supply-chain provider fixture failed: ${detail}\n`,
|
||||
);
|
||||
process.stderr.write("Supply-chain provider fixtures failed closed incorrectly\n");
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write(
|
||||
"Supply-chain provider fixture: verified PASS and unconfigured default restored\n",
|
||||
"Supply-chain provider fixtures: only the valid immutable fixture PASS\n",
|
||||
);
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { mkdir } from "node:fs/promises";
|
||||
|
||||
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
||||
import {
|
||||
createReleaseCandidateManifest,
|
||||
RELEASE_CANDIDATE_MANIFEST_PATH,
|
||||
releaseCandidateManifestSchema,
|
||||
} from "./lib/release-candidate.ts";
|
||||
|
||||
const manifest = await createReleaseCandidateManifest();
|
||||
await mkdir("artifacts/release", { recursive: true });
|
||||
await writeValidatedJsonArtifact({
|
||||
path: RELEASE_CANDIDATE_MANIFEST_PATH,
|
||||
schema: releaseCandidateManifestSchema,
|
||||
value: manifest,
|
||||
});
|
||||
process.stdout.write(
|
||||
`Immutable release candidate: ${manifest.distSha256} (${manifest.files.length} files)\n`,
|
||||
);
|
||||
@@ -1,13 +1,10 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { gzipSync } from "node:zlib";
|
||||
import {
|
||||
mkdir,
|
||||
readFile,
|
||||
stat,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
bundlePerformanceArtifactSchema,
|
||||
@@ -27,7 +24,6 @@ import {
|
||||
supplyChainDigest,
|
||||
validateDependencyReview,
|
||||
validateLicensePolicy,
|
||||
validateVulnerabilityReport,
|
||||
verifySupplyChainCoherence,
|
||||
type DependencyInventoryDiff,
|
||||
} from "./lib/supply-chain.ts";
|
||||
@@ -37,6 +33,7 @@ import {
|
||||
buildRepositoryFileInventory,
|
||||
parseRepositoryFileInventoryPolicy,
|
||||
} from "./lib/repository-file-inventory.ts";
|
||||
import { collectDistOutputs, distSha256 } from "./lib/release-candidate.ts";
|
||||
|
||||
type Document = Record<string, unknown>;
|
||||
|
||||
@@ -151,31 +148,8 @@ const repositoryInventory = await buildRepositoryFileInventory({
|
||||
generatedRoots: inventoryPolicy.generatedRoots,
|
||||
optionalRoots: inventoryPolicy.optionalRoots,
|
||||
});
|
||||
const outputFiles = repositoryInventory.generatedFiles.filter(
|
||||
(file) => file === "dist" || file.startsWith("dist/"),
|
||||
);
|
||||
if (outputFiles.length === 0) {
|
||||
throw new Error("dist is missing; run the production build first");
|
||||
}
|
||||
const outputs = await Promise.all(
|
||||
outputFiles.map(async (outputFile) => {
|
||||
const content = await readFile(outputFile);
|
||||
const metadata = await stat(outputFile);
|
||||
return {
|
||||
path: outputFile.replaceAll("\\", "/"),
|
||||
bytes: metadata.size,
|
||||
gzipBytes: gzipSync(content).byteLength,
|
||||
sha256: createHash("sha256").update(content).digest("hex"),
|
||||
};
|
||||
}),
|
||||
);
|
||||
const distDigest = supplyChainDigest(
|
||||
outputs.map(({ path: outputPath, bytes, sha256 }) => ({
|
||||
path: outputPath,
|
||||
bytes,
|
||||
sha256,
|
||||
})),
|
||||
);
|
||||
const outputs = await collectDistOutputs();
|
||||
const distDigest = distSha256(outputs);
|
||||
const inventory = await buildDependencyInventory();
|
||||
const licensePolicy = JSON.parse(
|
||||
await readFile("config/security/dependency-policy.json", "utf8"),
|
||||
@@ -229,44 +203,15 @@ if (baseline && baselineApproval) {
|
||||
baselineFailures.push("dependency baseline and approval are required");
|
||||
}
|
||||
|
||||
const vulnerabilityPolicy = JSON.parse(
|
||||
await readFile("config/security/vulnerability-policy.json", "utf8"),
|
||||
);
|
||||
const vulnerabilityExceptions = JSON.parse(
|
||||
await readFile("config/security/vulnerability-exceptions.json", "utf8"),
|
||||
);
|
||||
const vulnerabilityInput = process.env.VULNERABILITY_REPORT_PATH
|
||||
? await optionalJson(process.env.VULNERABILITY_REPORT_PATH)
|
||||
: null;
|
||||
const vulnerabilityResult = vulnerabilityInput
|
||||
? validateVulnerabilityReport(
|
||||
vulnerabilityInput,
|
||||
vulnerabilityPolicy,
|
||||
vulnerabilityExceptions,
|
||||
inventory.lockfileSha256,
|
||||
)
|
||||
: {
|
||||
passed: false,
|
||||
failures: ["external vulnerability provider report is missing"],
|
||||
blocking: [],
|
||||
};
|
||||
const vulnerabilityReport = {
|
||||
schemaVersion: 1,
|
||||
provider: vulnerabilityInput?.provider ?? "UNCONFIGURED",
|
||||
scannedLockfileSha256:
|
||||
vulnerabilityInput?.scannedLockfileSha256 ?? inventory.lockfileSha256,
|
||||
status: vulnerabilityInput
|
||||
? vulnerabilityResult.passed
|
||||
? "PASS"
|
||||
: "FAIL"
|
||||
: "FAIL_UNVERIFIED",
|
||||
findings: vulnerabilityInput?.findings ?? [],
|
||||
exceptionsApplied:
|
||||
vulnerabilityInput && vulnerabilityResult.passed
|
||||
? vulnerabilityExceptions.exceptions
|
||||
: [],
|
||||
failures: vulnerabilityResult.failures,
|
||||
blocking: vulnerabilityResult.blocking,
|
||||
provider: "UNCONFIGURED",
|
||||
scannedLockfileSha256: inventory.lockfileSha256,
|
||||
status: "FAIL_UNVERIFIED",
|
||||
findings: [],
|
||||
exceptionsApplied: [],
|
||||
failures: ["external vulnerability provider report is missing"],
|
||||
blocking: [],
|
||||
};
|
||||
|
||||
const sourceFiles = [...repositoryInventory.trackedFiles];
|
||||
@@ -364,40 +309,17 @@ const coherence = verifySupplyChainCoherence(
|
||||
distDigest,
|
||||
);
|
||||
|
||||
const attestationInput = process.env.PROVENANCE_ATTESTATION_PATH
|
||||
? await optionalJson(process.env.PROVENANCE_ATTESTATION_PATH)
|
||||
: null;
|
||||
const attestation = isRecord(attestationInput?.subject)
|
||||
? attestationInput.subject
|
||||
: {};
|
||||
const attestationSubject = isRecord(attestation.digest)
|
||||
? attestation.digest
|
||||
: {};
|
||||
const attestationPassed =
|
||||
attestationSubject.sha256 === distDigest &&
|
||||
typeof attestationInput?.provider === "string" &&
|
||||
Boolean(attestationInput.provider) &&
|
||||
typeof attestationInput?.signer === "string" &&
|
||||
Boolean(attestationInput.signer);
|
||||
const localFailures = [
|
||||
...licenseResult.failures,
|
||||
...baselineFailures,
|
||||
...reviewResult.failures,
|
||||
...coherence.failures,
|
||||
];
|
||||
if (vulnerabilityInput && !vulnerabilityResult.passed) {
|
||||
localFailures.push(
|
||||
...vulnerabilityResult.failures,
|
||||
...vulnerabilityResult.blocking,
|
||||
);
|
||||
}
|
||||
const localPassed = localFailures.length === 0;
|
||||
const promotionPassed =
|
||||
localPassed && vulnerabilityResult.passed && attestationPassed;
|
||||
const verification = {
|
||||
schemaVersion: 1,
|
||||
localStatus: localPassed ? "PASS" : "FAIL",
|
||||
promotionStatus: promotionPassed ? "PASS" : "FAIL_UNVERIFIED",
|
||||
promotionStatus: "FAIL_UNVERIFIED",
|
||||
lockfileSha256: inventory.lockfileSha256,
|
||||
sourceSetSha256,
|
||||
distSha256: distDigest,
|
||||
@@ -405,9 +327,7 @@ const verification = {
|
||||
dependencyDiff,
|
||||
highRiskReview: reviewResult.highRisk,
|
||||
vulnerabilityStatus: vulnerabilityReport.status,
|
||||
provenanceAttestationStatus: attestationPassed
|
||||
? "PASS"
|
||||
: "FAIL_UNVERIFIED",
|
||||
provenanceAttestationStatus: "FAIL_UNVERIFIED",
|
||||
failures: localFailures,
|
||||
};
|
||||
const bundleReport = {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { createPublicKey } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import {
|
||||
evaluatePromotionEvidence,
|
||||
type ProviderTrust,
|
||||
} from "./provider-evidence.ts";
|
||||
import {
|
||||
RELEASE_CANDIDATE_MANIFEST_PATH,
|
||||
releaseCandidateManifestSchema,
|
||||
verifyReleaseCandidate,
|
||||
} from "./release-candidate.ts";
|
||||
|
||||
export async function verifyPromotionInputs(
|
||||
environment: NodeJS.ProcessEnv = process.env,
|
||||
) {
|
||||
const manifestDocument = await requiredJson(RELEASE_CANDIDATE_MANIFEST_PATH);
|
||||
const manifest = releaseCandidateManifestSchema.parse(manifestDocument);
|
||||
const candidate = await verifyReleaseCandidate(manifestDocument);
|
||||
const localVerification = await requiredJson(
|
||||
"artifacts/security/supply-chain-verification.json",
|
||||
);
|
||||
const vulnerabilityReport = await optionalJson(
|
||||
environment.VULNERABILITY_REPORT_PATH,
|
||||
);
|
||||
const provenanceAttestation = await optionalJson(
|
||||
environment.PROVENANCE_ATTESTATION_PATH,
|
||||
);
|
||||
const result = evaluatePromotionEvidence({
|
||||
candidate: manifest,
|
||||
currentDistSha256: candidate.currentDistSha256 ?? "",
|
||||
localStatus: localVerification.localStatus,
|
||||
vulnerabilityReport,
|
||||
provenanceAttestation,
|
||||
vulnerabilityTrust: await readTrust(
|
||||
environment.VULNERABILITY_PUBLIC_KEY_PATH,
|
||||
environment.VULNERABILITY_KEY_ID,
|
||||
),
|
||||
provenanceTrust: await readTrust(
|
||||
environment.PROVENANCE_PUBLIC_KEY_PATH,
|
||||
environment.PROVENANCE_KEY_ID,
|
||||
),
|
||||
});
|
||||
const failures = [...candidate.failures, ...result.failures];
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
status:
|
||||
failures.length === 0 && result.status === "PASS"
|
||||
? ("PASS" as const)
|
||||
: ("FAIL_UNVERIFIED" as const),
|
||||
vulnerabilityStatus: result.vulnerabilityStatus,
|
||||
provenanceAttestationStatus: result.provenanceAttestationStatus,
|
||||
lockfileSha256: manifest.lockfileSha256,
|
||||
distSha256: manifest.distSha256,
|
||||
failures: Object.freeze(failures),
|
||||
});
|
||||
}
|
||||
|
||||
async function readTrust(
|
||||
publicKeyPath: string | undefined,
|
||||
keyId: string | undefined,
|
||||
): Promise<ProviderTrust | null> {
|
||||
if (!publicKeyPath || !keyId?.trim()) return null;
|
||||
try {
|
||||
return Object.freeze({
|
||||
keyId,
|
||||
publicKey: createPublicKey(await readFile(publicKeyPath, "utf8")),
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function optionalJson(file: string | undefined): Promise<unknown> {
|
||||
if (!file) return null;
|
||||
try {
|
||||
return JSON.parse(await readFile(file, "utf8")) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function requiredJson(file: string): Promise<Record<string, unknown>> {
|
||||
const value: unknown = JSON.parse(await readFile(file, "utf8"));
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new TypeError(`${file} must be a JSON object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { verify, type KeyLike } from "node:crypto";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { canonicalizeSupplyChainValue } from "./supply-chain.ts";
|
||||
|
||||
const sha256 = z.string().regex(/^[a-f0-9]{64}$/u);
|
||||
const nonEmptyString = z.string().trim().min(1);
|
||||
const signatureSchema = z
|
||||
.object({
|
||||
algorithm: z.literal("Ed25519"),
|
||||
keyId: nonEmptyString,
|
||||
value: z.string().regex(/^[A-Za-z0-9+/]+={0,2}$/u),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const vulnerabilityProviderReportSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(1),
|
||||
provider: nonEmptyString,
|
||||
generatedAt: z.iso.datetime(),
|
||||
scannedLockfileSha256: sha256,
|
||||
scannedDistSha256: sha256,
|
||||
findings: z.array(z.record(z.string(), z.json())),
|
||||
signature: signatureSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const provenanceProviderAttestationSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(1),
|
||||
provider: nonEmptyString,
|
||||
signer: nonEmptyString,
|
||||
generatedAt: z.iso.datetime(),
|
||||
subject: z
|
||||
.object({
|
||||
name: z.literal("dist"),
|
||||
digest: z.object({ sha256 }).strict(),
|
||||
})
|
||||
.strict(),
|
||||
signature: signatureSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const providerVerificationArtifactSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(1),
|
||||
status: z.enum(["PASS", "FAIL_UNVERIFIED"]),
|
||||
vulnerabilityStatus: z.enum(["PASS", "FAIL_UNVERIFIED"]),
|
||||
provenanceAttestationStatus: z.enum(["PASS", "FAIL_UNVERIFIED"]),
|
||||
lockfileSha256: sha256,
|
||||
distSha256: sha256,
|
||||
failures: z.array(z.string()),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type ProviderTrust = Readonly<{
|
||||
keyId: string;
|
||||
publicKey: KeyLike;
|
||||
}>;
|
||||
|
||||
export type PromotionEvidenceResult = Readonly<{
|
||||
status: "PASS" | "FAIL_UNVERIFIED";
|
||||
vulnerabilityStatus: "PASS" | "FAIL_UNVERIFIED";
|
||||
provenanceAttestationStatus: "PASS" | "FAIL_UNVERIFIED";
|
||||
failures: readonly string[];
|
||||
}>;
|
||||
|
||||
export function providerEvidenceSignaturePayload(value: unknown): Buffer {
|
||||
if (!isRecord(value)) return Buffer.from("null", "utf8");
|
||||
const { signature: _signature, ...payload } = value;
|
||||
return Buffer.from(
|
||||
JSON.stringify(canonicalizeSupplyChainValue(payload)),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
export function evaluatePromotionEvidence(input: Readonly<{
|
||||
candidate: Readonly<{ distSha256: string; lockfileSha256: string }>;
|
||||
currentDistSha256: string;
|
||||
localStatus: unknown;
|
||||
vulnerabilityReport: unknown;
|
||||
provenanceAttestation: unknown;
|
||||
vulnerabilityTrust: ProviderTrust | null;
|
||||
provenanceTrust: ProviderTrust | null;
|
||||
}>): PromotionEvidenceResult {
|
||||
const failures: string[] = [];
|
||||
let vulnerabilityStatus: "PASS" | "FAIL_UNVERIFIED" = "FAIL_UNVERIFIED";
|
||||
let provenanceAttestationStatus: "PASS" | "FAIL_UNVERIFIED" =
|
||||
"FAIL_UNVERIFIED";
|
||||
|
||||
if (input.localStatus !== "PASS") {
|
||||
failures.push("local supply-chain evidence is not PASS");
|
||||
}
|
||||
if (input.currentDistSha256 !== input.candidate.distSha256) {
|
||||
failures.push("candidate dist bytes changed after immutable build");
|
||||
}
|
||||
|
||||
const vulnerability = vulnerabilityProviderReportSchema.safeParse(
|
||||
input.vulnerabilityReport,
|
||||
);
|
||||
if (!vulnerability.success) {
|
||||
failures.push("external vulnerability provider report is missing or invalid");
|
||||
} else {
|
||||
if (
|
||||
vulnerability.data.scannedLockfileSha256 !==
|
||||
input.candidate.lockfileSha256
|
||||
) {
|
||||
failures.push("vulnerability report lockfile digest mismatch");
|
||||
}
|
||||
if (
|
||||
vulnerability.data.scannedDistSha256 !== input.candidate.distSha256
|
||||
) {
|
||||
failures.push("vulnerability report dist digest mismatch");
|
||||
}
|
||||
if (vulnerability.data.findings.length > 0) {
|
||||
failures.push("vulnerability report contains findings");
|
||||
}
|
||||
const signaturePassed = signatureMatches(
|
||||
vulnerability.data,
|
||||
input.vulnerabilityTrust,
|
||||
);
|
||||
if (!signaturePassed) {
|
||||
failures.push("vulnerability report signature verification failed");
|
||||
}
|
||||
if (
|
||||
vulnerability.data.scannedLockfileSha256 ===
|
||||
input.candidate.lockfileSha256 &&
|
||||
vulnerability.data.scannedDistSha256 === input.candidate.distSha256 &&
|
||||
vulnerability.data.findings.length === 0 &&
|
||||
input.currentDistSha256 === input.candidate.distSha256 &&
|
||||
input.localStatus === "PASS" &&
|
||||
signaturePassed
|
||||
) {
|
||||
vulnerabilityStatus = "PASS";
|
||||
}
|
||||
}
|
||||
|
||||
const provenance = provenanceProviderAttestationSchema.safeParse(
|
||||
input.provenanceAttestation,
|
||||
);
|
||||
if (!provenance.success) {
|
||||
failures.push("external signed provenance attestation is missing or invalid");
|
||||
} else {
|
||||
if (provenance.data.subject.digest.sha256 !== input.candidate.distSha256) {
|
||||
failures.push("provenance attestation dist digest mismatch");
|
||||
}
|
||||
const signaturePassed = signatureMatches(
|
||||
provenance.data,
|
||||
input.provenanceTrust,
|
||||
);
|
||||
if (!signaturePassed) {
|
||||
failures.push("provenance attestation signature verification failed");
|
||||
}
|
||||
if (
|
||||
provenance.data.subject.digest.sha256 === input.candidate.distSha256 &&
|
||||
input.currentDistSha256 === input.candidate.distSha256 &&
|
||||
input.localStatus === "PASS" &&
|
||||
signaturePassed
|
||||
) {
|
||||
provenanceAttestationStatus = "PASS";
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
status:
|
||||
failures.length === 0 &&
|
||||
vulnerabilityStatus === "PASS" &&
|
||||
provenanceAttestationStatus === "PASS"
|
||||
? "PASS"
|
||||
: "FAIL_UNVERIFIED",
|
||||
vulnerabilityStatus,
|
||||
provenanceAttestationStatus,
|
||||
failures: Object.freeze(failures),
|
||||
});
|
||||
}
|
||||
|
||||
function signatureMatches(
|
||||
evidence: z.infer<
|
||||
| typeof vulnerabilityProviderReportSchema
|
||||
| typeof provenanceProviderAttestationSchema
|
||||
>,
|
||||
trust: ProviderTrust | null,
|
||||
): boolean {
|
||||
if (!trust || evidence.signature.keyId !== trust.keyId) return false;
|
||||
try {
|
||||
return verify(
|
||||
null,
|
||||
providerEvidenceSignaturePayload(evidence),
|
||||
trust.publicKey,
|
||||
Buffer.from(evidence.signature.value, "base64"),
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { lstat, readFile, readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { gzipSync } from "node:zlib";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { supplyChainDigest } from "./supply-chain.ts";
|
||||
|
||||
const sha256 = z.string().regex(/^[a-f0-9]{64}$/u);
|
||||
const candidateFileSchema = z
|
||||
.object({
|
||||
path: z.string().min(1),
|
||||
bytes: z.int().nonnegative(),
|
||||
sha256,
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const releaseCandidateManifestSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(1),
|
||||
distSha256: sha256,
|
||||
lockfileSha256: sha256,
|
||||
bundleSha256: sha256,
|
||||
files: z.array(candidateFileSchema).min(1),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type ReleaseCandidateManifest = z.infer<
|
||||
typeof releaseCandidateManifestSchema
|
||||
>;
|
||||
|
||||
export const RELEASE_CANDIDATE_MANIFEST_PATH =
|
||||
"artifacts/release/release-candidate.json";
|
||||
|
||||
export const RELEASE_CANDIDATE_EVIDENCE_PATHS = Object.freeze([
|
||||
"artifacts/performance/bundle.json",
|
||||
"artifacts/quality/vite-module-inventory.json",
|
||||
"artifacts/release/build-manifest.json",
|
||||
"artifacts/release/checksums.txt",
|
||||
"artifacts/release/dependency-inventory.json",
|
||||
"artifacts/release/provenance.json",
|
||||
"artifacts/release/verification.json",
|
||||
"artifacts/release/sbom.cdx.json",
|
||||
"artifacts/security/dependency-diff.json",
|
||||
"artifacts/security/license-report.json",
|
||||
"artifacts/security/scan.sarif",
|
||||
"artifacts/security/supply-chain-coherence.json",
|
||||
"artifacts/security/supply-chain-verification.json",
|
||||
"artifacts/security/vulnerability-report.json",
|
||||
]);
|
||||
|
||||
export type DistOutput = Readonly<{
|
||||
path: string;
|
||||
bytes: number;
|
||||
gzipBytes: number;
|
||||
sha256: string;
|
||||
}>;
|
||||
|
||||
export async function collectDistOutputs(
|
||||
repositoryRoot = process.cwd(),
|
||||
): Promise<DistOutput[]> {
|
||||
const distRoot = path.resolve(repositoryRoot, "dist");
|
||||
const files = await regularFilesWithin(distRoot);
|
||||
if (files.length === 0) {
|
||||
throw new Error("dist is missing or empty; run the production build first");
|
||||
}
|
||||
return Promise.all(
|
||||
files.map(async (absolutePath) => {
|
||||
const content = await readFile(absolutePath);
|
||||
return Object.freeze({
|
||||
path: path
|
||||
.relative(repositoryRoot, absolutePath)
|
||||
.replaceAll(path.sep, "/"),
|
||||
bytes: content.byteLength,
|
||||
gzipBytes: gzipSync(content).byteLength,
|
||||
sha256: createHash("sha256").update(content).digest("hex"),
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function distSha256(outputs: readonly DistOutput[]): string {
|
||||
return supplyChainDigest(
|
||||
outputs.map(({ path: outputPath, bytes, sha256 }) => ({
|
||||
path: outputPath,
|
||||
bytes,
|
||||
sha256,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
export async function createReleaseCandidateManifest(
|
||||
repositoryRoot = process.cwd(),
|
||||
): Promise<ReleaseCandidateManifest> {
|
||||
const outputs = await collectDistOutputs(repositoryRoot);
|
||||
const evidence = await Promise.all(
|
||||
RELEASE_CANDIDATE_EVIDENCE_PATHS.map((file) =>
|
||||
digestRequiredFile(repositoryRoot, file),
|
||||
),
|
||||
);
|
||||
const files = [
|
||||
...outputs.map(({ path: outputPath, bytes, sha256 }) => ({
|
||||
path: outputPath,
|
||||
bytes,
|
||||
sha256,
|
||||
})),
|
||||
...evidence,
|
||||
].sort((left, right) => left.path.localeCompare(right.path));
|
||||
const dependencyInventory = JSON.parse(
|
||||
await readFile(
|
||||
path.resolve(repositoryRoot, "artifacts/release/dependency-inventory.json"),
|
||||
"utf8",
|
||||
),
|
||||
) as { lockfileSha256?: unknown };
|
||||
return releaseCandidateManifestSchema.parse({
|
||||
schemaVersion: 1,
|
||||
distSha256: distSha256(outputs),
|
||||
lockfileSha256: dependencyInventory.lockfileSha256,
|
||||
bundleSha256: supplyChainDigest(files),
|
||||
files,
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyReleaseCandidate(
|
||||
value: unknown,
|
||||
repositoryRoot = process.cwd(),
|
||||
): Promise<Readonly<{
|
||||
manifest: ReleaseCandidateManifest | null;
|
||||
currentDistSha256: string | null;
|
||||
failures: readonly string[];
|
||||
}>> {
|
||||
const parsed = releaseCandidateManifestSchema.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
return Object.freeze({
|
||||
manifest: null,
|
||||
currentDistSha256: null,
|
||||
failures: Object.freeze(["release candidate manifest schema mismatch"]),
|
||||
});
|
||||
}
|
||||
const failures: string[] = [];
|
||||
let actual: ReleaseCandidateManifest | null = null;
|
||||
try {
|
||||
actual = await createReleaseCandidateManifest(repositoryRoot);
|
||||
} catch (error) {
|
||||
failures.push(
|
||||
`release candidate inputs unreadable: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
if (actual) {
|
||||
if (parsed.data.distSha256 !== actual.distSha256) {
|
||||
failures.push("release candidate dist digest mismatch");
|
||||
}
|
||||
if (parsed.data.lockfileSha256 !== actual.lockfileSha256) {
|
||||
failures.push("release candidate lockfile digest mismatch");
|
||||
}
|
||||
if (parsed.data.bundleSha256 !== actual.bundleSha256) {
|
||||
failures.push("release candidate bundle digest mismatch");
|
||||
}
|
||||
if (JSON.stringify(parsed.data.files) !== JSON.stringify(actual.files)) {
|
||||
failures.push("release candidate file set or file digest mismatch");
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
manifest: parsed.data,
|
||||
currentDistSha256: actual?.distSha256 ?? null,
|
||||
failures: Object.freeze(failures),
|
||||
});
|
||||
}
|
||||
|
||||
async function digestRequiredFile(repositoryRoot: string, file: string) {
|
||||
const absolutePath = path.resolve(repositoryRoot, file);
|
||||
const relative = path.relative(repositoryRoot, absolutePath);
|
||||
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||
throw new Error(`candidate path escapes repository root: ${file}`);
|
||||
}
|
||||
const metadata = await lstat(absolutePath);
|
||||
if (!metadata.isFile()) {
|
||||
throw new Error(`candidate input is not a regular file: ${file}`);
|
||||
}
|
||||
const content = await readFile(absolutePath);
|
||||
return Object.freeze({
|
||||
path: file,
|
||||
bytes: content.byteLength,
|
||||
sha256: createHash("sha256").update(content).digest("hex"),
|
||||
});
|
||||
}
|
||||
|
||||
async function regularFilesWithin(directory: string): Promise<string[]> {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const files: string[] = [];
|
||||
for (const entry of entries.sort((left, right) =>
|
||||
left.name.localeCompare(right.name),
|
||||
)) {
|
||||
const target = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await regularFilesWithin(target)));
|
||||
} else if (entry.isFile()) {
|
||||
files.push(target);
|
||||
} else {
|
||||
throw new Error(`dist contains a non-regular entry: ${target}`);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { mkdir } from "node:fs/promises";
|
||||
|
||||
import { providerVerificationArtifactSchema } from "./lib/provider-evidence.ts";
|
||||
import { verifyPromotionInputs } from "./lib/promotion-verifier.ts";
|
||||
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
||||
|
||||
const report = await verifyPromotionInputs();
|
||||
await mkdir("artifacts/security", { recursive: true });
|
||||
await writeValidatedJsonArtifact({
|
||||
path: "artifacts/security/provider-verification.json",
|
||||
schema: providerVerificationArtifactSchema,
|
||||
value: report,
|
||||
});
|
||||
if (report.status !== "PASS") {
|
||||
process.stderr.write(
|
||||
`Provider evidence is FAIL_UNVERIFIED:\n- ${report.failures.join("\n- ")}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write("Provider evidence: PASS\n");
|
||||
@@ -0,0 +1,25 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import {
|
||||
RELEASE_CANDIDATE_MANIFEST_PATH,
|
||||
verifyReleaseCandidate,
|
||||
} from "./lib/release-candidate.ts";
|
||||
|
||||
let document: unknown = null;
|
||||
try {
|
||||
document = JSON.parse(
|
||||
await readFile(RELEASE_CANDIDATE_MANIFEST_PATH, "utf8"),
|
||||
);
|
||||
} catch {
|
||||
// The verifier reports a single fail-closed schema error below.
|
||||
}
|
||||
const result = await verifyReleaseCandidate(document);
|
||||
if (result.failures.length > 0) {
|
||||
process.stderr.write(
|
||||
`Release candidate verification failed:\n- ${result.failures.join("\n- ")}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write(
|
||||
`Release candidate verification: PASS (${result.manifest!.distSha256})\n`,
|
||||
);
|
||||
@@ -212,7 +212,7 @@ const artifact = {
|
||||
const passed = artifact.compatible && fixtures.every((fixture) => fixture.passed);
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
generatedAt: new Date().toISOString(),
|
||||
generatedAt: release.builtAt,
|
||||
artifact,
|
||||
fixtures,
|
||||
passed,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
|
||||
import {
|
||||
isValidSha512Integrity,
|
||||
@@ -9,6 +8,10 @@ import {
|
||||
verifySupplyChainCoherence,
|
||||
} from "./lib/supply-chain.ts";
|
||||
import { assertMatchesJsonSchema } from "./lib/json-schema.ts";
|
||||
import {
|
||||
collectDistOutputs,
|
||||
distSha256,
|
||||
} from "./lib/release-candidate.ts";
|
||||
|
||||
type Document = Record<string, unknown>;
|
||||
|
||||
@@ -30,17 +33,6 @@ async function readDocument(file: string): Promise<Document> {
|
||||
return parseDocument(await readFile(file, "utf8"), file);
|
||||
}
|
||||
|
||||
async function filesWithin(directory: string): Promise<string[]> {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const nested: string[][] = await Promise.all(
|
||||
entries.map((entry) => {
|
||||
const target = path.join(directory, entry.name);
|
||||
return entry.isDirectory() ? filesWithin(target) : [target];
|
||||
}),
|
||||
);
|
||||
return nested.flat().sort();
|
||||
}
|
||||
|
||||
const inventory = await readDocument(
|
||||
"artifacts/release/dependency-inventory.json",
|
||||
);
|
||||
@@ -72,24 +64,18 @@ const lockfileText = await readFile("pnpm-lock.yaml", "utf8");
|
||||
const lockfileSha256 = createHash("sha256")
|
||||
.update(lockfileText)
|
||||
.digest("hex");
|
||||
const outputs = await Promise.all(
|
||||
(await filesWithin("dist")).map(async (file) => {
|
||||
const content = await readFile(file);
|
||||
return {
|
||||
path: file.replaceAll("\\", "/"),
|
||||
bytes: (await stat(file)).size,
|
||||
sha256: createHash("sha256").update(content).digest("hex"),
|
||||
};
|
||||
}),
|
||||
);
|
||||
const distDigest = supplyChainDigest(outputs);
|
||||
const outputs = await collectDistOutputs();
|
||||
const distDigest = distSha256(outputs);
|
||||
const coherence = verifySupplyChainCoherence(
|
||||
sbom,
|
||||
inventory,
|
||||
provenance,
|
||||
distDigest,
|
||||
);
|
||||
const failures: string[] = [...artifactSchemaFailures, ...coherence.failures];
|
||||
const failures: string[] = [
|
||||
...artifactSchemaFailures,
|
||||
...coherence.failures,
|
||||
];
|
||||
if (
|
||||
inventory.lockfileSha256 !== lockfileSha256 ||
|
||||
verification.lockfileSha256 !== lockfileSha256
|
||||
|
||||
@@ -1,29 +1,19 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
|
||||
const verification = JSON.parse(
|
||||
await readFile(
|
||||
"artifacts/security/supply-chain-verification.json",
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
const passed = verification.promotionStatus === "PASS";
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
status: passed ? "PASS" : "FAIL_UNVERIFIED",
|
||||
vulnerabilityStatus: verification.vulnerabilityStatus,
|
||||
provenanceAttestationStatus:
|
||||
verification.provenanceAttestationStatus,
|
||||
lockfileSha256: verification.lockfileSha256,
|
||||
distSha256: verification.distSha256,
|
||||
};
|
||||
import { providerVerificationArtifactSchema } from "./lib/provider-evidence.ts";
|
||||
import { verifyPromotionInputs } from "./lib/promotion-verifier.ts";
|
||||
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
||||
|
||||
const report = await verifyPromotionInputs();
|
||||
await mkdir("artifacts/security", { recursive: true });
|
||||
await writeFile(
|
||||
"artifacts/security/promotion-verification.json",
|
||||
`${JSON.stringify(report, null, 2)}\n`,
|
||||
);
|
||||
if (!passed) {
|
||||
await writeValidatedJsonArtifact({
|
||||
path: "artifacts/security/promotion-verification.json",
|
||||
schema: providerVerificationArtifactSchema,
|
||||
value: report,
|
||||
});
|
||||
if (report.status !== "PASS") {
|
||||
process.stderr.write(
|
||||
"Supply-chain promotion is FAIL_UNVERIFIED: external vulnerability and signed provenance evidence are required.\n",
|
||||
`Supply-chain promotion is FAIL_UNVERIFIED:\n- ${report.failures.join("\n- ")}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { generateKeyPairSync, sign } from "node:crypto";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
@@ -17,6 +20,15 @@ import {
|
||||
selectIncludedInventoryFiles,
|
||||
} from "../../scripts/lib/secret-scan-policy.ts";
|
||||
import { checkSecurityFixtures } from "../../scripts/lib/security-fixture-check.ts";
|
||||
import {
|
||||
evaluatePromotionEvidence,
|
||||
providerEvidenceSignaturePayload,
|
||||
} from "../../scripts/lib/provider-evidence.ts";
|
||||
import {
|
||||
createReleaseCandidateManifest,
|
||||
RELEASE_CANDIDATE_EVIDENCE_PATHS,
|
||||
verifyReleaseCandidate,
|
||||
} from "../../scripts/lib/release-candidate.ts";
|
||||
|
||||
const integrity = `sha512-${Buffer.alloc(64, 7).toString("base64")}`;
|
||||
const dependency = {
|
||||
@@ -30,7 +42,249 @@ const dependency = {
|
||||
dependencies: [],
|
||||
};
|
||||
|
||||
const candidateDistSha256 = "1".repeat(64);
|
||||
const lockfileSha256 = "2".repeat(64);
|
||||
|
||||
function signedProviderEvidence(
|
||||
value: Record<string, unknown>,
|
||||
keyId: string,
|
||||
privateKey: ReturnType<typeof generateKeyPairSync>["privateKey"],
|
||||
) {
|
||||
return {
|
||||
...value,
|
||||
signature: {
|
||||
algorithm: "Ed25519",
|
||||
keyId,
|
||||
value: sign(
|
||||
null,
|
||||
providerEvidenceSignaturePayload(value),
|
||||
privateKey,
|
||||
).toString("base64"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("supply-chain policy", () => {
|
||||
it("rejects release candidate dist bytes changed after manifest creation", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "release-candidate-"));
|
||||
try {
|
||||
await mkdir(path.join(root, "dist/.vite"), { recursive: true });
|
||||
await writeFile(path.join(root, "dist/app.js"), "immutable\n");
|
||||
await writeFile(path.join(root, "dist/.vite/metadata.json"), "{}\n");
|
||||
for (const file of RELEASE_CANDIDATE_EVIDENCE_PATHS) {
|
||||
await mkdir(path.dirname(path.join(root, file)), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(root, file),
|
||||
file === "artifacts/release/dependency-inventory.json"
|
||||
? `${JSON.stringify({ lockfileSha256 })}\n`
|
||||
: `${file}\n`,
|
||||
);
|
||||
}
|
||||
const manifest = await createReleaseCandidateManifest(root);
|
||||
expect((await verifyReleaseCandidate(manifest, root)).failures).toEqual(
|
||||
[],
|
||||
);
|
||||
|
||||
await writeFile(path.join(root, "dist/app.js"), "mutated\n");
|
||||
expect(
|
||||
(await verifyReleaseCandidate(manifest, root)).failures,
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
"release candidate dist digest mismatch",
|
||||
"release candidate bundle digest mismatch",
|
||||
"release candidate file set or file digest mismatch",
|
||||
]),
|
||||
);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("fails promotion when external provider evidence is absent", () => {
|
||||
const result = evaluatePromotionEvidence({
|
||||
candidate: {
|
||||
distSha256: candidateDistSha256,
|
||||
lockfileSha256,
|
||||
},
|
||||
currentDistSha256: candidateDistSha256,
|
||||
localStatus: "PASS",
|
||||
vulnerabilityReport: null,
|
||||
provenanceAttestation: null,
|
||||
vulnerabilityTrust: null,
|
||||
provenanceTrust: null,
|
||||
});
|
||||
|
||||
expect(result.status).toBe("FAIL_UNVERIFIED");
|
||||
});
|
||||
|
||||
it("passes only signed provider evidence for the exact immutable candidate", () => {
|
||||
const vulnerabilityKeys = generateKeyPairSync("ed25519");
|
||||
const provenanceKeys = generateKeyPairSync("ed25519");
|
||||
const vulnerabilityReport = signedProviderEvidence(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
provider: "fixture-vulnerability-provider",
|
||||
generatedAt: "2026-08-01T00:00:00.000Z",
|
||||
scannedLockfileSha256: lockfileSha256,
|
||||
scannedDistSha256: candidateDistSha256,
|
||||
findings: [],
|
||||
},
|
||||
"fixture-vulnerability-key",
|
||||
vulnerabilityKeys.privateKey,
|
||||
);
|
||||
const provenanceAttestation = signedProviderEvidence(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
provider: "fixture-provenance-provider",
|
||||
signer: "fixture-workload-identity",
|
||||
generatedAt: "2026-08-01T00:00:00.000Z",
|
||||
subject: {
|
||||
name: "dist",
|
||||
digest: { sha256: candidateDistSha256 },
|
||||
},
|
||||
},
|
||||
"fixture-provenance-key",
|
||||
provenanceKeys.privateKey,
|
||||
);
|
||||
|
||||
const result = evaluatePromotionEvidence({
|
||||
candidate: {
|
||||
distSha256: candidateDistSha256,
|
||||
lockfileSha256,
|
||||
},
|
||||
currentDistSha256: candidateDistSha256,
|
||||
localStatus: "PASS",
|
||||
vulnerabilityReport,
|
||||
provenanceAttestation,
|
||||
vulnerabilityTrust: {
|
||||
keyId: "fixture-vulnerability-key",
|
||||
publicKey: vulnerabilityKeys.publicKey,
|
||||
},
|
||||
provenanceTrust: {
|
||||
keyId: "fixture-provenance-key",
|
||||
publicKey: provenanceKeys.publicKey,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: "PASS",
|
||||
vulnerabilityStatus: "PASS",
|
||||
provenanceAttestationStatus: "PASS",
|
||||
failures: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects correctly signed provider evidence for a different digest", () => {
|
||||
const vulnerabilityKeys = generateKeyPairSync("ed25519");
|
||||
const provenanceKeys = generateKeyPairSync("ed25519");
|
||||
const wrongDistSha256 = "3".repeat(64);
|
||||
const vulnerabilityReport = signedProviderEvidence(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
provider: "fixture-vulnerability-provider",
|
||||
generatedAt: "2026-08-01T00:00:00.000Z",
|
||||
scannedLockfileSha256: lockfileSha256,
|
||||
scannedDistSha256: wrongDistSha256,
|
||||
findings: [],
|
||||
},
|
||||
"fixture-vulnerability-key",
|
||||
vulnerabilityKeys.privateKey,
|
||||
);
|
||||
const provenanceAttestation = signedProviderEvidence(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
provider: "fixture-provenance-provider",
|
||||
signer: "fixture-workload-identity",
|
||||
generatedAt: "2026-08-01T00:00:00.000Z",
|
||||
subject: { name: "dist", digest: { sha256: wrongDistSha256 } },
|
||||
},
|
||||
"fixture-provenance-key",
|
||||
provenanceKeys.privateKey,
|
||||
);
|
||||
|
||||
const result = evaluatePromotionEvidence({
|
||||
candidate: {
|
||||
distSha256: candidateDistSha256,
|
||||
lockfileSha256,
|
||||
},
|
||||
currentDistSha256: candidateDistSha256,
|
||||
localStatus: "PASS",
|
||||
vulnerabilityReport,
|
||||
provenanceAttestation,
|
||||
vulnerabilityTrust: {
|
||||
keyId: "fixture-vulnerability-key",
|
||||
publicKey: vulnerabilityKeys.publicKey,
|
||||
},
|
||||
provenanceTrust: {
|
||||
keyId: "fixture-provenance-key",
|
||||
publicKey: provenanceKeys.publicKey,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.status).toBe("FAIL_UNVERIFIED");
|
||||
expect(result.failures).toEqual(
|
||||
expect.arrayContaining([
|
||||
"vulnerability report dist digest mismatch",
|
||||
"provenance attestation dist digest mismatch",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects candidate bytes changed after provider attestation", () => {
|
||||
const vulnerabilityKeys = generateKeyPairSync("ed25519");
|
||||
const provenanceKeys = generateKeyPairSync("ed25519");
|
||||
const vulnerabilityReport = signedProviderEvidence(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
provider: "fixture-vulnerability-provider",
|
||||
generatedAt: "2026-08-01T00:00:00.000Z",
|
||||
scannedLockfileSha256: lockfileSha256,
|
||||
scannedDistSha256: candidateDistSha256,
|
||||
findings: [],
|
||||
},
|
||||
"fixture-vulnerability-key",
|
||||
vulnerabilityKeys.privateKey,
|
||||
);
|
||||
const provenanceAttestation = signedProviderEvidence(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
provider: "fixture-provenance-provider",
|
||||
signer: "fixture-workload-identity",
|
||||
generatedAt: "2026-08-01T00:00:00.000Z",
|
||||
subject: {
|
||||
name: "dist",
|
||||
digest: { sha256: candidateDistSha256 },
|
||||
},
|
||||
},
|
||||
"fixture-provenance-key",
|
||||
provenanceKeys.privateKey,
|
||||
);
|
||||
|
||||
const result = evaluatePromotionEvidence({
|
||||
candidate: {
|
||||
distSha256: candidateDistSha256,
|
||||
lockfileSha256,
|
||||
},
|
||||
currentDistSha256: "4".repeat(64),
|
||||
localStatus: "PASS",
|
||||
vulnerabilityReport,
|
||||
provenanceAttestation,
|
||||
vulnerabilityTrust: {
|
||||
keyId: "fixture-vulnerability-key",
|
||||
publicKey: vulnerabilityKeys.publicKey,
|
||||
},
|
||||
provenanceTrust: {
|
||||
keyId: "fixture-provenance-key",
|
||||
publicKey: provenanceKeys.publicKey,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.status).toBe("FAIL_UNVERIFIED");
|
||||
expect(result.failures).toContain(
|
||||
"candidate dist bytes changed after immutable build",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["empty", []],
|
||||
["empty entry", [""]],
|
||||
|
||||
Reference in New Issue
Block a user