From 92e5cace5c1df2c90722623e2f25df296437e0f8 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Sun, 2 Aug 2026 06:39:41 +0900 Subject: [PATCH] fix: close immutable promotion trust gaps --- .gitea/workflows/quality-gates.yml | 3 + .../decisions/VD-09-supply-chain-evidence.md | 40 +- docs/operations/ci-quality-gates.md | 19 + docs/security/supply-chain.md | 21 +- package.json | 2 +- scripts/check-ci-contract.ts | 6 + .../check-supply-chain-provider-fixtures.ts | 296 ++++++---- scripts/generate-supply-chain.ts | 10 +- scripts/lib/local-release-evidence.ts | 533 ++++++++++++++++++ scripts/lib/promotion-verifier.ts | 65 ++- scripts/lib/provider-evidence.ts | 12 +- scripts/lib/release-candidate.ts | 14 +- scripts/lib/supply-chain-time.ts | 24 + scripts/verify-archived-local-evidence.ts | 19 + scripts/verify-supply-chain-artifacts.ts | 142 +---- tests/unit/supply-chain.test.ts | 296 +++++++++- 16 files changed, 1228 insertions(+), 274 deletions(-) create mode 100644 scripts/lib/local-release-evidence.ts create mode 100644 scripts/lib/supply-chain-time.ts create mode 100644 scripts/verify-archived-local-evidence.ts diff --git a/.gitea/workflows/quality-gates.yml b/.gitea/workflows/quality-gates.yml index 802e532..463cf31 100644 --- a/.gitea/workflows/quality-gates.yml +++ b/.gitea/workflows/quality-gates.yml @@ -137,6 +137,7 @@ jobs: 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 \ + pnpm-lock.yaml \ artifacts/performance/bundle.json \ artifacts/quality/vite-module-inventory.json \ artifacts/release/build-manifest.json \ @@ -167,6 +168,7 @@ jobs: timeout-minutes: 45 env: CANDIDATE_DIST_SHA256: ${{ needs.immutable_build.outputs.dist_sha256 }} + CANDIDATE_LOCKFILE_PATH: pnpm-lock.yaml VULNERABILITY_PROVIDER_COMMAND: ${{ vars.VULNERABILITY_PROVIDER_COMMAND }} VULNERABILITY_REPORT_PATH: provider-evidence/vulnerability-report.json steps: @@ -197,6 +199,7 @@ jobs: timeout-minutes: 45 env: CANDIDATE_DIST_SHA256: ${{ needs.immutable_build.outputs.dist_sha256 }} + CANDIDATE_LOCKFILE_PATH: pnpm-lock.yaml PROVENANCE_PROVIDER_COMMAND: ${{ vars.PROVENANCE_PROVIDER_COMMAND }} PROVENANCE_ATTESTATION_PATH: provider-evidence/provenance-attestation.json steps: diff --git a/docs/architecture/decisions/VD-09-supply-chain-evidence.md b/docs/architecture/decisions/VD-09-supply-chain-evidence.md index 48d9008..128e118 100644 --- a/docs/architecture/decisions/VD-09-supply-chain-evidence.md +++ b/docs/architecture/decisions/VD-09-supply-chain-evidence.md @@ -36,19 +36,26 @@ finding과 서명 성공으로 표현하면 local 검증과 release promotion을 5. inventory를 CycloneDX 1.6 SBOM으로 투영한다. component 수, lockfile digest, SRI, license와 dependency edge가 inventory와 일치해야 한다. 6. local in-toto/SLSA 형태 provenance statement는 source set, lockfile, SBOM과 - `dist` digest를 연결하되 `LOCAL_UNSIGNED`로 표시한다. 외부 attestation은 - provider, signer와 동일 dist subject digest가 있어야 한다. -7. vulnerability adapter는 `VULNERABILITY_REPORT_PATH`가 가리키는 - machine-readable provider report를 검증한다. report의 lock digest, provider, - severity와 exception owner/reviewer/reason/expiry가 유효해야 한다. -8. provider report가 없으면 local inventory/license/SBOM/coherence는 `PASS`, - promotion은 `FAIL_UNVERIFIED`다. 빈 finding을 만들어 vulnerability PASS로 - 표시하지 않는다. -9. secret scan은 source, scripts, tests, tracked config/schema, public, `dist`와 + `dist` digest를 연결하되 `LOCAL_UNSIGNED`로 표시한다. 이 문서는 외부 + provenance를 대신할 수 없다. +7. `immutable_build`는 raw `pnpm-lock.yaml`, `dist`, build/module inventory와 + 모든 local verification evidence를 한 번만 archive한다. Candidate manifest는 + raw lock bytes SHA-256, dependency inventory lock digest와 manifest + `lockfileSha256`의 exact 일치를 요구한다. +8. 두 provider job은 동일 archive를 각각 받아 외부 command를 실행한다. + Vulnerability report는 raw lock digest와 `distSha256`, provenance attestation은 + `{name: "dist", digest.sha256}`를 포함한다. 두 문서 모두 strict schema와 + 별도 trust path/key ID로 선택한 실제 Ed25519 public key 서명을 통과해야 한다. +9. provider report나 trusted key가 없으면 local + inventory/license/SBOM/coherence는 `PASS`, promotion은 + `FAIL_UNVERIFIED`다. 저장소 generator나 fixture가 production용 빈 finding 또는 + signed PASS를 만들지 않는다. +10. secret scan은 source, scripts, tests, tracked config/schema, public, `dist`와 generated release metadata를 검사한다. allowlist는 test path에만 허용하며 owner, reason과 expiry가 필요하다. 발견한 secret 원문은 artifact에 쓰지 않고 rule, path, line과 fingerprint만 남긴다. -10. `SOURCE_DATE_EPOCH`를 지원하고 같은 source/lock/config의 production build를 +11. `SOURCE_DATE_EPOCH`를 지원하고 supply-chain timestamp도 build manifest의 + 동일 epoch에 결합한다. 같은 source/lock/config의 production build를 두 번 실행해 전체 dist digest 일치를 검증한 뒤 일반 build를 복원한다. ## 실행 경계와 증적 @@ -61,8 +68,10 @@ package.json + frozen pnpm-lock.yaml + installed graph source/config/lock + production dist -> local provenance statement - -> optional vulnerability/attestation provider inputs - -> LOCAL PASS | promotion PASS/FAIL_UNVERIFIED + -> immutable archive + candidate manifest + distSha256 + -> external vulnerability provider + external provenance provider + -> read-only local revalidation + signature/digest verification + -> promotion PASS | FAIL_UNVERIFIED ``` - policy: `config/security/` @@ -82,8 +91,9 @@ source/config/lock + production dist - ordering-only digest, removal, integrity tamper, baseline tamper, high-risk self approval, denied license, critical vulnerability와 만료 exception, provider/digest 오류, SBOM/provenance 불일치 fixture를 검사한다. -- synthetic provider/attestation fixture는 promotion `PASS`를 증명한 후 기본 - `FAIL_UNVERIFIED` 상태를 복원한다. +- isolated temporary candidate/PEM/report fixture는 실제 environment path wiring을 + 통해 valid immutable 입력만 promotion `PASS`임을 증명한다. Production artifact를 + 덮어쓰거나 generator를 provider 모드로 재실행하지 않는다. - frozen install은 manifest/lock mismatch fixture를 실제 pnpm으로 거절한다. - source/config/dist 각각의 synthetic secret fixture가 실제 scan을 실패시키고 scoped test allowlist만 통과한다. @@ -97,7 +107,7 @@ attestation transparency/retention과 비밀 관리를 결정해야 한다. ## Rollback -외부 scanner/attestor adapter는 환경 입력을 제거하면 즉시 +외부 scanner/attestor command, report path 또는 trusted key 설정을 제거하면 즉시 `FAIL_UNVERIFIED`로 돌아간다. local inventory, lock integrity, license, SBOM, secret, reproducibility와 actual diff gate는 유지한다. scanner 장애를 이유로 promotion을 PASS로 변경하지 않는다. diff --git a/docs/operations/ci-quality-gates.md b/docs/operations/ci-quality-gates.md index d6925b2..0e8b2d9 100644 --- a/docs/operations/ci-quality-gates.md +++ b/docs/operations/ci-quality-gates.md @@ -32,6 +32,16 @@ full commit ID와 commit timestamp를 한 번 읽어 `SOURCE_DATE_EPOCH`를 유 release manifest도 같은 build ID, commit SHA, release ID와 timestamp를 가져야 한다. +Release tier의 `immutable_build`는 `dist`의 hidden `.vite` entries, raw +`pnpm-lock.yaml`, build/module inventory와 local release/security evidence를 한 +번 생성해 run ID/attempt로 구분된 archive로 게시한다. Raw lock bytes의 SHA-256은 +dependency inventory와 candidate manifest 양쪽의 `lockfileSha256`과 같아야 한다. +Vulnerability/provenance provider job은 같은 archive를 각각 내려받고 +`CANDIDATE_LOCKFILE_PATH`, `CANDIDATE_DIST_SHA256`를 외부 command에 전달한다. +Promotion은 같은 archive와 두 report를 별도 경로로 내려받은 뒤 local evidence를 +read-only로 다시 계산하고 Ed25519 signature/digest를 확인한다. Promotion job에는 +build/rebuild command가 없으며 검증한 archive 자체를 그대로 승격한다. + Provider baseline은 Gitea 1.26.4 이상과 Gitea Runner 1.0.0 이상이다. 이 workflow를 required check로 전환하기 전에 staging instance에서 `permissions`, `gitea.run_attempt`, `actions/upload-artifact@v4`를 포함한 한 @@ -132,6 +142,15 @@ Repository variables required by higher tiers: - `HOSTING_BASE_URL` for live header verification - `FIELD_WEB_VITALS_INPUT` for the privacy-approved field sample document - `MIN_ELIGIBLE_SAMPLES` after the baseline decision +- `VULNERABILITY_PROVIDER_COMMAND` and `PROVENANCE_PROVIDER_COMMAND` for the + independently operated scanner and attestor commands +- `VULNERABILITY_PUBLIC_KEY_PATH`, `VULNERABILITY_KEY_ID`, + `PROVENANCE_PUBLIC_KEY_PATH`, and `PROVENANCE_KEY_ID` for separately managed + trusted Ed25519 verification material + +If any external provider command, report, trust path, or key ID is absent, +promotion remains unavailable with `FAIL_UNVERIFIED`; there is no local +generator/restore fallback. Branch protection must mark each `FE-GATE-* / ` check required for its declared tier. This repository cannot configure server-side protection by diff --git a/docs/security/supply-chain.md b/docs/security/supply-chain.md index 439e028..22508c3 100644 --- a/docs/security/supply-chain.md +++ b/docs/security/supply-chain.md @@ -36,14 +36,29 @@ Promotion reads the provider files named by `VULNERABILITY_REPORT_PATH` and 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. +key IDs (`VULNERABILITY_PUBLIC_KEY_PATH`, `VULNERABILITY_KEY_ID`, +`PROVENANCE_PUBLIC_KEY_PATH`, and `PROVENANCE_KEY_ID`). Keys of another curve, +including Ed448, are rejected even if a document labels its algorithm +`Ed25519`. + +`immutable_build` archives the raw `pnpm-lock.yaml`, `dist` (including hidden +`.vite` files), the build manifest, module inventory, release verification, +secret-scan result, and local supply-chain evidence once. The candidate +manifest hashes the raw lockfile bytes and requires that digest to equal the +dependency inventory's `lockfileSha256`. The two provider jobs download this +same archive separately and receive `CANDIDATE_LOCKFILE_PATH` and +`CANDIDATE_DIST_SHA256`; configured `VULNERABILITY_PROVIDER_COMMAND` and +`PROVENANCE_PROVIDER_COMMAND` must emit the signed reports. If either provider input is absent, local verification remains meaningful but `artifacts/security/supply-chain-verification.json` records `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. +set and digests, then read-only revalidates the archived executable schemas, +raw lockfile, module inventory, build outputs, release coherence, SBOM, +provenance, security scan and supply-chain coherence. It never rebuilds or +rewrites candidate evidence. 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 0ac2fa1..44cf6c6 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,7 @@ "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: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:local-evidence": "node scripts/verify-release-candidate.ts && node scripts/verify-release.ts && node scripts/verify-supply-chain-artifacts.ts && node scripts/verify-archived-local-evidence.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", diff --git a/scripts/check-ci-contract.ts b/scripts/check-ci-contract.ts index 6d94dbb..6b1df3d 100644 --- a/scripts/check-ci-contract.ts +++ b/scripts/check-ci-contract.ts @@ -191,8 +191,14 @@ for (const requiredToken of [ 'CI_RUNNER_IMAGE: "${{ vars.RUNNER_IMAGE_DIGEST }}"', "VULNERABILITY_REPORT_PATH:", "PROVENANCE_ATTESTATION_PATH:", + "CANDIDATE_LOCKFILE_PATH: pnpm-lock.yaml", + "VULNERABILITY_PROVIDER_COMMAND:", + "PROVENANCE_PROVIDER_COMMAND:", "VULNERABILITY_PUBLIC_KEY_PATH:", + "VULNERABILITY_KEY_ID:", "PROVENANCE_PUBLIC_KEY_PATH:", + "PROVENANCE_KEY_ID:", + " pnpm-lock.yaml \\", "release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}", "corepack pnpm verify:provider-evidence", "corepack pnpm verify:promotion", diff --git a/scripts/check-supply-chain-provider-fixtures.ts b/scripts/check-supply-chain-provider-fixtures.ts index 7bc9f86..9a6fa0e 100644 --- a/scripts/check-supply-chain-provider-fixtures.ts +++ b/scripts/check-supply-chain-provider-fixtures.ts @@ -1,30 +1,199 @@ -import { generateKeyPairSync, sign } from "node:crypto"; -import { mkdir, writeFile } from "node:fs/promises"; +import { createHash, generateKeyPairSync, sign } from "node:crypto"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { providerEvidenceSignaturePayload } from "./lib/provider-evidence.ts"; +import { verifyPromotionInputs } from "./lib/promotion-verifier.ts"; import { - evaluatePromotionEvidence, - providerEvidenceSignaturePayload, -} from "./lib/provider-evidence.ts"; + createReleaseCandidateManifest, + RELEASE_CANDIDATE_EVIDENCE_PATHS, + RELEASE_CANDIDATE_MANIFEST_PATH, +} from "./lib/release-candidate.ts"; -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, - }, - provenanceTrust: { - keyId: "fixture-provenance-key", - publicKey: provenanceKeys.publicKey, - }, -}; +const fixtureRoot = await mkdtemp( + path.join(tmpdir(), "supply-chain-provider-fixture-"), +); +try { + const rawLockfile = "lockfileVersion: '9.0'\n"; + const lockfileSha256 = createHash("sha256") + .update(rawLockfile) + .digest("hex"); + await mkdir(path.join(fixtureRoot, "dist"), { recursive: true }); + await writeFile(path.join(fixtureRoot, "dist/app.js"), "immutable\n"); + await writeFile(path.join(fixtureRoot, "pnpm-lock.yaml"), rawLockfile); + for (const file of RELEASE_CANDIDATE_EVIDENCE_PATHS) { + if (file === "pnpm-lock.yaml") continue; + await mkdir(path.dirname(path.join(fixtureRoot, file)), { + recursive: true, + }); + const value = + file === "artifacts/release/dependency-inventory.json" + ? { lockfileSha256 } + : { fixture: file }; + await writeFile( + path.join(fixtureRoot, file), + `${JSON.stringify(value)}\n`, + ); + } + const candidate = await createReleaseCandidateManifest(fixtureRoot); + await writeFile( + path.join(fixtureRoot, RELEASE_CANDIDATE_MANIFEST_PATH), + `${JSON.stringify(candidate)}\n`, + ); + + const validEnvironment = await writeProviderEnvironment( + fixtureRoot, + "valid", + candidate.distSha256, + candidate.lockfileSha256, + ); + const wrongEnvironment = await writeProviderEnvironment( + fixtureRoot, + "wrong", + "3".repeat(64), + candidate.lockfileSha256, + ); + const acceptLocalEvidence = async () => ({ + status: "PASS" as const, + failures: [] as const, + }); + const fixtures = { + absent: await verifyPromotionInputs({ + repositoryRoot: fixtureRoot, + environment: {}, + verifyLocalEvidence: acceptLocalEvidence, + }), + validImmutable: await verifyPromotionInputs({ + repositoryRoot: fixtureRoot, + environment: validEnvironment, + verifyLocalEvidence: acceptLocalEvidence, + }), + wrongDigest: await verifyPromotionInputs({ + repositoryRoot: fixtureRoot, + environment: wrongEnvironment, + verifyLocalEvidence: acceptLocalEvidence, + }), + postAttestationMutation: null as Awaited< + ReturnType + > | null, + }; + await writeFile(path.join(fixtureRoot, "dist/app.js"), "mutated\n"); + fixtures.postAttestationMutation = await verifyPromotionInputs({ + repositoryRoot: fixtureRoot, + environment: validEnvironment, + verifyLocalEvidence: acceptLocalEvidence, + }); + + const passed = + 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, + 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, + 2, + )}\n`, + ); + if (!passed) { + process.stderr.write( + "Supply-chain provider fixtures failed closed incorrectly\n", + ); + process.exitCode = 1; + } else { + process.stdout.write( + "Supply-chain provider fixtures: only the valid immutable fixture PASS\n", + ); + } +} finally { + await rm(fixtureRoot, { recursive: true, force: true }); +} + +async function writeProviderEnvironment( + repositoryRoot: string, + name: string, + distDigest: string, + lockfileSha256: string, +): Promise { + const vulnerabilityKeys = generateKeyPairSync("ed25519"); + const provenanceKeys = generateKeyPairSync("ed25519"); + const directory = `provider/${name}`; + await mkdir(path.join(repositoryRoot, directory), { recursive: true }); + const vulnerability = signedEvidence( + { + schemaVersion: 1, + provider: "fixture-vulnerability-provider", + generatedAt: "2026-08-01T00:00:00.000Z", + scannedLockfileSha256: lockfileSha256, + scannedDistSha256: distDigest, + findings: [], + }, + "fixture-vulnerability-key", + vulnerabilityKeys.privateKey, + ); + const provenance = 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, + ); + await Promise.all([ + writeFile( + path.join(repositoryRoot, directory, "vulnerability.json"), + `${JSON.stringify(vulnerability)}\n`, + ), + writeFile( + path.join(repositoryRoot, directory, "provenance.json"), + `${JSON.stringify(provenance)}\n`, + ), + writeFile( + path.join(repositoryRoot, directory, "vulnerability.pem"), + vulnerabilityKeys.publicKey + .export({ type: "spki", format: "pem" }) + .toString(), + ), + writeFile( + path.join(repositoryRoot, directory, "provenance.pem"), + provenanceKeys.publicKey + .export({ type: "spki", format: "pem" }) + .toString(), + ), + ]); + return { + VULNERABILITY_REPORT_PATH: `${directory}/vulnerability.json`, + PROVENANCE_ATTESTATION_PATH: `${directory}/provenance.json`, + VULNERABILITY_PUBLIC_KEY_PATH: `${directory}/vulnerability.pem`, + VULNERABILITY_KEY_ID: "fixture-vulnerability-key", + PROVENANCE_PUBLIC_KEY_PATH: `${directory}/provenance.pem`, + PROVENANCE_KEY_ID: "fixture-provenance-key", + }; +} function signedEvidence( value: Record, keyId: string, - privateKey: typeof vulnerabilityKeys.privateKey, + privateKey: ReturnType["privateKey"], ) { return { ...value, @@ -39,90 +208,3 @@ function signedEvidence( }, }; } - -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 = - 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, - 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, - 2, - )}\n`, -); -if (!passed) { - process.stderr.write("Supply-chain provider fixtures failed closed incorrectly\n"); - process.exit(1); -} -process.stdout.write( - "Supply-chain provider fixtures: only the valid immutable fixture PASS\n", -); diff --git a/scripts/generate-supply-chain.ts b/scripts/generate-supply-chain.ts index 9f54398..c82adba 100644 --- a/scripts/generate-supply-chain.ts +++ b/scripts/generate-supply-chain.ts @@ -8,6 +8,7 @@ import { import { bundlePerformanceArtifactSchema, + buildManifestArtifactSchema, dependencyDiffArtifactSchema, dependencyInventoryArtifactSchema, licenseReportArtifactSchema, @@ -34,6 +35,7 @@ import { parseRepositoryFileInventoryPolicy, } from "./lib/repository-file-inventory.ts"; import { collectDistOutputs, distSha256 } from "./lib/release-candidate.ts"; +import { deterministicSupplyChainGeneratedAt } from "./lib/supply-chain-time.ts"; type Document = Record; @@ -138,6 +140,9 @@ export async function buildDependencyInventory() { } const packageJson = await jsonDocument("package.json"); +const buildManifest = buildManifestArtifactSchema.parse( + await jsonDocument("artifacts/release/build-manifest.json"), +); const secretScanPolicy = documentValue( JSON.parse(await readFile("config/security/secret-scan-policy.json", "utf8")), "secret scan policy", @@ -332,7 +337,10 @@ const verification = { }; const bundleReport = { schemaVersion: 1, - generatedAt: new Date().toISOString(), + generatedAt: deterministicSupplyChainGeneratedAt({ + generatedAt: buildManifest.generatedAt, + sourceDateEpoch: buildManifest.buildContext.sourceDateEpoch, + }), context: { nodeVersion: process.version, packageManager: String(packageJson.packageManager ?? ""), diff --git a/scripts/lib/local-release-evidence.ts b/scripts/lib/local-release-evidence.ts new file mode 100644 index 0000000..0f70dfc --- /dev/null +++ b/scripts/lib/local-release-evidence.ts @@ -0,0 +1,533 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +import { z, type ZodType } from "zod"; + +import { EXPECTED_CONTRACT_SET_PACKAGES } from "../../src/features/installed-contract-contributions.ts"; +import { + ROUTE_REGISTRY, + ROUTE_RUNTIME_CONTRACT, +} from "../../src/features/installed-feature-contracts.ts"; +import { + buildManifestArtifactSchema, + bundlePerformanceArtifactSchema, + dependencyDiffArtifactSchema, + dependencyInventoryArtifactSchema, + licenseReportArtifactSchema, + provenanceArtifactSchema, + releaseManifestArtifactSchema, + releaseVerificationArtifactSchema, + runtimeConfigArtifactSchema, + sbomArtifactSchema, + supplyChainVerificationArtifactSchema, + vulnerabilityReportArtifactSchema, +} from "../contracts/release-artifacts.ts"; +import { + CANONICAL_VITE_MANIFEST_PATH, + verifyBuildManifestOutputs, +} from "./build-manifest-outputs.ts"; +import { assertMatchesJsonSchema } from "./json-schema.ts"; +import type { ReleaseCandidateManifest } from "./release-candidate.ts"; +import { collectDistOutputs, distSha256 } from "./release-candidate.ts"; +import { verifyReleaseRuntimeCoherence } from "./release-runtime-coherence.ts"; +import { digestReleaseInputFiles } from "./release-input-evidence.ts"; +import { + buildRepositoryFileInventory, + parseRepositoryFileInventoryPolicy, +} from "./repository-file-inventory.ts"; +import { + isValidSha512Integrity, + parsePnpmLockfilePackages, + supplyChainDigest, + verifySupplyChainCoherence, +} from "./supply-chain.ts"; + +const sha256 = z.string().regex(/^[a-f0-9]{64}$/u); +export const supplyChainCoherenceReportSchema = z + .object({ + schemaVersion: z.literal(1), + status: z.enum(["PASS", "FAIL"]), + dependencyCount: z.int().nonnegative(), + lockfileSha256: sha256, + distSha256: sha256, + sbomSha256: sha256, + failures: z.array(z.string()), + }) + .strict(); + +export type SupplyChainCoherenceReport = z.infer< + typeof supplyChainCoherenceReportSchema +>; + +export async function verifyLocalSupplyChainEvidence( + repositoryRoot = process.cwd(), +): Promise { + const failures: string[] = []; + const inventory = await parseArtifact( + repositoryRoot, + "artifacts/release/dependency-inventory.json", + dependencyInventoryArtifactSchema, + "dependency inventory", + failures, + ); + const sbom = await parseArtifact( + repositoryRoot, + "artifacts/release/sbom.cdx.json", + sbomArtifactSchema, + "SBOM", + failures, + ); + const provenance = await parseArtifact( + repositoryRoot, + "artifacts/release/provenance.json", + provenanceArtifactSchema, + "local provenance", + failures, + ); + const verification = await parseArtifact( + repositoryRoot, + "artifacts/security/supply-chain-verification.json", + supplyChainVerificationArtifactSchema, + "supply-chain verification", + failures, + ); + + for (const [schemaPath, artifact, label] of [ + [ + "schemas/artifacts/dependency-inventory.schema.json", + inventory, + "dependency inventory", + ], + [ + "schemas/artifacts/supply-chain-verification.schema.json", + verification, + "supply-chain verification", + ], + ] as const) { + if (!artifact) continue; + try { + assertMatchesJsonSchema( + await readJson(repositoryRoot, schemaPath), + artifact, + label, + ); + } catch { + failures.push(`${label} JSON Schema mismatch`); + } + } + + let lockfileText = ""; + let lockfileSha256 = "0".repeat(64); + try { + const rawLockfile = await readFile( + path.join(repositoryRoot, "pnpm-lock.yaml"), + ); + lockfileText = rawLockfile.toString("utf8"); + lockfileSha256 = createHash("sha256").update(rawLockfile).digest("hex"); + } catch { + failures.push("raw pnpm-lock.yaml is missing or unreadable"); + } + + let distDigest = "0".repeat(64); + try { + distDigest = distSha256(await collectDistOutputs(repositoryRoot)); + } catch { + failures.push("candidate dist is missing or unreadable"); + } + const sbomSha256 = sbom ? supplyChainDigest(sbom) : "0".repeat(64); + + if (inventory && sbom && provenance) { + failures.push( + ...verifySupplyChainCoherence( + sbom, + inventory, + provenance, + distDigest, + ).failures, + ); + } + if ( + !inventory || + !verification || + inventory.lockfileSha256 !== lockfileSha256 || + verification.lockfileSha256 !== lockfileSha256 + ) { + failures.push("inventory/verification lockfile digest mismatch"); + } + if ( + !verification || + verification.localStatus !== "PASS" || + verification.failures.length > 0 || + verification.distSha256 !== distDigest || + verification.sbomSha256 !== sbomSha256 + ) { + failures.push("verification digest/status set is incoherent"); + } + if (inventory && sbom && provenance && verification) { + try { + const policy = parseRepositoryFileInventoryPolicy( + await readJson( + repositoryRoot, + "config/security/secret-scan-policy.json", + ), + ); + const repositoryInventory = await buildRepositoryFileInventory({ + repositoryRoot, + trackedRoots: policy.trackedRoots, + generatedRoots: policy.generatedRoots, + optionalRoots: policy.optionalRoots, + }); + const sourceSetSha256 = await digestReleaseInputFiles( + repositoryInventory.trackedFiles, + (file) => readFile(path.join(repositoryRoot, file)), + ); + if ( + verification.sourceSetSha256 !== sourceSetSha256 || + provenance.predicate.materials.sourceSetSha256 !== sourceSetSha256 || + provenance.predicate.materials.sbomSha256 !== sbomSha256 + ) { + failures.push("source/SBOM provenance materials are incoherent"); + } + } catch { + failures.push("release source inventory is unavailable or unreadable"); + } + } + + const lockRows = parsePnpmLockfilePackages(lockfileText); + const inventoryRows = inventory?.dependencies ?? []; + const inventoryByIdentity = new Map( + inventoryRows.map( + (entry) => [`${entry.name}@${entry.version}`, entry] as const, + ), + ); + if (lockRows.length !== inventoryRows.length) { + failures.push("transitive dependency count differs from lockfile"); + } + for (const lockRow of lockRows) { + const identity = `${lockRow.name}@${lockRow.version}`; + const dependency = inventoryByIdentity.get(identity); + if ( + !dependency || + dependency.integrity !== lockRow.integrity || + !isValidSha512Integrity(lockRow.integrity) + ) { + failures.push(`lockfile inventory integrity mismatch: ${identity}`); + } + } + + return supplyChainCoherenceReportSchema.parse({ + schemaVersion: 1, + status: failures.length === 0 ? "PASS" : "FAIL", + dependencyCount: inventoryRows.length, + lockfileSha256, + distSha256: distDigest, + sbomSha256, + failures, + }); +} + +export async function verifyArchivedLocalEvidence(input: Readonly<{ + repositoryRoot?: string; + candidate: ReleaseCandidateManifest; +}>): Promise> { + const repositoryRoot = path.resolve(input.repositoryRoot ?? process.cwd()); + const failures: string[] = []; + const supplyReport = await verifyLocalSupplyChainEvidence(repositoryRoot); + failures.push(...supplyReport.failures); + if (supplyReport.lockfileSha256 !== input.candidate.lockfileSha256) { + failures.push("candidate/raw lockfile digest mismatch"); + } + + const buildManifest = await parseArtifact( + repositoryRoot, + "artifacts/release/build-manifest.json", + buildManifestArtifactSchema, + "build manifest", + failures, + ); + const release = await parseArtifact( + repositoryRoot, + "dist/release-manifest.json", + releaseManifestArtifactSchema, + "release manifest", + failures, + ); + const runtime = await parseArtifact( + repositoryRoot, + "dist/config.json", + runtimeConfigArtifactSchema, + "runtime config", + failures, + ); + const storedRelease = await parseArtifact( + repositoryRoot, + "artifacts/release/verification.json", + releaseVerificationArtifactSchema, + "release verification", + failures, + ); + const storedSupply = await parseArtifact( + repositoryRoot, + "artifacts/security/supply-chain-coherence.json", + supplyChainCoherenceReportSchema, + "supply-chain coherence", + failures, + ); + + await validateSupportingArtifacts(repositoryRoot, failures); + if (buildManifest) { + try { + assertMatchesJsonSchema( + await readJson( + repositoryRoot, + "schemas/artifacts/build-manifest.schema.json", + ), + buildManifest, + "build manifest", + ); + } catch { + failures.push("build manifest JSON Schema mismatch"); + } + failures.push( + ...(await verifyBuildManifestOutputs(buildManifest, { repositoryRoot })), + ); + } + + if (release && runtime) { + if (!runtime.BUILD_ID || !runtime.RELEASE_ID) { + failures.push("runtime release identity is missing"); + } else { + const apiContractVersion = + release.schemaVersion === 1 && "API_CONTRACT_VERSION" in runtime + ? runtime.API_CONTRACT_VERSION + : undefined; + if (release.schemaVersion === 1 && apiContractVersion === undefined) { + failures.push("runtime API contract identity is missing"); + } + const coherence = await verifyReleaseRuntimeCoherence({ + release, + runtime: { + BUILD_ID: runtime.BUILD_ID, + RELEASE_ID: runtime.RELEASE_ID, + CONFIG_SCHEMA_VERSION: runtime.CONFIG_SCHEMA_VERSION, + ...(apiContractVersion === undefined + ? {} + : { API_CONTRACT_VERSION: apiContractVersion }), + }, + contractPackages: EXPECTED_CONTRACT_SET_PACKAGES, + }); + failures.push(...coherence.mismatches.map((item) => `release:${item}`)); + } + await verifyReleaseOutputs( + repositoryRoot, + release, + buildManifest, + failures, + ); + } + + if ( + !storedRelease || + !storedRelease.passed || + !storedRelease.artifact.checked || + !storedRelease.artifact.compatible || + storedRelease.artifact.mismatches.length > 0 || + storedRelease.fixtures.length === 0 || + storedRelease.fixtures.some((fixture) => !fixture.passed) || + storedRelease.artifact.releaseId !== release?.releaseId || + storedRelease.generatedAt !== release?.builtAt + ) { + failures.push("stored release verification is not a coherent PASS"); + } + if ( + !storedSupply || + storedSupply.status !== "PASS" || + storedSupply.failures.length > 0 || + storedSupply.dependencyCount !== supplyReport.dependencyCount || + storedSupply.lockfileSha256 !== supplyReport.lockfileSha256 || + storedSupply.distSha256 !== supplyReport.distSha256 || + storedSupply.sbomSha256 !== supplyReport.sbomSha256 + ) { + failures.push("stored supply-chain coherence is not a recomputed PASS"); + } + await verifySecretScan(repositoryRoot, failures); + + return Object.freeze({ + status: failures.length === 0 ? "PASS" : "FAIL", + failures: Object.freeze([...new Set(failures)]), + }); +} + +async function validateSupportingArtifacts( + repositoryRoot: string, + failures: string[], +): Promise { + const bundle = await parseArtifact( + repositoryRoot, + "artifacts/performance/bundle.json", + bundlePerformanceArtifactSchema, + "bundle report", + failures, + ); + if (bundle) { + try { + const actual = await collectDistOutputs(repositoryRoot); + if (JSON.stringify(bundle.outputs) !== JSON.stringify(actual)) { + failures.push("bundle report does not describe current dist bytes"); + } + } catch { + failures.push("bundle report dist inputs are unreadable"); + } + } + const dependencyDiff = await parseArtifact( + repositoryRoot, + "artifacts/security/dependency-diff.json", + dependencyDiffArtifactSchema, + "dependency diff", + failures, + ); + if (dependencyDiff && dependencyDiff.reviewFailures.length > 0) { + failures.push("dependency review evidence is not PASS"); + } + const license = await parseArtifact( + repositoryRoot, + "artifacts/security/license-report.json", + licenseReportArtifactSchema, + "license report", + failures, + ); + if (license && (license.status !== "PASS" || license.failures.length > 0)) { + failures.push("license report is not PASS"); + } + const vulnerability = await parseArtifact( + repositoryRoot, + "artifacts/security/vulnerability-report.json", + vulnerabilityReportArtifactSchema, + "local vulnerability report", + failures, + ); + if ( + vulnerability && + (vulnerability.status !== "FAIL_UNVERIFIED" || + vulnerability.provider !== "UNCONFIGURED") + ) { + failures.push("local vulnerability report may not satisfy promotion"); + } + const provenance = await parseArtifact( + repositoryRoot, + "artifacts/release/provenance.json", + provenanceArtifactSchema, + "local provenance", + failures, + ); + if ( + provenance?.predicate.runDetails.metadata.invocationId !== "LOCAL_UNSIGNED" + ) { + failures.push("local provenance must remain LOCAL_UNSIGNED"); + } +} + +async function verifyReleaseOutputs( + repositoryRoot: string, + release: z.infer, + buildManifest: z.infer | null, + failures: string[], +): Promise { + let viteManifest: Record = {}; + try { + const raw = await readFile( + path.join(repositoryRoot, CANONICAL_VITE_MANIFEST_PATH), + "utf8", + ); + viteManifest = asRecord(JSON.parse(raw), "Vite manifest"); + if (createHash("sha256").update(raw).digest("hex") !== release.assetManifestHash) { + failures.push("release asset manifest hash mismatch"); + } + } catch { + failures.push("Vite manifest is missing or invalid"); + } + if ( + buildManifest && + (buildManifest.buildId !== release.buildId || + buildManifest.commitSha !== release.commitSha || + buildManifest.releaseId !== release.releaseId || + buildManifest.generatedAt !== release.builtAt) + ) { + failures.push("build/release identity mismatch"); + } + const runtimeContracts: Readonly> = + ROUTE_RUNTIME_CONTRACT; + for (const definition of Object.values(ROUTE_REGISTRY)) { + const runtime = runtimeContracts[definition.routeId]; + const viteEntry = Object.values(viteManifest).find( + (entry) => + isRecord(entry) && + entry.name === runtime?.moduleId && + entry.isDynamicEntry === true, + ); + const file = isRecord(viteEntry) ? viteEntry.file : null; + if ( + typeof file !== "string" || + release.routeChunks[definition.chunkId] !== file || + buildManifest?.outputs.routeChunks[definition.chunkId] !== file + ) { + failures.push(`release route chunk mismatch: ${definition.chunkId}`); + } + } +} + +async function verifySecretScan( + repositoryRoot: string, + failures: string[], +): Promise { + try { + const sarif = asRecord( + await readJson(repositoryRoot, "artifacts/security/scan.sarif"), + "secret scan SARIF", + ); + const runs = Array.isArray(sarif.runs) ? sarif.runs : []; + if ( + sarif.version !== "2.1.0" || + runs.length !== 1 || + !isRecord(runs[0]) || + !Array.isArray(runs[0].results) || + runs[0].results.length !== 0 + ) { + failures.push("secret scan SARIF is not an empty PASS"); + } + } catch { + failures.push("secret scan SARIF is missing or invalid"); + } +} + +async function parseArtifact( + repositoryRoot: string, + file: string, + schema: ZodType, + label: string, + failures: string[], +): Promise { + try { + return schema.parse(await readJson(repositoryRoot, file)); + } catch { + failures.push(`${label} executable schema mismatch`); + return null; + } +} + +async function readJson(repositoryRoot: string, file: string): Promise { + return JSON.parse(await readFile(path.join(repositoryRoot, file), "utf8")); +} + +function asRecord(value: unknown, label: string): Record { + if (!isRecord(value)) throw new TypeError(`${label} must be a JSON object`); + return value; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} diff --git a/scripts/lib/promotion-verifier.ts b/scripts/lib/promotion-verifier.ts index f468804..b6c9ab8 100644 --- a/scripts/lib/promotion-verifier.ts +++ b/scripts/lib/promotion-verifier.ts @@ -1,5 +1,6 @@ import { createPublicKey } from "node:crypto"; import { readFile } from "node:fs/promises"; +import path from "node:path"; import { evaluatePromotionEvidence, @@ -10,38 +11,63 @@ import { releaseCandidateManifestSchema, verifyReleaseCandidate, } from "./release-candidate.ts"; +import { verifyArchivedLocalEvidence } from "./local-release-evidence.ts"; + +type LocalEvidenceVerifier = typeof verifyArchivedLocalEvidence; + +export type VerifyPromotionInputsOptions = Readonly<{ + environment?: NodeJS.ProcessEnv; + repositoryRoot?: string; + verifyLocalEvidence?: LocalEvidenceVerifier; +}>; export async function verifyPromotionInputs( - environment: NodeJS.ProcessEnv = process.env, + options: VerifyPromotionInputsOptions = {}, ) { - 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 environment = options.environment ?? process.env; + const repositoryRoot = path.resolve(options.repositoryRoot ?? process.cwd()); + const manifestDocument = await requiredJson( + repositoryRoot, + RELEASE_CANDIDATE_MANIFEST_PATH, ); + const manifest = releaseCandidateManifestSchema.parse(manifestDocument); + const candidate = await verifyReleaseCandidate( + manifestDocument, + repositoryRoot, + ); + const localEvidence = await ( + options.verifyLocalEvidence ?? verifyArchivedLocalEvidence + )({ repositoryRoot, candidate: manifest }); const vulnerabilityReport = await optionalJson( + repositoryRoot, environment.VULNERABILITY_REPORT_PATH, ); const provenanceAttestation = await optionalJson( + repositoryRoot, environment.PROVENANCE_ATTESTATION_PATH, ); const result = evaluatePromotionEvidence({ candidate: manifest, currentDistSha256: candidate.currentDistSha256 ?? "", - localStatus: localVerification.localStatus, + localStatus: localEvidence.status, vulnerabilityReport, provenanceAttestation, vulnerabilityTrust: await readTrust( + repositoryRoot, environment.VULNERABILITY_PUBLIC_KEY_PATH, environment.VULNERABILITY_KEY_ID, ), provenanceTrust: await readTrust( + repositoryRoot, environment.PROVENANCE_PUBLIC_KEY_PATH, environment.PROVENANCE_KEY_ID, ), }); - const failures = [...candidate.failures, ...result.failures]; + const failures = [ + ...candidate.failures, + ...localEvidence.failures, + ...result.failures, + ]; return Object.freeze({ schemaVersion: 1 as const, status: @@ -57,6 +83,7 @@ export async function verifyPromotionInputs( } async function readTrust( + repositoryRoot: string, publicKeyPath: string | undefined, keyId: string | undefined, ): Promise { @@ -64,24 +91,36 @@ async function readTrust( try { return Object.freeze({ keyId, - publicKey: createPublicKey(await readFile(publicKeyPath, "utf8")), + publicKey: createPublicKey( + await readFile(path.resolve(repositoryRoot, publicKeyPath), "utf8"), + ), }); } catch { return null; } } -async function optionalJson(file: string | undefined): Promise { +async function optionalJson( + repositoryRoot: string, + file: string | undefined, +): Promise { if (!file) return null; try { - return JSON.parse(await readFile(file, "utf8")) as unknown; + return JSON.parse( + await readFile(path.resolve(repositoryRoot, file), "utf8"), + ) as unknown; } catch { return null; } } -async function requiredJson(file: string): Promise> { - const value: unknown = JSON.parse(await readFile(file, "utf8")); +async function requiredJson( + repositoryRoot: string, + file: string, +): Promise> { + const value: unknown = JSON.parse( + await readFile(path.join(repositoryRoot, file), "utf8"), + ); if (!value || typeof value !== "object" || Array.isArray(value)) { throw new TypeError(`${file} must be a JSON object`); } diff --git a/scripts/lib/provider-evidence.ts b/scripts/lib/provider-evidence.ts index a8a1fa2..af23aa4 100644 --- a/scripts/lib/provider-evidence.ts +++ b/scripts/lib/provider-evidence.ts @@ -1,4 +1,4 @@ -import { verify, type KeyLike } from "node:crypto"; +import { verify, type KeyObject } from "node:crypto"; import { z } from "zod"; @@ -56,7 +56,7 @@ export const providerVerificationArtifactSchema = z export type ProviderTrust = Readonly<{ keyId: string; - publicKey: KeyLike; + publicKey: KeyObject; }>; export type PromotionEvidenceResult = Readonly<{ @@ -182,7 +182,13 @@ function signatureMatches( >, trust: ProviderTrust | null, ): boolean { - if (!trust || evidence.signature.keyId !== trust.keyId) return false; + if ( + !trust || + evidence.signature.keyId !== trust.keyId || + trust.publicKey.asymmetricKeyType !== "ed25519" + ) { + return false; + } try { return verify( null, diff --git a/scripts/lib/release-candidate.ts b/scripts/lib/release-candidate.ts index 98db7e2..657fe71 100644 --- a/scripts/lib/release-candidate.ts +++ b/scripts/lib/release-candidate.ts @@ -34,6 +34,7 @@ export const RELEASE_CANDIDATE_MANIFEST_PATH = "artifacts/release/release-candidate.json"; export const RELEASE_CANDIDATE_EVIDENCE_PATHS = Object.freeze([ + "pnpm-lock.yaml", "artifacts/performance/bundle.json", "artifacts/quality/vite-module-inventory.json", "artifacts/release/build-manifest.json", @@ -113,10 +114,21 @@ export async function createReleaseCandidateManifest( "utf8", ), ) as { lockfileSha256?: unknown }; + const rawLockfileSha256 = evidence.find( + (file) => file.path === "pnpm-lock.yaml", + )?.sha256; + if ( + typeof rawLockfileSha256 !== "string" || + dependencyInventory.lockfileSha256 !== rawLockfileSha256 + ) { + throw new Error( + "raw pnpm-lock digest mismatch with dependency inventory", + ); + } return releaseCandidateManifestSchema.parse({ schemaVersion: 1, distSha256: distSha256(outputs), - lockfileSha256: dependencyInventory.lockfileSha256, + lockfileSha256: rawLockfileSha256, bundleSha256: supplyChainDigest(files), files, }); diff --git a/scripts/lib/supply-chain-time.ts b/scripts/lib/supply-chain-time.ts new file mode 100644 index 0000000..a2dda7d --- /dev/null +++ b/scripts/lib/supply-chain-time.ts @@ -0,0 +1,24 @@ +export function deterministicSupplyChainGeneratedAt(input: Readonly<{ + generatedAt: string; + sourceDateEpoch: string | null; +}>): string { + const generatedAtMs = Date.parse(input.generatedAt); + if (!Number.isFinite(generatedAtMs)) { + throw new TypeError("build manifest generatedAt must be an ISO timestamp"); + } + if (input.sourceDateEpoch !== null) { + if (!/^(?:0|[1-9]\d*)$/u.test(input.sourceDateEpoch)) { + throw new TypeError("SOURCE_DATE_EPOCH must be whole seconds"); + } + const epoch = Number(input.sourceDateEpoch); + if ( + !Number.isSafeInteger(epoch) || + new Date(epoch * 1000).toISOString() !== input.generatedAt + ) { + throw new TypeError( + "build manifest generatedAt must match SOURCE_DATE_EPOCH", + ); + } + } + return new Date(generatedAtMs).toISOString(); +} diff --git a/scripts/verify-archived-local-evidence.ts b/scripts/verify-archived-local-evidence.ts new file mode 100644 index 0000000..6c8c1e7 --- /dev/null +++ b/scripts/verify-archived-local-evidence.ts @@ -0,0 +1,19 @@ +import { readFile } from "node:fs/promises"; + +import { verifyArchivedLocalEvidence } from "./lib/local-release-evidence.ts"; +import { + RELEASE_CANDIDATE_MANIFEST_PATH, + releaseCandidateManifestSchema, +} from "./lib/release-candidate.ts"; + +const candidate = releaseCandidateManifestSchema.parse( + JSON.parse(await readFile(RELEASE_CANDIDATE_MANIFEST_PATH, "utf8")), +); +const result = await verifyArchivedLocalEvidence({ candidate }); +if (result.status !== "PASS") { + process.stderr.write( + `Archived local evidence verification failed:\n- ${result.failures.join("\n- ")}\n`, + ); + process.exit(1); +} +process.stdout.write("Archived local evidence verification: PASS\n"); diff --git a/scripts/verify-supply-chain-artifacts.ts b/scripts/verify-supply-chain-artifacts.ts index 2c70e69..127cc53 100644 --- a/scripts/verify-supply-chain-artifacts.ts +++ b/scripts/verify-supply-chain-artifacts.ts @@ -1,138 +1,24 @@ -import { createHash } from "node:crypto"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { mkdir } from "node:fs/promises"; import { - isValidSha512Integrity, - parsePnpmLockfilePackages, - supplyChainDigest, - verifySupplyChainCoherence, -} from "./lib/supply-chain.ts"; -import { assertMatchesJsonSchema } from "./lib/json-schema.ts"; -import { - collectDistOutputs, - distSha256, -} from "./lib/release-candidate.ts"; + supplyChainCoherenceReportSchema, + verifyLocalSupplyChainEvidence, +} from "./lib/local-release-evidence.ts"; +import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts"; -type Document = Record; - -function isRecord(value: unknown): value is Document { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - -function parseDocument(text: string, label: string): Document { - const parsed: unknown = JSON.parse(text); - if (!isRecord(parsed)) throw new Error(`${label} must be a JSON object`); - return parsed; -} - -function recordRows(value: unknown): Document[] { - return Array.isArray(value) ? value.filter(isRecord) : []; -} - -async function readDocument(file: string): Promise { - return parseDocument(await readFile(file, "utf8"), file); -} - -const inventory = await readDocument( - "artifacts/release/dependency-inventory.json", -); -const sbom = await readDocument("artifacts/release/sbom.cdx.json"); -const provenance = await readDocument("artifacts/release/provenance.json"); -const verification = await readDocument( - "artifacts/security/supply-chain-verification.json", -); -const artifactSchemaFailures: string[] = []; -for (const [schemaPath, artifact, label] of [ - [ - "schemas/artifacts/dependency-inventory.schema.json", - inventory, - "dependency inventory", - ], - [ - "schemas/artifacts/supply-chain-verification.schema.json", - verification, - "supply-chain verification", - ], -] as const) { - try { - assertMatchesJsonSchema(await readDocument(schemaPath), artifact, label); - } catch { - artifactSchemaFailures.push(`${label} JSON Schema mismatch`); - } -} -const lockfileText = await readFile("pnpm-lock.yaml", "utf8"); -const lockfileSha256 = createHash("sha256") - .update(lockfileText) - .digest("hex"); -const outputs = await collectDistOutputs(); -const distDigest = distSha256(outputs); -const coherence = verifySupplyChainCoherence( - sbom, - inventory, - provenance, - distDigest, -); -const failures: string[] = [ - ...artifactSchemaFailures, - ...coherence.failures, -]; -if ( - inventory.lockfileSha256 !== lockfileSha256 || - verification.lockfileSha256 !== lockfileSha256 -) { - failures.push("inventory/verification lockfile digest mismatch"); -} -if ( - verification.distSha256 !== distDigest || - verification.sbomSha256 !== supplyChainDigest(sbom) -) { - failures.push("verification digest set is incoherent"); -} -const lockRows = parsePnpmLockfilePackages(lockfileText); -const inventoryRows = recordRows(inventory.dependencies); -const inventoryByIdentity = new Map( - inventoryRows.map( - (entry) => [ - `${String(entry.name ?? "")}@${String(entry.version ?? "")}`, - entry, - ] as const, - ), -); -if (lockRows.length !== inventoryRows.length) { - failures.push("transitive dependency count differs from lockfile"); -} -for (const lockRow of lockRows) { - const identity = `${lockRow.name}@${lockRow.version}`; - const dependency = inventoryByIdentity.get(identity); - if ( - !dependency || - dependency.integrity !== lockRow.integrity || - !isValidSha512Integrity(lockRow.integrity) - ) { - failures.push(`lockfile inventory integrity mismatch: ${identity}`); - } -} - -const report = { - schemaVersion: 1, - status: failures.length === 0 ? "PASS" : "FAIL", - dependencyCount: inventoryRows.length, - lockfileSha256, - distSha256: distDigest, - sbomSha256: supplyChainDigest(sbom), - failures, -}; +const report = await verifyLocalSupplyChainEvidence(); await mkdir("artifacts/security", { recursive: true }); -await writeFile( - "artifacts/security/supply-chain-coherence.json", - `${JSON.stringify(report, null, 2)}\n`, -); -if (failures.length > 0) { +await writeValidatedJsonArtifact({ + path: "artifacts/security/supply-chain-coherence.json", + schema: supplyChainCoherenceReportSchema, + value: report, +}); +if (report.status !== "PASS") { process.stderr.write( - `Supply-chain artifact coherence failed:\n- ${failures.join("\n- ")}\n`, + `Supply-chain artifact coherence failed:\n- ${report.failures.join("\n- ")}\n`, ); process.exit(1); } process.stdout.write( - `Supply-chain artifact coherence: PASS (${inventoryRows.length} dependencies)\n`, + `Supply-chain artifact coherence: PASS (${report.dependencyCount} dependencies)\n`, ); diff --git a/tests/unit/supply-chain.test.ts b/tests/unit/supply-chain.test.ts index a96fa22..d08f597 100644 --- a/tests/unit/supply-chain.test.ts +++ b/tests/unit/supply-chain.test.ts @@ -1,4 +1,4 @@ -import { generateKeyPairSync, sign } from "node:crypto"; +import { createHash, 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"; @@ -29,6 +29,8 @@ import { RELEASE_CANDIDATE_EVIDENCE_PATHS, verifyReleaseCandidate, } from "../../scripts/lib/release-candidate.ts"; +import { deterministicSupplyChainGeneratedAt } from "../../scripts/lib/supply-chain-time.ts"; +import { verifyPromotionInputs } from "../../scripts/lib/promotion-verifier.ts"; const integrity = `sha512-${Buffer.alloc(64, 7).toString("base64")}`; const dependency = { @@ -64,23 +66,240 @@ function signedProviderEvidence( }; } +async function createMinimalCandidateTree(root: string) { + const rawLockfile = "lockfileVersion: '9.0'\n"; + const rawLockfileSha256 = createHash("sha256") + .update(rawLockfile) + .digest("hex"); + await mkdir(path.join(root, "dist"), { recursive: true }); + await writeFile(path.join(root, "dist/app.js"), "immutable\n"); + await writeFile(path.join(root, "pnpm-lock.yaml"), rawLockfile); + for (const file of RELEASE_CANDIDATE_EVIDENCE_PATHS) { + if (file === "pnpm-lock.yaml") continue; + await mkdir(path.dirname(path.join(root, file)), { recursive: true }); + const value = + file === "artifacts/release/dependency-inventory.json" + ? { lockfileSha256: rawLockfileSha256 } + : file === "artifacts/security/supply-chain-verification.json" + ? { localStatus: "PASS" } + : { fixture: file }; + await writeFile(path.join(root, file), `${JSON.stringify(value)}\n`); + } + const manifest = await createReleaseCandidateManifest(root); + await writeFile( + path.join(root, "artifacts/release/release-candidate.json"), + `${JSON.stringify(manifest)}\n`, + ); + return manifest; +} + +async function writeProviderEnvironment( + root: string, + distDigest: string, + candidateLockfileSha256: string, +) { + 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: candidateLockfileSha256, + scannedDistSha256: distDigest, + 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: distDigest } }, + }, + "fixture-provenance-key", + provenanceKeys.privateKey, + ); + await mkdir(path.join(root, "provider"), { recursive: true }); + await Promise.all([ + writeFile( + path.join(root, "provider/vulnerability.json"), + `${JSON.stringify(vulnerabilityReport)}\n`, + ), + writeFile( + path.join(root, "provider/provenance.json"), + `${JSON.stringify(provenanceAttestation)}\n`, + ), + writeFile( + path.join(root, "provider/vulnerability.pem"), + vulnerabilityKeys.publicKey + .export({ type: "spki", format: "pem" }) + .toString(), + ), + writeFile( + path.join(root, "provider/provenance.pem"), + provenanceKeys.publicKey + .export({ type: "spki", format: "pem" }) + .toString(), + ), + ]); + return { + VULNERABILITY_REPORT_PATH: "provider/vulnerability.json", + PROVENANCE_ATTESTATION_PATH: "provider/provenance.json", + VULNERABILITY_PUBLIC_KEY_PATH: "provider/vulnerability.pem", + VULNERABILITY_KEY_ID: "fixture-vulnerability-key", + PROVENANCE_PUBLIC_KEY_PATH: "provider/provenance.pem", + PROVENANCE_KEY_ID: "fixture-provenance-key", + } satisfies NodeJS.ProcessEnv; +} + describe("supply-chain policy", () => { + it("wires candidate files, PEM trust, env report paths, and mutation checks", async () => { + const root = await mkdtemp(path.join(tmpdir(), "promotion-wiring-")); + try { + const manifest = await createMinimalCandidateTree(root); + const validEnvironment = await writeProviderEnvironment( + root, + manifest.distSha256, + manifest.lockfileSha256, + ); + const acceptLocalEvidence = async () => ({ + status: "PASS" as const, + failures: [] as const, + }); + const valid = await verifyPromotionInputs({ + repositoryRoot: root, + environment: validEnvironment, + verifyLocalEvidence: acceptLocalEvidence, + }); + const absent = await verifyPromotionInputs({ + repositoryRoot: root, + environment: {}, + verifyLocalEvidence: acceptLocalEvidence, + }); + const wrongEnvironment = await writeProviderEnvironment( + root, + "3".repeat(64), + manifest.lockfileSha256, + ); + const wrongDigest = await verifyPromotionInputs({ + repositoryRoot: root, + environment: wrongEnvironment, + verifyLocalEvidence: acceptLocalEvidence, + }); + await writeFile(path.join(root, "dist/app.js"), "mutated\n"); + const postAttestationMutation = await verifyPromotionInputs({ + repositoryRoot: root, + environment: validEnvironment, + verifyLocalEvidence: acceptLocalEvidence, + }); + + expect({ + valid: valid.status, + absent: absent.status, + wrongDigest: wrongDigest.status, + postAttestationMutation: postAttestationMutation.status, + }).toEqual({ + valid: "PASS", + absent: "FAIL_UNVERIFIED", + wrongDigest: "FAIL_UNVERIFIED", + postAttestationMutation: "FAIL_UNVERIFIED", + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("rejects a self-consistent candidate that merely claims localStatus PASS", async () => { + const root = await mkdtemp(path.join(tmpdir(), "promotion-local-status-")); + try { + const manifest = await createMinimalCandidateTree(root); + const environment = await writeProviderEnvironment( + root, + manifest.distSha256, + manifest.lockfileSha256, + ); + const localVerificationPath = path.join( + root, + "artifacts/security/supply-chain-verification.json", + ); + const before = await readFile(localVerificationPath, "utf8"); + const result = await verifyPromotionInputs({ + repositoryRoot: root, + environment, + }); + + expect(result.status).toBe("FAIL_UNVERIFIED"); + expect(result.failures).toEqual( + expect.arrayContaining([ + expect.stringMatching(/executable schema mismatch/u), + "local supply-chain evidence is not PASS", + ]), + ); + expect(await readFile(localVerificationPath, "utf8")).toBe(before); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("derives a stable supply-chain timestamp from the immutable build epoch", () => { + const input = { + generatedAt: "2026-08-01T00:00:00.000Z", + sourceDateEpoch: "1785542400", + }; + expect(deterministicSupplyChainGeneratedAt(input)).toBe( + "2026-08-01T00:00:00.000Z", + ); + expect(deterministicSupplyChainGeneratedAt(input)).toBe( + deterministicSupplyChainGeneratedAt({ ...input }), + ); + expect(() => + deterministicSupplyChainGeneratedAt({ + generatedAt: "not-a-time", + sourceDateEpoch: "1785542400", + }), + ).toThrow(/generatedAt/u); + expect(() => + deterministicSupplyChainGeneratedAt({ + generatedAt: "2026-08-01T00:00:00.000Z", + sourceDateEpoch: "1785542401", + }), + ).toThrow(/SOURCE_DATE_EPOCH/u); + }); + it("rejects release candidate dist bytes changed after manifest creation", async () => { const root = await mkdtemp(path.join(tmpdir(), "release-candidate-")); try { + const rawLockfile = "lockfileVersion: '9.0'\n"; + const rawLockfileSha256 = createHash("sha256") + .update(rawLockfile) + .digest("hex"); 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"); + await writeFile(path.join(root, "pnpm-lock.yaml"), rawLockfile); for (const file of RELEASE_CANDIDATE_EVIDENCE_PATHS) { + if (file === "pnpm-lock.yaml") continue; 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` + ? `${JSON.stringify({ lockfileSha256: rawLockfileSha256 })}\n` : `${file}\n`, ); } const manifest = await createReleaseCandidateManifest(root); + expect(manifest.lockfileSha256).toBe(rawLockfileSha256); + expect(manifest.files).toContainEqual( + expect.objectContaining({ + path: "pnpm-lock.yaml", + sha256: rawLockfileSha256, + }), + ); + expect(await createReleaseCandidateManifest(root)).toEqual(manifest); expect((await verifyReleaseCandidate(manifest, root)).failures).toEqual( [], ); @@ -100,6 +319,30 @@ describe("supply-chain policy", () => { } }); + it("rejects a dependency inventory digest that differs from raw pnpm-lock bytes", async () => { + const root = await mkdtemp(path.join(tmpdir(), "release-lockfile-")); + try { + await mkdir(path.join(root, "dist"), { recursive: true }); + await writeFile(path.join(root, "dist/app.js"), "immutable\n"); + await writeFile(path.join(root, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n"); + for (const file of RELEASE_CANDIDATE_EVIDENCE_PATHS) { + if (file === "pnpm-lock.yaml") continue; + 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`, + ); + } + await expect(createReleaseCandidateManifest(root)).rejects.toThrow( + /raw pnpm-lock digest mismatch/u, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + it("fails promotion when external provider evidence is absent", () => { const result = evaluatePromotionEvidence({ candidate: { @@ -285,6 +528,55 @@ describe("supply-chain policy", () => { ); }); + it("rejects Ed448 keys mislabeled as Ed25519 evidence", () => { + const vulnerabilityKeys = generateKeyPairSync("ed448"); + const provenanceKeys = generateKeyPairSync("ed448"); + 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, + ); + + expect( + 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, + }, + }).status, + ).toBe("FAIL_UNVERIFIED"); + }); + it.each([ ["empty", []], ["empty entry", [""]],