diff --git a/.gitea/workflows/quality-gates.yml b/.gitea/workflows/quality-gates.yml index fb8a13f..802e532 100644 --- a/.gitea/workflows/quality-gates.yml +++ b/.gitea/workflows/quality-gates.yml @@ -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 diff --git a/config/ci/gates.json b/config/ci/gates.json index 86842f5..7ed8574 100644 --- a/config/ci/gates.json +++ b/config/ci/gates.json @@ -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" }, diff --git a/docs/security/supply-chain.md b/docs/security/supply-chain.md index b967dc4..439e028 100644 --- a/docs/security/supply-chain.md +++ b/docs/security/supply-chain.md @@ -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 diff --git a/package.json b/package.json index 22ae7e5..0ac2fa1 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/check-ci-contract.ts b/scripts/check-ci-contract.ts index fb3f614..6d94dbb 100644 --- a/scripts/check-ci-contract.ts +++ b/scripts/check-ci-contract.ts @@ -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; +}; +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(?[\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", diff --git a/scripts/check-supply-chain-provider-fixtures.ts b/scripts/check-supply-chain-provider-fixtures.ts index aa8938a..7bc9f86 100644 --- a/scripts/check-supply-chain-provider-fixtures.ts +++ b/scripts/check-supply-chain-provider-fixtures.ts @@ -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; +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 { - 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, + 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", ); diff --git a/scripts/create-release-candidate.ts b/scripts/create-release-candidate.ts new file mode 100644 index 0000000..2725ef6 --- /dev/null +++ b/scripts/create-release-candidate.ts @@ -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`, +); diff --git a/scripts/generate-supply-chain.ts b/scripts/generate-supply-chain.ts index bb16593..9f54398 100644 --- a/scripts/generate-supply-chain.ts +++ b/scripts/generate-supply-chain.ts @@ -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; @@ -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 = { diff --git a/scripts/lib/promotion-verifier.ts b/scripts/lib/promotion-verifier.ts new file mode 100644 index 0000000..f468804 --- /dev/null +++ b/scripts/lib/promotion-verifier.ts @@ -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 { + 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 { + if (!file) return null; + try { + return JSON.parse(await readFile(file, "utf8")) as unknown; + } catch { + return null; + } +} + +async function requiredJson(file: string): Promise> { + 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; +} diff --git a/scripts/lib/provider-evidence.ts b/scripts/lib/provider-evidence.ts new file mode 100644 index 0000000..a8a1fa2 --- /dev/null +++ b/scripts/lib/provider-evidence.ts @@ -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 { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} diff --git a/scripts/lib/release-candidate.ts b/scripts/lib/release-candidate.ts new file mode 100644 index 0000000..98db7e2 --- /dev/null +++ b/scripts/lib/release-candidate.ts @@ -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 { + 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 { + 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> { + 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 { + 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; +} diff --git a/scripts/verify-provider-evidence.ts b/scripts/verify-provider-evidence.ts new file mode 100644 index 0000000..38dd560 --- /dev/null +++ b/scripts/verify-provider-evidence.ts @@ -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"); diff --git a/scripts/verify-release-candidate.ts b/scripts/verify-release-candidate.ts new file mode 100644 index 0000000..14c32d7 --- /dev/null +++ b/scripts/verify-release-candidate.ts @@ -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`, +); diff --git a/scripts/verify-release.ts b/scripts/verify-release.ts index 06e14ec..a577ad5 100644 --- a/scripts/verify-release.ts +++ b/scripts/verify-release.ts @@ -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, diff --git a/scripts/verify-supply-chain-artifacts.ts b/scripts/verify-supply-chain-artifacts.ts index a8ab245..2c70e69 100644 --- a/scripts/verify-supply-chain-artifacts.ts +++ b/scripts/verify-supply-chain-artifacts.ts @@ -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; @@ -30,17 +33,6 @@ async function readDocument(file: string): Promise { return parseDocument(await readFile(file, "utf8"), file); } -async function filesWithin(directory: string): Promise { - 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 diff --git a/scripts/verify-supply-chain-promotion.ts b/scripts/verify-supply-chain-promotion.ts index a9a6951..6cb6a26 100644 --- a/scripts/verify-supply-chain-promotion.ts +++ b/scripts/verify-supply-chain-promotion.ts @@ -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); } diff --git a/tests/unit/supply-chain.test.ts b/tests/unit/supply-chain.test.ts index 15308c0..a96fa22 100644 --- a/tests/unit/supply-chain.test.ts +++ b/tests/unit/supply-chain.test.ts @@ -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, + keyId: string, + privateKey: ReturnType["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", [""]],