From 30ceac23c11810f381360fb071d7ee4a56a9859a Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Sun, 2 Aug 2026 16:28:24 +0900 Subject: [PATCH] fix: harden provider and promotion evidence --- .gitea/workflows/quality-gates.yml | 54 +- config/ci/gates.json | 95 +- docs/operations/ci-quality-gates.md | 22 + docs/security/supply-chain.md | 28 + .../plans/2026-08-02-security-finalizer.md | 82 ++ .../check-supply-chain-provider-fixtures.ts | 99 +- scripts/cleanup-verified-promotion.ts | 25 + scripts/contracts/ci-gates.ts | 87 +- scripts/contracts/promotion-artifacts.ts | 18 +- scripts/contracts/release-artifacts.ts | 125 +++ scripts/create-release-candidate.ts | 10 + scripts/generate-ci-workflow.ts | 26 + scripts/lib/ci-candidate-archive.ts | 56 ++ scripts/lib/local-release-evidence.ts | 508 ++++++++++- scripts/lib/promotion-stager.ts | 579 +++++++----- scripts/lib/promotion-verifier.ts | 182 +++- scripts/lib/provider-evidence.ts | 435 ++++++--- scripts/lib/provider-supervisor.ts | 143 +++ scripts/lib/provider-upload-validator.ts | 95 +- scripts/lib/release-candidate.ts | 3 + scripts/run-and-validate-provider.ts | 86 +- scripts/stage-verified-promotion.ts | 28 +- scripts/verify-archived-local-evidence.ts | 5 +- .../security-followup-archive.test.ts | 127 +++ .../ci-workflow-generation.test.ts.snap | 54 +- tests/unit/ci-artifact-contract.test.ts | 774 ++++++++++------ tests/unit/ci-workflow-generation.test.ts | 32 +- tests/unit/security-followup.test.ts | 863 ++++++++++++++++++ tests/unit/supply-chain.test.ts | 396 ++++---- 29 files changed, 3961 insertions(+), 1076 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-02-security-finalizer.md create mode 100644 scripts/cleanup-verified-promotion.ts create mode 100644 scripts/lib/provider-supervisor.ts create mode 100644 tests/integration/security-followup-archive.test.ts create mode 100644 tests/unit/security-followup.test.ts diff --git a/.gitea/workflows/quality-gates.yml b/.gitea/workflows/quality-gates.yml index 5df76a7..a2785a3 100644 --- a/.gitea/workflows/quality-gates.yml +++ b/.gitea/workflows/quality-gates.yml @@ -156,6 +156,7 @@ jobs: artifacts/release/sbom.cdx.json \ artifacts/security/dependency-diff.json \ artifacts/security/license-report.json \ + artifacts/security/local-evidence-assessment.json \ artifacts/security/scan.sarif \ artifacts/security/supply-chain-coherence.json \ artifacts/security/supply-chain-verification.json \ @@ -174,11 +175,16 @@ jobs: needs: immutable_build runs-on: ubuntu-latest timeout-minutes: 45 + outputs: + invocation_nonce: ${{ steps.supervise_vulnerability.outputs.invocation_nonce }} env: CANDIDATE_ARCHIVE_SHA256: "${{ needs.immutable_build.outputs.archive_sha256 }}" CANDIDATE_ARCHIVE_PATH: ".release/vulnerability-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" - CANDIDATE_DIST_SHA256: "${{ needs.immutable_build.outputs.dist_sha256 }}" - CANDIDATE_LOCKFILE_PATH: .release/verified-vulnerability/pnpm-lock.yaml + CI_RUN_ID: "${{ gitea.run_id }}" + CI_RUN_ATTEMPT: "${{ gitea.run_attempt }}" + EXPECTED_SOURCE_REVISION: "${{ gitea.sha }}" + VULNERABILITY_PUBLIC_KEY_PATH: "${{ vars.VULNERABILITY_PUBLIC_KEY_PATH }}" + VULNERABILITY_KEY_ID: "${{ vars.VULNERABILITY_KEY_ID }}" VULNERABILITY_PROVIDER_COMMAND: "${{ vars.VULNERABILITY_PROVIDER_COMMAND }}" VULNERABILITY_REPORT_PATH: provider-evidence/untrusted/vulnerability-report.json VALIDATED_PROVIDER_REPORT_PATH: provider-evidence/vulnerability-report.json @@ -198,9 +204,8 @@ jobs: with: name: "release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}" path: .release/vulnerability-candidate - - name: Verify and extract the candidate through one inode-bound operation - run: node scripts/verify-ci-candidate-archive.ts --archive ".release/vulnerability-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" --extract-to ".release/verified-vulnerability" - name: Run and validate external vulnerability provider in one trusted supervisor + id: supervise_vulnerability run: node scripts/run-and-validate-provider.ts --kind vulnerability - name: Confirm sealed vulnerability provider evidence run: test -s "$VALIDATED_PROVIDER_REPORT_PATH" @@ -216,11 +221,16 @@ jobs: needs: immutable_build runs-on: ubuntu-latest timeout-minutes: 45 + outputs: + invocation_nonce: ${{ steps.supervise_provenance.outputs.invocation_nonce }} env: CANDIDATE_ARCHIVE_SHA256: "${{ needs.immutable_build.outputs.archive_sha256 }}" CANDIDATE_ARCHIVE_PATH: ".release/provenance-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" - CANDIDATE_DIST_SHA256: "${{ needs.immutable_build.outputs.dist_sha256 }}" - CANDIDATE_LOCKFILE_PATH: .release/verified-provenance/pnpm-lock.yaml + CI_RUN_ID: "${{ gitea.run_id }}" + CI_RUN_ATTEMPT: "${{ gitea.run_attempt }}" + EXPECTED_SOURCE_REVISION: "${{ gitea.sha }}" + PROVENANCE_PUBLIC_KEY_PATH: "${{ vars.PROVENANCE_PUBLIC_KEY_PATH }}" + PROVENANCE_KEY_ID: "${{ vars.PROVENANCE_KEY_ID }}" PROVENANCE_PROVIDER_COMMAND: "${{ vars.PROVENANCE_PROVIDER_COMMAND }}" PROVENANCE_ATTESTATION_PATH: provider-evidence/untrusted/provenance-attestation.json VALIDATED_PROVIDER_REPORT_PATH: provider-evidence/provenance-attestation.json @@ -240,9 +250,8 @@ jobs: with: name: "release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}" path: .release/provenance-candidate - - name: Verify and extract the candidate through one inode-bound operation - run: node scripts/verify-ci-candidate-archive.ts --archive ".release/provenance-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" --extract-to ".release/verified-provenance" - name: Run and validate external provenance provider in one trusted supervisor + id: supervise_provenance run: node scripts/run-and-validate-provider.ts --kind provenance - name: Confirm sealed provenance provider evidence run: test -s "$VALIDATED_PROVIDER_REPORT_PATH" @@ -261,13 +270,16 @@ jobs: env: CANDIDATE_ARCHIVE_SHA256: "${{ needs.immutable_build.outputs.archive_sha256 }}" CANDIDATE_ARCHIVE_PATH: ".release/candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" - CANDIDATE_ROOT: "${{ gitea.workspace }}/.release/verified-candidate" + CI_RUN_ID: "${{ gitea.run_id }}" + CI_RUN_ATTEMPT: "${{ gitea.run_attempt }}" VULNERABILITY_REPORT_PATH: "${{ gitea.workspace }}/.release/vulnerability/vulnerability-report.json" PROVENANCE_ATTESTATION_PATH: "${{ gitea.workspace }}/.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 }}" + VULNERABILITY_INVOCATION_NONCE: "${{ needs.vulnerability_provider.outputs.invocation_nonce }}" + PROVENANCE_INVOCATION_NONCE: "${{ needs.provenance_provider.outputs.invocation_nonce }}" steps: - uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 with: @@ -294,21 +306,31 @@ jobs: with: name: "provenance-provider-${{ gitea.run_id }}-${{ gitea.run_attempt }}" path: .release/provenance - - name: Verify and extract the candidate through one inode-bound operation - run: node scripts/verify-ci-candidate-archive.ts --archive ".release/candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" --extract-to ".release/verified-candidate" - name: Finalize verified promotion from inode-bound captured inputs + id: finalize run: node scripts/stage-verified-promotion.ts - name: Upload promoted release uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7 with: name: "promoted-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}" path: | - .release/promoted-staging/release-candidate.tar.gz - .release/promoted-staging/vulnerability-report.json - .release/promoted-staging/provenance-attestation.json - .release/promoted-staging/provider-verification.json - .release/promoted-staging/promotion-verification.json + ${{ steps.finalize.outputs.staging_root }}/release-candidate.tar.gz + ${{ steps.finalize.outputs.staging_root }}/vulnerability-report.json + ${{ steps.finalize.outputs.staging_root }}/provenance-attestation.json + ${{ steps.finalize.outputs.staging_root }}/provider-verification.json + ${{ steps.finalize.outputs.staging_root }}/promotion-verification.json if-no-files-found: error + - name: Always remove private promotion staging + if: always() + env: + PROMOTION_STAGING_ROOT: ${{ steps.finalize.outputs.staging_root }} + PROMOTION_CLEANUP_TOKEN: ${{ steps.finalize.outputs.cleanup_token }} + PROMOTION_RUNNER_TEMP_DEV: ${{ steps.finalize.outputs.runner_temp_dev }} + PROMOTION_RUNNER_TEMP_INO: ${{ steps.finalize.outputs.runner_temp_ino }} + run: | + if [ -n "$PROMOTION_STAGING_ROOT" ] && [ -n "$PROMOTION_CLEANUP_TOKEN" ]; then + node scripts/cleanup-verified-promotion.ts + fi production_gate: name: "${{ matrix.gate }} / ${{ matrix.name }}" diff --git a/config/ci/gates.json b/config/ci/gates.json index 3b53c81..00ffa5a 100644 --- a/config/ci/gates.json +++ b/config/ci/gates.json @@ -2180,6 +2180,7 @@ "artifacts/release/sbom.cdx.json", "artifacts/security/dependency-diff.json", "artifacts/security/license-report.json", + "artifacts/security/local-evidence-assessment.json", "artifacts/security/scan.sarif", "artifacts/security/supply-chain-coherence.json", "artifacts/security/supply-chain-verification.json", @@ -2220,12 +2221,24 @@ "value": ".release/vulnerability-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" }, { - "name": "CANDIDATE_DIST_SHA256", - "value": "${{ needs.immutable_build.outputs.dist_sha256 }}" + "name": "CI_RUN_ID", + "value": "${{ gitea.run_id }}" }, { - "name": "CANDIDATE_LOCKFILE_PATH", - "value": ".release/verified-vulnerability/pnpm-lock.yaml" + "name": "CI_RUN_ATTEMPT", + "value": "${{ gitea.run_attempt }}" + }, + { + "name": "EXPECTED_SOURCE_REVISION", + "value": "${{ gitea.sha }}" + }, + { + "name": "VULNERABILITY_PUBLIC_KEY_PATH", + "value": "${{ vars.VULNERABILITY_PUBLIC_KEY_PATH }}" + }, + { + "name": "VULNERABILITY_KEY_ID", + "value": "${{ vars.VULNERABILITY_KEY_ID }}" }, { "name": "VULNERABILITY_PROVIDER_COMMAND", @@ -2255,14 +2268,10 @@ "transferId": "release-candidate", "path": ".release/vulnerability-candidate" }, - { - "kind": "extract", - "archivePath": ".release/vulnerability-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz", - "targetRoot": ".release/verified-vulnerability" - }, { "kind": "run-provider", - "provider": "vulnerability" + "provider": "vulnerability", + "stepId": "supervise_vulnerability" }, { "kind": "validate-provider-evidence", @@ -2299,12 +2308,24 @@ "value": ".release/provenance-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" }, { - "name": "CANDIDATE_DIST_SHA256", - "value": "${{ needs.immutable_build.outputs.dist_sha256 }}" + "name": "CI_RUN_ID", + "value": "${{ gitea.run_id }}" }, { - "name": "CANDIDATE_LOCKFILE_PATH", - "value": ".release/verified-provenance/pnpm-lock.yaml" + "name": "CI_RUN_ATTEMPT", + "value": "${{ gitea.run_attempt }}" + }, + { + "name": "EXPECTED_SOURCE_REVISION", + "value": "${{ gitea.sha }}" + }, + { + "name": "PROVENANCE_PUBLIC_KEY_PATH", + "value": "${{ vars.PROVENANCE_PUBLIC_KEY_PATH }}" + }, + { + "name": "PROVENANCE_KEY_ID", + "value": "${{ vars.PROVENANCE_KEY_ID }}" }, { "name": "PROVENANCE_PROVIDER_COMMAND", @@ -2334,14 +2355,10 @@ "transferId": "release-candidate", "path": ".release/provenance-candidate" }, - { - "kind": "extract", - "archivePath": ".release/provenance-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz", - "targetRoot": ".release/verified-provenance" - }, { "kind": "run-provider", - "provider": "provenance" + "provider": "provenance", + "stepId": "supervise_provenance" }, { "kind": "validate-provider-evidence", @@ -2380,8 +2397,12 @@ "value": ".release/candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" }, { - "name": "CANDIDATE_ROOT", - "value": "${{ gitea.workspace }}/.release/verified-candidate" + "name": "CI_RUN_ID", + "value": "${{ gitea.run_id }}" + }, + { + "name": "CI_RUN_ATTEMPT", + "value": "${{ gitea.run_attempt }}" }, { "name": "VULNERABILITY_REPORT_PATH", @@ -2406,6 +2427,14 @@ { "name": "PROVENANCE_KEY_ID", "value": "${{ vars.PROVENANCE_KEY_ID }}" + }, + { + "name": "VULNERABILITY_INVOCATION_NONCE", + "value": "${{ needs.vulnerability_provider.outputs.invocation_nonce }}" + }, + { + "name": "PROVENANCE_INVOCATION_NONCE", + "value": "${{ needs.provenance_provider.outputs.invocation_nonce }}" } ], "steps": [ @@ -2434,24 +2463,24 @@ "path": ".release/provenance" }, { - "kind": "extract", - "archivePath": ".release/candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz", - "targetRoot": ".release/verified-candidate" - }, - { - "kind": "verify-promotion" + "kind": "verify-promotion", + "stepId": "finalize" }, { "kind": "upload", "transferId": "promoted-release", "name": "promoted-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}", "paths": [ - ".release/promoted-staging/release-candidate.tar.gz", - ".release/promoted-staging/vulnerability-report.json", - ".release/promoted-staging/provenance-attestation.json", - ".release/promoted-staging/provider-verification.json", - ".release/promoted-staging/promotion-verification.json" + "${{ steps.finalize.outputs.staging_root }}/release-candidate.tar.gz", + "${{ steps.finalize.outputs.staging_root }}/vulnerability-report.json", + "${{ steps.finalize.outputs.staging_root }}/provenance-attestation.json", + "${{ steps.finalize.outputs.staging_root }}/provider-verification.json", + "${{ steps.finalize.outputs.staging_root }}/promotion-verification.json" ] + }, + { + "kind": "cleanup-promotion", + "finalizerStepId": "finalize" } ] }, diff --git a/docs/operations/ci-quality-gates.md b/docs/operations/ci-quality-gates.md index 34da475..fdf3425 100644 --- a/docs/operations/ci-quality-gates.md +++ b/docs/operations/ci-quality-gates.md @@ -196,6 +196,14 @@ 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. +Each provider command must atomically emit strict provider evidence v2 from +the supervisor bindings for evidence type, issued/expires timestamps, run +ID/attempt, `PROVIDER_INVOCATION_NONCE`, source identity, candidate digests, +key ID, and DER-SPKI key fingerprint. Promotion receives the two supervisor +job outputs as `VULNERABILITY_INVOCATION_NONCE` and +`PROVENANCE_INVOCATION_NONCE`; do not replace them with constants or values +parsed from provider reports. + Promotion verification/staging step과 promoted-release upload action 사이에는 어떤 step도 둘 수 없고 upload에는 `if: always()`를 사용할 수 없다. 이 인접성은 실패한 검증의 publication을 막지만 staging path를 upload action에 @@ -207,6 +215,20 @@ consumer도 artifact service나 transfer action을 신뢰 경계 밖으로 보 manifest와 signed provider evidence에 바인딩된 digest를 다운로드 후 다시 검증해야 한다. 현재 producer-side adjacency 자체는 consumer-side digest revalidation을 대신하지 않는다. +Finalizer output은 `RUNNER_TEMP` 아래 random private directory이며 exact-five +upload는 `${{ steps.finalize.outputs.staging_root }}`만 사용한다. 바로 다음 +`always()` cleanup은 finalizer의 token과 runner-temp device/inode를 모두 +요구한다. stable `.release/promoted-staging` directory를 만들거나 재사용하지 +않는다. exact five는 captured archive/report 두 개와 process 안에서 생성한 +provider/promotion verification v3 두 개이며 promotion record는 provider record, +local assessment, report hashes와 run/source/candidate/nonces/key identities/ +trust-policy hash를 함께 bind한다. 이 descriptor-relative 정리는 ancestor 교체와 symlink leaf를 +fail-closed로 처리하지만 upload action의 same-UID pathname reopen 또는 atomic +`renameat2` handoff를 보장하지 않는다. staging Gitea smoke/native adapter 확인 +전에는 그 경계를 닫았다고 보고하지 않는다. 실제 smoke는 exact-five +upload-download와 success, validation failure, upload failure, cancellation 각각의 +cleanup을 관찰해야 한다. 현재 repository에는 native uploader나 `renameat2` +보장이 없다. 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 38c9da4..f4b71ac 100644 --- a/docs/security/supply-chain.md +++ b/docs/security/supply-chain.md @@ -106,6 +106,27 @@ or direct access to the sealed evidence path. Missing sandbox support, stale or misplaced outputs, command failure/timeout, and post-command candidate drift all stop publication. +Provider documents are strict schema v2. Their Ed25519 signature covers the +supervisor-supplied evidence type, validity window, run ID/attempt, independent +32-byte invocation nonce, archived source identity, and all four candidate +digests. Each provider job exposes its supervisor-generated nonce as a job +output; promotion treats those outputs as the independent expected values and +never lets a report define its own expected nonce. A report from another +attempt, source, archive, nonce, or key fingerprint is fail-closed even when it +has been correctly re-signed. + +The immutable archive contains a strict producer-local assessment. Promotion +revalidates it from captured archive members without reopening checkout policy +or source paths. The finalizer captures the archive, both reports, and both +public keys once, generates both verification v3 records in memory, and writes +exactly five mode-`0400` files beneath a random mode-`0700` directory in +`RUNNER_TEMP`. The exact five are the captured archive, captured vulnerability +report, captured provenance attestation, generated provider-verification v3, +and generated promotion-verification v3. The promotion record binds the exact +provider-record hash, local-assessment hash, both report hashes, run/source/ +candidate identities, both nonces, both key IDs/fingerprints, and canonical +trust-policy hash. It never creates or reuses `.release/promoted-staging`. + The final promotion verification/staging step must be immediately adjacent to the promoted-release upload, and that upload must not use `always()`. This reduces the post-verification mutation window but does not seal a pathname @@ -116,6 +137,13 @@ service and transfer actions also remain outside the candidate's cryptographic identity: every downstream consumer must revalidate the downloaded archive, manifest member digests and signed provider evidence. Producer-side adjacency does not provide consumer-side digest revalidation. +The immediately following upload action still reopens pathnames. The +descriptor-relative staging and cleanup code does not claim an atomic +`renameat2` handoff or close a malicious same-UID Gitea upload adapter; the +staging Gitea smoke/native platform adapter remains the required closure for +that boundary. That smoke must exercise exact-five upload and download plus +cleanup on success, validation failure, upload failure, and cancellation. No +native uploader or `renameat2` guarantee exists in this repository today. Approved vulnerability exceptions require vulnerability/package identity, owner, a different reviewer, reason and expiry. Expired or self-approved diff --git a/docs/superpowers/plans/2026-08-02-security-finalizer.md b/docs/superpowers/plans/2026-08-02-security-finalizer.md new file mode 100644 index 0000000..076cd1c --- /dev/null +++ b/docs/superpowers/plans/2026-08-02-security-finalizer.md @@ -0,0 +1,82 @@ +# Security Finalizer Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Finalize a captured immutable candidate into one private random exact-five staging directory with strict v3 verification records and deterministic cleanup. + +**Architecture:** `finalizeVerifiedPromotion` captures the archive, provider reports, and public keys before validation, evaluates only those captured bytes against archived local evidence, generates both v3 records in memory, and publishes five read-only files under a descriptor-relative runner-temp directory. The generated workflow consumes the returned staging path immediately and always invokes the token-bound cleanup CLI. + +**Tech Stack:** Node.js 24, TypeScript, Zod, Vitest, bubblewrap-independent filesystem primitives, generated Gitea Actions YAML. + +## Global Constraints + +- Never accept pre-existing provider- or promotion-verification JSON as an input. +- Stage exactly archive, vulnerability report, provenance attestation, provider verification v3, and promotion verification v3. +- Use injected time and randomness for deterministic tests. +- Use a random runner-temp directory at mode `0700`, files at `0400`, and `O_EXCL | O_NOFOLLOW` creation. +- Do not claim that TypeScript closes the Gitea upload action pathname-reopen issue or guarantees `renameat2` semantics. + +--- + +### Task 1: Exact-five finalizer contract + +**Files:** +- Modify: `tests/unit/ci-artifact-contract.test.ts` +- Modify: `scripts/lib/promotion-stager.ts` +- Modify: `scripts/contracts/promotion-artifacts.ts` + +**Interfaces:** +- Consumes: `finalizeVerifiedPromotion(input, dependencies)` with captured archive/report/key inputs. +- Produces: `{ stagingRoot, cleanupToken, files }` where `files` is the canonical exact-five name/digest list. + +- [ ] **Step 1: Write failing tests** for no pre-existing records, strict distinct v3 roles, exact provider-record and local-assessment hashes, full run/source/candidate/nonces/key/trust bindings, key rotation, captured-source mutation, and no output on failures. +- [ ] **Step 2: Run RED:** `corepack pnpm exec vitest run tests/unit/ci-artifact-contract.test.ts -t "verified promotion finalizer" --maxWorkers=1` and retain the first contract failure. +- [ ] **Step 3: Implement minimal finalizer changes** so all validation and record generation consume captured bytes and both PASS records are created only after local/provider PASS. +- [ ] **Step 4: Run GREEN:** rerun the focused Vitest command and require zero failures. + +### Task 2: Private staging and cleanup + +**Files:** +- Modify: `tests/unit/ci-artifact-contract.test.ts` +- Modify: `scripts/lib/promotion-stager.ts` +- Modify: `scripts/cleanup-verified-promotion.ts` + +**Interfaces:** +- Consumes: injected `randomBytes`, runner-temp root, cleanup token. +- Produces: descriptor-relative random staging at `0700`, exact files at `0400`, and token-bound cleanup. + +- [ ] **Step 1: Write failing tests** for deterministic naming, modes, stable-path absence, exclusive no-follow creation, parent/leaf substitution, success cleanup, and failure cleanup. +- [ ] **Step 2: Run RED:** use the Task 1 focused Vitest command and retain the first filesystem-boundary failure. +- [ ] **Step 3: Implement minimal private publication and cleanup changes** using `/proc/self/fd` where available, bounded writes, identity rechecks, and removal of owned partial roots. +- [ ] **Step 4: Run GREEN:** rerun the focused Vitest command and require zero failures. + +### Task 3: Workflow handoff + +**Files:** +- Modify: `config/ci/gates.json` +- Modify: `scripts/contracts/ci-gates.ts` +- Modify: `scripts/stage-verified-promotion.ts` +- Modify: `.gitea/workflows/quality-gates.yml` +- Modify: `tests/unit/ci-workflow-generation.test.ts` + +**Interfaces:** +- Consumes: finalizer step outputs `staging_root` and `cleanup_token`. +- Produces: setup, three downloads, finalizer, immediate non-`always()` exact-five upload, and `always()` cleanup ordering. + +- [ ] **Step 1: Write/update failing workflow assertions** that reject standalone extraction, stable staging paths, missing `--ignore-scripts`, upload indirection, or cleanup ordering drift. +- [ ] **Step 2: Run RED:** `node scripts/generate-ci-workflow.ts --check` and the workflow snapshot test. +- [ ] **Step 3: Update the CI contract/config and regenerate YAML** with the finalizer output path and cleanup environment. +- [ ] **Step 4: Run GREEN:** require workflow byte check and snapshot test PASS. + +### Task 4: Full verification and durable report + +**Files:** +- Modify: `.superpowers/sdd/2026-08-01-quality-architecture-remediation/task-3-report.md` + +**Interfaces:** +- Consumes: focused finalizer, provider, workflow, type, and lint evidence. +- Produces: durable RED/GREEN evidence and a commit-ready report without overclaiming platform handoff guarantees. + +- [ ] **Step 1: Run verification:** focused finalizer/provider tests, `check:supply-chain:provider-fixtures`, workflow `--check`, `check:types`, and `lint`. +- [ ] **Step 2: Append exact RED/GREEN commands and outcomes** to the task report, including the remaining Gitea upload and `renameat2` limitations. +- [ ] **Step 3: Inspect diff/status** and report completion before committing. diff --git a/scripts/check-supply-chain-provider-fixtures.ts b/scripts/check-supply-chain-provider-fixtures.ts index 0af6a40..143da92 100644 --- a/scripts/check-supply-chain-provider-fixtures.ts +++ b/scripts/check-supply-chain-provider-fixtures.ts @@ -3,15 +3,31 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import { providerEvidenceSignaturePayload } from "./lib/provider-evidence.ts"; +import { + providerEvidenceSignaturePayload, + providerPublicKeyFingerprint, +} from "./lib/provider-evidence.ts"; +import { localEvidenceAssessmentArtifactSchema } from "./contracts/release-artifacts.ts"; import { verifyPromotionInputs } from "./lib/promotion-verifier.ts"; import { createReleaseCandidateManifest, + LOCAL_EVIDENCE_ASSESSMENT_PATH, RELEASE_CANDIDATE_EVIDENCE_PATHS, RELEASE_CANDIDATE_MANIFEST_PATH, releaseCandidateManifestSchema, } from "./lib/release-candidate.ts"; +const NOW = Date.parse("2026-08-02T01:00:00.000Z"); +const FIXTURE_SOURCE = Object.freeze({ + revision: "a".repeat(40), + sourceSetSha256: "b".repeat(64), +}); +const FIXTURE_LOCAL_IDENTITY = Object.freeze({ + sourceRevision: FIXTURE_SOURCE.revision, + sourceSetSha256: FIXTURE_SOURCE.sourceSetSha256, + assessmentSha256: "c".repeat(64), +}); + const fixtureRoot = await mkdtemp( path.join(tmpdir(), "supply-chain-provider-fixture-"), ); @@ -25,18 +41,27 @@ try { ), ) as unknown, ); + const actualAssessment = localEvidenceAssessmentArtifactSchema.parse( + JSON.parse( + await readFile(path.join(repositoryRoot, LOCAL_EVIDENCE_ASSESSMENT_PATH), "utf8"), + ) as unknown, + ); const actualProviderEnvironment = absoluteProviderEnvironment( fixtureRoot, await writeProviderEnvironment( fixtureRoot, "actual", - actualCandidate.distSha256, - actualCandidate.lockfileSha256, + actualCandidate, + { + revision: actualAssessment.source.revision, + sourceSetSha256: actualAssessment.source.sourceSetSha256, + }, ), ); const actualDefaultVerifier = await verifyPromotionInputs({ artifactType: "provider-verification", environment: actualProviderEnvironment, + nowEpochMs: () => NOW, }); const rawLockfile = "lockfileVersion: '9.0'\n"; @@ -69,17 +94,19 @@ try { const validEnvironment = await writeProviderEnvironment( fixtureRoot, "valid", - candidate.distSha256, - candidate.lockfileSha256, + candidate, + FIXTURE_SOURCE, ); const wrongEnvironment = await writeProviderEnvironment( fixtureRoot, "wrong", - "3".repeat(64), - candidate.lockfileSha256, + candidate, + FIXTURE_SOURCE, + { distSha256: "3".repeat(64) }, ); const acceptLocalEvidence = async () => ({ status: "PASS" as const, + identity: FIXTURE_LOCAL_IDENTITY, failures: [] as const, }); const fixtures = { @@ -88,18 +115,21 @@ try { repositoryRoot: fixtureRoot, environment: {}, verifyLocalEvidence: acceptLocalEvidence, + nowEpochMs: () => NOW, }), validImmutable: await verifyPromotionInputs({ artifactType: "provider-verification", repositoryRoot: fixtureRoot, environment: validEnvironment, verifyLocalEvidence: acceptLocalEvidence, + nowEpochMs: () => NOW, }), wrongDigest: await verifyPromotionInputs({ artifactType: "provider-verification", repositoryRoot: fixtureRoot, environment: wrongEnvironment, verifyLocalEvidence: acceptLocalEvidence, + nowEpochMs: () => NOW, }), postAttestationMutation: null as Awaited< ReturnType @@ -111,6 +141,7 @@ try { repositoryRoot: fixtureRoot, environment: validEnvironment, verifyLocalEvidence: acceptLocalEvidence, + nowEpochMs: () => NOW, }); const passed = @@ -179,40 +210,63 @@ function absoluteProviderEnvironment( async function writeProviderEnvironment( repositoryRoot: string, name: string, - distDigest: string, - lockfileSha256: string, + candidate: Awaited>, + source: Readonly<{ revision: string; sourceSetSha256: string }>, + overrides: Readonly<{ distSha256?: string }> = {}, ): Promise { const vulnerabilityKeys = generateKeyPairSync("ed25519"); const provenanceKeys = generateKeyPairSync("ed25519"); const directory = `provider/${name}`; + const archiveBytes = `fixture archive ${name}\n`; + const archiveSha256 = createHash("sha256").update(archiveBytes).digest("hex"); + const candidateIdentity = { + archiveSha256, + bundleSha256: candidate.bundleSha256, + distSha256: overrides.distSha256 ?? candidate.distSha256, + lockfileSha256: candidate.lockfileSha256, + }; + const sourceIdentity = { + revision: source.revision, + sourceSetSha256: source.sourceSetSha256, + }; await mkdir(path.join(repositoryRoot, directory), { recursive: true }); const vulnerability = signedEvidence( { - schemaVersion: 1, + schemaVersion: 2, + evidenceType: "vulnerability-report", provider: "fixture-vulnerability-provider", - generatedAt: "2026-08-01T00:00:00.000Z", - scannedLockfileSha256: lockfileSha256, - scannedDistSha256: distDigest, + issuedAt: "2026-08-02T01:00:00.000Z", + expiresAt: "2026-08-02T02:00:00.000Z", + run: { id: "fixture-run", attempt: 1, invocationNonce: "1".repeat(64) }, + source: sourceIdentity, + candidate: candidateIdentity, findings: [], }, "fixture-vulnerability-key", + vulnerabilityKeys.publicKey, vulnerabilityKeys.privateKey, ); const provenance = signedEvidence( { - schemaVersion: 1, + schemaVersion: 2, + evidenceType: "provenance-attestation", provider: "fixture-provenance-provider", signer: "fixture-workload-identity", - generatedAt: "2026-08-01T00:00:00.000Z", - subject: { name: "dist", digest: { sha256: distDigest } }, + issuedAt: "2026-08-02T01:00:00.000Z", + expiresAt: "2026-08-02T02:00:00.000Z", + run: { id: "fixture-run", attempt: 1, invocationNonce: "2".repeat(64) }, + source: sourceIdentity, + candidate: candidateIdentity, + subject: { name: "dist", digest: { sha256: candidateIdentity.distSha256 } }, }, "fixture-provenance-key", + provenanceKeys.publicKey, provenanceKeys.privateKey, ); await Promise.all([ writeFile( path.join(repositoryRoot, directory, "candidate.tar.gz"), - "fixture archive\n", + archiveBytes, ), writeFile( path.join(repositoryRoot, directory, "vulnerability.json"), @@ -237,9 +291,12 @@ async function writeProviderEnvironment( ]); return { CANDIDATE_ARCHIVE_PATH: `${directory}/candidate.tar.gz`, - CANDIDATE_ARCHIVE_SHA256: createHash("sha256") - .update("fixture archive\n") - .digest("hex"), + CANDIDATE_ARCHIVE_SHA256: archiveSha256, + CI_RUN_ID: "fixture-run", + CI_RUN_ATTEMPT: "1", + EXPECTED_SOURCE_REVISION: source.revision, + VULNERABILITY_INVOCATION_NONCE: "1".repeat(64), + PROVENANCE_INVOCATION_NONCE: "2".repeat(64), VULNERABILITY_REPORT_PATH: `${directory}/vulnerability.json`, PROVENANCE_ATTESTATION_PATH: `${directory}/provenance.json`, VULNERABILITY_PUBLIC_KEY_PATH: `${directory}/vulnerability.pem`, @@ -252,6 +309,7 @@ async function writeProviderEnvironment( function signedEvidence( value: Record, keyId: string, + publicKey: ReturnType["publicKey"], privateKey: ReturnType["privateKey"], ) { return { @@ -259,6 +317,7 @@ function signedEvidence( signature: { algorithm: "Ed25519", keyId, + publicKeyFingerprint: providerPublicKeyFingerprint(publicKey), value: sign( null, providerEvidenceSignaturePayload(value), diff --git a/scripts/cleanup-verified-promotion.ts b/scripts/cleanup-verified-promotion.ts new file mode 100644 index 0000000..0489e8e --- /dev/null +++ b/scripts/cleanup-verified-promotion.ts @@ -0,0 +1,25 @@ +import { cleanupFinalizedPromotion } from "./lib/promotion-stager.ts"; + +const required = (name: string): string => { + const value = process.env[name]; + if (!value) throw new TypeError(`promotion cleanup environment is missing ${name}`); + return value; +}; +const requiredIdentity = (name: string): number => { + const value = Number(required(name)); + if (!Number.isSafeInteger(value) || value <= 0) { + throw new TypeError(`promotion cleanup environment has invalid ${name}`); + } + return value; +}; + +await cleanupFinalizedPromotion({ + runnerTempRoot: required("RUNNER_TEMP"), + stagingRoot: required("PROMOTION_STAGING_ROOT"), + cleanupToken: required("PROMOTION_CLEANUP_TOKEN"), + runnerTempIdentity: { + dev: requiredIdentity("PROMOTION_RUNNER_TEMP_DEV"), + ino: requiredIdentity("PROMOTION_RUNNER_TEMP_INO"), + }, +}); +process.stdout.write("Promotion staging cleanup: PASS\n"); diff --git a/scripts/contracts/ci-gates.ts b/scripts/contracts/ci-gates.ts index 53ebf56..f1b0c78 100644 --- a/scripts/contracts/ci-gates.ts +++ b/scripts/contracts/ci-gates.ts @@ -10,7 +10,7 @@ import { RELEASE_CANDIDATE_MANIFEST_PATH, } from "../lib/release-candidate.ts"; import { validatePackageScriptGraph } from "../lib/package-script-graph.ts"; -import { PROMOTED_STAGING_PATHS } from "./promotion-artifacts.ts"; +import { PROMOTED_UPLOAD_PATHS } from "./promotion-artifacts.ts"; const ciActionRegistrationSchema = z .object({ @@ -332,7 +332,11 @@ const extractStep = z }) .strict(); const providerStep = z - .object({ kind: z.literal("run-provider"), provider: z.enum(["vulnerability", "provenance"]) }) + .object({ + kind: z.literal("run-provider"), + provider: z.enum(["vulnerability", "provenance"]), + stepId: id, + }) .strict(); const validateProviderStep = z .object({ @@ -340,7 +344,12 @@ const validateProviderStep = z provider: z.enum(["vulnerability", "provenance"]), }) .strict(); -const promotionStep = z.object({ kind: z.literal("verify-promotion") }).strict(); +const promotionStep = z + .object({ kind: z.literal("verify-promotion"), stepId: id }) + .strict(); +const cleanupPromotionStep = z + .object({ kind: z.literal("cleanup-promotion"), finalizerStepId: id }) + .strict(); const jobStepSchema = z.discriminatedUnion("kind", [ checkoutStep, @@ -356,6 +365,7 @@ const jobStepSchema = z.discriminatedUnion("kind", [ providerStep, validateProviderStep, promotionStep, + cleanupPromotionStep, ]); const jobSchema = z @@ -756,9 +766,9 @@ function validateContractSemantics( merge_gate: ["checkout", "setup-node", "frozen-install", "browser-install", "run-gate", "upload"], release_gate: ["checkout", "setup-node", "frozen-install", "browser-install", "run-gate", "upload"], immutable_build: ["checkout", "setup-node", "frozen-install", "run-gate", "archive-candidate", "upload"], - vulnerability_provider: ["checkout", "setup-node", "frozen-install", "download", "extract", "run-provider", "validate-provider-evidence", "upload"], - provenance_provider: ["checkout", "setup-node", "frozen-install", "download", "extract", "run-provider", "validate-provider-evidence", "upload"], - promotion: ["checkout", "setup-node", "frozen-install", "download", "download", "download", "extract", "verify-promotion", "upload"], + vulnerability_provider: ["checkout", "setup-node", "frozen-install", "download", "run-provider", "validate-provider-evidence", "upload"], + provenance_provider: ["checkout", "setup-node", "frozen-install", "download", "run-provider", "validate-provider-evidence", "upload"], + promotion: ["checkout", "setup-node", "frozen-install", "download", "download", "download", "verify-promotion", "upload", "cleanup-promotion"], production_gate: ["checkout", "setup-node", "frozen-install", "run-gate", "upload"], field_gate: ["checkout", "setup-node", "frozen-install", "run-gate", "upload"], documentation_gate: ["checkout", "setup-node", "frozen-install", "run-gate", "upload"], @@ -776,8 +786,11 @@ function validateContractSemantics( vulnerability_provider: [ { name: "CANDIDATE_ARCHIVE_SHA256", value: "${{ needs.immutable_build.outputs.archive_sha256 }}" }, { name: "CANDIDATE_ARCHIVE_PATH", value: ".release/vulnerability-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" }, - { name: "CANDIDATE_DIST_SHA256", value: "${{ needs.immutable_build.outputs.dist_sha256 }}" }, - { name: "CANDIDATE_LOCKFILE_PATH", value: ".release/verified-vulnerability/pnpm-lock.yaml" }, + { name: "CI_RUN_ID", value: "${{ gitea.run_id }}" }, + { name: "CI_RUN_ATTEMPT", value: "${{ gitea.run_attempt }}" }, + { name: "EXPECTED_SOURCE_REVISION", value: "${{ gitea.sha }}" }, + { name: "VULNERABILITY_PUBLIC_KEY_PATH", value: "${{ vars.VULNERABILITY_PUBLIC_KEY_PATH }}" }, + { name: "VULNERABILITY_KEY_ID", value: "${{ vars.VULNERABILITY_KEY_ID }}" }, { name: "VULNERABILITY_PROVIDER_COMMAND", value: "${{ vars.VULNERABILITY_PROVIDER_COMMAND }}" }, { name: "VULNERABILITY_REPORT_PATH", value: "provider-evidence/untrusted/vulnerability-report.json" }, { name: "VALIDATED_PROVIDER_REPORT_PATH", value: "provider-evidence/vulnerability-report.json" }, @@ -785,8 +798,11 @@ function validateContractSemantics( provenance_provider: [ { name: "CANDIDATE_ARCHIVE_SHA256", value: "${{ needs.immutable_build.outputs.archive_sha256 }}" }, { name: "CANDIDATE_ARCHIVE_PATH", value: ".release/provenance-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" }, - { name: "CANDIDATE_DIST_SHA256", value: "${{ needs.immutable_build.outputs.dist_sha256 }}" }, - { name: "CANDIDATE_LOCKFILE_PATH", value: ".release/verified-provenance/pnpm-lock.yaml" }, + { name: "CI_RUN_ID", value: "${{ gitea.run_id }}" }, + { name: "CI_RUN_ATTEMPT", value: "${{ gitea.run_attempt }}" }, + { name: "EXPECTED_SOURCE_REVISION", value: "${{ gitea.sha }}" }, + { name: "PROVENANCE_PUBLIC_KEY_PATH", value: "${{ vars.PROVENANCE_PUBLIC_KEY_PATH }}" }, + { name: "PROVENANCE_KEY_ID", value: "${{ vars.PROVENANCE_KEY_ID }}" }, { name: "PROVENANCE_PROVIDER_COMMAND", value: "${{ vars.PROVENANCE_PROVIDER_COMMAND }}" }, { name: "PROVENANCE_ATTESTATION_PATH", value: "provider-evidence/untrusted/provenance-attestation.json" }, { name: "VALIDATED_PROVIDER_REPORT_PATH", value: "provider-evidence/provenance-attestation.json" }, @@ -794,13 +810,16 @@ function validateContractSemantics( promotion: [ { name: "CANDIDATE_ARCHIVE_SHA256", value: "${{ needs.immutable_build.outputs.archive_sha256 }}" }, { name: "CANDIDATE_ARCHIVE_PATH", value: ".release/candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" }, - { name: "CANDIDATE_ROOT", value: "${{ gitea.workspace }}/.release/verified-candidate" }, + { name: "CI_RUN_ID", value: "${{ gitea.run_id }}" }, + { name: "CI_RUN_ATTEMPT", value: "${{ gitea.run_attempt }}" }, { name: "VULNERABILITY_REPORT_PATH", value: "${{ gitea.workspace }}/.release/vulnerability/vulnerability-report.json" }, { name: "PROVENANCE_ATTESTATION_PATH", value: "${{ gitea.workspace }}/.release/provenance/provenance-attestation.json" }, { name: "VULNERABILITY_PUBLIC_KEY_PATH", value: "${{ vars.VULNERABILITY_PUBLIC_KEY_PATH }}" }, { name: "VULNERABILITY_KEY_ID", value: "${{ vars.VULNERABILITY_KEY_ID }}" }, { name: "PROVENANCE_PUBLIC_KEY_PATH", value: "${{ vars.PROVENANCE_PUBLIC_KEY_PATH }}" }, { name: "PROVENANCE_KEY_ID", value: "${{ vars.PROVENANCE_KEY_ID }}" }, + { name: "VULNERABILITY_INVOCATION_NONCE", value: "${{ needs.vulnerability_provider.outputs.invocation_nonce }}" }, + { name: "PROVENANCE_INVOCATION_NONCE", value: "${{ needs.provenance_provider.outputs.invocation_nonce }}" }, ], production_gate: [], field_gate: [ @@ -875,12 +894,14 @@ function validateContractSemantics( if (upload?.kind === "upload" && upload.always) { issue("promotion upload must not use always"); } + const cleanupIndex = order.indexOf("cleanup-promotion"); if ( - order.indexOf("extract") < order.lastIndexOf("download") || - order.indexOf("verify-promotion") < order.indexOf("extract") || - order.indexOf("upload") < order.indexOf("verify-promotion") + order.includes("extract") || + verificationIndex < order.lastIndexOf("download") || + uploadIndex < verificationIndex || + cleanupIndex !== uploadIndex + 1 ) { - issue("promotion formula order must download, verify, then upload"); + issue("promotion formula order must download, finalize, upload, then cleanup without extraction"); } } const immutable = contract.jobs.find(({ id }) => id === "immutable_build"); @@ -905,7 +926,7 @@ function validateContractSemantics( const promotionUpload = promotion?.steps.find( (step) => step.kind === "upload" && step.transferId === "promoted-release", ); - if (!promotionUpload || promotionUpload.kind !== "upload" || JSON.stringify(promotionUpload.paths) !== JSON.stringify(PROMOTED_STAGING_PATHS)) { + if (!promotionUpload || promotionUpload.kind !== "upload" || JSON.stringify(promotionUpload.paths) !== JSON.stringify(PROMOTED_UPLOAD_PATHS)) { issue("promotion upload bundle must contain the exact five typed paths"); } @@ -934,10 +955,9 @@ function validateCanonicalStepFields( const providerExpectations = { vulnerability_provider: { provider: "vulnerability", + stepId: "supervise_vulnerability", downloadPath: ".release/vulnerability-candidate", archivePath: ".release/vulnerability-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz", - targetRoot: ".release/verified-vulnerability", - lockfilePath: ".release/verified-vulnerability/pnpm-lock.yaml", rawPath: "provider-evidence/untrusted/vulnerability-report.json", rawName: "VULNERABILITY_REPORT_PATH", sealedPath: "provider-evidence/vulnerability-report.json", @@ -945,10 +965,9 @@ function validateCanonicalStepFields( }, provenance_provider: { provider: "provenance", + stepId: "supervise_provenance", downloadPath: ".release/provenance-candidate", archivePath: ".release/provenance-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz", - targetRoot: ".release/verified-provenance", - lockfilePath: ".release/verified-provenance/pnpm-lock.yaml", rawPath: "provider-evidence/untrusted/provenance-attestation.json", rawName: "PROVENANCE_ATTESTATION_PATH", sealedPath: "provider-evidence/provenance-attestation.json", @@ -959,7 +978,6 @@ function validateCanonicalStepFields( const job = contract.jobs.find(({ id }) => id === jobId); const environment = new Map(job?.environment.map(({ name, value }) => [name, value])); const download = job?.steps.find(({ kind }) => kind === "download"); - const extract = job?.steps.find(({ kind }) => kind === "extract"); const runProvider = job?.steps.find(({ kind }) => kind === "run-provider"); const validateProvider = job?.steps.find(({ kind }) => kind === "validate-provider-evidence"); const upload = job?.steps.find( @@ -967,11 +985,9 @@ function validateCanonicalStepFields( ); if ( !download || download.kind !== "download" || download.transferId !== "release-candidate" || download.path !== expected.downloadPath || - !extract || extract.kind !== "extract" || extract.archivePath !== expected.archivePath || extract.targetRoot !== expected.targetRoot || - !runProvider || runProvider.kind !== "run-provider" || runProvider.provider !== expected.provider || + !runProvider || runProvider.kind !== "run-provider" || runProvider.provider !== expected.provider || runProvider.stepId !== expected.stepId || !validateProvider || validateProvider.kind !== "validate-provider-evidence" || validateProvider.provider !== expected.provider || environment.get("CANDIDATE_ARCHIVE_PATH") !== expected.archivePath || - environment.get("CANDIDATE_LOCKFILE_PATH") !== expected.lockfilePath || environment.get(expected.rawName) !== expected.rawPath || environment.get("VALIDATED_PROVIDER_REPORT_PATH") !== expected.sealedPath || !upload || upload.kind !== "upload" || JSON.stringify(upload.paths) !== JSON.stringify([expected.sealedPath]) @@ -987,15 +1003,18 @@ function validateCanonicalStepFields( { kind: "download", transferId: "vulnerability-provider-evidence", path: ".release/vulnerability" }, { kind: "download", transferId: "provenance-provider-evidence", path: ".release/provenance" }, ]; - const promotionExtract = promotion?.steps.find(({ kind }) => kind === "extract"); + const promotionFinalizer = promotion?.steps.find(({ kind }) => kind === "verify-promotion"); + const promotionCleanup = promotion?.steps.find(({ kind }) => kind === "cleanup-promotion"); if ( JSON.stringify(promotionDownloads) !== JSON.stringify(expectedDownloads) || - !promotionExtract || - promotionExtract.kind !== "extract" || - promotionExtract.archivePath !== ".release/candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" || - promotionExtract.targetRoot !== ".release/verified-candidate" + !promotionFinalizer || + promotionFinalizer.kind !== "verify-promotion" || + promotionFinalizer.stepId !== "finalize" || + !promotionCleanup || + promotionCleanup.kind !== "cleanup-promotion" || + promotionCleanup.finalizerStepId !== "finalize" ) { - issue("promotion download and extraction fields must remain linked"); + issue("promotion download fields and finalizer/cleanup step identities must remain linked"); } } @@ -1008,7 +1027,7 @@ function validateJobStepKinds( "gate-single": new Set(["checkout", "setup-node", "frozen-install", "run-gate", "upload"]), immutable: new Set(["checkout", "setup-node", "frozen-install", "run-gate", "archive-candidate", "upload"]), provider: new Set(["checkout", "setup-node", "frozen-install", "download", "validate-candidate-archive", "extract", "run-provider", "validate-provider-evidence", "upload"]), - promotion: new Set(["checkout", "setup-node", "frozen-install", "download", "validate-candidate-archive", "extract", "verify-promotion", "upload"]), + promotion: new Set(["checkout", "setup-node", "frozen-install", "download", "verify-promotion", "upload", "cleanup-promotion"]), }; for (const step of job.steps) { if (!allowed[job.kind].has(step.kind)) { @@ -1016,16 +1035,12 @@ function validateJobStepKinds( } } const kinds = job.steps.map(({ kind }) => kind); - const extractIndex = kinds.indexOf("extract"); - if ((job.kind === "provider" || job.kind === "promotion") && extractIndex < 0) { - issue(`verified extraction step is missing: ${job.id}`); - } if (job.kind === "provider") { const providerIndex = kinds.indexOf("run-provider"); const validateProviderIndex = kinds.indexOf("validate-provider-evidence"); const uploadIndex = kinds.indexOf("upload"); if ( - providerIndex < extractIndex || + providerIndex < kinds.lastIndexOf("download") || validateProviderIndex < providerIndex || uploadIndex < validateProviderIndex ) { diff --git a/scripts/contracts/promotion-artifacts.ts b/scripts/contracts/promotion-artifacts.ts index 6552067..0133e66 100644 --- a/scripts/contracts/promotion-artifacts.ts +++ b/scripts/contracts/promotion-artifacts.ts @@ -1,7 +1,13 @@ -export const PROMOTED_STAGING_PATHS = Object.freeze([ - ".release/promoted-staging/release-candidate.tar.gz", - ".release/promoted-staging/vulnerability-report.json", - ".release/promoted-staging/provenance-attestation.json", - ".release/promoted-staging/provider-verification.json", - ".release/promoted-staging/promotion-verification.json", +export const PROMOTED_FILE_NAMES = Object.freeze([ + "release-candidate.tar.gz", + "vulnerability-report.json", + "provenance-attestation.json", + "provider-verification.json", + "promotion-verification.json", ] as const); + +export type PromotedFileName = (typeof PROMOTED_FILE_NAMES)[number]; + +export const PROMOTED_UPLOAD_PATHS = Object.freeze( + PROMOTED_FILE_NAMES.map((name) => `\${{ steps.finalize.outputs.staging_root }}/${name}`), +); diff --git a/scripts/contracts/release-artifacts.ts b/scripts/contracts/release-artifacts.ts index 05add5b..9a4a2bb 100644 --- a/scripts/contracts/release-artifacts.ts +++ b/scripts/contracts/release-artifacts.ts @@ -7,6 +7,131 @@ const timestamp = z.iso.datetime(); const sha256 = z.string().regex(/^[a-f0-9]{64}$/u); const jsonObject = z.record(z.string(), z.json()); +const canonicalTimestamp = z + .string() + .regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u) + .refine((value) => new Date(value).toISOString() === value, { + message: "must be a canonical ISO-8601 UTC timestamp", + }); +const safeRepositoryPath = z + .string() + .min(1) + .max(1_024) + .refine( + (value) => + !value.startsWith("-") && + !value.startsWith("/") && + !value.includes("\\") && + !value.split("/").some((segment) => segment === "" || segment === "." || segment === "..") && + ![...value].some((character) => { + const codePoint = character.codePointAt(0)!; + return codePoint <= 0x1f || codePoint === 0x7f; + }), + { message: "must be a safe canonical repository-relative path" }, + ); +const assessmentInputRowSchema = z + .object({ + path: safeRepositoryPath, + bytes: z.int().nonnegative().max(268_435_456), + sha256, + }) + .strict(); +const assessmentStatusSchema = z.enum(["PASS", "FAIL"]); + +function addCanonicalInputIssues( + rows: readonly Readonly<{ path: string }>[], + pathPrefix: "policyInputs" | "evidenceInputs", + context: z.RefinementCtx, +): void { + const paths = rows.map(({ path }) => path); + const canonical = [...paths].sort((left, right) => + left < right ? -1 : left > right ? 1 : 0, + ); + if (JSON.stringify(paths) !== JSON.stringify(canonical)) { + context.addIssue({ + code: "custom", + path: [pathPrefix], + message: "must be in canonical ASCII path order", + }); + } + if (new Set(paths).size !== paths.length) { + context.addIssue({ + code: "custom", + path: [pathPrefix], + message: "must not contain duplicate paths", + }); + } +} + +export const localEvidenceAssessmentArtifactSchema = z + .object({ + schemaVersion: z.literal(1), + artifactType: z.literal("local-evidence-assessment"), + generatedAt: canonicalTimestamp, + status: assessmentStatusSchema, + verifier: z + .object({ + id: nonEmptyString, + version: nonEmptyString, + sourceSha256: sha256, + }) + .strict(), + source: z + .object({ + revision: z.string().regex(/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u), + sourceSetSha256: sha256, + }) + .strict(), + candidate: z + .object({ distSha256: sha256, lockfileSha256: sha256, sbomSha256: sha256 }) + .strict(), + policyInputs: z.array(assessmentInputRowSchema).min(1).max(256), + evidenceInputs: z.array(assessmentInputRowSchema).min(1).max(4_096), + checks: z + .object({ + release: assessmentStatusSchema, + supplyChain: assessmentStatusSchema, + dependencyPolicy: assessmentStatusSchema, + licensePolicy: assessmentStatusSchema, + vulnerabilityPolicy: assessmentStatusSchema, + secretScan: assessmentStatusSchema, + }) + .strict(), + failures: z.array(z.string()), + }) + .strict() + .superRefine((assessment, context) => { + addCanonicalInputIssues(assessment.policyInputs, "policyInputs", context); + addCanonicalInputIssues(assessment.evidenceInputs, "evidenceInputs", context); + const failedChecks = Object.values(assessment.checks).filter( + (status) => status === "FAIL", + ); + if ( + assessment.status === "PASS" && + (failedChecks.length > 0 || assessment.failures.length > 0) + ) { + context.addIssue({ + code: "custom", + path: ["status"], + message: "PASS requires all six checks PASS and no failures", + }); + } + if ( + assessment.status === "FAIL" && + (failedChecks.length === 0 || assessment.failures.length === 0) + ) { + context.addIssue({ + code: "custom", + path: ["status"], + message: "FAIL requires a failed check and a failure diagnostic", + }); + } + }); + +export type LocalEvidenceAssessment = z.infer< + typeof localEvidenceAssessmentArtifactSchema +>; + export const moduleInventoryArtifactSchema = z .object({ schemaVersion: z.literal(1), diff --git a/scripts/create-release-candidate.ts b/scripts/create-release-candidate.ts index 2725ef6..7ac2e79 100644 --- a/scripts/create-release-candidate.ts +++ b/scripts/create-release-candidate.ts @@ -1,12 +1,22 @@ import { mkdir } from "node:fs/promises"; import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts"; +import { localEvidenceAssessmentArtifactSchema } from "./contracts/release-artifacts.ts"; +import { createLocalEvidenceAssessment } from "./lib/local-release-evidence.ts"; import { createReleaseCandidateManifest, + LOCAL_EVIDENCE_ASSESSMENT_PATH, RELEASE_CANDIDATE_MANIFEST_PATH, releaseCandidateManifestSchema, } from "./lib/release-candidate.ts"; +const assessment = await createLocalEvidenceAssessment(); +await mkdir("artifacts/security", { recursive: true }); +await writeValidatedJsonArtifact({ + path: LOCAL_EVIDENCE_ASSESSMENT_PATH, + schema: localEvidenceAssessmentArtifactSchema, + value: assessment, +}); const manifest = await createReleaseCandidateManifest(); await mkdir("artifacts/release", { recursive: true }); await writeValidatedJsonArtifact({ diff --git a/scripts/generate-ci-workflow.ts b/scripts/generate-ci-workflow.ts index 09baaba..91d242a 100644 --- a/scripts/generate-ci-workflow.ts +++ b/scripts/generate-ci-workflow.ts @@ -131,6 +131,16 @@ function renderJob( ` ${archive.archiveOutputName}: \${{ steps.${archive.stepId}.outputs.${archive.archiveOutputName} }}`, ); } + if (job.kind === "provider") { + const supervisor = job.steps.find((step) => step.kind === "run-provider"); + if (!supervisor || supervisor.kind !== "run-provider") { + throw new TypeError("provider job lacks supervisor step"); + } + lines.push( + " outputs:", + ` invocation_nonce: \${{ steps.${supervisor.stepId}.outputs.invocation_nonce }}`, + ); + } if (job.environment.length > 0) { lines.push(" env:"); for (const binding of job.environment) { @@ -229,6 +239,7 @@ function renderStep( case "run-provider": { return [ ` - name: Run and validate external ${step.provider} provider in one trusted supervisor`, + ` id: ${yamlKey(step.stepId)}`, ` run: node scripts/run-and-validate-provider.ts --kind ${step.provider}`, ]; } @@ -240,8 +251,23 @@ function renderStep( case "verify-promotion": return [ " - name: Finalize verified promotion from inode-bound captured inputs", + ` id: ${yamlKey(step.stepId)}`, " run: node scripts/stage-verified-promotion.ts", ]; + case "cleanup-promotion": + return [ + " - name: Always remove private promotion staging", + " if: always()", + " env:", + ` PROMOTION_STAGING_ROOT: \${{ steps.${step.finalizerStepId}.outputs.staging_root }}`, + ` PROMOTION_CLEANUP_TOKEN: \${{ steps.${step.finalizerStepId}.outputs.cleanup_token }}`, + ` PROMOTION_RUNNER_TEMP_DEV: \${{ steps.${step.finalizerStepId}.outputs.runner_temp_dev }}`, + ` PROMOTION_RUNNER_TEMP_INO: \${{ steps.${step.finalizerStepId}.outputs.runner_temp_ino }}`, + " run: |", + ' if [ -n "$PROMOTION_STAGING_ROOT" ] && [ -n "$PROMOTION_CLEANUP_TOKEN" ]; then', + " node scripts/cleanup-verified-promotion.ts", + " fi", + ]; case "upload": { const lines = [ ` - name: Upload ${humanize(step.transferId)}`, diff --git a/scripts/lib/ci-candidate-archive.ts b/scripts/lib/ci-candidate-archive.ts index 5abfccf..db5cfcd 100644 --- a/scripts/lib/ci-candidate-archive.ts +++ b/scripts/lib/ci-candidate-archive.ts @@ -36,6 +36,62 @@ const MAX_MEMBER_PATH_BYTES = 1_024; const TAR_EXECUTABLE = "/usr/bin/tar"; const TAR_ENVIRONMENT = Object.freeze({ PATH: "/usr/bin:/bin", LC_ALL: "C", LANG: "C" }); +export type CapturedCandidateArchive = Readonly<{ + bytes: Buffer; + archiveSha256: string; +}>; + +export async function captureCiCandidateArchive(input: Readonly<{ + archivePath: string; + expectedSha256: string; +}>): Promise { + if (!/^[a-f0-9]{64}$/u.test(input.expectedSha256)) { + throw new TypeError("expected candidate archive SHA-256 is invalid"); + } + const absolute = path.resolve(input.archivePath); + const before = await lstat(absolute); + if (!before.isFile() || before.isSymbolicLink()) { + throw new TypeError("candidate archive must be a regular non-symlink file"); + } + if (before.size <= 0 || before.size > MAX_ARCHIVE_BYTES) { + throw new RangeError(`candidate archive size is outside 1..${MAX_ARCHIVE_BYTES}`); + } + const handle = await open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW); + let bytes: Buffer; + try { + assertSameIdentity(before, await handle.stat()); + bytes = await readCapturedArchive(handle, before.size); + assertSameIdentity(before, await handle.stat()); + } finally { + await handle.close(); + } + const archiveSha256 = createHash("sha256").update(bytes).digest("hex"); + if (archiveSha256 !== input.expectedSha256) { + throw new Error("candidate archive SHA-256 mismatch"); + } + return Object.freeze({ bytes, archiveSha256 }); +} + +export async function withVerifiedCapturedCandidate(input: Readonly<{ + captured: CapturedCandidateArchive; + verify: (view: Readonly<{ + extractionRoot: string; + manifest: ReleaseCandidateManifest; + }>) => Promise; +}>): Promise { + let result: T | undefined; + await verifyCapturedCiCandidateArchive( + input.captured.bytes, + input.captured.archiveSha256, + { + verifyExtracted: async (extractionRoot, manifest) => { + result = await input.verify({ extractionRoot, manifest }); + }, + }, + ); + return result as T; +} + export async function verifyCiCandidateArchive( input: Readonly<{ archivePath: string; diff --git a/scripts/lib/local-release-evidence.ts b/scripts/lib/local-release-evidence.ts index 9406079..96bb8e2 100644 --- a/scripts/lib/local-release-evidence.ts +++ b/scripts/lib/local-release-evidence.ts @@ -15,6 +15,7 @@ import { dependencyDiffArtifactSchema, dependencyInventoryArtifactSchema, licenseReportArtifactSchema, + localEvidenceAssessmentArtifactSchema, provenanceArtifactSchema, releaseManifestArtifactSchema, releaseVerificationArtifactSchema, @@ -28,8 +29,15 @@ import { 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 { + LOCAL_EVIDENCE_ASSESSMENT_PATH, + RELEASE_CANDIDATE_EVIDENCE_PATHS, + RELEASE_CANDIDATE_MANIFEST_PATH, + collectDistOutputs, + distSha256, + releaseCandidateManifestSchema, + type ReleaseCandidateManifest, +} from "./release-candidate.ts"; import { verifyReleaseRuntimeCoherence } from "./release-runtime-coherence.ts"; import { digestReleaseInputFiles } from "./release-input-evidence.ts"; import { @@ -290,7 +298,503 @@ export async function verifyLocalSupplyChainEvidence( }); } +export const LOCAL_EVIDENCE_VERIFIER_ID = + "clean-architecture-frontend-template/local-evidence-verifier"; +export const LOCAL_EVIDENCE_VERIFIER_VERSION = "1"; +export const LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS = Object.freeze([ + "scripts/contracts/release-artifacts.ts", + "scripts/create-release-candidate.ts", + "scripts/generate-supply-chain.ts", + "scripts/lib/build-manifest-outputs.ts", + "scripts/lib/json-schema.ts", + "scripts/lib/local-policy-evidence.ts", + "scripts/lib/local-release-evidence.ts", + "scripts/lib/release-candidate.ts", + "scripts/lib/release-input-evidence.ts", + "scripts/lib/release-runtime-coherence.ts", + "scripts/lib/repository-file-inventory.ts", + "scripts/lib/secret-scan-evaluator.ts", + "scripts/lib/secret-scan-policy.ts", + "scripts/lib/supply-chain.ts", + "scripts/lib/validated-json-artifact.ts", + "src/contracts/release-artifacts.ts", +] as const); +export const LOCAL_EVIDENCE_POLICY_INPUT_PATHS = Object.freeze([ + "config/security/dependency-baseline.approval.json", + "config/security/dependency-baseline.json", + "config/security/dependency-change-evidence.json", + "config/security/dependency-policy.json", + "config/security/secret-scan-policy.json", + "config/security/vulnerability-exceptions.json", + "config/security/vulnerability-policy.json", + "schemas/artifacts/build-manifest.schema.json", + "schemas/artifacts/dependency-inventory.schema.json", + "schemas/artifacts/supply-chain-verification.schema.json", + ...LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS, +] as const); + +export async function createLocalEvidenceAssessment( + repositoryRoot = process.cwd(), +): Promise> { + const root = path.resolve(repositoryRoot); + const outputs = await collectDistOutputs(root); + const evidencePaths = RELEASE_CANDIDATE_EVIDENCE_PATHS.filter( + (memberPath) => memberPath !== LOCAL_EVIDENCE_ASSESSMENT_PATH, + ); + const evidenceInputs = ( + await Promise.all([ + ...outputs.map(async ({ path: memberPath }) => digestInput(root, memberPath)), + ...evidencePaths.map((memberPath) => digestInput(root, memberPath)), + ]) + ).sort((left, right) => asciiCompare(left.path, right.path)); + const lockfile = evidenceInputs.find(({ path: memberPath }) => memberPath === "pnpm-lock.yaml"); + const sbom = evidenceInputs.find( + ({ path: memberPath }) => memberPath === "artifacts/release/sbom.cdx.json", + ); + if (!lockfile || !sbom) throw new Error("local assessment candidate inputs are incomplete"); + const candidate: ReleaseCandidateManifest = { + schemaVersion: 1, + distSha256: distSha256(outputs), + lockfileSha256: lockfile.sha256, + bundleSha256: supplyChainDigest(evidenceInputs), + files: evidenceInputs, + }; + const evaluated = await evaluateProducerLocalChecks(root, candidate); + const [build, release, provenance, supply, sbomDocument, policyInputs] = await Promise.all([ + readJson(root, "artifacts/release/build-manifest.json").then((value) => + buildManifestArtifactSchema.parse(value), + ), + readJson(root, "dist/release-manifest.json").then((value) => + releaseManifestArtifactSchema.parse(value), + ), + readJson(root, "artifacts/release/provenance.json").then((value) => + provenanceArtifactSchema.parse(value), + ), + readJson(root, "artifacts/security/supply-chain-verification.json").then((value) => + supplyChainVerificationArtifactSchema.parse(value), + ), + readJson(root, "artifacts/release/sbom.cdx.json").then((value) => + sbomArtifactSchema.parse(value), + ), + Promise.all( + LOCAL_EVIDENCE_POLICY_INPUT_PATHS.map((policyPath) => + digestInput(root, policyPath), + ), + ), + ]); + const identityFailures: string[] = []; + if (build.commitSha !== release.commitSha) { + identityFailures.push("producer build/release source revision mismatch"); + } + if ( + provenance.predicate.materials.sourceSetSha256 !== supply.sourceSetSha256 + ) { + identityFailures.push("producer provenance/supply source-set mismatch"); + } + if (supply.distSha256 !== candidate.distSha256) { + identityFailures.push("producer supply/candidate dist digest mismatch"); + } + if (supply.lockfileSha256 !== candidate.lockfileSha256) { + identityFailures.push("producer supply/candidate lockfile digest mismatch"); + } + if (supply.sbomSha256 !== supplyChainDigest(sbomDocument)) { + identityFailures.push("producer supply/candidate SBOM digest mismatch"); + } + const checks = { + ...evaluated.checks, + ...(identityFailures.some((failure) => failure.includes("build/release")) + ? { release: "FAIL" as const } + : {}), + ...(identityFailures.some((failure) => !failure.includes("build/release")) + ? { supplyChain: "FAIL" as const } + : {}), + }; + const failures = [...evaluated.failures, ...identityFailures]; + const status = failures.length === 0 && Object.values(checks).every( + (check) => check === "PASS", + ) + ? ("PASS" as const) + : ("FAIL" as const); + const verifierSources = policyInputs.filter(({ path: policyPath }) => + (LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS as readonly string[]).includes(policyPath), + ); + if (verifierSources.length !== LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS.length) { + throw new Error("local assessment verifier source set is incomplete"); + } + return localEvidenceAssessmentArtifactSchema.parse({ + schemaVersion: 1, + artifactType: "local-evidence-assessment", + generatedAt: build.generatedAt, + status, + verifier: { + id: LOCAL_EVIDENCE_VERIFIER_ID, + version: LOCAL_EVIDENCE_VERIFIER_VERSION, + sourceSha256: supplyChainDigest(verifierSources), + }, + source: { + revision: build.commitSha, + sourceSetSha256: supply.sourceSetSha256, + }, + candidate: { + distSha256: candidate.distSha256, + lockfileSha256: candidate.lockfileSha256, + sbomSha256: sbom.sha256, + }, + policyInputs, + evidenceInputs, + checks, + failures, + }); +} + +type LocalCheckName = + | "release" + | "supplyChain" + | "dependencyPolicy" + | "licensePolicy" + | "vulnerabilityPolicy" + | "secretScan"; + +async function evaluateProducerLocalChecks( + root: string, + candidate: ReleaseCandidateManifest, +): Promise>; + failures: readonly string[]; +}>> { + const checks: Record = { + release: "PASS", + supplyChain: "PASS", + dependencyPolicy: "PASS", + licensePolicy: "PASS", + vulnerabilityPolicy: "PASS", + secretScan: "PASS", + }; + const failures: string[] = []; + const evaluate = async ( + check: LocalCheckName, + operation: () => Promise, + ): Promise => { + try { + const diagnostics = await operation(); + if (diagnostics.length > 0) { + checks[check] = "FAIL"; + failures.push(...diagnostics.map((failure) => `${check}:${failure}`)); + } + } catch (error) { + checks[check] = "FAIL"; + failures.push( + `${check}:${error instanceof Error ? error.message : String(error)}`, + ); + } + }; + + await evaluate("release", async () => { + const [build, release, stored] = await Promise.all([ + readJson(root, "artifacts/release/build-manifest.json").then((value) => + buildManifestArtifactSchema.parse(value), + ), + readJson(root, "dist/release-manifest.json").then((value) => + releaseManifestArtifactSchema.parse(value), + ), + readJson(root, "artifacts/release/verification.json").then((value) => + releaseVerificationArtifactSchema.parse(value), + ), + ]); + const diagnostics: string[] = []; + if ( + build.commitSha !== release.commitSha || + build.buildId !== release.buildId || + build.releaseId !== release.releaseId || + build.generatedAt !== release.builtAt + ) { + diagnostics.push("build/release identity mismatch"); + } + if ( + !stored.passed || + !stored.artifact.checked || + !stored.artifact.compatible || + stored.artifact.mismatches.length > 0 || + stored.artifact.releaseId !== release.releaseId || + stored.generatedAt !== release.builtAt || + stored.fixtures.length === 0 || + stored.fixtures.some((fixture) => !fixture.passed) + ) { + diagnostics.push("stored release verification is not a coherent PASS"); + } + return diagnostics; + }); + await evaluate("supplyChain", async () => { + const [supply, coherence] = await Promise.all([ + readJson(root, "artifacts/security/supply-chain-verification.json").then((value) => + supplyChainVerificationArtifactSchema.parse(value), + ), + readJson(root, "artifacts/security/supply-chain-coherence.json").then((value) => + supplyChainCoherenceReportSchema.parse(value), + ), + ]); + const diagnostics: string[] = []; + if ( + supply.localStatus !== "PASS" || + supply.failures.length > 0 || + supply.distSha256 !== candidate.distSha256 || + supply.lockfileSha256 !== candidate.lockfileSha256 || + coherence.status !== "PASS" || + coherence.failures.length > 0 || + coherence.distSha256 !== candidate.distSha256 || + coherence.lockfileSha256 !== candidate.lockfileSha256 + ) { + diagnostics.push("stored supply-chain evidence is not a coherent PASS"); + } + return diagnostics; + }); + await evaluate("dependencyPolicy", async () => { + const [inventory, stored] = await Promise.all([ + readJson(root, "artifacts/release/dependency-inventory.json").then((value) => + dependencyInventoryArtifactSchema.parse(value), + ), + readJson(root, "artifacts/security/dependency-diff.json").then((value) => + dependencyDiffArtifactSchema.parse(value), + ), + ]); + const recomputed = recomputeDependencyEvidence({ + inventory, + baseline: await optionalReadJson(root, "config/security/dependency-baseline.json"), + baselineApproval: await optionalReadJson( + root, + "config/security/dependency-baseline.approval.json", + ), + dependencyChangeEvidence: await readJson( + root, + "config/security/dependency-change-evidence.json", + ), + }); + return compareStoredDependencyEvidence(recomputed, stored); + }); + await evaluate("licensePolicy", async () => { + const [inventory, stored, policy] = await Promise.all([ + readJson(root, "artifacts/release/dependency-inventory.json").then((value) => + dependencyInventoryArtifactSchema.parse(value), + ), + readJson(root, "artifacts/security/license-report.json").then((value) => + licenseReportArtifactSchema.parse(value), + ), + readJson(root, "config/security/dependency-policy.json"), + ]); + return compareStoredLicenseEvidence( + recomputeLicenseEvidence({ inventory, policy }), + stored, + ); + }); + await evaluate("vulnerabilityPolicy", async () => { + const vulnerability = vulnerabilityReportArtifactSchema.parse( + await readJson(root, "artifacts/security/vulnerability-report.json"), + ); + return compareStoredLocalVulnerabilityReport( + candidate.lockfileSha256, + vulnerability, + ); + }); + await evaluate("secretScan", async () => { + const evaluation = await evaluateRepositorySecretScan({ repositoryRoot: root }); + return verifyStoredSecretScan( + evaluation, + await readJson(root, "artifacts/security/scan.sarif"), + ); + }); + return Object.freeze({ checks: Object.freeze(checks), failures: Object.freeze(failures) }); +} + +async function digestInput( + repositoryRoot: string, + memberPath: string, +): Promise> { + const absolute = path.resolve(repositoryRoot, memberPath); + const relative = path.relative(repositoryRoot, absolute); + if ( + relative === "" || + relative === ".." || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative) + ) { + throw new TypeError(`local assessment input escapes repository: ${memberPath}`); + } + const bytes = await readFile(absolute); + return Object.freeze({ + path: memberPath, + bytes: bytes.byteLength, + sha256: createHash("sha256").update(bytes).digest("hex"), + }); +} + +function asciiCompare(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + export async function verifyArchivedLocalEvidence(input: Readonly<{ + extractionRoot: string; + expectedManifest: ReleaseCandidateManifest; +}>): Promise; + failures: readonly string[]; +}>> { + const extractionRoot = path.resolve(input.extractionRoot); + const failures: string[] = []; + let extractedManifest: ReleaseCandidateManifest | null = null; + try { + extractedManifest = releaseCandidateManifestSchema.parse( + await readJson(extractionRoot, RELEASE_CANDIDATE_MANIFEST_PATH), + ); + } catch { + failures.push("extracted release candidate manifest is missing or invalid"); + } + if ( + extractedManifest && + JSON.stringify(extractedManifest) !== JSON.stringify(input.expectedManifest) + ) { + failures.push("caller expectedManifest differs from extracted manifest"); + } + + let assessment: z.infer | null = null; + let assessmentSha256 = ""; + let assessmentBytes: Buffer | null = null; + try { + assessmentBytes = await readFile( + path.join(extractionRoot, LOCAL_EVIDENCE_ASSESSMENT_PATH), + ); + assessmentSha256 = createHash("sha256").update(assessmentBytes).digest("hex"); + assessment = localEvidenceAssessmentArtifactSchema.parse( + JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(assessmentBytes)) as unknown, + ); + } catch { + failures.push("local evidence assessment is missing or invalid"); + } + + if (assessment && extractedManifest) { + const assessmentMember = extractedManifest.files.find( + ({ path: memberPath }) => memberPath === LOCAL_EVIDENCE_ASSESSMENT_PATH, + ); + if ( + !assessmentMember || + assessmentMember.bytes !== assessmentBytes?.byteLength || + assessmentMember.sha256 !== assessmentSha256 + ) { + failures.push("local assessment manifest binding mismatch"); + } + if ( + assessment.verifier.id !== LOCAL_EVIDENCE_VERIFIER_ID || + assessment.verifier.version !== LOCAL_EVIDENCE_VERIFIER_VERSION + ) { + failures.push("local assessment verifier identity mismatch"); + } + const policyPaths = assessment.policyInputs.map(({ path: policyPath }) => policyPath); + if (JSON.stringify(policyPaths) !== JSON.stringify(LOCAL_EVIDENCE_POLICY_INPUT_PATHS)) { + failures.push("local assessment policyInputs exact set mismatch"); + } + const verifierSources = assessment.policyInputs.filter(({ path: policyPath }) => + (LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS as readonly string[]).includes(policyPath), + ); + if ( + verifierSources.length !== LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS.length || + supplyChainDigest(verifierSources) !== assessment.verifier.sourceSha256 + ) { + failures.push("local assessment verifier-source digest mismatch"); + } + + const expectedEvidenceInputs = extractedManifest.files.filter( + ({ path: memberPath }) => memberPath !== LOCAL_EVIDENCE_ASSESSMENT_PATH, + ); + if (JSON.stringify(assessment.evidenceInputs) !== JSON.stringify(expectedEvidenceInputs)) { + failures.push("local assessment evidenceInputs exact member binding mismatch"); + } + const sbom = extractedManifest.files.find( + ({ path: memberPath }) => memberPath === "artifacts/release/sbom.cdx.json", + ); + if ( + assessment.candidate.distSha256 !== extractedManifest.distSha256 || + assessment.candidate.lockfileSha256 !== extractedManifest.lockfileSha256 || + !sbom || + assessment.candidate.sbomSha256 !== sbom.sha256 + ) { + failures.push("local assessment candidate digest binding mismatch"); + } + if (assessment.status !== "PASS" || Object.values(assessment.checks).includes("FAIL")) { + failures.push("local evidence assessment is not PASS"); + } + + const identities = await readArchivedIdentities(extractionRoot, failures); + if ( + identities.buildRevision !== assessment.source.revision || + identities.releaseRevision !== assessment.source.revision + ) { + failures.push("local assessment source revision identity mismatch"); + } + if ( + identities.provenanceSourceSetSha256 !== assessment.source.sourceSetSha256 || + identities.supplySourceSetSha256 !== assessment.source.sourceSetSha256 + ) { + failures.push("local assessment source-set identity mismatch"); + } + } + + const uniqueFailures = Object.freeze([...new Set(failures)]); + const passingAssessment = uniqueFailures.length === 0 ? assessment : null; + return Object.freeze({ + status: passingAssessment ? "PASS" : "FAIL", + identity: passingAssessment + ? Object.freeze({ + sourceRevision: passingAssessment.source.revision, + sourceSetSha256: passingAssessment.source.sourceSetSha256, + assessmentSha256, + }) + : null, + failures: uniqueFailures, + }); +} + +async function readArchivedIdentities( + extractionRoot: string, + failures: string[], +): Promise> { + try { + const [buildDocument, releaseDocument, provenanceDocument, supplyDocument] = await Promise.all([ + readJson(extractionRoot, "artifacts/release/build-manifest.json"), + readJson(extractionRoot, "dist/release-manifest.json"), + readJson(extractionRoot, "artifacts/release/provenance.json"), + readJson(extractionRoot, "artifacts/security/supply-chain-verification.json"), + ]); + const build = buildManifestArtifactSchema.parse(buildDocument); + const release = releaseManifestArtifactSchema.parse(releaseDocument); + const provenance = provenanceArtifactSchema.parse(provenanceDocument); + const supply = supplyChainVerificationArtifactSchema.parse(supplyDocument); + return Object.freeze({ + buildRevision: build.commitSha, + releaseRevision: release.commitSha, + provenanceSourceSetSha256: provenance.predicate.materials.sourceSetSha256, + supplySourceSetSha256: supply.sourceSetSha256, + }); + } catch { + failures.push("archived source/build/provenance identities are missing or invalid"); + return Object.freeze({ + buildRevision: null, + releaseRevision: null, + provenanceSourceSetSha256: null, + supplySourceSetSha256: null, + }); + } +} + +export async function assessLocalEvidenceForProducer(input: Readonly<{ repositoryRoot?: string; candidate: ReleaseCandidateManifest; }>): Promise void; -}>; + evaluatePromotionEvidence, + providerPublicKeyFingerprint, + providerVerificationArtifactSchema, + PROMOTION_VERIFIER_ID, + PROMOTION_VERIFIER_VERSION, + provenanceProviderAttestationSchema, + trustPolicySha256, + vulnerabilityProviderReportSchema, + type ProviderTrust, +} from "./provider-evidence.ts"; +import { + captureCiCandidateArchive, + withVerifiedCapturedCandidate, +} from "./ci-candidate-archive.ts"; +import { verifyArchivedLocalEvidence } from "./local-release-evidence.ts"; +import { readBoundedRegularFile } from "./ci-artifact-validator.ts"; type StagedFile = Readonly<{ - destinationName: string; + name: PromotedFileName; bytes: Buffer; - digest: string; + sha256: string; }>; -export async function stageVerifiedPromotion(input: Readonly<{ +export type FinalizedPromotion = Readonly<{ + stagingRoot: string; + cleanupToken: string; + runnerTempIdentity: Readonly<{ dev: number; ino: number }>; + files: readonly Readonly<{ name: PromotedFileName; sha256: string }>[]; +}>; + +export async function finalizeVerifiedPromotion(input: Readonly<{ repositoryRoot: string; archivePath: string; expectedArchiveSha256: string; @@ -44,203 +58,330 @@ export async function stageVerifiedPromotion(input: Readonly<{ vulnerabilityKeyId: string; provenancePublicKeyPath: string; provenanceKeyId: string; + expectedRun: Readonly<{ id: string; attempt: number; sourceRevision: string }>; + vulnerabilityInvocationNonce: string; + provenanceInvocationNonce: string; + runnerTempRoot: string; }>, dependencies: Readonly<{ - verifyLocalEvidence?: typeof verifyArchivedLocalEvidence; + captureArchive?: typeof captureCiCandidateArchive; + nowEpochMs?: () => number; + randomBytes?: (bytes: number) => Buffer; afterCapture?: () => Promise; - beforePublishRename?: () => Promise; -}> = {}): Promise>> { + beforePublish?: () => Promise; + afterStagingWrite?: () => Promise; +}> = {}): Promise { const root = path.resolve(input.repositoryRoot); - if (!/^[a-f0-9]{64}$/u.test(input.expectedArchiveSha256)) { - throw new TypeError("promotion archive SHA-256 is invalid"); - } - const sources: PromotionSource[] = [ - { - sourcePath: input.archivePath, - destinationName: "release-candidate.tar.gz", - maxBytes: 268_435_456, - validate: (bytes) => { - if (sha256(bytes) !== input.expectedArchiveSha256) { - throw new Error("promotion archive SHA-256 changed before staging"); - } - }, - }, - { - sourcePath: input.vulnerabilityReportPath, - destinationName: "vulnerability-report.json", - maxBytes: 16_777_216, - validate: (bytes) => vulnerabilityProviderReportSchema.parse(parseJson(bytes)), - }, - { - sourcePath: input.provenanceAttestationPath, - destinationName: "provenance-attestation.json", - maxBytes: 16_777_216, - validate: (bytes) => provenanceProviderAttestationSchema.parse(parseJson(bytes)), - }, - { - sourcePath: "artifacts/security/provider-verification.json", - destinationName: "provider-verification.json", - maxBytes: 4_194_304, - validate: (bytes) => providerVerificationArtifactSchema.parse(parseJson(bytes)), - }, - { - sourcePath: "artifacts/security/promotion-verification.json", - destinationName: "promotion-verification.json", - maxBytes: 4_194_304, - validate: (bytes) => providerVerificationArtifactSchema.parse(parseJson(bytes)), - }, - ]; - const [captured, vulnerabilityPublicKey, provenancePublicKey] = await Promise.all([ - Promise.all( - sources.map(async (source) => { - const relativePath = repositoryRelative(root, source.sourcePath); - const bytes = await readBoundedRegularFile({ - root, - relativePath, - maxBytes: source.maxBytes, - }); - source.validate(bytes); - return Object.freeze({ ...source, bytes, digest: sha256(bytes) }); - }), - ), - capture(root, input.vulnerabilityPublicKeyPath, 1_048_576), - capture(root, input.provenancePublicKeyPath, 1_048_576), - ]); + const capturedArchive = await (dependencies.captureArchive ?? captureCiCandidateArchive)({ + archivePath: input.archivePath, + expectedSha256: input.expectedArchiveSha256, + }); + const [vulnerabilityBytes, provenanceBytes, vulnerabilityKeyBytes, provenanceKeyBytes] = + await Promise.all([ + capture(root, input.vulnerabilityReportPath, 16_777_216), + capture(root, input.provenanceAttestationPath, 16_777_216), + capture(root, input.vulnerabilityPublicKeyPath, 1_048_576), + capture(root, input.provenancePublicKeyPath, 1_048_576), + ]); await dependencies.afterCapture?.(); - let capturedLocalStatus: "PASS" | "FAIL" = "FAIL"; - const archive = await verifyCapturedCiCandidateArchive( - captured[0]!.bytes, - input.expectedArchiveSha256, - { - verifyExtracted: async (extractionRoot, manifest) => { - const candidate = await verifyReleaseCandidate(manifest, extractionRoot); - if (candidate.failures.length > 0) { - throw new Error(`captured candidate failed final verification: ${candidate.failures.join(", ")}`); - } - const local = await (dependencies.verifyLocalEvidence ?? verifyArchivedLocalEvidence)({ - repositoryRoot: extractionRoot, - candidate: manifest, - }); - if (local.status !== "PASS" || local.failures.length > 0) { - throw new Error(`captured local evidence failed final verification: ${local.failures.join(", ")}`); - } - capturedLocalStatus = local.status; - }, - }, + + const vulnerabilityTrust = capturedTrust( + input.vulnerabilityKeyId, + vulnerabilityKeyBytes, ); - const vulnerability = vulnerabilityProviderReportSchema.parse(parseJson(captured[1]!.bytes)); - const provenance = provenanceProviderAttestationSchema.parse(parseJson(captured[2]!.bytes)); - const reevaluated = evaluatePromotionEvidence({ - candidate: archive.manifest, - currentDistSha256: archive.manifest.distSha256, - localStatus: capturedLocalStatus, - vulnerabilityReport: vulnerability, - provenanceAttestation: provenance, - vulnerabilityTrust: { - keyId: input.vulnerabilityKeyId, - publicKey: createPublicKey( - new TextDecoder("utf-8", { fatal: true }).decode(vulnerabilityPublicKey), - ), - }, - provenanceTrust: { - keyId: input.provenanceKeyId, - publicKey: createPublicKey( - new TextDecoder("utf-8", { fatal: true }).decode(provenancePublicKey), - ), + const provenanceTrust = capturedTrust( + input.provenanceKeyId, + provenanceKeyBytes, + ); + const vulnerabilityReport = vulnerabilityProviderReportSchema.parse( + parseJson(vulnerabilityBytes), + ); + const provenanceAttestation = provenanceProviderAttestationSchema.parse( + parseJson(provenanceBytes), + ); + const now = (dependencies.nowEpochMs ?? Date.now)(); + const verifiedAt = new Date(now).toISOString(); + + const generated = await withVerifiedCapturedCandidate({ + captured: capturedArchive, + verify: async ({ extractionRoot, manifest }) => { + const local = await verifyArchivedLocalEvidence({ + extractionRoot, + expectedManifest: manifest, + }); + if (local.status !== "PASS" || !local.identity) { + throw new Error( + `captured local evidence failed final verification: ${local.failures.join(", ")}`, + ); + } + if (local.identity.sourceRevision !== input.expectedRun.sourceRevision) { + throw new Error("captured source revision differs from expected promotion revision"); + } + const expected = { + run: { id: input.expectedRun.id, attempt: input.expectedRun.attempt }, + source: { + revision: local.identity.sourceRevision, + sourceSetSha256: local.identity.sourceSetSha256, + }, + candidate: { + archiveSha256: capturedArchive.archiveSha256, + bundleSha256: manifest.bundleSha256, + distSha256: manifest.distSha256, + lockfileSha256: manifest.lockfileSha256, + }, + vulnerabilityInvocationNonce: input.vulnerabilityInvocationNonce, + provenanceInvocationNonce: input.provenanceInvocationNonce, + } as const; + const reevaluated = evaluatePromotionEvidence({ + expected, + localStatus: local.status, + vulnerabilityReport, + provenanceAttestation, + vulnerabilityTrust, + provenanceTrust, + nowEpochMs: () => now, + }); + if (reevaluated.status !== "PASS") { + throw new Error( + `captured provider evidence failed trusted revalidation: ${reevaluated.failures.join(", ")}`, + ); + } + const providerEvidence = { + vulnerabilityReportSha256: sha256(vulnerabilityBytes), + provenanceAttestationSha256: sha256(provenanceBytes), + vulnerabilityInvocationNonce: input.vulnerabilityInvocationNonce, + provenanceInvocationNonce: input.provenanceInvocationNonce, + vulnerabilityKeyId: vulnerabilityTrust.keyId, + vulnerabilityKeyFingerprint: vulnerabilityTrust.publicKeyFingerprint, + provenanceKeyId: provenanceTrust.keyId, + provenanceKeyFingerprint: provenanceTrust.publicKeyFingerprint, + } as const; + const trustDigest = trustPolicySha256({ vulnerabilityTrust, provenanceTrust }); + const common = { + schemaVersion: 3 as const, + verifiedAt, + status: "PASS" as const, + verifier: { + id: PROMOTION_VERIFIER_ID, + version: PROMOTION_VERIFIER_VERSION, + }, + run: expected.run, + source: expected.source, + candidate: expected.candidate, + providerEvidence, + trustPolicySha256: trustDigest, + failures: [] as const, + }; + const providerRecord = providerVerificationArtifactSchema.parse({ + ...common, + artifactType: "provider-verification", + vulnerabilityStatus: reevaluated.vulnerabilityStatus, + provenanceAttestationStatus: reevaluated.provenanceAttestationStatus, + }); + const providerRecordBytes = canonicalJsonBytes(providerRecord); + const promotionRecord = providerVerificationArtifactSchema.parse({ + ...common, + artifactType: "promotion-verification", + localEvidenceStatus: local.status, + localEvidenceAssessmentSha256: local.identity.assessmentSha256, + providerVerificationSha256: sha256(providerRecordBytes), + }); + return Object.freeze({ + providerRecordBytes, + promotionRecordBytes: canonicalJsonBytes(promotionRecord), + }); }, }); - if (reevaluated.status !== "PASS" || reevaluated.failures.length > 0) { - throw new Error(`captured provider evidence failed trusted revalidation: ${reevaluated.failures.join(", ")}`); - } - const expectedBindings = { - candidateArchiveSha256: captured[0]!.digest, - vulnerabilityReportSha256: captured[1]!.digest, - provenanceAttestationSha256: captured[2]!.digest, - }; - for (const [index, expectedArtifactType] of [ - [3, "provider-verification"], - [4, "promotion-verification"], - ] as const) { - const verification = providerVerificationArtifactSchema.parse(parseJson(captured[index]!.bytes)); - if (verification.artifactType !== expectedArtifactType) { - throw new Error( - `${captured[index]!.destinationName} artifactType role mismatch: expected ${expectedArtifactType}`, - ); - } - if ( - verification.status !== reevaluated.status || - verification.vulnerabilityStatus !== reevaluated.vulnerabilityStatus || - verification.provenanceAttestationStatus !== reevaluated.provenanceAttestationStatus || - verification.failures.length > 0 - ) { - throw new Error(`${captured[index]!.destinationName} status disagrees with trusted revalidation`); - } - if (verification.lockfileSha256 !== archive.manifest.lockfileSha256) { - throw new Error(`${captured[index]!.destinationName} lockfileSha256 digest mismatch`); - } - if (verification.distSha256 !== archive.manifest.distSha256) { - throw new Error(`${captured[index]!.destinationName} distSha256 digest mismatch`); - } - for (const [binding, expectedDigest] of Object.entries(expectedBindings) as ReadonlyArray< - readonly [keyof typeof expectedBindings, string] - >) { - if (verification[binding] !== expectedDigest) { - throw new Error(`${captured[index]!.destinationName} ${binding} digest mismatch`); - } - } - } - const stagedFiles: readonly StagedFile[] = captured; - const releaseRoot = path.join(root, ".release"); - const releaseIdentity = await ensureSafePublishDirectory(root, releaseRoot); - const stagingRoot = path.join(releaseRoot, "promoted-staging"); - await assertSafePublishLeaf(stagingRoot, ".release/promoted-staging"); - if (await exists(stagingRoot)) throw new Error("promotion staging target already exists"); - const temporary = await mkdtemp(path.join(root, `.promoted-staging.${randomUUID()}.`)); - let ownsTemporary = true; + const stagedFiles: readonly StagedFile[] = Object.freeze([ + staged("release-candidate.tar.gz", capturedArchive.bytes), + staged("vulnerability-report.json", vulnerabilityBytes), + staged("provenance-attestation.json", provenanceBytes), + staged("provider-verification.json", generated.providerRecordBytes), + staged("promotion-verification.json", generated.promotionRecordBytes), + ]); + if ( + JSON.stringify(stagedFiles.map(({ name }) => name)) !== + JSON.stringify(PROMOTED_FILE_NAMES) + ) { + throw new Error("promotion exact-five canonical file order drift"); + } + await dependencies.beforePublish?.(); + return publishPrivateStaging( + input.runnerTempRoot, + input.expectedRun, + stagedFiles, + dependencies.randomBytes ?? cryptoRandomBytes, + dependencies.afterStagingWrite, + ); +} + +export const stageVerifiedPromotion = finalizeVerifiedPromotion; + +export async function cleanupFinalizedPromotion(input: Readonly<{ + runnerTempRoot: string; + stagingRoot: string; + cleanupToken: string; + runnerTempIdentity: Readonly<{ dev: number; ino: number }>; +}>, dependencies: Readonly<{ + beforeRemove?: () => Promise; +}> = {}): Promise { + const parent = path.resolve(input.runnerTempRoot); + const expected = path.join(parent, input.cleanupToken); + if ( + !/^[A-Za-z0-9._-]+-[a-f0-9]{32}$/u.test(input.cleanupToken) || + path.resolve(input.stagingRoot) !== expected || + !Number.isSafeInteger(input.runnerTempIdentity.dev) || + input.runnerTempIdentity.dev <= 0 || + !Number.isSafeInteger(input.runnerTempIdentity.ino) || + input.runnerTempIdentity.ino <= 0 + ) { + throw new TypeError("promotion cleanup root/token mismatch"); + } + const parentHandle = await open( + parent, + constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW, + ); try { - for (const source of stagedFiles) { + const openedParent = await parentHandle.stat(); + assertRunnerTempIdentity(openedParent, input.runnerTempIdentity); + const descriptorRoot = `/proc/self/fd/${parentHandle.fd}`; + const descriptorMetadata = await stat(descriptorRoot); + if (!descriptorMetadata.isDirectory()) { + throw new Error("descriptor-relative cleanup is unavailable"); + } + const descriptorExpected = path.join(descriptorRoot, input.cleanupToken); + let metadata; + try { + metadata = await lstat(descriptorExpected); + } catch (error) { + if (hasErrorCode(error, "ENOENT")) return; + throw error; + } + if (metadata.isSymbolicLink() || !metadata.isDirectory()) { + throw new TypeError("promotion cleanup leaf is unsafe"); + } + await dependencies.beforeRemove?.(); + const visibleParent = await lstat(parent); + assertRunnerTempIdentity(visibleParent, input.runnerTempIdentity); + await rm(descriptorExpected, { recursive: true, force: true }); + const afterParent = await lstat(parent); + assertRunnerTempIdentity(afterParent, input.runnerTempIdentity); + } finally { + await parentHandle.close(); + } +} + +async function publishPrivateStaging( + runnerTempRoot: string, + run: Readonly<{ id: string; attempt: number }>, + files: readonly StagedFile[], + randomBytes: (bytes: number) => Buffer, + afterStagingWrite?: () => Promise, +): Promise { + const parentPath = path.resolve(runnerTempRoot); + const before = await lstat(parentPath); + if (!before.isDirectory() || before.isSymbolicLink()) { + throw new TypeError("runner temporary root must be a real directory"); + } + const parentHandle = await open( + parentPath, + constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW, + ); + const tokenBytes = randomBytes(16); + if (tokenBytes.byteLength !== 16) { + await parentHandle.close(); + throw new TypeError("promotion staging nonce must contain exactly 128 random bits"); + } + const safeRun = run.id.replaceAll(/[^A-Za-z0-9._-]/gu, "_").slice(0, 64) || "run"; + const cleanupToken = `promotion-${safeRun}-${run.attempt}-${tokenBytes.toString("hex")}`; + const descriptorRoot = `/proc/self/fd/${parentHandle.fd}`; + const descriptorStaging = path.join(descriptorRoot, cleanupToken); + const visibleStaging = path.join(parentPath, cleanupToken); + let ownsStaging = false; + try { + const procMetadata = await stat(descriptorRoot); + if (!procMetadata.isDirectory()) throw new Error("descriptor-relative staging is unavailable"); + await mkdir(descriptorStaging, { mode: 0o700 }); + ownsStaging = true; + for (const file of files) { const handle = await open( - path.join(temporary, source.destinationName), - constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, - 0o600, + path.join(descriptorStaging, file.name), + constants.O_WRONLY | + constants.O_CREAT | + constants.O_EXCL | + constants.O_NOFOLLOW, + 0o400, ); try { - await handle.writeFile(source.bytes); + await handle.writeFile(file.bytes); await handle.sync(); } finally { await handle.close(); } } - await syncDirectory(temporary); - await dependencies.beforePublishRename?.(); - const currentReleaseIdentity = await ensureSafePublishDirectory(root, releaseRoot); + await syncDirectory(descriptorStaging); + await syncHandle(parentHandle); + await afterStagingWrite?.(); + const after = await lstat(parentPath); if ( - releaseIdentity.dev <= 0 || - releaseIdentity.ino <= 0 || - currentReleaseIdentity.dev !== releaseIdentity.dev || - currentReleaseIdentity.ino !== releaseIdentity.ino + after.dev !== before.dev || + after.ino !== before.ino || + after.isSymbolicLink() || + !after.isDirectory() ) { - throw new Error("promotion staging parent identity changed"); + throw new Error("runner temporary parent identity changed during staging"); } - await assertSafePublishLeaf(stagingRoot, ".release/promoted-staging"); - if (await exists(stagingRoot)) throw new Error("promotion staging target already exists"); - await rename(temporary, stagingRoot); - ownsTemporary = false; - await syncDirectory(releaseRoot); + const visible = await lstat(visibleStaging); + if (!visible.isDirectory() || visible.isSymbolicLink()) { + throw new Error("promotion staging visibility identity mismatch"); + } + ownsStaging = false; + return Object.freeze({ + stagingRoot: visibleStaging, + cleanupToken, + runnerTempIdentity: Object.freeze({ dev: before.dev, ino: before.ino }), + files: Object.freeze( + files.map(({ name, sha256: digest }) => Object.freeze({ name, sha256: digest })), + ), + }); } finally { - if (ownsTemporary) await rm(temporary, { recursive: true, force: true }); + if (ownsStaging) { + await rm(descriptorStaging, { recursive: true, force: true }).catch(() => undefined); + } + await parentHandle.close(); } - return Object.freeze( - stagedFiles.map(({ destinationName, digest }) => - Object.freeze({ path: `.release/promoted-staging/${destinationName}`, sha256: digest }), - ), +} + +function assertRunnerTempIdentity( + metadata: Readonly<{ dev: number; ino: number; isDirectory: () => boolean; isSymbolicLink?: () => boolean }>, + expected: Readonly<{ dev: number; ino: number }>, +): void { + if ( + metadata.dev !== expected.dev || + metadata.ino !== expected.ino || + !metadata.isDirectory() || + metadata.isSymbolicLink?.() + ) { + throw new Error("runner temporary parent identity changed during cleanup"); + } +} + +function capturedTrust(keyId: string, bytes: Buffer): ProviderTrust { + const publicKey = createPublicKey( + new TextDecoder("utf-8", { fatal: true }).decode(bytes), ); + return Object.freeze({ + keyId, + publicKey, + publicKeyFingerprint: providerPublicKeyFingerprint(publicKey), + }); } async function capture(root: string, configuredPath: string, maxBytes: number): Promise { const absolute = path.resolve(root, configuredPath); const relative = path.relative(root, absolute); - const outside = relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative); + const outside = + relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative); return readBoundedRegularFile({ root: outside ? path.dirname(absolute) : root, relativePath: outside ? path.basename(absolute) : relative.replaceAll(path.sep, "/"), @@ -248,43 +389,43 @@ async function capture(root: string, configuredPath: string, maxBytes: number): }); } -function parseJson(bytes: Buffer): unknown { - return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown; +function staged(name: PromotedFileName, bytes: Buffer): StagedFile { + return Object.freeze({ name, bytes, sha256: sha256(bytes) }); } -function repositoryRelative(root: string, configuredPath: string): string { - const absolute = path.resolve(root, configuredPath); - const relative = path.relative(root, absolute); - if (relative === "" || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { - throw new TypeError(`promotion source escapes repository: ${configuredPath}`); +function canonicalJsonBytes(value: unknown): Buffer { + return Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8"); +} + +function parseJson(bytes: Buffer): unknown { + try { + return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown; + } catch { + throw new TypeError("captured provider evidence is not valid UTF-8 JSON"); } - return relative.replaceAll(path.sep, "/"); } function sha256(bytes: Buffer): string { return createHash("sha256").update(bytes).digest("hex"); } -async function exists(target: string): Promise { +async function syncDirectory(directory: string): Promise { + const handle = await open( + directory, + constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW, + ); try { - await lstat(target); - return true; - } catch (error) { - if (hasErrorCode(error, "ENOENT")) return false; - throw error; + await syncHandle(handle); + } finally { + await handle.close(); } } -async function syncDirectory(directory: string): Promise { - const handle = await open(directory, constants.O_RDONLY); +async function syncHandle(handle: Awaited>): Promise { try { - try { - await handle.sync(); - } catch (error) { - if (!hasErrorCode(error, "EINVAL") && !hasErrorCode(error, "ENOTSUP")) throw error; - } - } finally { - await handle.close(); + await handle.sync(); + } catch (error) { + if (!hasErrorCode(error, "EINVAL") && !hasErrorCode(error, "ENOTSUP")) throw error; } } diff --git a/scripts/lib/promotion-verifier.ts b/scripts/lib/promotion-verifier.ts index 66b176b..889d59e 100644 --- a/scripts/lib/promotion-verifier.ts +++ b/scripts/lib/promotion-verifier.ts @@ -2,7 +2,11 @@ import { createHash, createPublicKey } from "node:crypto"; import path from "node:path"; import { + PROMOTION_VERIFIER_ID, + PROMOTION_VERIFIER_VERSION, evaluatePromotionEvidence, + providerPublicKeyFingerprint, + trustPolicySha256, type ProviderVerificationArtifactType, type ProviderTrust, } from "./provider-evidence.ts"; @@ -13,6 +17,7 @@ import { } from "./release-candidate.ts"; import { verifyArchivedLocalEvidence } from "./local-release-evidence.ts"; import { readBoundedRegularFile } from "./ci-artifact-validator.ts"; +import { supplyChainDigest } from "./supply-chain.ts"; type LocalEvidenceVerifier = typeof verifyArchivedLocalEvidence; @@ -23,6 +28,7 @@ export type VerifyPromotionInputsOptions = Readonly<{ providerEvidenceRoot?: string; trustRoot?: string; verifyLocalEvidence?: LocalEvidenceVerifier; + nowEpochMs?: () => number; }>; export async function verifyPromotionInputs( @@ -75,25 +81,73 @@ export async function verifyPromotionInputs( ); const localEvidence = await ( options.verifyLocalEvidence ?? verifyArchivedLocalEvidence - )({ repositoryRoot, candidate: manifest }); + )({ extractionRoot: repositoryRoot, expectedManifest: manifest }); const vulnerabilityReport = parseCapturedJson(vulnerabilityCapture.bytes); const provenanceAttestation = parseCapturedJson(provenanceCapture.bytes); + const vulnerabilityTrust = await readProviderTrust( + trustRoot, + environment.VULNERABILITY_PUBLIC_KEY_PATH, + environment.VULNERABILITY_KEY_ID, + ); + const provenanceTrust = await readProviderTrust( + trustRoot, + environment.PROVENANCE_PUBLIC_KEY_PATH, + environment.PROVENANCE_KEY_ID, + ); + const runId = environment.CI_RUN_ID ?? "missing-run"; + const runAttempt = Number(environment.CI_RUN_ATTEMPT); + if (!environment.CI_RUN_ID) inputFailures.push("provider expected run ID is missing"); + if (!Number.isInteger(runAttempt) || runAttempt < 1 || runAttempt > 1_000) { + inputFailures.push("provider expected run attempt is missing or invalid"); + } + if (!localEvidence.identity) { + inputFailures.push("archived local evidence identity is unavailable"); + } + if ( + environment.EXPECTED_SOURCE_REVISION && + localEvidence.identity && + environment.EXPECTED_SOURCE_REVISION !== localEvidence.identity.sourceRevision + ) { + inputFailures.push( + `provider expected source revision mismatch: expected ${environment.EXPECTED_SOURCE_REVISION}, archived ${localEvidence.identity.sourceRevision}`, + ); + } + const vulnerabilityInvocationNonce = requiredExpectedNonce( + environment.VULNERABILITY_INVOCATION_NONCE, + "vulnerability", + inputFailures, + ); + const provenanceInvocationNonce = requiredExpectedNonce( + environment.PROVENANCE_INVOCATION_NONCE, + "provenance", + inputFailures, + ); + const expected = { + run: { id: runId, attempt: Number.isInteger(runAttempt) ? runAttempt : 1 }, + source: { + revision: + localEvidence.identity?.sourceRevision ?? + environment.EXPECTED_SOURCE_REVISION ?? + "0".repeat(40), + sourceSetSha256: localEvidence.identity?.sourceSetSha256 ?? "0".repeat(64), + }, + candidate: { + archiveSha256: archive.sha256 ?? "0".repeat(64), + bundleSha256: manifest.bundleSha256, + distSha256: manifest.distSha256, + lockfileSha256: manifest.lockfileSha256, + }, + vulnerabilityInvocationNonce, + provenanceInvocationNonce, + } as const; const result = evaluatePromotionEvidence({ - candidate: manifest, - currentDistSha256: candidate.currentDistSha256 ?? "", + expected, localStatus: localEvidence.status, vulnerabilityReport, provenanceAttestation, - vulnerabilityTrust: await readProviderTrust( - trustRoot, - environment.VULNERABILITY_PUBLIC_KEY_PATH, - environment.VULNERABILITY_KEY_ID, - ), - provenanceTrust: await readProviderTrust( - trustRoot, - environment.PROVENANCE_PUBLIC_KEY_PATH, - environment.PROVENANCE_KEY_ID, - ), + vulnerabilityTrust, + provenanceTrust, + nowEpochMs: options.nowEpochMs, }); const failures = [ ...inputFailures, @@ -101,21 +155,93 @@ export async function verifyPromotionInputs( ...localEvidence.failures, ...result.failures, ]; - return Object.freeze({ - schemaVersion: 2 as const, + const now = (options.nowEpochMs ?? Date.now)(); + const common = { + schemaVersion: 3 as const, artifactType: options.artifactType, + verifiedAt: new Date(now).toISOString(), 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, - candidateArchiveSha256: archive.sha256, - vulnerabilityReportSha256: vulnerabilityCapture.sha256, - provenanceAttestationSha256: provenanceCapture.sha256, + verifier: Object.freeze({ + id: PROMOTION_VERIFIER_ID, + version: PROMOTION_VERIFIER_VERSION, + }), + run: expected.run, + source: expected.source, + candidate: expected.candidate, + providerEvidence: Object.freeze({ + vulnerabilityReportSha256: vulnerabilityCapture.sha256 ?? "0".repeat(64), + provenanceAttestationSha256: provenanceCapture.sha256 ?? "0".repeat(64), + vulnerabilityInvocationNonce: expected.vulnerabilityInvocationNonce, + provenanceInvocationNonce: expected.provenanceInvocationNonce, + vulnerabilityKeyId: + vulnerabilityTrust?.keyId ?? environment.VULNERABILITY_KEY_ID ?? "missing-key", + vulnerabilityKeyFingerprint: + vulnerabilityTrust?.publicKeyFingerprint ?? `sha256:${"0".repeat(64)}`, + provenanceKeyId: + provenanceTrust?.keyId ?? environment.PROVENANCE_KEY_ID ?? "missing-key", + provenanceKeyFingerprint: + provenanceTrust?.publicKeyFingerprint ?? `sha256:${"0".repeat(64)}`, + }), + trustPolicySha256: verificationTrustPolicySha256( + vulnerabilityTrust, + provenanceTrust, + environment, + ), failures: Object.freeze(failures), + }; + return options.artifactType === "provider-verification" + ? Object.freeze({ + ...common, + artifactType: "provider-verification" as const, + vulnerabilityStatus: result.vulnerabilityStatus, + provenanceAttestationStatus: result.provenanceAttestationStatus, + }) + : Object.freeze({ + ...common, + artifactType: "promotion-verification" as const, + localEvidenceStatus: localEvidence.status, + localEvidenceAssessmentSha256: + localEvidence.identity?.assessmentSha256 ?? "0".repeat(64), + providerVerificationSha256: + environment.PROVIDER_VERIFICATION_SHA256 ?? "0".repeat(64), + }); +} + +function requiredExpectedNonce( + value: string | undefined, + label: "vulnerability" | "provenance", + failures: string[], +): string { + if (value && /^[a-f0-9]{64}$/u.test(value)) return value; + failures.push(`${label} expected invocation nonce is missing or invalid`); + return "0".repeat(64); +} + +function verificationTrustPolicySha256( + vulnerabilityTrust: ProviderTrust | null, + provenanceTrust: ProviderTrust | null, + environment: NodeJS.ProcessEnv, +): string { + if (vulnerabilityTrust && provenanceTrust) { + return trustPolicySha256({ vulnerabilityTrust, provenanceTrust }); + } + return supplyChainDigest({ + algorithm: "Ed25519", + vulnerability: { + keyId: vulnerabilityTrust?.keyId ?? environment.VULNERABILITY_KEY_ID ?? "missing-key", + publicKeyFingerprint: + vulnerabilityTrust?.publicKeyFingerprint ?? `sha256:${"0".repeat(64)}`, + }, + provenance: { + keyId: provenanceTrust?.keyId ?? environment.PROVENANCE_KEY_ID ?? "missing-key", + publicKeyFingerprint: + provenanceTrust?.publicKeyFingerprint ?? `sha256:${"0".repeat(64)}`, + }, + issuedAtFutureSkewMs: 5 * 60 * 1_000, + maximumLifetimeMs: 2 * 60 * 60 * 1_000, }); } @@ -126,13 +252,15 @@ export async function readProviderTrust( ): Promise { if (!publicKeyPath || !keyId?.trim()) return null; try { + const publicKey = createPublicKey( + new TextDecoder("utf-8", { fatal: true }).decode( + await boundedConfiguredFile(repositoryRoot, publicKeyPath, 1_048_576), + ), + ); return Object.freeze({ keyId, - publicKey: createPublicKey( - new TextDecoder("utf-8", { fatal: true }).decode( - await boundedConfiguredFile(repositoryRoot, publicKeyPath, 1_048_576), - ), - ), + publicKey, + publicKeyFingerprint: providerPublicKeyFingerprint(publicKey), }); } catch { return null; diff --git a/scripts/lib/provider-evidence.ts b/scripts/lib/provider-evidence.ts index 3336c3c..1abcfd4 100644 --- a/scripts/lib/provider-evidence.ts +++ b/scripts/lib/provider-evidence.ts @@ -1,90 +1,145 @@ -import { verify, type KeyObject } from "node:crypto"; +import { createHash, verify, type KeyObject } from "node:crypto"; import { z } from "zod"; -import { canonicalizeSupplyChainValue } from "./supply-chain.ts"; +import { + canonicalizeSupplyChainValue, + supplyChainDigest, +} from "./supply-chain.ts"; + +export const PROVIDER_FUTURE_SKEW_MS = 5 * 60 * 1_000; +export const PROVIDER_MAX_LIFETIME_MS = 2 * 60 * 60 * 1_000; +export const PROMOTION_VERIFIER_ID = + "clean-architecture-frontend-template/promotion-verifier"; +export const PROMOTION_VERIFIER_VERSION = "3"; const sha256 = z.string().regex(/^[a-f0-9]{64}$/u); -const nonEmptyString = z.string().trim().min(1); +const fingerprint = z.string().regex(/^sha256:[a-f0-9]{64}$/u); +const revision = z.string().regex(/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u); +const nonce = z.string().regex(/^[a-f0-9]{64}$/u); +const nonEmptyString = z.string().min(1); +const timestamp = z + .string() + .regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u) + .refine((value) => new Date(value).toISOString() === value); +const runSchema = z + .object({ id: z.string().min(1).max(128), attempt: z.int().min(1).max(1_000) }) + .strict(); +const sourceSchema = z + .object({ revision, sourceSetSha256: sha256 }) + .strict(); +const candidateSchema = z + .object({ + archiveSha256: sha256, + bundleSha256: sha256, + distSha256: sha256, + lockfileSha256: sha256, + }) + .strict(); +const providerRunSchema = runSchema.extend({ invocationNonce: nonce }).strict(); const signatureSchema = z .object({ algorithm: z.literal("Ed25519"), keyId: nonEmptyString, - value: z.string().regex(/^[A-Za-z0-9+/]+={0,2}$/u), + publicKeyFingerprint: fingerprint, + value: z.string().regex(/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u), }) .strict(); +const providerCommon = { + schemaVersion: z.literal(2), + provider: nonEmptyString, + issuedAt: timestamp, + expiresAt: timestamp, + run: providerRunSchema, + source: sourceSchema, + candidate: candidateSchema, + signature: signatureSchema, +} as const; export const vulnerabilityProviderReportSchema = z .object({ - schemaVersion: z.literal(1), - provider: nonEmptyString, - generatedAt: z.iso.datetime(), - scannedLockfileSha256: sha256, - scannedDistSha256: sha256, + ...providerCommon, + evidenceType: z.literal("vulnerability-report"), findings: z.array(z.record(z.string(), z.json())), - signature: signatureSchema, }) .strict(); export const provenanceProviderAttestationSchema = z .object({ - schemaVersion: z.literal(1), - provider: nonEmptyString, + ...providerCommon, + evidenceType: z.literal("provenance-attestation"), signer: nonEmptyString, - generatedAt: z.iso.datetime(), subject: z - .object({ - name: z.literal("dist"), - digest: z.object({ sha256 }).strict(), - }) + .object({ name: z.literal("dist"), digest: z.object({ sha256 }).strict() }) .strict(), - signature: signatureSchema, + }) + .strict(); + +const verificationCommon = { + schemaVersion: z.literal(3), + verifiedAt: timestamp, + status: z.enum(["PASS", "FAIL_UNVERIFIED"]), + verifier: z + .object({ id: nonEmptyString, version: nonEmptyString }) + .strict(), + run: runSchema, + source: sourceSchema, + candidate: candidateSchema, + providerEvidence: z + .object({ + vulnerabilityReportSha256: sha256, + provenanceAttestationSha256: sha256, + vulnerabilityInvocationNonce: nonce, + provenanceInvocationNonce: nonce, + vulnerabilityKeyId: nonEmptyString, + vulnerabilityKeyFingerprint: fingerprint, + provenanceKeyId: nonEmptyString, + provenanceKeyFingerprint: fingerprint, + }) + .strict(), + trustPolicySha256: sha256, + failures: z.array(z.string()), +} as const; + +const providerVerificationV3Schema = z + .object({ + ...verificationCommon, + artifactType: z.literal("provider-verification"), + vulnerabilityStatus: z.enum(["PASS", "FAIL_UNVERIFIED"]), + provenanceAttestationStatus: z.enum(["PASS", "FAIL_UNVERIFIED"]), + }) + .strict(); + +const promotionVerificationV3Schema = z + .object({ + ...verificationCommon, + artifactType: z.literal("promotion-verification"), + localEvidenceStatus: z.enum(["PASS", "FAIL"]), + localEvidenceAssessmentSha256: sha256, + providerVerificationSha256: sha256, }) .strict(); export const providerVerificationArtifactSchema = z - .object({ - schemaVersion: z.literal(2), - artifactType: z.enum(["provider-verification", "promotion-verification"]), - status: z.enum(["PASS", "FAIL_UNVERIFIED"]), - vulnerabilityStatus: z.enum(["PASS", "FAIL_UNVERIFIED"]), - provenanceAttestationStatus: z.enum(["PASS", "FAIL_UNVERIFIED"]), - lockfileSha256: sha256, - distSha256: sha256, - candidateArchiveSha256: sha256.nullable(), - vulnerabilityReportSha256: sha256.nullable(), - provenanceAttestationSha256: sha256.nullable(), - failures: z.array(z.string()), - }) - .strict() - .superRefine((artifact, context) => { - const passing = - artifact.status === "PASS" && - artifact.vulnerabilityStatus === "PASS" && - artifact.provenanceAttestationStatus === "PASS" && - artifact.failures.length === 0; - if ((artifact.status === "PASS") !== passing) { + .discriminatedUnion("artifactType", [ + providerVerificationV3Schema, + promotionVerificationV3Schema, + ]) + .superRefine((record, context) => { + const subordinatePass = + record.artifactType === "provider-verification" + ? record.vulnerabilityStatus === "PASS" && + record.provenanceAttestationStatus === "PASS" + : record.localEvidenceStatus === "PASS"; + const coherentPass = subordinatePass && record.failures.length === 0; + if ((record.status === "PASS") !== coherentPass) { context.addIssue({ code: "custom", path: ["status"], - message: "verification PASS must agree with provider statuses and failures", + message: "verification PASS must agree with subordinate statuses and failures", }); } - if ( - artifact.status === "PASS" && - [ - artifact.candidateArchiveSha256, - artifact.vulnerabilityReportSha256, - artifact.provenanceAttestationSha256, - ].some((digest) => digest === null) - ) { - context.addIssue({ - code: "custom", - path: ["candidateArchiveSha256"], - message: "passing verification requires every exact input digest", - }); - } - if (artifact.status === "FAIL_UNVERIFIED" && artifact.failures.length === 0) { + if (record.status === "FAIL_UNVERIFIED" && record.failures.length === 0) { context.addIssue({ code: "custom", path: ["failures"], @@ -100,6 +155,20 @@ export type ProviderVerificationArtifactType = z.infer< export type ProviderTrust = Readonly<{ keyId: string; publicKey: KeyObject; + publicKeyFingerprint: string; +}>; + +export type ExpectedPromotionContext = Readonly<{ + run: Readonly<{ id: string; attempt: number }>; + source: Readonly<{ revision: string; sourceSetSha256: string }>; + candidate: Readonly<{ + archiveSha256: string; + bundleSha256: string; + distSha256: string; + lockfileSha256: string; + }>; + vulnerabilityInvocationNonce: string; + provenanceInvocationNonce: string; }>; export type PromotionEvidenceResult = Readonly<{ @@ -109,35 +178,134 @@ export type PromotionEvidenceResult = Readonly<{ failures: readonly string[]; }>; +export function validateProviderEvidence(input: Readonly<{ + kind: "vulnerability" | "provenance"; + value: unknown; + expected: ExpectedPromotionContext; + trust: ProviderTrust | null; + nowEpochMs?: () => number; +}>): Readonly<{ + evidence: unknown | null; + status: "PASS" | "FAIL_UNVERIFIED"; + failures: readonly string[]; +}> { + const failures: string[] = []; + const now = (input.nowEpochMs ?? Date.now)(); + if (input.kind === "vulnerability") { + const parsed = vulnerabilityProviderReportSchema.safeParse(input.value); + if (!parsed.success) { + return Object.freeze({ + evidence: null, + status: "FAIL_UNVERIFIED", + failures: Object.freeze([ + "external vulnerability provider report is missing or invalid", + ]), + }); + } + validateCommonContext( + "vulnerability report", + parsed.data, + input.expected, + input.expected.vulnerabilityInvocationNonce, + input.trust, + now, + failures, + ); + if (parsed.data.findings.length > 0) { + failures.push("vulnerability report contains findings"); + } + return Object.freeze({ + evidence: parsed.data, + status: failures.length === 0 ? "PASS" : "FAIL_UNVERIFIED", + failures: Object.freeze(failures), + }); + } + const parsed = provenanceProviderAttestationSchema.safeParse(input.value); + if (!parsed.success) { + return Object.freeze({ + evidence: null, + status: "FAIL_UNVERIFIED", + failures: Object.freeze([ + "external signed provenance attestation is missing or invalid", + ]), + }); + } + validateCommonContext( + "provenance attestation", + parsed.data, + input.expected, + input.expected.provenanceInvocationNonce, + input.trust, + now, + failures, + ); + if (parsed.data.subject.digest.sha256 !== input.expected.candidate.distSha256) { + failures.push("provenance attestation subject dist digest mismatch"); + } + return Object.freeze({ + evidence: parsed.data, + status: failures.length === 0 ? "PASS" : "FAIL_UNVERIFIED", + failures: Object.freeze(failures), + }); +} + 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", - ); + return Buffer.from(JSON.stringify(canonicalizeSupplyChainValue(payload)), "utf8"); +} + +export function providerPublicKeyFingerprint(publicKey: KeyObject): string { + if (publicKey.asymmetricKeyType !== "ed25519") { + throw new TypeError("provider trust key must be Ed25519"); + } + return `sha256:${createHash("sha256") + .update(publicKey.export({ type: "spki", format: "der" })) + .digest("hex")}`; +} + +export function createTrustPolicy(input: Readonly<{ + vulnerabilityTrust: ProviderTrust; + provenanceTrust: ProviderTrust; +}>) { + return Object.freeze({ + algorithm: "Ed25519" as const, + vulnerability: Object.freeze({ + keyId: input.vulnerabilityTrust.keyId, + publicKeyFingerprint: input.vulnerabilityTrust.publicKeyFingerprint, + }), + provenance: Object.freeze({ + keyId: input.provenanceTrust.keyId, + publicKeyFingerprint: input.provenanceTrust.publicKeyFingerprint, + }), + issuedAtFutureSkewMs: PROVIDER_FUTURE_SKEW_MS, + maximumLifetimeMs: PROVIDER_MAX_LIFETIME_MS, + }); +} + +export function trustPolicySha256(input: Readonly<{ + vulnerabilityTrust: ProviderTrust; + provenanceTrust: ProviderTrust; +}>): string { + return supplyChainDigest(createTrustPolicy(input)); } export function evaluatePromotionEvidence(input: Readonly<{ - candidate: Readonly<{ distSha256: string; lockfileSha256: string }>; - currentDistSha256: string; + expected: ExpectedPromotionContext; localStatus: unknown; vulnerabilityReport: unknown; provenanceAttestation: unknown; vulnerabilityTrust: ProviderTrust | null; provenanceTrust: ProviderTrust | null; + nowEpochMs?: () => number; }>): 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 now = (input.nowEpochMs ?? Date.now)(); + let vulnerabilityStatus: "PASS" | "FAIL_UNVERIFIED" = "FAIL_UNVERIFIED"; + let provenanceAttestationStatus: "PASS" | "FAIL_UNVERIFIED" = "FAIL_UNVERIFIED"; const vulnerability = vulnerabilityProviderReportSchema.safeParse( input.vulnerabilityReport, @@ -145,36 +313,20 @@ export function evaluatePromotionEvidence(input: Readonly<{ 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"); - } + const before = failures.length; + validateCommonContext( + "vulnerability report", + vulnerability.data, + input.expected, + input.expected.vulnerabilityInvocationNonce, + input.vulnerabilityTrust, + now, + failures, + ); 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 - ) { + if (failures.length === before && input.localStatus === "PASS") { vulnerabilityStatus = "PASS"; } } @@ -185,22 +337,20 @@ export function evaluatePromotionEvidence(input: Readonly<{ 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( + const before = failures.length; + validateCommonContext( + "provenance attestation", provenance.data, + input.expected, + input.expected.provenanceInvocationNonce, input.provenanceTrust, + now, + failures, ); - if (!signaturePassed) { - failures.push("provenance attestation signature verification failed"); + if (provenance.data.subject.digest.sha256 !== input.expected.candidate.distSha256) { + failures.push("provenance attestation subject dist digest mismatch"); } - if ( - provenance.data.subject.digest.sha256 === input.candidate.distSha256 && - input.currentDistSha256 === input.candidate.distSha256 && - input.localStatus === "PASS" && - signaturePassed - ) { + if (failures.length === before && input.localStatus === "PASS") { provenanceAttestationStatus = "PASS"; } } @@ -218,29 +368,78 @@ export function evaluatePromotionEvidence(input: Readonly<{ }); } -function signatureMatches( +function validateCommonContext( + label: "vulnerability report" | "provenance attestation", evidence: z.infer< | typeof vulnerabilityProviderReportSchema | typeof provenanceProviderAttestationSchema >, + expected: ExpectedPromotionContext, + expectedNonce: string, trust: ProviderTrust | null, -): boolean { + now: number, + failures: string[], +): void { + if ( + evidence.run.id !== expected.run.id || + evidence.run.attempt !== expected.run.attempt + ) { + failures.push(`${label} run identity mismatch`); + } + if (evidence.run.invocationNonce !== expectedNonce) { + failures.push(`${label} invocation nonce mismatch`); + } + if ( + evidence.source.revision !== expected.source.revision || + evidence.source.sourceSetSha256 !== expected.source.sourceSetSha256 + ) { + failures.push(`${label} source identity mismatch`); + } + if (JSON.stringify(evidence.candidate) !== JSON.stringify(expected.candidate)) { + failures.push(`${label} candidate identity mismatch`); + } + validateEvidenceTime(label, evidence.issuedAt, evidence.expiresAt, now, failures); if ( !trust || evidence.signature.keyId !== trust.keyId || - trust.publicKey.asymmetricKeyType !== "ed25519" + evidence.signature.publicKeyFingerprint !== trust.publicKeyFingerprint ) { - return false; + failures.push(`${label} trust identity mismatch`); + return; } try { - return verify( - null, - providerEvidenceSignaturePayload(evidence), - trust.publicKey, - Buffer.from(evidence.signature.value, "base64"), - ); + if ( + providerPublicKeyFingerprint(trust.publicKey) !== trust.publicKeyFingerprint || + !verify( + null, + providerEvidenceSignaturePayload(evidence), + trust.publicKey, + Buffer.from(evidence.signature.value, "base64"), + ) + ) { + failures.push(`${label} signature verification failed`); + } } catch { - return false; + failures.push(`${label} signature verification failed`); + } +} + +function validateEvidenceTime( + label: string, + issuedAt: string, + expiresAt: string, + now: number, + failures: string[], +): void { + const issued = Date.parse(issuedAt); + const expires = Date.parse(expiresAt); + if (issued > now + PROVIDER_FUTURE_SKEW_MS) { + failures.push(`${label} issuedAt exceeds allowed future skew`); + } + if (expires <= now) failures.push(`${label} is expired`); + if (expires <= issued) failures.push(`${label} validity window is not positive`); + if (expires - issued > PROVIDER_MAX_LIFETIME_MS) { + failures.push(`${label} validity window exceeds two hours`); } } diff --git a/scripts/lib/provider-supervisor.ts b/scripts/lib/provider-supervisor.ts new file mode 100644 index 0000000..144c84d --- /dev/null +++ b/scripts/lib/provider-supervisor.ts @@ -0,0 +1,143 @@ +import { randomBytes as cryptoRandomBytes } from "node:crypto"; + +import { + captureCiCandidateArchive, + withVerifiedCapturedCandidate, + type CapturedCandidateArchive, +} from "./ci-candidate-archive.ts"; +import { verifyArchivedLocalEvidence } from "./local-release-evidence.ts"; +import type { ExpectedPromotionContext, ProviderTrust } from "./provider-evidence.ts"; +import { validateProviderUpload } from "./provider-upload-validator.ts"; + +export type ProviderInvocation = Readonly<{ + candidateRoot: string; + environment: Readonly>; +}>; + +export async function superviseProviderEvidence(input: Readonly<{ + kind: "vulnerability" | "provenance"; + archivePath: string; + expectedArchiveSha256: string; + expectedRun: Readonly<{ id: string; attempt: number; sourceRevision: string }>; + trust: ProviderTrust; + executeProvider: (invocation: ProviderInvocation) => Promise; + captureReport: () => Promise; +}>, dependencies: Readonly<{ + captureArchive?: typeof captureCiCandidateArchive; + withVerifiedCandidate?: typeof withVerifiedCapturedCandidate; + verifyLocalEvidence?: typeof verifyArchivedLocalEvidence; + validateUpload?: typeof validateProviderUpload; + randomBytes?: (bytes: number) => Buffer; + nowEpochMs?: () => number; +}> = {}): Promise> { + const captured = await (dependencies.captureArchive ?? captureCiCandidateArchive)({ + archivePath: input.archivePath, + expectedSha256: input.expectedArchiveSha256, + }); + const nonceBytes = (dependencies.randomBytes ?? cryptoRandomBytes)(32); + if (nonceBytes.byteLength !== 32) { + throw new TypeError("provider invocation nonce must contain exactly 32 bytes"); + } + const invocationNonce = nonceBytes.toString("hex"); + const now = (dependencies.nowEpochMs ?? Date.now)(); + const result = await (dependencies.withVerifiedCandidate ?? withVerifiedCapturedCandidate)({ + captured, + verify: async ({ extractionRoot, manifest }) => { + const local = await (dependencies.verifyLocalEvidence ?? verifyArchivedLocalEvidence)({ + extractionRoot, + expectedManifest: manifest, + }); + if (local.status !== "PASS" || !local.identity) { + throw new Error( + `provider candidate local assessment failed: ${local.failures.join("; ")}`, + ); + } + if (local.identity.sourceRevision !== input.expectedRun.sourceRevision) { + throw new Error("provider candidate source revision mismatch"); + } + const expectedContext: ExpectedPromotionContext = Object.freeze({ + run: Object.freeze({ id: input.expectedRun.id, attempt: input.expectedRun.attempt }), + source: Object.freeze({ + revision: local.identity.sourceRevision, + sourceSetSha256: local.identity.sourceSetSha256, + }), + candidate: Object.freeze({ + archiveSha256: captured.archiveSha256, + bundleSha256: manifest.bundleSha256, + distSha256: manifest.distSha256, + lockfileSha256: manifest.lockfileSha256, + }), + vulnerabilityInvocationNonce: + input.kind === "vulnerability" ? invocationNonce : "0".repeat(64), + provenanceInvocationNonce: + input.kind === "provenance" ? invocationNonce : "0".repeat(64), + }); + const issuedAt = new Date(now).toISOString(); + const expiresAt = new Date(now + 60 * 60 * 1_000).toISOString(); + await input.executeProvider({ + candidateRoot: extractionRoot, + environment: providerInvocationEnvironment({ + kind: input.kind, + expectedContext, + invocationNonce, + issuedAt, + expiresAt, + trust: input.trust, + }), + }); + const capturedReport = await input.captureReport(); + const evidence = await (dependencies.validateUpload ?? validateProviderUpload)({ + kind: input.kind, + verifiedManifest: manifest, + archiveSha256: captured.archiveSha256, + candidateRoot: extractionRoot, + capturedReport, + expectedContext, + trust: input.trust, + nowEpochMs: () => now, + }); + return Object.freeze({ evidence, invocationNonce, expectedContext }); + }, + }); + return result; +} + +export function providerInvocationEnvironment(input: Readonly<{ + kind: "vulnerability" | "provenance"; + expectedContext: ExpectedPromotionContext; + invocationNonce: string; + issuedAt: string; + expiresAt: string; + trust: ProviderTrust; +}>): Readonly> { + return Object.freeze({ + PROVIDER_EVIDENCE_SCHEMA_VERSION: "2", + PROVIDER_EVIDENCE_TYPE: + input.kind === "vulnerability" + ? "vulnerability-report" + : "provenance-attestation", + PROVIDER_ISSUED_AT: input.issuedAt, + PROVIDER_EXPIRES_AT: input.expiresAt, + PROVIDER_INVOCATION_NONCE: input.invocationNonce, + PROVIDER_KEY_ID: input.trust.keyId, + PROVIDER_PUBLIC_KEY_FINGERPRINT: input.trust.publicKeyFingerprint, + CI_RUN_ID: input.expectedContext.run.id, + CI_RUN_ATTEMPT: String(input.expectedContext.run.attempt), + SOURCE_REVISION: input.expectedContext.source.revision, + SOURCE_SET_SHA256: input.expectedContext.source.sourceSetSha256, + CANDIDATE_ROOT: "/candidate", + CANDIDATE_LOCKFILE_PATH: "/candidate/pnpm-lock.yaml", + CANDIDATE_ARCHIVE_SHA256: input.expectedContext.candidate.archiveSha256, + CANDIDATE_BUNDLE_SHA256: input.expectedContext.candidate.bundleSha256, + CANDIDATE_DIST_SHA256: input.expectedContext.candidate.distSha256, + CANDIDATE_LOCKFILE_SHA256: input.expectedContext.candidate.lockfileSha256, + }); +} + +export type CaptureArchiveDependency = ( + input: Readonly<{ archivePath: string; expectedSha256: string }>, +) => Promise; diff --git a/scripts/lib/provider-upload-validator.ts b/scripts/lib/provider-upload-validator.ts index 4dd95e8..321510b 100644 --- a/scripts/lib/provider-upload-validator.ts +++ b/scripts/lib/provider-upload-validator.ts @@ -1,74 +1,59 @@ -import { createHash } from "node:crypto"; -import path from "node:path"; - import { - provenanceProviderAttestationSchema, - vulnerabilityProviderReportSchema, + validateProviderEvidence, + type ExpectedPromotionContext, + type ProviderTrust, } from "./provider-evidence.ts"; import { verifyReleaseCandidate, + type ReleaseCandidateManifest, } from "./release-candidate.ts"; -import { readBoundedRegularFile } from "./ci-artifact-validator.ts"; -import { verifyCiCandidateArchive } from "./ci-candidate-archive.ts"; export async function validateProviderUpload(input: Readonly<{ kind: "vulnerability" | "provenance"; + verifiedManifest: ReleaseCandidateManifest; + archiveSha256: string; candidateRoot: string; - archivePath: string; - expectedArchiveSha256: string; - reportPath: string; - workspaceRoot?: string; - expectedDistSha256: string; + capturedReport: Buffer; + expectedContext: ExpectedPromotionContext; + trust: ProviderTrust; + nowEpochMs?: () => number; }>): Promise { - if (!/^[a-f0-9]{64}$/u.test(input.expectedDistSha256)) { - throw new TypeError("expected candidate dist SHA-256 is invalid"); + if ( + input.expectedContext.candidate.archiveSha256 !== input.archiveSha256 || + input.expectedContext.candidate.bundleSha256 !== input.verifiedManifest.bundleSha256 || + input.expectedContext.candidate.distSha256 !== input.verifiedManifest.distSha256 || + input.expectedContext.candidate.lockfileSha256 !== input.verifiedManifest.lockfileSha256 + ) { + throw new Error("provider supervisor expected candidate context mismatch"); } - const archive = await verifyCiCandidateArchive({ - archivePath: input.archivePath, - expectedSha256: input.expectedArchiveSha256, - }); - const manifest = archive.manifest; - if (manifest.distSha256 !== input.expectedDistSha256) { - throw new Error("provider input candidate dist digest mismatch"); - } - const verifiedCandidate = await verifyReleaseCandidate(manifest, input.candidateRoot); + const verifiedCandidate = await verifyReleaseCandidate( + input.verifiedManifest, + input.candidateRoot, + ); if (verifiedCandidate.failures.length > 0) { throw new Error( `provider input candidate root changed: ${verifiedCandidate.failures.join("; ")}`, ); } - const reportAbsolute = path.resolve(input.reportPath); - const reportRoot = path.resolve(input.workspaceRoot ?? process.cwd()); - const reportRelative = path.relative(reportRoot, reportAbsolute).replaceAll(path.sep, "/"); - const report = JSON.parse( - new TextDecoder("utf-8", { fatal: true }).decode( - await readBoundedRegularFile({ - root: reportRoot, - relativePath: reportRelative, - maxBytes: 8_388_608, - }), - ), - ) as unknown; - if (input.kind === "vulnerability") { - const parsed = vulnerabilityProviderReportSchema.parse(report); - const lockfile = await readBoundedRegularFile({ - root: input.candidateRoot, - relativePath: "pnpm-lock.yaml", - maxBytes: 67_108_864, - }); - const lockfileSha256 = createHash("sha256").update(lockfile).digest("hex"); - if ( - parsed.scannedDistSha256 !== manifest.distSha256 || - parsed.scannedLockfileSha256 !== manifest.lockfileSha256 || - lockfileSha256 !== manifest.lockfileSha256 - ) { - throw new Error("vulnerability provider evidence candidate digest mismatch"); - } - return parsed; + let report: unknown; + try { + report = JSON.parse( + new TextDecoder("utf-8", { fatal: true }).decode(input.capturedReport), + ) as unknown; + } catch { + throw new TypeError("provider output is not canonical UTF-8 JSON"); } - const parsed = provenanceProviderAttestationSchema.parse(report); - if (parsed.subject.digest.sha256 !== manifest.distSha256) { - throw new Error("provenance provider evidence candidate digest mismatch"); + const evaluated = validateProviderEvidence({ + kind: input.kind, + value: report, + expected: input.expectedContext, + trust: input.trust, + nowEpochMs: input.nowEpochMs, + }); + if (evaluated.status !== "PASS" || !evaluated.evidence) { + throw new Error( + `provider evidence context validation failed: ${evaluated.failures.join("; ")}`, + ); } - return parsed; + return evaluated.evidence; } diff --git a/scripts/lib/release-candidate.ts b/scripts/lib/release-candidate.ts index 0bbd90f..ceb7682 100644 --- a/scripts/lib/release-candidate.ts +++ b/scripts/lib/release-candidate.ts @@ -32,6 +32,8 @@ export type ReleaseCandidateManifest = z.infer< export const RELEASE_CANDIDATE_MANIFEST_PATH = "artifacts/release/release-candidate.json"; +export const LOCAL_EVIDENCE_ASSESSMENT_PATH = + "artifacts/security/local-evidence-assessment.json"; export const RELEASE_CANDIDATE_EVIDENCE_PATHS = Object.freeze([ "pnpm-lock.yaml", @@ -45,6 +47,7 @@ export const RELEASE_CANDIDATE_EVIDENCE_PATHS = Object.freeze([ "artifacts/release/sbom.cdx.json", "artifacts/security/dependency-diff.json", "artifacts/security/license-report.json", + LOCAL_EVIDENCE_ASSESSMENT_PATH, "artifacts/security/scan.sarif", "artifacts/security/supply-chain-coherence.json", "artifacts/security/supply-chain-verification.json", diff --git a/scripts/run-and-validate-provider.ts b/scripts/run-and-validate-provider.ts index 483db15..161d210 100644 --- a/scripts/run-and-validate-provider.ts +++ b/scripts/run-and-validate-provider.ts @@ -1,6 +1,6 @@ import { spawn } from "node:child_process"; import { constants } from "node:fs"; -import { access, lstat, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { access, appendFile, lstat, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -8,7 +8,9 @@ import { provenanceProviderAttestationSchema, vulnerabilityProviderReportSchema, } from "./lib/provider-evidence.ts"; -import { validateProviderUpload } from "./lib/provider-upload-validator.ts"; +import { readBoundedRegularFile } from "./lib/ci-artifact-validator.ts"; +import { readProviderTrust } from "./lib/promotion-verifier.ts"; +import { superviseProviderEvidence } from "./lib/provider-supervisor.ts"; import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts"; import { assertSafePublishLeaf, @@ -29,22 +31,37 @@ const reportPath = ? process.env.VULNERABILITY_REPORT_PATH : process.env.PROVENANCE_ATTESTATION_PATH; const sealedPath = process.env.VALIDATED_PROVIDER_REPORT_PATH; -const candidateLockfile = process.env.CANDIDATE_LOCKFILE_PATH; const archivePath = process.env.CANDIDATE_ARCHIVE_PATH; const archiveSha256 = process.env.CANDIDATE_ARCHIVE_SHA256; -const candidateDistSha256 = process.env.CANDIDATE_DIST_SHA256; +const publicKeyPath = kind === "vulnerability" + ? process.env.VULNERABILITY_PUBLIC_KEY_PATH + : process.env.PROVENANCE_PUBLIC_KEY_PATH; +const keyId = kind === "vulnerability" + ? process.env.VULNERABILITY_KEY_ID + : process.env.PROVENANCE_KEY_ID; +const runId = process.env.GITEA_RUN_ID ?? process.env.GITHUB_RUN_ID ?? process.env.CI_RUN_ID; +const runAttemptSource = process.env.GITEA_RUN_ATTEMPT ?? + process.env.GITHUB_RUN_ATTEMPT ?? process.env.CI_RUN_ATTEMPT; +const sourceRevision = process.env.EXPECTED_SOURCE_REVISION ?? process.env.VITE_COMMIT_SHA; if ( !command || !reportPath || !sealedPath || - !candidateLockfile || !archivePath || !archiveSha256 || - !candidateDistSha256 + !publicKeyPath || + !keyId || + !runId || + !runAttemptSource || + !sourceRevision ) { process.stderr.write("Provider supervisor environment is incomplete\n"); process.exit(2); } +const runAttempt = Number(runAttemptSource); +if (!Number.isInteger(runAttempt) || runAttempt < 1 || runAttempt > 1_000) { + throw new TypeError("provider supervisor run attempt is invalid"); +} const workspaceRoot = process.cwd(); const reportAbsolute = path.resolve(reportPath); @@ -62,22 +79,30 @@ await prepareMissingProviderOutput(workspaceRoot, sealedAbsolute, sealedPath, "s await access("/usr/bin/bwrap", constants.X_OK).catch(() => { throw new Error("provider sandbox unavailable: /usr/bin/bwrap is required"); }); - -const childEnvironment = createProviderEnvironment(kind, reportPath, { - candidateLockfile, - archivePath, - archiveSha256, - candidateDistSha256, -}); -await runProviderInSandbox(command, childEnvironment, rawDirectory, workspaceRoot); -const parsed = await validateProviderUpload({ +const trust = await readProviderTrust(workspaceRoot, publicKeyPath, keyId); +if (!trust) throw new TypeError("provider supervisor trust key is invalid"); +const supervised = await superviseProviderEvidence({ kind, - candidateRoot: path.dirname(path.resolve(candidateLockfile)), archivePath, expectedArchiveSha256: archiveSha256, - reportPath, - workspaceRoot, - expectedDistSha256: candidateDistSha256, + expectedRun: { id: runId, attempt: runAttempt, sourceRevision }, + trust, + executeProvider: async ({ candidateRoot, environment }) => { + const childEnvironment = createProviderEnvironment(kind, reportPath, environment); + await runProviderInSandbox( + command, + childEnvironment, + rawDirectory, + workspaceRoot, + candidateRoot, + ); + }, + captureReport: () => + readBoundedRegularFile({ + root: workspaceRoot, + relativePath: path.relative(workspaceRoot, reportAbsolute).replaceAll(path.sep, "/"), + maxBytes: 8_388_608, + }), }); await assertSafePublishLeaf(sealedAbsolute, sealedPath); await writeValidatedJsonArtifact({ @@ -86,19 +111,21 @@ await writeValidatedJsonArtifact({ kind === "vulnerability" ? vulnerabilityProviderReportSchema : provenanceProviderAttestationSchema, - value: parsed, + value: supervised.evidence, }); +if (process.env.GITHUB_OUTPUT) { + await appendFile( + process.env.GITHUB_OUTPUT, + `invocation_nonce=${supervised.invocationNonce}\n`, + "utf8", + ); +} process.stdout.write(`${kind} provider supervised validation: PASS\n`); function createProviderEnvironment( providerKind: "vulnerability" | "provenance", rawReportPath: string, - candidate: Readonly<{ - candidateLockfile: string; - archivePath: string; - archiveSha256: string; - candidateDistSha256: string; - }>, + bindings: Readonly>, ): NodeJS.ProcessEnv { const environment: NodeJS.ProcessEnv = { PATH: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin", @@ -107,10 +134,7 @@ function createProviderEnvironment( CI: "true", GITHUB_ENV: "/tmp/github-env", GITHUB_PATH: "/tmp/github-path", - CANDIDATE_LOCKFILE_PATH: candidate.candidateLockfile, - CANDIDATE_ARCHIVE_PATH: candidate.archivePath, - CANDIDATE_ARCHIVE_SHA256: candidate.archiveSha256, - CANDIDATE_DIST_SHA256: candidate.candidateDistSha256, + ...bindings, ...(providerKind === "vulnerability" ? { VULNERABILITY_REPORT_PATH: rawReportPath } : { PROVENANCE_ATTESTATION_PATH: rawReportPath }), @@ -132,6 +156,7 @@ async function runProviderInSandbox( environment: NodeJS.ProcessEnv, rawDirectory: string, workspaceRoot: string, + candidateRoot: string, ): Promise { const scratch = await mkdtemp(path.join(tmpdir(), "ci-provider-sandbox-")); try { @@ -177,6 +202,7 @@ async function runProviderInSandbox( } arguments_.push( "--bind", rawDirectory, rawDirectory, + "--ro-bind", candidateRoot, "/candidate", "--chdir", workspaceRoot, "/bin/sh", "-eu", "-c", command, ); diff --git a/scripts/stage-verified-promotion.ts b/scripts/stage-verified-promotion.ts index 3660cb4..8e1364c 100644 --- a/scripts/stage-verified-promotion.ts +++ b/scripts/stage-verified-promotion.ts @@ -1,4 +1,6 @@ -import { stageVerifiedPromotion } from "./lib/promotion-stager.ts"; +import { appendFile } from "node:fs/promises"; + +import { finalizeVerifiedPromotion } from "./lib/promotion-stager.ts"; const required = (name: string): string => { const value = process.env[name]; @@ -6,7 +8,7 @@ const required = (name: string): string => { return value; }; -const staged = await stageVerifiedPromotion({ +const staged = await finalizeVerifiedPromotion({ repositoryRoot: process.cwd(), archivePath: required("CANDIDATE_ARCHIVE_PATH"), expectedArchiveSha256: required("CANDIDATE_ARCHIVE_SHA256"), @@ -16,7 +18,27 @@ const staged = await stageVerifiedPromotion({ vulnerabilityKeyId: required("VULNERABILITY_KEY_ID"), provenancePublicKeyPath: required("PROVENANCE_PUBLIC_KEY_PATH"), provenanceKeyId: required("PROVENANCE_KEY_ID"), + expectedRun: { + id: process.env.GITEA_RUN_ID ?? process.env.GITHUB_RUN_ID ?? required("CI_RUN_ID"), + attempt: Number(process.env.GITEA_RUN_ATTEMPT ?? process.env.GITHUB_RUN_ATTEMPT ?? required("CI_RUN_ATTEMPT")), + sourceRevision: process.env.EXPECTED_SOURCE_REVISION ?? required("VITE_COMMIT_SHA"), + }, + vulnerabilityInvocationNonce: required("VULNERABILITY_INVOCATION_NONCE"), + provenanceInvocationNonce: required("PROVENANCE_INVOCATION_NONCE"), + runnerTempRoot: required("RUNNER_TEMP"), }); +const output = required("GITHUB_OUTPUT"); +await appendFile( + output, + [ + `staging_root=${staged.stagingRoot}`, + `cleanup_token=${staged.cleanupToken}`, + `runner_temp_dev=${staged.runnerTempIdentity.dev}`, + `runner_temp_ino=${staged.runnerTempIdentity.ino}`, + "", + ].join("\n"), + { encoding: "utf8" }, +); process.stdout.write( - `Promotion staging: ${staged.map(({ path, sha256 }) => `${path}=${sha256}`).join(", ")} PASS\n`, + `Promotion staging: ${staged.files.map(({ name, sha256 }) => `${name}=${sha256}`).join(", ")} PASS\n`, ); diff --git a/scripts/verify-archived-local-evidence.ts b/scripts/verify-archived-local-evidence.ts index 6c8c1e7..34b1fde 100644 --- a/scripts/verify-archived-local-evidence.ts +++ b/scripts/verify-archived-local-evidence.ts @@ -9,7 +9,10 @@ import { const candidate = releaseCandidateManifestSchema.parse( JSON.parse(await readFile(RELEASE_CANDIDATE_MANIFEST_PATH, "utf8")), ); -const result = await verifyArchivedLocalEvidence({ candidate }); +const result = await verifyArchivedLocalEvidence({ + extractionRoot: process.cwd(), + expectedManifest: candidate, +}); if (result.status !== "PASS") { process.stderr.write( `Archived local evidence verification failed:\n- ${result.failures.join("\n- ")}\n`, diff --git a/tests/integration/security-followup-archive.test.ts b/tests/integration/security-followup-archive.test.ts new file mode 100644 index 0000000..8ab34a2 --- /dev/null +++ b/tests/integration/security-followup-archive.test.ts @@ -0,0 +1,127 @@ +import { spawnSync } from "node:child_process"; +import { cp, mkdtemp, readFile, rm, symlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { expect, it } from "vitest"; + +import { + captureCiCandidateArchive, + withVerifiedCapturedCandidate, +} from "../../scripts/lib/ci-candidate-archive.ts"; +import { verifyArchivedLocalEvidence } from "../../scripts/lib/local-release-evidence.ts"; +import { + RELEASE_CANDIDATE_EVIDENCE_PATHS, + RELEASE_CANDIDATE_MANIFEST_PATH, + releaseCandidateManifestSchema, +} from "../../scripts/lib/release-candidate.ts"; + +it( + "builds a real candidate assessment and passes the default archived verifier from the captured archive", + async () => { + const sourceRoot = process.cwd(); + const fixtureRoot = await mkdtemp(path.join(tmpdir(), "security-followup-producer-")); + try { + await cp(sourceRoot, fixtureRoot, { + recursive: true, + filter: (source) => { + const relative = path.relative(sourceRoot, source); + if (!relative) return true; + const first = relative.split(path.sep)[0]; + return ![ + ".release", + "artifacts", + "dist", + "node_modules", + ].includes(first ?? ""); + }, + }); + await cp(path.join(sourceRoot, "artifacts"), path.join(fixtureRoot, "artifacts"), { + recursive: true, + }); + await rm(path.join(fixtureRoot, "artifacts/release"), { + recursive: true, + force: true, + }); + await symlink(path.join(sourceRoot, "node_modules"), path.join(fixtureRoot, "node_modules"), "dir"); + const git = spawnSync("git", ["show", "-s", "--format=%H%n%ct", "HEAD"], { + cwd: sourceRoot, + encoding: "utf8", + }); + expect(git.status, git.stderr).toBe(0); + const [revision, sourceDateEpoch] = git.stdout.trim().split(/\r?\n/u); + const build = spawnSync( + "corepack", + ["pnpm", "build:release-candidate"], + { + cwd: fixtureRoot, + encoding: "utf8", + timeout: 120_000, + maxBuffer: 32 * 1024 * 1024, + env: { + ...process.env, + CI: "true", + VITE_BUILD_ID: "security-followup-integration", + VITE_COMMIT_SHA: revision, + RELEASE_ID: "security-followup-integration", + SOURCE_DATE_EPOCH: sourceDateEpoch, + CI_RUNNER_IMAGE: `fixture@sha256:${"a".repeat(64)}`, + }, + }, + ); + expect(build.status, `${build.stdout}\n${build.stderr}`).toBe(0); + const manifest = releaseCandidateManifestSchema.parse( + JSON.parse( + await readFile(path.join(fixtureRoot, RELEASE_CANDIDATE_MANIFEST_PATH), "utf8"), + ) as unknown, + ); + const archivePath = path.join(fixtureRoot, "candidate.tar.gz"); + const archived = spawnSync( + "/usr/bin/tar", + [ + "--sort=name", + "--mtime=@0", + "--owner=0", + "--group=0", + "--numeric-owner", + "-czf", + archivePath, + "dist", + ...RELEASE_CANDIDATE_EVIDENCE_PATHS, + RELEASE_CANDIDATE_MANIFEST_PATH, + ], + { cwd: fixtureRoot, encoding: "utf8" }, + ); + expect(archived.status, archived.stderr).toBe(0); + const archiveBytes = await readFile(archivePath); + const expectedSha256 = await import("node:crypto").then(({ createHash }) => + createHash("sha256").update(archiveBytes).digest("hex"), + ); + const captured = await captureCiCandidateArchive({ archivePath, expectedSha256 }); + const verified = await withVerifiedCapturedCandidate({ + captured, + verify: ({ extractionRoot, manifest: extractedManifest }) => + verifyArchivedLocalEvidence({ + extractionRoot, + expectedManifest: extractedManifest, + }), + }); + + expect(manifest.files).toContainEqual( + expect.objectContaining({ + path: "artifacts/security/local-evidence-assessment.json", + }), + ); + expect(verified).toEqual( + expect.objectContaining({ + status: "PASS", + identity: expect.objectContaining({ sourceRevision: revision }), + failures: [], + }), + ); + } finally { + await rm(fixtureRoot, { recursive: true, force: true }); + } + }, + 150_000, +); diff --git a/tests/unit/__snapshots__/ci-workflow-generation.test.ts.snap b/tests/unit/__snapshots__/ci-workflow-generation.test.ts.snap index 35e3db2..fab03c1 100644 --- a/tests/unit/__snapshots__/ci-workflow-generation.test.ts.snap +++ b/tests/unit/__snapshots__/ci-workflow-generation.test.ts.snap @@ -159,6 +159,7 @@ jobs: artifacts/release/sbom.cdx.json \\ artifacts/security/dependency-diff.json \\ artifacts/security/license-report.json \\ + artifacts/security/local-evidence-assessment.json \\ artifacts/security/scan.sarif \\ artifacts/security/supply-chain-coherence.json \\ artifacts/security/supply-chain-verification.json \\ @@ -177,11 +178,16 @@ jobs: needs: immutable_build runs-on: ubuntu-latest timeout-minutes: 45 + outputs: + invocation_nonce: \${{ steps.supervise_vulnerability.outputs.invocation_nonce }} env: CANDIDATE_ARCHIVE_SHA256: "\${{ needs.immutable_build.outputs.archive_sha256 }}" CANDIDATE_ARCHIVE_PATH: ".release/vulnerability-candidate/release-candidate-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}.tar.gz" - CANDIDATE_DIST_SHA256: "\${{ needs.immutable_build.outputs.dist_sha256 }}" - CANDIDATE_LOCKFILE_PATH: .release/verified-vulnerability/pnpm-lock.yaml + CI_RUN_ID: "\${{ gitea.run_id }}" + CI_RUN_ATTEMPT: "\${{ gitea.run_attempt }}" + EXPECTED_SOURCE_REVISION: "\${{ gitea.sha }}" + VULNERABILITY_PUBLIC_KEY_PATH: "\${{ vars.VULNERABILITY_PUBLIC_KEY_PATH }}" + VULNERABILITY_KEY_ID: "\${{ vars.VULNERABILITY_KEY_ID }}" VULNERABILITY_PROVIDER_COMMAND: "\${{ vars.VULNERABILITY_PROVIDER_COMMAND }}" VULNERABILITY_REPORT_PATH: provider-evidence/untrusted/vulnerability-report.json VALIDATED_PROVIDER_REPORT_PATH: provider-evidence/vulnerability-report.json @@ -201,9 +207,8 @@ jobs: with: name: "release-candidate-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}" path: .release/vulnerability-candidate - - name: Verify and extract the candidate through one inode-bound operation - run: node scripts/verify-ci-candidate-archive.ts --archive ".release/vulnerability-candidate/release-candidate-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}.tar.gz" --extract-to ".release/verified-vulnerability" - name: Run and validate external vulnerability provider in one trusted supervisor + id: supervise_vulnerability run: node scripts/run-and-validate-provider.ts --kind vulnerability - name: Confirm sealed vulnerability provider evidence run: test -s "$VALIDATED_PROVIDER_REPORT_PATH" @@ -219,11 +224,16 @@ jobs: needs: immutable_build runs-on: ubuntu-latest timeout-minutes: 45 + outputs: + invocation_nonce: \${{ steps.supervise_provenance.outputs.invocation_nonce }} env: CANDIDATE_ARCHIVE_SHA256: "\${{ needs.immutable_build.outputs.archive_sha256 }}" CANDIDATE_ARCHIVE_PATH: ".release/provenance-candidate/release-candidate-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}.tar.gz" - CANDIDATE_DIST_SHA256: "\${{ needs.immutable_build.outputs.dist_sha256 }}" - CANDIDATE_LOCKFILE_PATH: .release/verified-provenance/pnpm-lock.yaml + CI_RUN_ID: "\${{ gitea.run_id }}" + CI_RUN_ATTEMPT: "\${{ gitea.run_attempt }}" + EXPECTED_SOURCE_REVISION: "\${{ gitea.sha }}" + PROVENANCE_PUBLIC_KEY_PATH: "\${{ vars.PROVENANCE_PUBLIC_KEY_PATH }}" + PROVENANCE_KEY_ID: "\${{ vars.PROVENANCE_KEY_ID }}" PROVENANCE_PROVIDER_COMMAND: "\${{ vars.PROVENANCE_PROVIDER_COMMAND }}" PROVENANCE_ATTESTATION_PATH: provider-evidence/untrusted/provenance-attestation.json VALIDATED_PROVIDER_REPORT_PATH: provider-evidence/provenance-attestation.json @@ -243,9 +253,8 @@ jobs: with: name: "release-candidate-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}" path: .release/provenance-candidate - - name: Verify and extract the candidate through one inode-bound operation - run: node scripts/verify-ci-candidate-archive.ts --archive ".release/provenance-candidate/release-candidate-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}.tar.gz" --extract-to ".release/verified-provenance" - name: Run and validate external provenance provider in one trusted supervisor + id: supervise_provenance run: node scripts/run-and-validate-provider.ts --kind provenance - name: Confirm sealed provenance provider evidence run: test -s "$VALIDATED_PROVIDER_REPORT_PATH" @@ -264,13 +273,16 @@ jobs: env: CANDIDATE_ARCHIVE_SHA256: "\${{ needs.immutable_build.outputs.archive_sha256 }}" CANDIDATE_ARCHIVE_PATH: ".release/candidate/release-candidate-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}.tar.gz" - CANDIDATE_ROOT: "\${{ gitea.workspace }}/.release/verified-candidate" + CI_RUN_ID: "\${{ gitea.run_id }}" + CI_RUN_ATTEMPT: "\${{ gitea.run_attempt }}" VULNERABILITY_REPORT_PATH: "\${{ gitea.workspace }}/.release/vulnerability/vulnerability-report.json" PROVENANCE_ATTESTATION_PATH: "\${{ gitea.workspace }}/.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 }}" + VULNERABILITY_INVOCATION_NONCE: "\${{ needs.vulnerability_provider.outputs.invocation_nonce }}" + PROVENANCE_INVOCATION_NONCE: "\${{ needs.provenance_provider.outputs.invocation_nonce }}" steps: - uses: https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 with: @@ -297,21 +309,31 @@ jobs: with: name: "provenance-provider-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}" path: .release/provenance - - name: Verify and extract the candidate through one inode-bound operation - run: node scripts/verify-ci-candidate-archive.ts --archive ".release/candidate/release-candidate-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}.tar.gz" --extract-to ".release/verified-candidate" - name: Finalize verified promotion from inode-bound captured inputs + id: finalize run: node scripts/stage-verified-promotion.ts - name: Upload promoted release uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7 with: name: "promoted-release-\${{ gitea.run_id }}-\${{ gitea.run_attempt }}" path: | - .release/promoted-staging/release-candidate.tar.gz - .release/promoted-staging/vulnerability-report.json - .release/promoted-staging/provenance-attestation.json - .release/promoted-staging/provider-verification.json - .release/promoted-staging/promotion-verification.json + \${{ steps.finalize.outputs.staging_root }}/release-candidate.tar.gz + \${{ steps.finalize.outputs.staging_root }}/vulnerability-report.json + \${{ steps.finalize.outputs.staging_root }}/provenance-attestation.json + \${{ steps.finalize.outputs.staging_root }}/provider-verification.json + \${{ steps.finalize.outputs.staging_root }}/promotion-verification.json if-no-files-found: error + - name: Always remove private promotion staging + if: always() + env: + PROMOTION_STAGING_ROOT: \${{ steps.finalize.outputs.staging_root }} + PROMOTION_CLEANUP_TOKEN: \${{ steps.finalize.outputs.cleanup_token }} + PROMOTION_RUNNER_TEMP_DEV: \${{ steps.finalize.outputs.runner_temp_dev }} + PROMOTION_RUNNER_TEMP_INO: \${{ steps.finalize.outputs.runner_temp_ino }} + run: | + if [ -n "$PROMOTION_STAGING_ROOT" ] && [ -n "$PROMOTION_CLEANUP_TOKEN" ]; then + node scripts/cleanup-verified-promotion.ts + fi production_gate: name: "\${{ matrix.gate }} / \${{ matrix.name }}" diff --git a/tests/unit/ci-artifact-contract.test.ts b/tests/unit/ci-artifact-contract.test.ts index 5767eda..9767f9d 100644 --- a/tests/unit/ci-artifact-contract.test.ts +++ b/tests/unit/ci-artifact-contract.test.ts @@ -1,11 +1,11 @@ import { spawnSync } from "node:child_process"; import { createHash, generateKeyPairSync, sign } from "node:crypto"; -import { link, mkdir, mkdtemp, open, readFile, readdir, rename, rm, symlink, writeFile } from "node:fs/promises"; +import { cp, link, lstat, mkdir, mkdtemp, open, readFile, readdir, rename, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { setTimeout as delay } from "node:timers/promises"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterAll, afterEach, describe, expect, it } from "vitest"; import type { CiGateArtifact, @@ -13,27 +13,33 @@ import type { } from "../../scripts/contracts/ci-gates.ts"; import { readBoundedRegularFile, validateCiArtifact } from "../../scripts/lib/ci-artifact-validator.ts"; import { writeCiGateLogAtomic } from "../../scripts/lib/ci-gate-log.ts"; -import { verifyCiCandidateArchive } from "../../scripts/lib/ci-candidate-archive.ts"; +import { captureCiCandidateArchive, verifyCiCandidateArchive } from "../../scripts/lib/ci-candidate-archive.ts"; import { CANDIDATE_ARCHIVE_USAGE, parseCandidateArchiveArguments, } from "../../scripts/lib/ci-candidate-archive-cli.ts"; import { validateProviderUpload } from "../../scripts/lib/provider-upload-validator.ts"; import { - PROMOTED_STAGING_PATHS, + cleanupFinalizedPromotion, stageVerifiedPromotion, } from "../../scripts/lib/promotion-stager.ts"; +import { PROMOTED_FILE_NAMES } from "../../scripts/contracts/promotion-artifacts.ts"; import { providerEvidenceSignaturePayload, + providerPublicKeyFingerprint, providerVerificationArtifactSchema, } from "../../scripts/lib/provider-evidence.ts"; +import { localEvidenceAssessmentArtifactSchema } from "../../scripts/contracts/release-artifacts.ts"; +import { readProviderTrust } from "../../scripts/lib/promotion-verifier.ts"; import { createReleaseCandidateManifest, + LOCAL_EVIDENCE_ASSESSMENT_PATH, RELEASE_CANDIDATE_EVIDENCE_PATHS, RELEASE_CANDIDATE_MANIFEST_PATH, } from "../../scripts/lib/release-candidate.ts"; const temporaryRoots: string[] = []; +let providerBaseRoot: string | undefined; const sha256 = (value: Buffer | string) => createHash("sha256").update(value).digest("hex"); @@ -42,6 +48,11 @@ afterEach(async () => { temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })), ); }); +afterAll(async () => { + if (providerBaseRoot) { + await rm(providerBaseRoot, { recursive: true, force: true }); + } +}); async function temporaryRoot(prefix: string): Promise { const root = await mkdtemp(path.join(tmpdir(), prefix)); @@ -459,31 +470,21 @@ describe("candidate archive and provider upload boundaries", () => { it("validates provider JSON against candidate dist and lockfile digests", async () => { const fixture = await createProviderFixture(); await expect( - validateProviderUpload({ - kind: "vulnerability", - candidateRoot: fixture.candidateRoot, - archivePath: fixture.archivePath, - expectedArchiveSha256: fixture.archiveSha256, - workspaceRoot: fixture.root, - reportPath: fixture.reportPath, - expectedDistSha256: fixture.distSha256, - }), + validateProviderUpload(providerValidationInput(fixture)), ).resolves.toEqual(expect.objectContaining({ provider: "fixture" })); - const report = JSON.parse(await readFile(fixture.reportPath, "utf8")) as Record; - report.scannedDistSha256 = "f".repeat(64); - await writeFile(fixture.reportPath, `${JSON.stringify(report)}\n`); + const report = JSON.parse(await readFile(fixture.reportPath, "utf8")) as Record; + report.candidate.distSha256 = "f".repeat(64); + report.signature.value = sign( + null, + providerEvidenceSignaturePayload(report), + fixture.privateKey, + ).toString("base64"); await expect( - validateProviderUpload({ - kind: "vulnerability", - candidateRoot: fixture.candidateRoot, - archivePath: fixture.archivePath, - expectedArchiveSha256: fixture.archiveSha256, - workspaceRoot: fixture.root, - reportPath: fixture.reportPath, - expectedDistSha256: fixture.distSha256, - }), - ).rejects.toThrow(/candidate digest mismatch/i); - }); + validateProviderUpload( + providerValidationInput(fixture, Buffer.from(`${JSON.stringify(report)}\n`)), + ), + ).rejects.toThrow(/candidate identity mismatch/i); + }, 30_000); it("uses the reverified archive manifest when extracted candidate files are mutated", async () => { const fixture = await createProviderFixture(); @@ -499,15 +500,7 @@ describe("candidate archive and provider upload boundaries", () => { `${JSON.stringify(mutableManifest)}\n`, ); await expect( - validateProviderUpload({ - kind: "vulnerability", - candidateRoot: fixture.candidateRoot, - archivePath: fixture.archivePath, - expectedArchiveSha256: fixture.archiveSha256, - workspaceRoot: fixture.root, - reportPath: fixture.reportPath, - expectedDistSha256: fixture.distSha256, - }), + validateProviderUpload(providerValidationInput(fixture)), ).rejects.toThrow(/candidate root changed/i); }); @@ -518,14 +511,10 @@ describe("candidate archive and provider upload boundaries", () => { await rm(fixture.reportPath); await symlink(path.relative(path.dirname(fixture.reportPath), real), fixture.reportPath); await expect( - validateProviderUpload({ - kind: "vulnerability", - candidateRoot: fixture.candidateRoot, - archivePath: fixture.archivePath, - expectedArchiveSha256: fixture.archiveSha256, - workspaceRoot: fixture.root, - reportPath: fixture.reportPath, - expectedDistSha256: fixture.distSha256, + readBoundedRegularFile({ + root: fixture.root, + relativePath: path.relative(fixture.root, fixture.reportPath), + maxBytes: 8_388_608, }), ).rejects.toThrow(/not a regular file/i); }); @@ -534,14 +523,10 @@ describe("candidate archive and provider upload boundaries", () => { const fixture = await createProviderFixture(); await writeFile(fixture.reportPath, Buffer.alloc(8_388_609, 0x20)); await expect( - validateProviderUpload({ - kind: "vulnerability", - candidateRoot: fixture.candidateRoot, - archivePath: fixture.archivePath, - expectedArchiveSha256: fixture.archiveSha256, - workspaceRoot: fixture.root, - reportPath: fixture.reportPath, - expectedDistSha256: fixture.distSha256, + readBoundedRegularFile({ + root: fixture.root, + relativePath: path.relative(fixture.root, fixture.reportPath), + maxBytes: 8_388_608, }), ).rejects.toThrow(/size is outside/u); }); @@ -560,7 +545,6 @@ describe("candidate archive and provider upload boundaries", () => { it("drains and kills provider background processes before sealing evidence", async () => { const fixture = await createProviderFixture(); - const rawReport = await readFile(fixture.reportPath, "utf8"); await rm(fixture.reportPath); const sealedPath = path.join( fixture.root, @@ -571,7 +555,7 @@ describe("candidate archive and provider upload boundaries", () => { const mutator = [ "process.on('SIGTERM', () => {});", "setTimeout(() => {", - ` require('node:fs').writeFileSync(${JSON.stringify(path.join(fixture.candidateRoot, "dist/app.js"))}, 'mutated\\n');`, + ` require('node:fs').writeFileSync(${JSON.stringify(fixture.protectedCandidatePath)}, 'mutated\\n');`, ` require('node:fs').writeFileSync(${JSON.stringify(sealedPath)}, '{"mutated":true}\\n');`, ` require('node:fs').writeFileSync(${JSON.stringify(mutatorMarker)}, 'ran\\n');`, "}, 1200);", @@ -580,8 +564,7 @@ describe("candidate archive and provider upload boundaries", () => { providerScript, [ "import { spawn } from 'node:child_process';", - "import { writeFileSync } from 'node:fs';", - `writeFileSync(${JSON.stringify(fixture.reportPath)}, ${JSON.stringify(rawReport)});`, + providerV2WriterSource(), `const child = spawn(process.execPath, ['-e', ${JSON.stringify(mutator)}], { stdio: 'ignore' });`, "child.unref();", ].join("\n"), @@ -592,7 +575,9 @@ describe("candidate archive and provider upload boundaries", () => { }); expect(result.status, result.stderr).toBe(0); await delay(1_500); - await expect(readFile(path.join(fixture.candidateRoot, "dist/app.js"), "utf8")).resolves.toBe("app\n"); + await expect(readFile(fixture.protectedCandidatePath)).resolves.toEqual( + fixture.protectedCandidateBytes, + ); await expect(readFile(mutatorMarker)).rejects.toMatchObject({ code: "ENOENT" }); expect(JSON.parse(await readFile(sealedPath, "utf8"))).toEqual( expect.objectContaining({ provider: "fixture" }), @@ -601,7 +586,6 @@ describe("candidate archive and provider upload boundaries", () => { it("does not expose or mutate a host path outside the sandboxed workspace", async () => { const fixture = await createProviderFixture(); - const rawReport = await readFile(fixture.reportPath, "utf8"); await rm(fixture.reportPath); const outside = await temporaryRoot("provider-host-canary-"); const canary = path.join(outside, "secret-canary"); @@ -611,7 +595,7 @@ describe("candidate archive and provider upload boundaries", () => { "import { readFileSync, writeFileSync } from 'node:fs';", `try { readFileSync(${JSON.stringify(canary)}); process.exit(9); } catch {}`, `try { writeFileSync(${JSON.stringify(canary)}, 'mutated\\n'); } catch {}`, - `writeFileSync(${JSON.stringify(fixture.reportPath)}, ${JSON.stringify(rawReport)});`, + providerV2WriterSource({ importFs: false }), ].join("\n")); const result = runProviderSupervisor(fixture, { command: `node ${JSON.stringify(providerScript)}`, @@ -622,171 +606,177 @@ describe("candidate archive and provider upload boundaries", () => { }, 10_000); }); -describe("verified promotion staging", () => { - it("publishes the exact five captured promotion inputs", async () => { +describe("verified promotion finalizer", () => { + it("creates no input records and publishes deterministic exact-five strict v3 bindings", async () => { const fixture = await createPromotionStagingFixture(); - const staged = await stageVerifiedPromotion(fixture.input, fixture.dependencies); - expect(staged.map(({ path: stagedPath }) => stagedPath)).toEqual(PROMOTED_STAGING_PATHS); - for (const [stagedPath, expected] of fixture.expectedStagedBytes) { - await expect(readFile(path.join(fixture.root, stagedPath))).resolves.toEqual(expected); - expect(staged.find(({ path: candidate }) => candidate === stagedPath)?.sha256).toBe( - sha256(expected), - ); + let archiveCaptureCount = 0; + await expect(readFile(path.join(fixture.root, "artifacts/security/provider-verification.json"))) + .rejects.toMatchObject({ code: "ENOENT" }); + await expect(readFile(path.join(fixture.root, "artifacts/security/promotion-verification.json"))) + .rejects.toMatchObject({ code: "ENOENT" }); + + const dependencies = { + ...fixture.dependencies, + captureArchive: async (input: Parameters[0]) => { + archiveCaptureCount += 1; + return captureCiCandidateArchive(input); + }, + }; + const finalized = await stageVerifiedPromotion(fixture.input, dependencies); + expect(archiveCaptureCount).toBe(1); + expect(finalized.files.map(({ name }) => name)).toEqual(PROMOTED_FILE_NAMES); + expect((await readdir(finalized.stagingRoot)).sort()).toEqual([...PROMOTED_FILE_NAMES].sort()); + expect(finalized.stagingRoot).toBe(path.join(fixture.runnerTempRoot, fixture.cleanupToken)); + expect(finalized.stagingRoot).not.toContain(".release/promoted-staging"); + expect((await lstat(finalized.stagingRoot)).mode & 0o777).toBe(0o700); + for (const file of finalized.files) { + expect((await lstat(path.join(finalized.stagingRoot, file.name))).mode & 0o777).toBe(0o400); + expect(file.sha256).toBe(sha256(await readFile(path.join(finalized.stagingRoot, file.name)))); } - expect(staged).toHaveLength(5); - await writeFile(fixture.input.vulnerabilityReportPath, "mutated after capture\n"); - await expect( - readFile(path.join(fixture.root, ".release/promoted-staging/vulnerability-report.json")), - ).resolves.toEqual( - fixture.expectedStagedBytes.get( - ".release/promoted-staging/vulnerability-report.json", - ), - ); - }); - it("rejects archive digest drift before publishing staging", async () => { - const fixture = await createPromotionStagingFixture(); - await expect( - stageVerifiedPromotion({ - ...fixture.input, - expectedArchiveSha256: "0".repeat(64), - }, fixture.dependencies), - ).rejects.toThrow(/archive SHA-256/u); - await expect( - readFile(path.join(fixture.root, ".release/promoted-staging/release-candidate.tar.gz")), - ).rejects.toMatchObject({ code: "ENOENT" }); - }); + const providerBytes = await readFile(path.join(finalized.stagingRoot, "provider-verification.json")); + const promotionBytes = await readFile(path.join(finalized.stagingRoot, "promotion-verification.json")); + const provider = providerVerificationArtifactSchema.parse(JSON.parse(providerBytes.toString("utf8"))); + const promotion = providerVerificationArtifactSchema.parse(JSON.parse(promotionBytes.toString("utf8"))); + expect(provider.artifactType).toBe("provider-verification"); + expect(promotion.artifactType).toBe("promotion-verification"); + if (provider.artifactType !== "provider-verification" || promotion.artifactType !== "promotion-verification") { + throw new Error("verification record role narrowing failed"); + } + expect(provider.status).toBe("PASS"); + expect(promotion.status).toBe("PASS"); + expect(promotion.providerVerificationSha256).toBe(sha256(providerBytes)); + expect(promotion.localEvidenceAssessmentSha256).toBe(fixture.assessmentSha256); + expect(promotion.run).toEqual(fixture.expectedContext.run); + expect(promotion.source).toEqual(fixture.expectedContext.source); + expect(promotion.candidate).toEqual(fixture.expectedContext.candidate); + expect(promotion.providerEvidence).toEqual(provider.providerEvidence); + expect(promotion.providerEvidence).toEqual(fixture.expectedProviderEvidence); + expect(promotion.trustPolicySha256).toBe(provider.trustPolicySha256); + expect(provider.vulnerabilityStatus).toBe("PASS"); + expect(provider.provenanceAttestationStatus).toBe("PASS"); - it("rejects a byte-different signed report whose producer digest is stale", async () => { - const fixture = await createPromotionStagingFixture(); - const report = JSON.parse( - await readFile(fixture.input.vulnerabilityReportPath, "utf8"), - ) as unknown; - const replacement = Buffer.from(`${JSON.stringify(report, null, 2)}\n`); - await writeFile(fixture.input.vulnerabilityReportPath, replacement); - await expect(stageVerifiedPromotion(fixture.input, fixture.dependencies)).rejects.toThrow( - /vulnerabilityReportSha256 digest mismatch/i, - ); - }); + await cleanupFinalizedPromotion(finalizedCleanup(fixture, finalized)); + const repeated = await stageVerifiedPromotion(fixture.input, dependencies); + expect(archiveCaptureCount).toBe(2); + await expect(readFile(path.join(repeated.stagingRoot, "provider-verification.json"))) + .resolves.toEqual(providerBytes); + await expect(readFile(path.join(repeated.stagingRoot, "promotion-verification.json"))) + .resolves.toEqual(promotionBytes); + await cleanupFinalizedPromotion(finalizedCleanup(fixture, repeated)); + }, 30_000); - it.each([ - ["provider-verification.json", "promotion-verification.json"], - ["promotion-verification.json", "provider-verification.json"], - ] as const)("rejects %s copied into the %s role", async (sourceName, targetName) => { + it("stages captured archive and reports and validates captured keys after source mutation", async () => { const fixture = await createPromotionStagingFixture(); - const sourcePath = path.join(fixture.root, "artifacts/security", sourceName); - const targetPath = path.join(fixture.root, "artifacts/security", targetName); - await writeFile(targetPath, await readFile(sourcePath)); - await expect(stageVerifiedPromotion(fixture.input, fixture.dependencies)).rejects.toThrow( - /artifactType role mismatch/i, - ); - }); - - it("stages captured bytes and uses captured trust keys after source mutation", async () => { - const fixture = await createPromotionStagingFixture(); - const sourceMappings = [ - [fixture.input.archivePath, ".release/promoted-staging/release-candidate.tar.gz"], - [fixture.input.vulnerabilityReportPath, ".release/promoted-staging/vulnerability-report.json"], - [fixture.input.provenanceAttestationPath, ".release/promoted-staging/provenance-attestation.json"], - [path.join(fixture.root, "artifacts/security/provider-verification.json"), ".release/promoted-staging/provider-verification.json"], - [path.join(fixture.root, "artifacts/security/promotion-verification.json"), ".release/promoted-staging/promotion-verification.json"], - ] as const; - const originalSources = new Map(await Promise.all( - sourceMappings.map(async ([sourcePath]) => [sourcePath, await readFile(sourcePath)] as const), - )); - const staged = await stageVerifiedPromotion(fixture.input, { + const finalized = await stageVerifiedPromotion(fixture.input, { ...fixture.dependencies, afterCapture: async () => { await Promise.all([ writeFile(fixture.input.archivePath, "replaced archive\n"), writeFile(fixture.input.vulnerabilityReportPath, "replaced vulnerability\n"), writeFile(fixture.input.provenanceAttestationPath, "replaced provenance\n"), - writeFile(path.join(fixture.root, "artifacts/security/provider-verification.json"), "replaced provider verification\n"), - writeFile(path.join(fixture.root, "artifacts/security/promotion-verification.json"), "replaced promotion verification\n"), - writeFile(fixture.input.vulnerabilityPublicKeyPath, "replaced key\n"), - writeFile(fixture.input.provenancePublicKeyPath, "replaced key\n"), + writeFile(fixture.input.vulnerabilityPublicKeyPath, "replaced vulnerability key\n"), + writeFile(fixture.input.provenancePublicKeyPath, "replaced provenance key\n"), ]); }, }); - for (const [sourcePath, stagedPath] of sourceMappings) { - const original = originalSources.get(sourcePath)!; - await expect(readFile(path.join(fixture.root, stagedPath))).resolves.toEqual(original); - expect(staged.find(({ path: candidate }) => candidate === stagedPath)?.sha256) - .toBe(sha256(original)); + for (const [name, bytes] of fixture.capturedSources) { + await expect(readFile(path.join(finalized.stagingRoot, name))).resolves.toEqual(bytes); } - }); + await cleanupFinalizedPromotion(finalizedCleanup(fixture, finalized)); + }, 30_000); - it("reruns archived local evidence instead of trusting a pre-existing PASS JSON", async () => { + it.each([ + ["archive digest", async (fixture: Awaited>) => { + fixture.input = { ...fixture.input, expectedArchiveSha256: "0".repeat(64) }; + }, /archive SHA-256/u], + ["report mutation", async (fixture: Awaited>) => { + await writeFile(fixture.input.vulnerabilityReportPath, "mutated report\n"); + }, /invalid|provider/u], + ["key rotation", async (fixture: Awaited>) => { + const rotated = generateKeyPairSync("ed25519"); + await writeFile(fixture.input.vulnerabilityPublicKeyPath, rotated.publicKey.export({ type: "spki", format: "pem" })); + }, /provider evidence failed|trust identity/u], + ["expected nonce replay", async (fixture: Awaited>) => { + fixture.input = { ...fixture.input, vulnerabilityInvocationNonce: "9".repeat(64) }; + }, /invocation nonce/u], + ] as const)("rejects %s without staging or PASS records", async (_label, mutate, diagnostic) => { const fixture = await createPromotionStagingFixture(); - await expect(stageVerifiedPromotion(fixture.input)).rejects.toThrow( - /captured local evidence failed final verification/i, - ); - }); + await mutate(fixture); + await expect(stageVerifiedPromotion(fixture.input, fixture.dependencies)).rejects.toThrow(diagnostic); + await expect(readdir(fixture.runnerTempRoot)).resolves.toEqual([]); + }, 30_000); - it("rejects symlinked and oversized promotion sources", async () => { + it("rejects symlinked and oversized captured sources without staging", async () => { const linked = await createPromotionStagingFixture(); const realReport = path.join(linked.root, "real-vulnerability-report.json"); await writeFile(realReport, await readFile(linked.input.vulnerabilityReportPath)); await rm(linked.input.vulnerabilityReportPath); await symlink(realReport, linked.input.vulnerabilityReportPath); await expect(stageVerifiedPromotion(linked.input, linked.dependencies)).rejects.toThrow(/regular file/i); + await expect(readdir(linked.runnerTempRoot)).resolves.toEqual([]); const oversized = await createPromotionStagingFixture(); - await writeFile( - oversized.input.vulnerabilityReportPath, - Buffer.alloc(16_777_217, 0x20), - ); + await writeFile(oversized.input.vulnerabilityReportPath, Buffer.alloc(16_777_217, 0x20)); await expect(stageVerifiedPromotion(oversized.input, oversized.dependencies)).rejects.toThrow(/size is outside/i); - }); + await expect(readdir(oversized.runnerTempRoot)).resolves.toEqual([]); + }, 30_000); - it.each(["directory", "symlink"] as const)( - "rejects a pre-existing %s staging target", - async (targetKind) => { - const fixture = await createPromotionStagingFixture(); - const target = path.join(fixture.root, ".release/promoted-staging"); - if (targetKind === "directory") { - await mkdir(target, { recursive: true }); - } else { - const outside = await temporaryRoot("promotion-staging-outside-"); - await symlink(outside, target); - } - await expect(stageVerifiedPromotion(fixture.input, fixture.dependencies)).rejects.toThrow( - /publish leaf is unsafe|target already exists/i, - ); - }, - ); - - it("rejects a .release ancestor symlink introduced after capture", async () => { + it("detects a runner-temp parent identity swap and removes its owned partial staging", async () => { const fixture = await createPromotionStagingFixture(); - const original = path.join(fixture.root, ".release-original"); - const outside = await temporaryRoot("promotion-release-symlink-"); - await expect( - stageVerifiedPromotion(fixture.input, { - ...fixture.dependencies, - afterCapture: async () => { - await rename(path.join(fixture.root, ".release"), original); - await symlink(outside, path.join(fixture.root, ".release")); - }, - }), - ).rejects.toThrow(/publish ancestor is unsafe|publish directory/u); - await expect(readdir(outside)).resolves.toEqual([]); - }); - - it("detects a staging parent identity swap and cleans its owned temporary", async () => { - const fixture = await createPromotionStagingFixture(); - const displaced = path.join(fixture.root, ".release-displaced"); - const dependencies = { + const displaced = `${fixture.runnerTempRoot}-displaced`; + await expect(stageVerifiedPromotion(fixture.input, { ...fixture.dependencies, - beforePublishRename: async () => { - await rename(path.join(fixture.root, ".release"), displaced); - await mkdir(path.join(fixture.root, ".release")); + afterStagingWrite: async () => { + await rename(fixture.runnerTempRoot, displaced); + await mkdir(fixture.runnerTempRoot, { mode: 0o700 }); }, - } as Parameters[1]; - await expect(stageVerifiedPromotion(fixture.input, dependencies)).rejects.toThrow( - /parent identity changed/u, - ); - expect((await readdir(displaced)).filter((entry) => entry.startsWith(".promoted-staging."))).toEqual([]); - }); + })).rejects.toThrow(/parent identity changed/u); + await expect(readdir(displaced)).resolves.toEqual([]); + await expect(readdir(fixture.runnerTempRoot)).resolves.toEqual([]); + }, 30_000); + + it("cleanup is token-bound and removes only the finalized private directory", async () => { + const fixture = await createPromotionStagingFixture(); + const finalized = await stageVerifiedPromotion(fixture.input, fixture.dependencies); + const canary = path.join(fixture.runnerTempRoot, "canary"); + await writeFile(canary, "unchanged\n"); + await expect(cleanupFinalizedPromotion({ + runnerTempRoot: fixture.runnerTempRoot, + stagingRoot: finalized.stagingRoot, + cleanupToken: `${finalized.cleanupToken}-wrong`, + runnerTempIdentity: finalized.runnerTempIdentity, + })).rejects.toThrow(/root\/token mismatch/u); + await cleanupFinalizedPromotion(finalizedCleanup(fixture, finalized)); + await expect(readFile(canary, "utf8")).resolves.toBe("unchanged\n"); + await expect(lstat(finalized.stagingRoot)).rejects.toMatchObject({ code: "ENOENT" }); + }, 30_000); + + it("cleanup rejects a swapped runner-temp ancestor and a symlinked staging leaf", async () => { + const swapped = await createPromotionStagingFixture(); + const finalized = await stageVerifiedPromotion(swapped.input, swapped.dependencies); + const displaced = `${swapped.runnerTempRoot}-cleanup-displaced`; + await expect(cleanupFinalizedPromotion(finalizedCleanup(swapped, finalized), { + beforeRemove: async () => { + await rename(swapped.runnerTempRoot, displaced); + await mkdir(swapped.runnerTempRoot, { mode: 0o700 }); + }, + })).rejects.toThrow(/parent identity changed/u); + await expect(readdir(swapped.runnerTempRoot)).resolves.toEqual([]); + await expect(readdir(displaced)).resolves.toEqual([finalized.cleanupToken]); + + const linked = await createPromotionStagingFixture(); + const linkedFinalized = await stageVerifiedPromotion(linked.input, linked.dependencies); + const saved = `${linkedFinalized.stagingRoot}-saved`; + const outside = await temporaryRoot("promotion-cleanup-outside-"); + const canary = path.join(outside, "canary"); + await writeFile(canary, "unchanged\n"); + await rename(linkedFinalized.stagingRoot, saved); + await symlink(outside, linkedFinalized.stagingRoot); + await expect(cleanupFinalizedPromotion(finalizedCleanup(linked, linkedFinalized))) + .rejects.toThrow(/leaf is unsafe/u); + await expect(readFile(canary, "utf8")).resolves.toBe("unchanged\n"); + }, 30_000); }); function runProviderSupervisor( @@ -803,17 +793,67 @@ function runProviderSupervisor( env: { ...process.env, VULNERABILITY_PROVIDER_COMMAND: input.command, + VULNERABILITY_PROVIDER_PRIVATE_KEY_PATH: fixture.privateKeyPath, + VULNERABILITY_PUBLIC_KEY_PATH: fixture.publicKeyPath, + VULNERABILITY_KEY_ID: fixture.keyId, VULNERABILITY_REPORT_PATH: fixture.reportPath, VALIDATED_PROVIDER_REPORT_PATH: input.sealedPath, - CANDIDATE_LOCKFILE_PATH: path.join(fixture.candidateRoot, "pnpm-lock.yaml"), CANDIDATE_ARCHIVE_PATH: fixture.archivePath, CANDIDATE_ARCHIVE_SHA256: fixture.archiveSha256, - CANDIDATE_DIST_SHA256: fixture.distSha256, + CI_RUN_ID: fixture.expectedContext.run.id, + CI_RUN_ATTEMPT: String(fixture.expectedContext.run.attempt), + EXPECTED_SOURCE_REVISION: fixture.expectedContext.source.revision, }, }, ); } +function providerValidationInput( + fixture: Awaited>, + capturedReport: Buffer = fixture.reportBytes, +) { + return { + kind: "vulnerability" as const, + verifiedManifest: fixture.candidate, + archiveSha256: fixture.archiveSha256, + candidateRoot: fixture.candidateRoot, + capturedReport, + expectedContext: fixture.expectedContext, + trust: fixture.trust, + nowEpochMs: () => fixture.now, + }; +} + +function providerV2WriterSource( + options: Readonly<{ importFs?: boolean }> = {}, +): string { + return [ + "import { createPrivateKey, sign } from 'node:crypto';", + ...(options.importFs === false + ? [] + : ["import { readFileSync, writeFileSync } from 'node:fs';"]), + "const canonical = (value) => {", + " if (Array.isArray(value)) return value.map(canonical).sort((left, right) => String(JSON.stringify(left)).localeCompare(String(JSON.stringify(right))));", + " if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => [key, canonical(item)]));", + " return value;", + "};", + "const unsigned = {", + " schemaVersion: Number(process.env.PROVIDER_EVIDENCE_SCHEMA_VERSION),", + " evidenceType: process.env.PROVIDER_EVIDENCE_TYPE,", + " provider: 'fixture',", + " issuedAt: process.env.PROVIDER_ISSUED_AT,", + " expiresAt: process.env.PROVIDER_EXPIRES_AT,", + " run: { id: process.env.CI_RUN_ID, attempt: Number(process.env.CI_RUN_ATTEMPT), invocationNonce: process.env.PROVIDER_INVOCATION_NONCE },", + " source: { revision: process.env.SOURCE_REVISION, sourceSetSha256: process.env.SOURCE_SET_SHA256 },", + " candidate: { archiveSha256: process.env.CANDIDATE_ARCHIVE_SHA256, bundleSha256: process.env.CANDIDATE_BUNDLE_SHA256, distSha256: process.env.CANDIDATE_DIST_SHA256, lockfileSha256: process.env.CANDIDATE_LOCKFILE_SHA256 },", + " findings: [],", + "};", + "const privateKey = createPrivateKey(readFileSync(process.env.VULNERABILITY_PROVIDER_PRIVATE_KEY_PATH));", + "const value = sign(null, Buffer.from(JSON.stringify(canonical(unsigned))), privateKey).toString('base64');", + "writeFileSync(process.env.VULNERABILITY_REPORT_PATH, `${JSON.stringify({ ...unsigned, signature: { algorithm: 'Ed25519', keyId: process.env.PROVIDER_KEY_ID, publicKeyFingerprint: process.env.PROVIDER_PUBLIC_KEY_FINGERPRINT, value } })}\\n`);", + ].join("\n"); +} + async function createCandidateArchiveFixture( options: Readonly<{ extraMember?: boolean; repeatedExtraMembers?: number; duplicateMember?: boolean; oversizedManifest?: boolean }> = {}, ): Promise> { @@ -856,32 +896,83 @@ async function createCandidateArchiveFixture( } async function createProviderFixture() { + const base = await ensureProviderBaseFixture(); const root = await temporaryRoot("ci-provider-upload-"); - const candidateRoot = path.join(root, "candidate"); - const lockfile = Buffer.from("lockfileVersion: '9.0'\n"); - const lockfileSha256 = sha256(lockfile); - await writeArtifact(candidateRoot, "pnpm-lock.yaml", lockfile); - await writeArtifact(candidateRoot, "dist/app.js", "app\n"); - for (const evidencePath of RELEASE_CANDIDATE_EVIDENCE_PATHS) { - if (evidencePath === "pnpm-lock.yaml") continue; - const content = evidencePath === "artifacts/release/dependency-inventory.json" - ? `${JSON.stringify({ lockfileSha256 })}\n` - : `${evidencePath}\n`; - await writeArtifact(candidateRoot, evidencePath, content); + await cp(base, root, { recursive: true }); + const candidateRoot = root; + const candidate = JSON.parse( + await readFile(path.join(root, RELEASE_CANDIDATE_MANIFEST_PATH), "utf8"), + ) as Awaited>; + const protectedCandidateRelative = candidate.files.find((file) => + file.path.startsWith("dist/"), + )?.path; + if (!protectedCandidateRelative) { + throw new Error("provider fixture candidate has no dist file"); } - const candidate = await createReleaseCandidateManifest(candidateRoot); - await writeArtifact(candidateRoot, RELEASE_CANDIDATE_MANIFEST_PATH, `${JSON.stringify(candidate)}\n`); - const distSha256 = candidate.distSha256; + const protectedCandidatePath = path.join(root, protectedCandidateRelative); + const protectedCandidateBytes = await readFile(protectedCandidatePath); + const assessment = localEvidenceAssessmentArtifactSchema.parse( + JSON.parse( + await readFile(path.join(root, LOCAL_EVIDENCE_ASSESSMENT_PATH), "utf8"), + ) as unknown, + ); const archivePath = path.join(root, "candidate.tar.gz"); - const tar = spawnSync( - "tar", - ["-czf", archivePath, "dist", ...RELEASE_CANDIDATE_EVIDENCE_PATHS, RELEASE_CANDIDATE_MANIFEST_PATH], - { cwd: candidateRoot, encoding: "utf8" }, + const archiveSha256 = sha256(await readFile(archivePath)); + const keys = generateKeyPairSync("ed25519"); + const keyId = "fixture-vulnerability-key"; + const publicKeyPath = path.join(root, "keys/vulnerability.pem"); + const privateKeyPath = path.join(root, "keys/vulnerability-private.pem"); + await writeArtifact( + root, + "keys/vulnerability.pem", + keys.publicKey.export({ type: "spki", format: "pem" }), ); - if (tar.status !== 0) throw new Error(tar.stderr); - const archiveSha256 = sha256( - await import("node:fs/promises").then(({ readFile }) => readFile(archivePath)), + await writeArtifact( + root, + "keys/vulnerability-private.pem", + keys.privateKey.export({ type: "pkcs8", format: "pem" }), ); + const trust = (await readProviderTrust(root, publicKeyPath, keyId))!; + const now = Date.parse("2026-08-02T01:00:00.000Z"); + const expectedContext = { + run: { id: "fixture-run", attempt: 1 }, + source: { + revision: assessment.source.revision, + sourceSetSha256: assessment.source.sourceSetSha256, + }, + candidate: { + archiveSha256, + bundleSha256: candidate.bundleSha256, + distSha256: candidate.distSha256, + lockfileSha256: candidate.lockfileSha256, + }, + vulnerabilityInvocationNonce: "1".repeat(64), + provenanceInvocationNonce: "0".repeat(64), + } as const; + const unsigned = { + schemaVersion: 2 as const, + evidenceType: "vulnerability-report" as const, + provider: "fixture", + issuedAt: "2026-08-02T01:00:00.000Z", + expiresAt: "2026-08-02T02:00:00.000Z", + run: { ...expectedContext.run, invocationNonce: expectedContext.vulnerabilityInvocationNonce }, + source: expectedContext.source, + candidate: expectedContext.candidate, + findings: [], + }; + const report = { + ...unsigned, + signature: { + algorithm: "Ed25519" as const, + keyId, + publicKeyFingerprint: providerPublicKeyFingerprint(keys.publicKey), + value: sign( + null, + providerEvidenceSignaturePayload(unsigned), + keys.privateKey, + ).toString("base64"), + }, + }; const reportPath = path.join( root, "provider-evidence/untrusted/vulnerability-report.json", @@ -889,27 +980,132 @@ async function createProviderFixture() { await writeArtifact( root, "provider-evidence/untrusted/vulnerability-report.json", - `${JSON.stringify({ schemaVersion: 1, provider: "fixture", generatedAt: "2026-08-02T00:00:00.000Z", scannedLockfileSha256: lockfileSha256, scannedDistSha256: distSha256, findings: [], signature: { algorithm: "Ed25519", keyId: "fixture", value: "AA==" } })}\n`, + `${JSON.stringify(report)}\n`, ); - return { root, candidateRoot, archivePath, archiveSha256, reportPath, distSha256 }; + const reportBytes = Buffer.from(`${JSON.stringify(report)}\n`); + return { + root, + candidateRoot, + candidate, + protectedCandidatePath, + protectedCandidateBytes, + archivePath, + archiveSha256, + reportPath, + reportBytes, + expectedContext, + trust, + now, + publicKeyPath, + privateKeyPath, + privateKey: keys.privateKey, + keyId, + providerWriter: path.join(root, "provider-v2-writer.mjs"), + }; +} + +async function ensureProviderBaseFixture(): Promise { + if (providerBaseRoot) return providerBaseRoot; + const sourceRoot = process.cwd(); + const root = await mkdtemp(path.join(tmpdir(), "ci-provider-v2-base-")); + await cp(sourceRoot, root, { + recursive: true, + filter: (source) => { + const relative = path.relative(sourceRoot, source); + if (!relative) return true; + const first = relative.split(path.sep)[0]; + return ![".release", "artifacts", "dist", "node_modules"].includes(first ?? ""); + }, + }); + await cp(path.join(sourceRoot, "artifacts"), path.join(root, "artifacts"), { + recursive: true, + }); + await rm(path.join(root, "artifacts/release"), { recursive: true, force: true }); + await symlink(path.join(sourceRoot, "node_modules"), path.join(root, "node_modules"), "dir"); + const git = spawnSync("git", ["show", "-s", "--format=%H%n%ct", "HEAD"], { + cwd: sourceRoot, + encoding: "utf8", + }); + if (git.status !== 0) throw new Error(git.stderr); + const [revision, sourceDateEpoch] = git.stdout.trim().split(/\r?\n/u); + const build = spawnSync("corepack", ["pnpm", "build:release-candidate"], { + cwd: root, + encoding: "utf8", + timeout: 120_000, + maxBuffer: 32 * 1024 * 1024, + env: { + ...process.env, + CI: "true", + VITE_BUILD_ID: "provider-v2-fixture", + VITE_COMMIT_SHA: revision, + RELEASE_ID: "provider-v2-fixture", + SOURCE_DATE_EPOCH: sourceDateEpoch, + CI_RUNNER_IMAGE: `fixture@sha256:${"a".repeat(64)}`, + }, + }); + if (build.status !== 0) throw new Error(`${build.stdout}\n${build.stderr}`); + const archivePath = path.join(root, "candidate.tar.gz"); + const tar = spawnSync( + "/usr/bin/tar", + [ + "--sort=name", + "--mtime=@0", + "--owner=0", + "--group=0", + "--numeric-owner", + "-czf", + archivePath, + "dist", + ...RELEASE_CANDIDATE_EVIDENCE_PATHS, + RELEASE_CANDIDATE_MANIFEST_PATH, + ], + { cwd: root, encoding: "utf8" }, + ); + if (tar.status !== 0) throw new Error(tar.stderr); + providerBaseRoot = root; + return root; } async function createPromotionStagingFixture() { - const candidateFixture = await createCandidateArchiveFixture(); - const root = path.dirname(candidateFixture.archivePath); - const candidate = await verifyCiCandidateArchive({ - archivePath: candidateFixture.archivePath, - }); + const base = await ensureProviderBaseFixture(); + const root = await temporaryRoot("promotion-finalizer-"); + const archivePath = path.join(root, "inputs/release-candidate.tar.gz"); + await mkdir(path.dirname(archivePath), { recursive: true }); + await cp(path.join(base, "candidate.tar.gz"), archivePath); + const candidate = await verifyCiCandidateArchive({ archivePath }); + const candidateArchiveBytes = await readFile(archivePath); + const assessmentBytes = await readFile(path.join(base, LOCAL_EVIDENCE_ASSESSMENT_PATH)); + const assessment = localEvidenceAssessmentArtifactSchema.parse( + JSON.parse(assessmentBytes.toString("utf8")) as unknown, + ); const vulnerabilityKeys = generateKeyPairSync("ed25519"); const provenanceKeys = generateKeyPairSync("ed25519"); const vulnerabilityKeyId = "fixture-vulnerability"; const provenanceKeyId = "fixture-provenance"; + const vulnerabilityInvocationNonce = "5".repeat(64); + const provenanceInvocationNonce = "6".repeat(64); + const expectedContext = { + run: { id: "fixture-run", attempt: 1 }, + source: { + revision: assessment.source.revision, + sourceSetSha256: assessment.source.sourceSetSha256, + }, + candidate: { + archiveSha256: sha256(candidateArchiveBytes), + bundleSha256: candidate.manifest.bundleSha256, + distSha256: candidate.manifest.distSha256, + lockfileSha256: candidate.manifest.lockfileSha256, + }, + } as const; const vulnerabilityUnsigned = { - schemaVersion: 1 as const, + schemaVersion: 2 as const, + evidenceType: "vulnerability-report" as const, provider: "fixture-vulnerability", - generatedAt: "2026-08-02T00:00:00.000Z", - scannedLockfileSha256: candidate.manifest.lockfileSha256, - scannedDistSha256: candidate.manifest.distSha256, + issuedAt: "2026-08-02T01:00:00.000Z", + expiresAt: "2026-08-02T02:00:00.000Z", + run: { ...expectedContext.run, invocationNonce: vulnerabilityInvocationNonce }, + source: expectedContext.source, + candidate: expectedContext.candidate, findings: [], }; const vulnerabilityReport = { @@ -917,6 +1113,7 @@ async function createPromotionStagingFixture() { signature: { algorithm: "Ed25519" as const, keyId: vulnerabilityKeyId, + publicKeyFingerprint: providerPublicKeyFingerprint(vulnerabilityKeys.publicKey), value: sign( null, providerEvidenceSignaturePayload(vulnerabilityUnsigned), @@ -925,10 +1122,15 @@ async function createPromotionStagingFixture() { }, }; const provenanceUnsigned = { - schemaVersion: 1 as const, + schemaVersion: 2 as const, + evidenceType: "provenance-attestation" as const, provider: "fixture-provenance", signer: "fixture-signer", - generatedAt: "2026-08-02T00:00:00.000Z", + issuedAt: "2026-08-02T01:00:00.000Z", + expiresAt: "2026-08-02T02:00:00.000Z", + run: { ...expectedContext.run, invocationNonce: provenanceInvocationNonce }, + source: expectedContext.source, + candidate: expectedContext.candidate, subject: { name: "dist" as const, digest: { sha256: candidate.manifest.distSha256 }, @@ -939,6 +1141,7 @@ async function createPromotionStagingFixture() { signature: { algorithm: "Ed25519" as const, keyId: provenanceKeyId, + publicKeyFingerprint: providerPublicKeyFingerprint(provenanceKeys.publicKey), value: sign( null, providerEvidenceSignaturePayload(provenanceUnsigned), @@ -946,50 +1149,26 @@ async function createPromotionStagingFixture() { ).toString("base64"), }, }; - const candidateArchiveBytes = await readFile(candidateFixture.archivePath); const vulnerabilityReportBytes = Buffer.from(`${JSON.stringify(vulnerabilityReport)}\n`); const provenanceAttestationBytes = Buffer.from(`${JSON.stringify(provenanceAttestation)}\n`); - const verificationBindings = { - candidateArchiveSha256: sha256(candidateArchiveBytes), - vulnerabilityReportSha256: sha256(vulnerabilityReportBytes), - provenanceAttestationSha256: sha256(provenanceAttestationBytes), - }; - const verificationBase = { - schemaVersion: 2 as const, - status: "PASS" as const, - vulnerabilityStatus: "PASS" as const, - provenanceAttestationStatus: "PASS" as const, - lockfileSha256: candidate.manifest.lockfileSha256, - distSha256: candidate.manifest.distSha256, - ...verificationBindings, - failures: [], - }; - const providerVerificationBytes = Buffer.from(`${JSON.stringify({ - ...verificationBase, - artifactType: "provider-verification", - })}\n`); - const promotionVerificationBytes = Buffer.from(`${JSON.stringify({ - ...verificationBase, - artifactType: "promotion-verification", - })}\n`); const vulnerabilityReportPath = path.join( root, - ".release/vulnerability/vulnerability-report.json", + "inputs/vulnerability-report.json", ); const provenanceAttestationPath = path.join( root, - ".release/provenance/provenance-attestation.json", + "inputs/provenance-attestation.json", ); const vulnerabilityPublicKeyPath = path.join(root, "keys/vulnerability.pem"); const provenancePublicKeyPath = path.join(root, "keys/provenance.pem"); await writeArtifact( root, - ".release/vulnerability/vulnerability-report.json", + "inputs/vulnerability-report.json", vulnerabilityReportBytes, ); await writeArtifact( root, - ".release/provenance/provenance-attestation.json", + "inputs/provenance-attestation.json", provenanceAttestationBytes, ); await writeArtifact( @@ -1002,54 +1181,65 @@ async function createPromotionStagingFixture() { "keys/provenance.pem", provenanceKeys.publicKey.export({ type: "spki", format: "pem" }), ); - await writeArtifact( - root, - "artifacts/security/provider-verification.json", - providerVerificationBytes, - ); - await writeArtifact( - root, - "artifacts/security/promotion-verification.json", - promotionVerificationBytes, - ); - const expectedStagedBytes = new Map([ - [ - ".release/promoted-staging/release-candidate.tar.gz", - candidateArchiveBytes, - ], - [ - ".release/promoted-staging/vulnerability-report.json", - await readFile(vulnerabilityReportPath), - ], - [ - ".release/promoted-staging/provenance-attestation.json", - await readFile(provenanceAttestationPath), - ], - [ - ".release/promoted-staging/provider-verification.json", - await readFile(path.join(root, "artifacts/security/provider-verification.json")), - ], - [ - ".release/promoted-staging/promotion-verification.json", - await readFile(path.join(root, "artifacts/security/promotion-verification.json")), - ], - ]); + const runnerTempRoot = path.join(root, "runner-temp"); + await mkdir(runnerTempRoot, { mode: 0o700 }); + const cleanupToken = `promotion-fixture-run-1-${"2a".repeat(16)}`; + const expectedProviderEvidence = { + vulnerabilityReportSha256: sha256(vulnerabilityReportBytes), + provenanceAttestationSha256: sha256(provenanceAttestationBytes), + vulnerabilityInvocationNonce, + provenanceInvocationNonce, + vulnerabilityKeyId, + vulnerabilityKeyFingerprint: providerPublicKeyFingerprint(vulnerabilityKeys.publicKey), + provenanceKeyId, + provenanceKeyFingerprint: providerPublicKeyFingerprint(provenanceKeys.publicKey), + }; return { root, + runnerTempRoot, + cleanupToken, + assessmentSha256: sha256(assessmentBytes), + expectedContext, + expectedProviderEvidence, + capturedSources: new Map([ + ["release-candidate.tar.gz", candidateArchiveBytes], + ["vulnerability-report.json", vulnerabilityReportBytes], + ["provenance-attestation.json", provenanceAttestationBytes], + ]), dependencies: { - verifyLocalEvidence: async () => ({ status: "PASS" as const, failures: [] }), + nowEpochMs: () => Date.parse("2026-08-02T01:00:00.000Z"), + randomBytes: (bytes: number) => Buffer.alloc(bytes, 0x2a), }, - expectedStagedBytes, input: { repositoryRoot: root, - archivePath: candidateFixture.archivePath, - expectedArchiveSha256: sha256(await readFile(candidateFixture.archivePath)), + archivePath, + expectedArchiveSha256: sha256(candidateArchiveBytes), vulnerabilityReportPath, provenanceAttestationPath, vulnerabilityPublicKeyPath, vulnerabilityKeyId, provenancePublicKeyPath, provenanceKeyId, + expectedRun: { + id: expectedContext.run.id, + attempt: expectedContext.run.attempt, + sourceRevision: expectedContext.source.revision, + }, + vulnerabilityInvocationNonce, + provenanceInvocationNonce, + runnerTempRoot, }, }; } + +function finalizedCleanup( + fixture: Awaited>, + finalized: Awaited>, +) { + return { + runnerTempRoot: fixture.runnerTempRoot, + stagingRoot: finalized.stagingRoot, + cleanupToken: finalized.cleanupToken, + runnerTempIdentity: finalized.runnerTempIdentity, + }; +} diff --git a/tests/unit/ci-workflow-generation.test.ts b/tests/unit/ci-workflow-generation.test.ts index e0f9e48..5f18be9 100644 --- a/tests/unit/ci-workflow-generation.test.ts +++ b/tests/unit/ci-workflow-generation.test.ts @@ -151,16 +151,19 @@ describe("CI gate contract", () => { ["candidate output identity drift", (value: Record) => { const job = value.jobs.find((candidate: any) => candidate.id === "immutable_build"); job.steps.find((step: any) => step.kind === "archive-candidate").archiveOutputName = "renamed"; }, /candidate output identity drift/i], ["stage cycle", (value: Record) => (value.stages[0].needs = ["release"]), /stage dependency cycle/i], ["provider adapter target drift", (value: Record) => (value.providerAdapter = "package.json"), /canonical generated workflow/i], - ["workflow root extraction", (value: Record) => (value.jobs.find((candidate: any) => candidate.id === "promotion").steps.find((step: any) => step.kind === "extract").targetRoot = ".."), /unsafe workflow path/i], + ["promotion standalone extraction", (value: Record) => value.jobs.find((candidate: any) => candidate.id === "promotion").steps.splice(6, 0, { kind: "extract", archivePath: ".release/candidate/candidate.tar.gz", targetRoot: ".release/verified-candidate" }), /step kind extract is forbidden|job step sequence drift/i], ["normalized upload root", (value: Record) => (value.jobs[0].steps.find((step: any) => step.kind === "upload").paths = ["foo/.."]), /unsafe workflow path/i], ["immutable archive field drift", (value: Record) => (value.jobs.find((candidate: any) => candidate.id === "immutable_build").steps.find((step: any) => step.kind === "archive-candidate").archivePath = ".release/other.tar.gz"), /candidate output identity drift|archive and upload fields must remain linked/i], ["provider role drift", (value: Record) => (value.jobs.find((candidate: any) => candidate.id === "vulnerability_provider").steps.find((step: any) => step.kind === "run-provider").provider = "provenance"), /provider archive, extraction, evidence, and upload fields must remain linked/i], ["provider archive SHA environment drift", (value: Record) => (value.jobs.find((candidate: any) => candidate.id === "vulnerability_provider").environment.find((entry: any) => entry.name === "CANDIDATE_ARCHIVE_SHA256").value = "0".repeat(64)), /job environment binding drift/i], - ["promotion transfer swap", (value: Record) => (value.jobs.find((candidate: any) => candidate.id === "promotion").steps.find((step: any) => step.kind === "download").transferId = "vulnerability-provider-evidence"), /promotion download and extraction fields must remain linked|duplicate.*download/i], + ["promotion transfer swap", (value: Record) => (value.jobs.find((candidate: any) => candidate.id === "promotion").steps.find((step: any) => step.kind === "download").transferId = "vulnerability-provider-evidence"), /promotion download fields.*remain linked|duplicate.*download/i], ["raw provider upload", (value: Record) => (value.jobs.find((candidate: any) => candidate.id === "vulnerability_provider").steps.find((step: any) => step.kind === "upload").paths = ["provider-evidence/untrusted/vulnerability-report.json"]), /provider archive, extraction, evidence, and upload fields must remain linked/i], - ["intervening promotion step", (value: Record) => value.jobs.find((candidate: any) => candidate.id === "promotion").steps.splice(-1, 0, { kind: "frozen-install" }), /promotion verification and upload must be immediately adjacent/i], - ["promotion upload path drift", (value: Record) => (value.jobs.find((candidate: any) => candidate.id === "promotion").steps.at(-1).paths[0] = ".release/promoted-staging/replaced.tar.gz"), /exact five typed paths/i], - ["always promotion upload", (value: Record) => (value.jobs.find((candidate: any) => candidate.id === "promotion").steps.at(-1).always = true), /promotion upload must not use always/i], + ["intervening promotion step", (value: Record) => { const steps = value.jobs.find((candidate: any) => candidate.id === "promotion").steps; steps.splice(steps.findIndex((step: any) => step.kind === "upload"), 0, { kind: "frozen-install" }); }, /promotion verification and upload must be immediately adjacent/i], + ["promotion upload path drift", (value: Record) => (value.jobs.find((candidate: any) => candidate.id === "promotion").steps.find((step: any) => step.kind === "upload").paths[0] = ".release/promoted-staging/replaced.tar.gz"), /exact five typed paths/i], + ["always promotion upload", (value: Record) => (value.jobs.find((candidate: any) => candidate.id === "promotion").steps.find((step: any) => step.kind === "upload").always = true), /promotion upload must not use always/i], + ["provider nonce output step drift", (value: Record) => (value.jobs.find((candidate: any) => candidate.id === "vulnerability_provider").steps.find((step: any) => step.kind === "run-provider").stepId = "renamed"), /provider.*linked|step identity/i], + ["promotion nonce binding drift", (value: Record) => (value.jobs.find((candidate: any) => candidate.id === "promotion").environment.find((entry: any) => entry.name === "VULNERABILITY_INVOCATION_NONCE").value = "5".repeat(64)), /job environment binding drift/i], + ["missing promotion cleanup", (value: Record) => { const job = value.jobs.find((candidate: any) => candidate.id === "promotion"); job.steps = job.steps.filter((step: any) => step.kind !== "cleanup-promotion"); }, /job step sequence drift|cleanup/i], ])("rejects semantic mutation: %s", async (_name, mutate, diagnostic) => { const contract = await loadCiGateContract(process.cwd()); const candidate = JSON.parse(JSON.stringify(contract)) as Record; @@ -448,10 +451,25 @@ describe("CI workflow generation", () => { ).toHaveLength(9); expect(first).not.toMatch(/corepack pnpm install --frozen-lockfile$/mu); expect(first).toContain("verify-ci-candidate-archive.ts --archive"); - expect(first).toContain("--extract-to"); + expect(first).not.toContain("--extract-to"); expect(first).not.toMatch(/\btar\s+[^\n]*--extract/u); expect(first).toContain("node scripts/stage-verified-promotion.ts"); - expect(first).toContain(".release/promoted-staging/release-candidate.tar.gz"); + expect(first).toContain("outputs:\n invocation_nonce: ${{ steps.supervise_vulnerability.outputs.invocation_nonce }}"); + expect(first).toContain("outputs:\n invocation_nonce: ${{ steps.supervise_provenance.outputs.invocation_nonce }}"); + expect(first).toContain('VULNERABILITY_INVOCATION_NONCE: "${{ needs.vulnerability_provider.outputs.invocation_nonce }}"'); + expect(first).toContain('PROVENANCE_INVOCATION_NONCE: "${{ needs.provenance_provider.outputs.invocation_nonce }}"'); + expect(first).toContain("${{ steps.finalize.outputs.staging_root }}/release-candidate.tar.gz"); + expect(first).not.toContain(".release/promoted-staging"); + const finalizerIndex = first.indexOf("node scripts/stage-verified-promotion.ts"); + const promotedUploadIndex = first.indexOf("Upload promoted release"); + const cleanupStepIndex = first.indexOf("- name: Always remove private promotion staging"); + const cleanupIndex = first.indexOf("node scripts/cleanup-verified-promotion.ts"); + expect(finalizerIndex).toBeGreaterThan(0); + expect(promotedUploadIndex).toBeGreaterThan(finalizerIndex); + expect(cleanupStepIndex).toBeGreaterThan(promotedUploadIndex); + expect(cleanupIndex).toBeGreaterThan(cleanupStepIndex); + expect(first.slice(promotedUploadIndex, cleanupStepIndex)).not.toContain("if: always()"); + expect(first.slice(cleanupStepIndex, cleanupIndex)).toContain("if: always()"); const actionUses = [...first.matchAll(/^\s+-?\s*uses: (.+)$/gmu)].map((match) => match[1]); expect(actionUses).toHaveLength(32); expect(new Set(actionUses)).toEqual( diff --git a/tests/unit/security-followup.test.ts b/tests/unit/security-followup.test.ts new file mode 100644 index 0000000..6f39ccd --- /dev/null +++ b/tests/unit/security-followup.test.ts @@ -0,0 +1,863 @@ +import { + createHash, + generateKeyPairSync, + sign, + type KeyObject, +} 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"; + +import { localEvidenceAssessmentArtifactSchema } from "../../scripts/contracts/release-artifacts.ts"; +import { verifyArchivedLocalEvidence } from "../../scripts/lib/local-release-evidence.ts"; +import { + evaluatePromotionEvidence, + providerEvidenceSignaturePayload, + providerPublicKeyFingerprint, +} from "../../scripts/lib/provider-evidence.ts"; +import { readProviderTrust } from "../../scripts/lib/promotion-verifier.ts"; +import { superviseProviderEvidence } from "../../scripts/lib/provider-supervisor.ts"; +import { + LOCAL_EVIDENCE_ASSESSMENT_PATH, + distSha256, + type ReleaseCandidateManifest, +} from "../../scripts/lib/release-candidate.ts"; +import { supplyChainDigest } from "../../scripts/lib/supply-chain.ts"; + +const digest = (value: string): string => + createHash("sha256").update(value).digest("hex"); +const digestBytes = (value: Buffer): string => + createHash("sha256").update(value).digest("hex"); + +function passingAssessment(): any { + return { + schemaVersion: 1 as const, + artifactType: "local-evidence-assessment" as const, + generatedAt: "2026-08-02T00:00:00.000Z", + status: "PASS" as const, + verifier: { + id: "clean-architecture-frontend-template/local-evidence-verifier", + version: "1", + sourceSha256: digest("verifier source"), + }, + source: { + revision: "a".repeat(40), + sourceSetSha256: digest("source set"), + }, + candidate: { + distSha256: digest("dist"), + lockfileSha256: digest("lockfile"), + sbomSha256: digest("sbom"), + }, + policyInputs: [ + { + path: "config/security/dependency-policy.json", + bytes: 3, + sha256: digest("{}\n"), + }, + ], + evidenceInputs: [ + { path: "pnpm-lock.yaml", bytes: 9, sha256: digest("lockfile\n") }, + ], + checks: { + release: "PASS" as const, + supplyChain: "PASS" as const, + dependencyPolicy: "PASS" as const, + licensePolicy: "PASS" as const, + vulnerabilityPolicy: "PASS" as const, + secretScan: "PASS" as const, + }, + failures: [] as string[], + }; +} + +describe("security follow-up contracts", () => { + it("rejects a PASS local assessment with a failed check or failure diagnostic", () => { + const failedCheck = passingAssessment(); + failedCheck.checks.secretScan = "FAIL"; + const failureDiagnostic = passingAssessment(); + failureDiagnostic.failures.push("secret scan failed"); + + expect(localEvidenceAssessmentArtifactSchema.safeParse(failedCheck).success).toBe(false); + expect(localEvidenceAssessmentArtifactSchema.safeParse(failureDiagnostic).success).toBe(false); + expect(localEvidenceAssessmentArtifactSchema.parse(passingAssessment()).status).toBe("PASS"); + }); + + it("passes archived verification from extracted members without checkout source or policy paths", async () => { + const fixture = await createArchivedAssessmentFixture(); + try { + const result = await verifyArchivedLocalEvidence({ + extractionRoot: fixture.root, + expectedManifest: fixture.manifest, + }); + + expect(result).toEqual({ + status: "PASS", + identity: { + sourceRevision: "a".repeat(40), + sourceSetSha256: digest("source set"), + assessmentSha256: fixture.assessmentSha256, + }, + failures: [], + }); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } + }); + + it("rejects archived verification when the assessment is absent", async () => { + const fixture = await createArchivedAssessmentFixture(); + try { + await rm(path.join(fixture.root, LOCAL_EVIDENCE_ASSESSMENT_PATH)); + const result = await verifyArchivedLocalEvidence({ + extractionRoot: fixture.root, + expectedManifest: fixture.manifest, + }); + expect(result.status).toBe("FAIL"); + expect(result.failures).toContain("local evidence assessment is missing or invalid"); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } + }); + + it("rejects digest-bound raw identity evidence that is not valid under its strict producer schema", async () => { + const fixture = await createArchivedAssessmentFixture(); + try { + const provenancePath = "artifacts/release/provenance.json"; + const malformed = Buffer.from( + `${JSON.stringify({ predicate: { materials: { sourceSetSha256: digest("source set") } } })}\n`, + ); + await writeFile(path.join(fixture.root, provenancePath), malformed); + const assessmentPath = path.join(fixture.root, LOCAL_EVIDENCE_ASSESSMENT_PATH); + const assessment = localEvidenceAssessmentArtifactSchema.parse( + JSON.parse(await readFile(assessmentPath, "utf8")) as unknown, + ); + assessment.evidenceInputs = assessment.evidenceInputs.map((row) => + row.path === provenancePath + ? { path: provenancePath, bytes: malformed.byteLength, sha256: digestBytes(malformed) } + : row, + ); + const assessmentBytes = Buffer.from(`${JSON.stringify(assessment)}\n`); + await writeFile(assessmentPath, assessmentBytes); + const files = fixture.manifest.files.map((row) => { + if (row.path === provenancePath) { + return { path: provenancePath, bytes: malformed.byteLength, sha256: digestBytes(malformed) }; + } + if (row.path === LOCAL_EVIDENCE_ASSESSMENT_PATH) { + return { + path: LOCAL_EVIDENCE_ASSESSMENT_PATH, + bytes: assessmentBytes.byteLength, + sha256: digestBytes(assessmentBytes), + }; + } + return row; + }); + const manifest = { ...fixture.manifest, files, bundleSha256: supplyChainDigest(files) }; + await writeFile( + path.join(fixture.root, "artifacts/release/release-candidate.json"), + `${JSON.stringify(manifest)}\n`, + ); + + const result = await verifyArchivedLocalEvidence({ + extractionRoot: fixture.root, + expectedManifest: manifest, + }); + expect(result.status).toBe("FAIL"); + expect(result.failures).toContain( + "archived source/build/provenance identities are missing or invalid", + ); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } + }); + + it("accepts signed provider v2 evidence only for the exact run, source, archive, and nonce", () => { + const now = Date.parse("2026-08-02T01:00:00.000Z"); + const vulnerabilityKeys = generateKeyPairSync("ed25519"); + const provenanceKeys = generateKeyPairSync("ed25519"); + const expected = providerExpectedContext(); + const vulnerability = signedProviderV2( + { + ...expected, + schemaVersion: 2, + evidenceType: "vulnerability-report", + provider: "fixture-vulnerability", + issuedAt: "2026-08-02T01:00:00.000Z", + expiresAt: "2026-08-02T02:00:00.000Z", + run: { ...expected.run, invocationNonce: "1".repeat(64) }, + findings: [], + }, + "vulnerability-key", + vulnerabilityKeys.publicKey, + vulnerabilityKeys.privateKey, + ); + const provenance = signedProviderV2( + { + ...expected, + schemaVersion: 2, + evidenceType: "provenance-attestation", + provider: "fixture-provenance", + signer: "fixture-workload", + issuedAt: "2026-08-02T01:00:00.000Z", + expiresAt: "2026-08-02T02:00:00.000Z", + run: { ...expected.run, invocationNonce: "2".repeat(64) }, + subject: { name: "dist", digest: { sha256: expected.candidate.distSha256 } }, + }, + "provenance-key", + provenanceKeys.publicKey, + provenanceKeys.privateKey, + ); + const result = evaluatePromotionEvidence({ + expected: { + ...expected, + vulnerabilityInvocationNonce: "1".repeat(64), + provenanceInvocationNonce: "2".repeat(64), + }, + localStatus: "PASS", + vulnerabilityReport: vulnerability, + provenanceAttestation: provenance, + vulnerabilityTrust: trust("vulnerability-key", vulnerabilityKeys.publicKey), + provenanceTrust: trust("provenance-key", provenanceKeys.publicKey), + nowEpochMs: () => now, + }); + + expect(result).toEqual({ + status: "PASS", + vulnerabilityStatus: "PASS", + provenanceAttestationStatus: "PASS", + failures: [], + }); + const replayed = evaluatePromotionEvidence({ + expected: { + ...expected, + run: { id: expected.run.id, attempt: 2 }, + vulnerabilityInvocationNonce: "1".repeat(64), + provenanceInvocationNonce: "2".repeat(64), + }, + localStatus: "PASS", + vulnerabilityReport: vulnerability, + provenanceAttestation: provenance, + vulnerabilityTrust: trust("vulnerability-key", vulnerabilityKeys.publicKey), + provenanceTrust: trust("provenance-key", provenanceKeys.publicKey), + nowEpochMs: () => now, + }); + expect(replayed.status).toBe("FAIL_UNVERIFIED"); + expect(replayed.failures).toEqual( + expect.arrayContaining([ + "vulnerability report run identity mismatch", + "provenance attestation run identity mismatch", + ]), + ); + }); + + it.each(["vulnerability", "provenance"] as const)( + "rejects correctly re-signed %s v2 context/time/replay drift", + (kind) => { + const now = Date.parse("2026-08-02T01:00:00.000Z"); + const vulnerabilityKeys = generateKeyPairSync("ed25519"); + const provenanceKeys = generateKeyPairSync("ed25519"); + const expected = providerExpectedContext(); + const baseVulnerability = providerUnsigned("vulnerability", expected); + const baseProvenance = providerUnsigned("provenance", expected); + const validVulnerability = signedProviderV2( + baseVulnerability, + "vulnerability-key", + vulnerabilityKeys.publicKey, + vulnerabilityKeys.privateKey, + ); + const validProvenance = signedProviderV2( + baseProvenance, + "provenance-key", + provenanceKeys.publicKey, + provenanceKeys.privateKey, + ); + const rawCases: Array) => Record, + RegExp, + ]> = [ + ["schema v1", (value) => ({ ...value, schemaVersion: 1 }), /missing or invalid/u], + [ + "evidence type", + (value) => ({ + ...value, + evidenceType: + kind === "vulnerability" + ? "provenance-attestation" + : "vulnerability-report", + }), + /missing or invalid/u, + ], + ...(["archiveSha256", "bundleSha256", "distSha256", "lockfileSha256"] as const).map( + (field) => [ + `candidate ${field}`, + (value: Record) => ({ + ...value, + candidate: { ...value.candidate, [field]: "f".repeat(64) }, + ...(kind === "provenance" && field === "distSha256" + ? { + subject: { + name: "dist", + digest: { sha256: "f".repeat(64) }, + }, + } + : {}), + }), + /candidate identity|subject dist/u, + ] as const, + ), + [ + "different archive with same dist and lockfile", + (value) => ({ + ...value, + candidate: { ...value.candidate, archiveSha256: "e".repeat(64) }, + }), + /candidate identity/u, + ], + [ + "source revision", + (value) => ({ ...value, source: { ...value.source, revision: "c".repeat(40) } }), + /source identity/u, + ], + [ + "source set", + (value) => ({ ...value, source: { ...value.source, sourceSetSha256: "c".repeat(64) } }), + /source identity/u, + ], + [ + "run id", + (value) => ({ ...value, run: { ...value.run, id: "other-run" } }), + /run identity/u, + ], + [ + "run attempt replay", + (value) => ({ ...value, run: { ...value.run, attempt: 2 } }), + /run identity/u, + ], + [ + "different nonce", + (value) => ({ ...value, run: { ...value.run, invocationNonce: "3".repeat(64) } }), + /invocation nonce/u, + ], + [ + "missing nonce", + (value) => { + const run = { ...value.run }; + delete run.invocationNonce; + return { ...value, run }; + }, + /missing or invalid/u, + ], + [ + "uppercase nonce", + (value) => ({ ...value, run: { ...value.run, invocationNonce: "A".repeat(64) } }), + /missing or invalid/u, + ], + [ + "short nonce", + (value) => ({ ...value, run: { ...value.run, invocationNonce: "1".repeat(62) } }), + /missing or invalid/u, + ], + [ + "issued future boundary", + (value) => ({ ...value, issuedAt: "2026-08-02T01:05:00.001Z" }), + /future skew/u, + ], + [ + "expiry equality", + (value) => ({ ...value, expiresAt: "2026-08-02T01:00:00.000Z" }), + /expired/u, + ], + [ + "expiry past", + (value) => ({ ...value, expiresAt: "2026-08-02T00:59:59.999Z" }), + /expired/u, + ], + [ + "zero lifetime", + (value) => ({ + ...value, + issuedAt: "2026-08-02T01:01:00.000Z", + expiresAt: "2026-08-02T01:01:00.000Z", + }), + /not positive/u, + ], + [ + "negative lifetime", + (value) => ({ + ...value, + issuedAt: "2026-08-02T01:02:00.000Z", + expiresAt: "2026-08-02T01:01:59.999Z", + }), + /not positive/u, + ], + [ + "lifetime above two hours", + (value) => ({ + ...value, + issuedAt: "2026-08-02T01:00:00.000Z", + expiresAt: "2026-08-02T03:00:00.001Z", + }), + /exceeds two hours/u, + ], + [ + "wrong fingerprint", + (value) => ({ + ...value, + signature: { + ...value.signature, + publicKeyFingerprint: `sha256:${"d".repeat(64)}`, + }, + }), + /trust identity/u, + ], + ]; + const cases = rawCases.map(([name, mutate, failure]) => ({ + name, + mutate, + failure, + })); + + for (const testCase of cases) { + const base = kind === "vulnerability" ? baseVulnerability : baseProvenance; + const mutated = testCase.mutate(structuredClone(base)); + const resigned = signedProviderV2( + mutated, + kind === "vulnerability" ? "vulnerability-key" : "provenance-key", + kind === "vulnerability" ? vulnerabilityKeys.publicKey : provenanceKeys.publicKey, + kind === "vulnerability" ? vulnerabilityKeys.privateKey : provenanceKeys.privateKey, + "signature" in mutated && mutated.signature?.publicKeyFingerprint + ? mutated.signature.publicKeyFingerprint + : undefined, + ); + const result = evaluatePromotionEvidence({ + expected: { + ...expected, + vulnerabilityInvocationNonce: "1".repeat(64), + provenanceInvocationNonce: "2".repeat(64), + }, + localStatus: "PASS", + vulnerabilityReport: + kind === "vulnerability" ? resigned : validVulnerability, + provenanceAttestation: + kind === "provenance" ? resigned : validProvenance, + vulnerabilityTrust: trust("vulnerability-key", vulnerabilityKeys.publicKey), + provenanceTrust: trust("provenance-key", provenanceKeys.publicKey), + nowEpochMs: () => now, + }); + expect(result.status, testCase.name).toBe("FAIL_UNVERIFIED"); + expect(result.failures.join("\n"), testCase.name).toMatch(testCase.failure); + } + }, + ); + + it("canonicalizes provider fingerprints from DER SPKI across PEM wrapping and rejects Ed448", async () => { + const root = await mkdtemp(path.join(tmpdir(), "provider-fingerprint-")); + try { + const ed25519 = generateKeyPairSync("ed25519").publicKey; + const pem = ed25519.export({ type: "spki", format: "pem" }).toString(); + const body = pem.replace(/-----[^-]+-----|\s/gu, ""); + const wrapped = (width: number) => + `-----BEGIN PUBLIC KEY-----\n${body.match(new RegExp(`.{1,${width}}`, "gu"))!.join("\n")}\n-----END PUBLIC KEY-----\n`; + await writeFile(path.join(root, "a.pem"), wrapped(64)); + await writeFile(path.join(root, "b.pem"), wrapped(32)); + const first = await readProviderTrust(root, "a.pem", "fixture-key"); + const second = await readProviderTrust(root, "b.pem", "fixture-key"); + expect(first?.publicKeyFingerprint).toBe(providerPublicKeyFingerprint(ed25519)); + expect(second?.publicKeyFingerprint).toBe(first?.publicKeyFingerprint); + + const ed448 = generateKeyPairSync("ed448").publicKey; + await writeFile(root + "/ed448.pem", ed448.export({ type: "spki", format: "pem" })); + await expect(readProviderTrust(root, "ed448.pem", "fixture-key")).resolves.toBeNull(); + expect(() => providerPublicKeyFingerprint(ed448)).toThrow(/must be Ed25519/u); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("captures the downloaded archive pathname exactly once in the provider supervisor", async () => { + const keys = generateKeyPairSync("ed25519"); + const expected = providerExpectedContext(); + let captureCount = 0; + let receivedEnvironment: Readonly> | undefined; + const manifest: ReleaseCandidateManifest = { + schemaVersion: 1, + distSha256: expected.candidate.distSha256, + lockfileSha256: expected.candidate.lockfileSha256, + bundleSha256: expected.candidate.bundleSha256, + files: [{ path: "pnpm-lock.yaml", bytes: 1, sha256: expected.candidate.lockfileSha256 }], + }; + const result = await superviseProviderEvidence( + { + kind: "vulnerability", + archivePath: "/downloads/candidate.tar.gz", + expectedArchiveSha256: expected.candidate.archiveSha256, + expectedRun: { + id: expected.run.id, + attempt: expected.run.attempt, + sourceRevision: expected.source.revision, + }, + trust: trust("vulnerability-key", keys.publicKey), + executeProvider: async ({ environment }) => { + receivedEnvironment = environment; + }, + captureReport: async () => Buffer.from("{}\n"), + }, + { + captureArchive: async (input) => { + captureCount += 1; + expect(input).toEqual({ + archivePath: "/downloads/candidate.tar.gz", + expectedSha256: expected.candidate.archiveSha256, + }); + return { + bytes: Buffer.from("captured archive"), + archiveSha256: expected.candidate.archiveSha256, + }; + }, + withVerifiedCandidate: (async (input: any) => + input.verify({ extractionRoot: "/captured/extraction", manifest })) as any, + verifyLocalEvidence: async () => ({ + status: "PASS", + identity: { + sourceRevision: expected.source.revision, + sourceSetSha256: expected.source.sourceSetSha256, + assessmentSha256: digest("assessment"), + }, + failures: [], + }), + validateUpload: (async (input: any) => { + expect("archivePath" in input).toBe(false); + return { sealed: true }; + }) as any, + randomBytes: () => Buffer.alloc(32, 0x11), + nowEpochMs: () => Date.parse("2026-08-02T01:00:00.000Z"), + }, + ); + + expect(captureCount).toBe(1); + expect(receivedEnvironment).toEqual( + expect.objectContaining({ + PROVIDER_EVIDENCE_SCHEMA_VERSION: "2", + PROVIDER_INVOCATION_NONCE: "11".repeat(32), + PROVIDER_ISSUED_AT: "2026-08-02T01:00:00.000Z", + PROVIDER_EXPIRES_AT: "2026-08-02T02:00:00.000Z", + CI_RUN_ID: expected.run.id, + CI_RUN_ATTEMPT: "1", + SOURCE_REVISION: expected.source.revision, + CANDIDATE_ARCHIVE_SHA256: expected.candidate.archiveSha256, + }), + ); + expect(result.evidence).toEqual({ sealed: true }); + }); +}); + +function providerExpectedContext() { + return { + run: { id: "run-42", attempt: 1 }, + source: { revision: "b".repeat(40), sourceSetSha256: digest("provider source") }, + candidate: { + archiveSha256: digest("archive"), + bundleSha256: digest("bundle"), + distSha256: digest("provider dist"), + lockfileSha256: digest("provider lockfile"), + }, + } as const; +} + +function fingerprint(publicKey: KeyObject): string { + return `sha256:${createHash("sha256") + .update(publicKey.export({ type: "spki", format: "der" })) + .digest("hex")}`; +} + +function trust(keyId: string, publicKey: KeyObject) { + return { keyId, publicKey, publicKeyFingerprint: fingerprint(publicKey) }; +} + +function signedProviderV2( + unsigned: Record, + keyId: string, + publicKey: KeyObject, + privateKey: KeyObject, + fingerprintOverride?: string, +) { + const { signature: existingSignature, ...payload } = unsigned; + const value = { + ...payload, + signature: { + algorithm: "Ed25519" as const, + keyId, + publicKeyFingerprint: + fingerprintOverride ?? + (existingSignature && typeof existingSignature === "object" && + "publicKeyFingerprint" in existingSignature + ? String(existingSignature.publicKeyFingerprint) + : fingerprint(publicKey)), + value: "", + }, + }; + value.signature.value = sign( + null, + providerEvidenceSignaturePayload(value), + privateKey, + ).toString("base64"); + return value; +} + +function providerUnsigned( + kind: "vulnerability" | "provenance", + expected: ReturnType, +): Record { + const common = { + ...expected, + schemaVersion: 2, + evidenceType: + kind === "vulnerability" + ? "vulnerability-report" + : "provenance-attestation", + provider: `fixture-${kind}`, + issuedAt: "2026-08-02T01:00:00.000Z", + expiresAt: "2026-08-02T02:00:00.000Z", + run: { + ...expected.run, + invocationNonce: kind === "vulnerability" ? "1".repeat(64) : "2".repeat(64), + }, + }; + return kind === "vulnerability" + ? { ...common, findings: [] } + : { + ...common, + signer: "fixture-workload", + subject: { + name: "dist", + digest: { sha256: expected.candidate.distSha256 }, + }, + }; +} + +async function createArchivedAssessmentFixture(): Promise<{ + root: string; + manifest: ReleaseCandidateManifest; + assessmentSha256: string; +}> { + const root = await mkdtemp(path.join(tmpdir(), "archived-assessment-")); + const sourceRevision = "a".repeat(40); + const sourceSetSha256 = digest("source set"); + const releaseManifestBytes = Buffer.from( + `${JSON.stringify({ + schemaVersion: 1, + appVersion: "1.0.0", + buildId: "build-1", + commitSha: sourceRevision, + configSchemaVersion: "1", + apiContractVersion: "1", + assetManifestHash: digest("vite manifest"), + releaseId: "release-1", + builtAt: "2026-08-02T00:00:00.000Z", + routeChunks: { home: "assets/home.js" }, + })}\n`, + ); + const distInputs = [ + { path: "dist/app.js", bytes: Buffer.byteLength("app\n"), sha256: digest("app\n"), gzipBytes: 0 }, + { + path: "dist/release-manifest.json", + bytes: releaseManifestBytes.byteLength, + sha256: digestBytes(releaseManifestBytes), + gzipBytes: 0, + }, + ]; + const candidateDist = distSha256(distInputs); + const sbomBytes = Buffer.from( + `${JSON.stringify({ + bomFormat: "CycloneDX", + specVersion: "1.6", + serialNumber: "urn:uuid:00000000-0000-4000-8000-000000000001", + version: 1, + metadata: { + component: { type: "application", name: "fixture", version: "1.0.0" }, + properties: [], + }, + components: [], + dependencies: [], + })}\n`, + ); + const sbomSha256 = digestBytes(sbomBytes); + const lockfileBytes = Buffer.from("lockfile\n"); + const lockfileDigest = digestBytes(lockfileBytes); + const buildManifest = { + schemaVersion: 1, + buildId: "build-1", + commitSha: sourceRevision, + releaseId: "release-1", + moduleInventoryHash: digest("module inventory"), + generatedAt: "2026-08-02T00:00:00.000Z", + buildContext: { + nodeVersion: "v24.0.0", + packageManagerVersion: "11.0.0", + runnerImage: "linux-x64", + sourceDateEpoch: "1785638400", + }, + outputs: { + directory: "dist", + viteManifest: "dist/.vite/manifest.json", + moduleInventory: "artifacts/quality/vite-module-inventory.json", + routeChunks: { home: "assets/home.js" }, + runtimeConfigSchema: "dist/runtime-config.schema.json", + }, + }; + const provenance = { + _type: "https://in-toto.io/Statement/v1", + subject: [{ name: "dist", digest: { sha256: candidateDist } }], + predicateType: "https://slsa.dev/provenance/v1", + predicate: { + buildDefinition: { + buildType: "https://vite.dev/build/v1", + externalParameters: {}, + internalParameters: {}, + resolvedDependencies: [ + { uri: "pnpm-lock.yaml", digest: { sha256: lockfileDigest } }, + ], + }, + runDetails: { + builder: { id: "fixture-builder" }, + metadata: { invocationId: "LOCAL_UNSIGNED" }, + }, + materials: { lockfileSha256: lockfileDigest, sourceSetSha256, sbomSha256 }, + }, + }; + const supplyVerification = { + schemaVersion: 1, + localStatus: "PASS", + promotionStatus: "FAIL_UNVERIFIED", + lockfileSha256: lockfileDigest, + sourceSetSha256, + distSha256: candidateDist, + sbomSha256, + dependencyDiff: { added: [], removed: [], changed: [], upgrades: [] }, + highRiskReview: [], + vulnerabilityStatus: "FAIL_UNVERIFIED", + provenanceAttestationStatus: "FAIL_UNVERIFIED", + failures: [], + }; + const members = new Map([ + ["dist/app.js", Buffer.from("app\n")], + ["dist/release-manifest.json", releaseManifestBytes], + ["pnpm-lock.yaml", lockfileBytes], + ["artifacts/release/build-manifest.json", Buffer.from(`${JSON.stringify(buildManifest)}\n`)], + ["artifacts/release/provenance.json", Buffer.from(`${JSON.stringify(provenance)}\n`)], + [ + "artifacts/security/supply-chain-verification.json", + Buffer.from(`${JSON.stringify(supplyVerification)}\n`), + ], + ["artifacts/release/sbom.cdx.json", sbomBytes], + ]); + const evidenceInputs = [...members.entries()] + .map(([memberPath, bytes]) => ({ + path: memberPath, + bytes: bytes.byteLength, + sha256: digestBytes(bytes), + })) + .sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0)); + const policyPaths = [ + "config/security/dependency-baseline.approval.json", + "config/security/dependency-baseline.json", + "config/security/dependency-change-evidence.json", + "config/security/dependency-policy.json", + "config/security/secret-scan-policy.json", + "config/security/vulnerability-exceptions.json", + "config/security/vulnerability-policy.json", + "schemas/artifacts/build-manifest.schema.json", + "schemas/artifacts/dependency-inventory.schema.json", + "schemas/artifacts/supply-chain-verification.schema.json", + "scripts/contracts/release-artifacts.ts", + "scripts/create-release-candidate.ts", + "scripts/generate-supply-chain.ts", + "scripts/lib/build-manifest-outputs.ts", + "scripts/lib/json-schema.ts", + "scripts/lib/local-policy-evidence.ts", + "scripts/lib/local-release-evidence.ts", + "scripts/lib/release-candidate.ts", + "scripts/lib/release-input-evidence.ts", + "scripts/lib/release-runtime-coherence.ts", + "scripts/lib/repository-file-inventory.ts", + "scripts/lib/secret-scan-evaluator.ts", + "scripts/lib/secret-scan-policy.ts", + "scripts/lib/supply-chain.ts", + "scripts/lib/validated-json-artifact.ts", + "src/contracts/release-artifacts.ts", + ]; + const sbomRow = evidenceInputs.find( + ({ path: memberPath }) => memberPath === "artifacts/release/sbom.cdx.json", + )!; + const policyInputs = policyPaths.map((policyPath) => ({ + path: policyPath, + bytes: 2, + sha256: digest(`policy:${policyPath}`), + })); + const verifierPaths = new Set([ + "scripts/contracts/release-artifacts.ts", + "scripts/create-release-candidate.ts", + "scripts/generate-supply-chain.ts", + "scripts/lib/build-manifest-outputs.ts", + "scripts/lib/json-schema.ts", + "scripts/lib/local-policy-evidence.ts", + "scripts/lib/local-release-evidence.ts", + "scripts/lib/release-candidate.ts", + "scripts/lib/release-input-evidence.ts", + "scripts/lib/release-runtime-coherence.ts", + "scripts/lib/repository-file-inventory.ts", + "scripts/lib/secret-scan-evaluator.ts", + "scripts/lib/secret-scan-policy.ts", + "scripts/lib/supply-chain.ts", + "scripts/lib/validated-json-artifact.ts", + "src/contracts/release-artifacts.ts", + ]); + const assessment = localEvidenceAssessmentArtifactSchema.parse({ + ...passingAssessment(), + verifier: { + id: "clean-architecture-frontend-template/local-evidence-verifier", + version: "1", + sourceSha256: supplyChainDigest( + policyInputs.filter(({ path: policyPath }) => verifierPaths.has(policyPath)), + ), + }, + source: { revision: sourceRevision, sourceSetSha256 }, + candidate: { + distSha256: candidateDist, + lockfileSha256: evidenceInputs.find(({ path: memberPath }) => memberPath === "pnpm-lock.yaml")! + .sha256, + sbomSha256: sbomRow.sha256, + }, + policyInputs, + evidenceInputs, + }); + const assessmentBytes = Buffer.from(`${JSON.stringify(assessment)}\n`); + members.set(LOCAL_EVIDENCE_ASSESSMENT_PATH, assessmentBytes); + for (const [memberPath, bytes] of members) { + await mkdir(path.dirname(path.join(root, memberPath)), { recursive: true }); + await writeFile(path.join(root, memberPath), bytes); + } + const files = [...members.entries()] + .map(([memberPath, bytes]) => ({ + path: memberPath, + bytes: bytes.byteLength, + sha256: digestBytes(bytes), + })) + .sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0)); + const manifest: ReleaseCandidateManifest = { + schemaVersion: 1, + distSha256: assessment.candidate.distSha256, + lockfileSha256: assessment.candidate.lockfileSha256, + bundleSha256: supplyChainDigest(files), + files, + }; + await mkdir(path.join(root, "artifacts/release"), { recursive: true }); + await writeFile( + path.join(root, "artifacts/release/release-candidate.json"), + `${JSON.stringify(manifest)}\n`, + ); + return { root, manifest, assessmentSha256: digestBytes(assessmentBytes) }; +} diff --git a/tests/unit/supply-chain.test.ts b/tests/unit/supply-chain.test.ts index 281bdf3..be6770f 100644 --- a/tests/unit/supply-chain.test.ts +++ b/tests/unit/supply-chain.test.ts @@ -23,6 +23,7 @@ import { checkSecurityFixtures } from "../../scripts/lib/security-fixture-check. import { evaluatePromotionEvidence, providerEvidenceSignaturePayload, + providerPublicKeyFingerprint, providerVerificationArtifactSchema, } from "../../scripts/lib/provider-evidence.ts"; import { @@ -51,17 +52,41 @@ const dependency = { const candidateDistSha256 = "1".repeat(64); const lockfileSha256 = "2".repeat(64); +const NOW = Date.parse("2026-08-02T01:00:00.000Z"); +const sourceIdentity = Object.freeze({ + revision: "a".repeat(40), + sourceSetSha256: "b".repeat(64), +}); +const localIdentity = Object.freeze({ + sourceRevision: sourceIdentity.revision, + sourceSetSha256: sourceIdentity.sourceSetSha256, + assessmentSha256: "c".repeat(64), +}); +const expectedProviderContext = Object.freeze({ + run: Object.freeze({ id: "fixture-run", attempt: 1 }), + source: sourceIdentity, + candidate: Object.freeze({ + archiveSha256: "3".repeat(64), + bundleSha256: "4".repeat(64), + distSha256: candidateDistSha256, + lockfileSha256, + }), + vulnerabilityInvocationNonce: "5".repeat(64), + provenanceInvocationNonce: "6".repeat(64), +}); function signedProviderEvidence( value: Record, keyId: string, privateKey: ReturnType["privateKey"], + publicKeyFingerprint: string, ) { return { ...value, signature: { algorithm: "Ed25519", keyId, + publicKeyFingerprint, value: sign( null, providerEvidenceSignaturePayload(value), @@ -71,6 +96,53 @@ function signedProviderEvidence( }; } +function providerPair(input: Readonly<{ + vulnerabilityKeys: ReturnType; + provenanceKeys: ReturnType; + candidate?: typeof expectedProviderContext.candidate; + vulnerabilityFingerprint?: string; + provenanceFingerprint?: string; +}>) { + const candidate = input.candidate ?? expectedProviderContext.candidate; + const vulnerabilityFingerprint = input.vulnerabilityFingerprint ?? + providerPublicKeyFingerprint(input.vulnerabilityKeys.publicKey); + const provenanceFingerprint = input.provenanceFingerprint ?? + providerPublicKeyFingerprint(input.provenanceKeys.publicKey); + return { + vulnerabilityReport: signedProviderEvidence({ + schemaVersion: 2, + evidenceType: "vulnerability-report", + provider: "fixture-vulnerability-provider", + issuedAt: "2026-08-02T01:00:00.000Z", + expiresAt: "2026-08-02T02:00:00.000Z", + run: { ...expectedProviderContext.run, invocationNonce: expectedProviderContext.vulnerabilityInvocationNonce }, + source: expectedProviderContext.source, + candidate, + findings: [], + }, "fixture-vulnerability-key", input.vulnerabilityKeys.privateKey, vulnerabilityFingerprint), + provenanceAttestation: signedProviderEvidence({ + schemaVersion: 2, + evidenceType: "provenance-attestation", + provider: "fixture-provenance-provider", + issuedAt: "2026-08-02T01:00:00.000Z", + expiresAt: "2026-08-02T02:00:00.000Z", + run: { ...expectedProviderContext.run, invocationNonce: expectedProviderContext.provenanceInvocationNonce }, + source: expectedProviderContext.source, + candidate, + signer: "fixture-workload-identity", + subject: { name: "dist", digest: { sha256: candidate.distSha256 } }, + }, "fixture-provenance-key", input.provenanceKeys.privateKey, provenanceFingerprint), + }; +} + +function providerTrust( + keyId: string, + publicKey: ReturnType["publicKey"], + publicKeyFingerprint = providerPublicKeyFingerprint(publicKey), +) { + return { keyId, publicKey, publicKeyFingerprint }; +} + async function createMinimalCandidateTree(root: string) { const rawLockfile = "lockfileVersion: '9.0'\n"; const rawLockfileSha256 = createHash("sha256") @@ -100,37 +172,54 @@ async function createMinimalCandidateTree(root: string) { async function writeProviderEnvironment( root: string, - distDigest: string, - candidateLockfileSha256: string, + candidate: Awaited>, + overrides: Readonly<{ distSha256?: string }> = {}, ) { const vulnerabilityKeys = generateKeyPairSync("ed25519"); const provenanceKeys = generateKeyPairSync("ed25519"); + const archiveBytes = "fixture archive\n"; + const candidateIdentity = { + archiveSha256: createHash("sha256").update(archiveBytes).digest("hex"), + bundleSha256: candidate.bundleSha256, + distSha256: overrides.distSha256 ?? candidate.distSha256, + lockfileSha256: candidate.lockfileSha256, + }; const vulnerabilityReport = signedProviderEvidence( { - schemaVersion: 1, + schemaVersion: 2, + evidenceType: "vulnerability-report", provider: "fixture-vulnerability-provider", - generatedAt: "2026-08-01T00:00:00.000Z", - scannedLockfileSha256: candidateLockfileSha256, - scannedDistSha256: distDigest, + issuedAt: "2026-08-02T01:00:00.000Z", + expiresAt: "2026-08-02T02:00:00.000Z", + run: { id: "fixture-run", attempt: 1, invocationNonce: "5".repeat(64) }, + source: sourceIdentity, + candidate: candidateIdentity, findings: [], }, "fixture-vulnerability-key", vulnerabilityKeys.privateKey, + providerPublicKeyFingerprint(vulnerabilityKeys.publicKey), ); const provenanceAttestation = signedProviderEvidence( { - schemaVersion: 1, + schemaVersion: 2, + evidenceType: "provenance-attestation", provider: "fixture-provenance-provider", signer: "fixture-workload-identity", - generatedAt: "2026-08-01T00:00:00.000Z", - subject: { name: "dist", digest: { sha256: distDigest } }, + issuedAt: "2026-08-02T01:00:00.000Z", + expiresAt: "2026-08-02T02:00:00.000Z", + run: { id: "fixture-run", attempt: 1, invocationNonce: "6".repeat(64) }, + source: sourceIdentity, + candidate: candidateIdentity, + subject: { name: "dist", digest: { sha256: candidateIdentity.distSha256 } }, }, "fixture-provenance-key", provenanceKeys.privateKey, + providerPublicKeyFingerprint(provenanceKeys.publicKey), ); await mkdir(path.join(root, "provider"), { recursive: true }); await Promise.all([ - writeFile(path.join(root, "provider/candidate.tar.gz"), "fixture archive\n"), + writeFile(path.join(root, "provider/candidate.tar.gz"), archiveBytes), writeFile( path.join(root, "provider/vulnerability.json"), `${JSON.stringify(vulnerabilityReport)}\n`, @@ -155,8 +244,13 @@ async function writeProviderEnvironment( return { CANDIDATE_ARCHIVE_PATH: "provider/candidate.tar.gz", CANDIDATE_ARCHIVE_SHA256: createHash("sha256") - .update("fixture archive\n") + .update(archiveBytes) .digest("hex"), + CI_RUN_ID: "fixture-run", + CI_RUN_ATTEMPT: "1", + EXPECTED_SOURCE_REVISION: sourceIdentity.revision, + VULNERABILITY_INVOCATION_NONCE: "5".repeat(64), + PROVENANCE_INVOCATION_NONCE: "6".repeat(64), VULNERABILITY_REPORT_PATH: "provider/vulnerability.json", PROVENANCE_ATTESTATION_PATH: "provider/provenance.json", VULNERABILITY_PUBLIC_KEY_PATH: "provider/vulnerability.pem", @@ -167,32 +261,37 @@ async function writeProviderEnvironment( } describe("supply-chain policy", () => { - it("emits a strict role-bound v2 verification record from exact input bytes", async () => { - const root = await mkdtemp(path.join(tmpdir(), "promotion-verification-v2-")); + it("emits a strict role-bound v3 verification record from exact input bytes", async () => { + const root = await mkdtemp(path.join(tmpdir(), "promotion-verification-v3-")); try { const manifest = await createMinimalCandidateTree(root); - const environment = await writeProviderEnvironment( - root, - manifest.distSha256, - manifest.lockfileSha256, - ); + const environment = await writeProviderEnvironment(root, manifest); const report = await verifyPromotionInputs({ artifactType: "provider-verification", repositoryRoot: root, environment, - verifyLocalEvidence: async () => ({ status: "PASS" as const, failures: [] }), - } as Parameters[0]); + verifyLocalEvidence: async () => ({ + status: "PASS" as const, + identity: localIdentity, + failures: [] as const, + }), + nowEpochMs: () => NOW, + }); expect(providerVerificationArtifactSchema.parse(report)).toEqual( expect.objectContaining({ - schemaVersion: 2, + schemaVersion: 3, artifactType: "provider-verification", - candidateArchiveSha256: environment.CANDIDATE_ARCHIVE_SHA256, - vulnerabilityReportSha256: createHash("sha256") - .update(await readFile(path.join(root, environment.VULNERABILITY_REPORT_PATH!))) - .digest("hex"), - provenanceAttestationSha256: createHash("sha256") - .update(await readFile(path.join(root, environment.PROVENANCE_ATTESTATION_PATH!))) - .digest("hex"), + candidate: expect.objectContaining({ + archiveSha256: environment.CANDIDATE_ARCHIVE_SHA256, + }), + providerEvidence: expect.objectContaining({ + vulnerabilityReportSha256: createHash("sha256") + .update(await readFile(path.join(root, environment.VULNERABILITY_REPORT_PATH!))) + .digest("hex"), + provenanceAttestationSha256: createHash("sha256") + .update(await readFile(path.join(root, environment.PROVENANCE_ATTESTATION_PATH!))) + .digest("hex"), + }), }), ); } finally { @@ -204,13 +303,10 @@ describe("supply-chain policy", () => { 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 validEnvironment = await writeProviderEnvironment(root, manifest); const acceptLocalEvidence = async () => ({ status: "PASS" as const, + identity: localIdentity, failures: [] as const, }); const valid = await verifyPromotionInputs({ @@ -218,23 +314,34 @@ describe("supply-chain policy", () => { repositoryRoot: root, environment: validEnvironment, verifyLocalEvidence: acceptLocalEvidence, + nowEpochMs: () => NOW, }); const absent = await verifyPromotionInputs({ artifactType: "provider-verification", repositoryRoot: root, environment: {}, verifyLocalEvidence: acceptLocalEvidence, + nowEpochMs: () => NOW, + }); + const replayedNonce = await verifyPromotionInputs({ + artifactType: "provider-verification", + repositoryRoot: root, + environment: { + ...validEnvironment, + VULNERABILITY_INVOCATION_NONCE: "9".repeat(64), + }, + verifyLocalEvidence: acceptLocalEvidence, + nowEpochMs: () => NOW, + }); + const wrongEnvironment = await writeProviderEnvironment(root, manifest, { + distSha256: "3".repeat(64), }); - const wrongEnvironment = await writeProviderEnvironment( - root, - "3".repeat(64), - manifest.lockfileSha256, - ); const wrongDigest = await verifyPromotionInputs({ artifactType: "provider-verification", repositoryRoot: root, environment: wrongEnvironment, verifyLocalEvidence: acceptLocalEvidence, + nowEpochMs: () => NOW, }); await writeFile(path.join(root, "dist/app.js"), "mutated\n"); const postAttestationMutation = await verifyPromotionInputs({ @@ -242,19 +349,23 @@ describe("supply-chain policy", () => { repositoryRoot: root, environment: validEnvironment, verifyLocalEvidence: acceptLocalEvidence, + nowEpochMs: () => NOW, }); expect({ valid: valid.status, absent: absent.status, wrongDigest: wrongDigest.status, + replayedNonce: replayedNonce.status, postAttestationMutation: postAttestationMutation.status, }).toEqual({ valid: "PASS", absent: "FAIL_UNVERIFIED", wrongDigest: "FAIL_UNVERIFIED", + replayedNonce: "FAIL_UNVERIFIED", postAttestationMutation: "FAIL_UNVERIFIED", }); + expect(replayedNonce.failures).toContain("vulnerability report invocation nonce mismatch"); } finally { await rm(root, { recursive: true, force: true }); } @@ -264,11 +375,7 @@ describe("supply-chain policy", () => { 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 environment = await writeProviderEnvironment(root, manifest); const localVerificationPath = path.join( root, "artifacts/security/supply-chain-verification.json", @@ -278,12 +385,13 @@ describe("supply-chain policy", () => { artifactType: "provider-verification", repositoryRoot: root, environment, + nowEpochMs: () => NOW, }); expect(result.status).toBe("FAIL_UNVERIFIED"); expect(result.failures).toEqual( expect.arrayContaining([ - expect.stringMatching(/executable schema mismatch/u), + "local evidence assessment is missing or invalid", "local supply-chain evidence is not PASS", ]), ); @@ -393,16 +501,13 @@ describe("supply-chain policy", () => { it("fails promotion when external provider evidence is absent", () => { const result = evaluatePromotionEvidence({ - candidate: { - distSha256: candidateDistSha256, - lockfileSha256, - }, - currentDistSha256: candidateDistSha256, + expected: expectedProviderContext, localStatus: "PASS", vulnerabilityReport: null, provenanceAttestation: null, vulnerabilityTrust: null, provenanceTrust: null, + nowEpochMs: () => NOW, }); expect(result.status).toBe("FAIL_UNVERIFIED"); @@ -411,50 +516,25 @@ describe("supply-chain policy", () => { 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 { vulnerabilityReport, provenanceAttestation } = providerPair({ + vulnerabilityKeys, + provenanceKeys, + }); const result = evaluatePromotionEvidence({ - candidate: { - distSha256: candidateDistSha256, - lockfileSha256, - }, - currentDistSha256: candidateDistSha256, + expected: expectedProviderContext, localStatus: "PASS", vulnerabilityReport, provenanceAttestation, - vulnerabilityTrust: { - keyId: "fixture-vulnerability-key", - publicKey: vulnerabilityKeys.publicKey, - }, - provenanceTrust: { - keyId: "fixture-provenance-key", - publicKey: provenanceKeys.publicKey, - }, + vulnerabilityTrust: providerTrust( + "fixture-vulnerability-key", + vulnerabilityKeys.publicKey, + ), + provenanceTrust: providerTrust( + "fixture-provenance-key", + provenanceKeys.publicKey, + ), + nowEpochMs: () => NOW, }); expect(result).toMatchObject({ @@ -469,54 +549,27 @@ describe("supply-chain policy", () => { 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 { vulnerabilityReport, provenanceAttestation } = providerPair({ + vulnerabilityKeys, + provenanceKeys, + candidate: { ...expectedProviderContext.candidate, distSha256: wrongDistSha256 }, + }); const result = evaluatePromotionEvidence({ - candidate: { - distSha256: candidateDistSha256, - lockfileSha256, - }, - currentDistSha256: candidateDistSha256, + expected: expectedProviderContext, localStatus: "PASS", vulnerabilityReport, provenanceAttestation, - vulnerabilityTrust: { - keyId: "fixture-vulnerability-key", - publicKey: vulnerabilityKeys.publicKey, - }, - provenanceTrust: { - keyId: "fixture-provenance-key", - publicKey: provenanceKeys.publicKey, - }, + vulnerabilityTrust: providerTrust("fixture-vulnerability-key", vulnerabilityKeys.publicKey), + provenanceTrust: providerTrust("fixture-provenance-key", provenanceKeys.publicKey), + nowEpochMs: () => NOW, }); expect(result.status).toBe("FAIL_UNVERIFIED"); expect(result.failures).toEqual( expect.arrayContaining([ - "vulnerability report dist digest mismatch", - "provenance attestation dist digest mismatch", + "vulnerability report candidate identity mismatch", + "provenance attestation candidate identity mismatch", ]), ); }); @@ -524,103 +577,56 @@ describe("supply-chain policy", () => { 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 { vulnerabilityReport, provenanceAttestation } = providerPair({ + vulnerabilityKeys, + provenanceKeys, + }); const result = evaluatePromotionEvidence({ - candidate: { - distSha256: candidateDistSha256, - lockfileSha256, + expected: { + ...expectedProviderContext, + candidate: { ...expectedProviderContext.candidate, distSha256: "4".repeat(64) }, }, - currentDistSha256: "4".repeat(64), localStatus: "PASS", vulnerabilityReport, provenanceAttestation, - vulnerabilityTrust: { - keyId: "fixture-vulnerability-key", - publicKey: vulnerabilityKeys.publicKey, - }, - provenanceTrust: { - keyId: "fixture-provenance-key", - publicKey: provenanceKeys.publicKey, - }, + vulnerabilityTrust: providerTrust("fixture-vulnerability-key", vulnerabilityKeys.publicKey), + provenanceTrust: providerTrust("fixture-provenance-key", provenanceKeys.publicKey), + nowEpochMs: () => NOW, }); expect(result.status).toBe("FAIL_UNVERIFIED"); - expect(result.failures).toContain( - "candidate dist bytes changed after immutable build", - ); + expect(result.failures).toContain("vulnerability report candidate identity mismatch"); }); 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, - ); + const fakeFingerprint = `sha256:${"7".repeat(64)}`; + const { vulnerabilityReport, provenanceAttestation } = providerPair({ + vulnerabilityKeys, + provenanceKeys, + vulnerabilityFingerprint: fakeFingerprint, + provenanceFingerprint: fakeFingerprint, + }); expect( evaluatePromotionEvidence({ - candidate: { distSha256: candidateDistSha256, lockfileSha256 }, - currentDistSha256: candidateDistSha256, + expected: expectedProviderContext, localStatus: "PASS", vulnerabilityReport, provenanceAttestation, vulnerabilityTrust: { keyId: "fixture-vulnerability-key", publicKey: vulnerabilityKeys.publicKey, + publicKeyFingerprint: fakeFingerprint, }, provenanceTrust: { keyId: "fixture-provenance-key", publicKey: provenanceKeys.publicKey, + publicKeyFingerprint: fakeFingerprint, }, + nowEpochMs: () => NOW, }).status, ).toBe("FAIL_UNVERIFIED"); });