fix: harden provider and promotion evidence

This commit is contained in:
DongHyeonka
2026-08-02 16:28:24 +09:00
parent 42ffb79997
commit 30ceac23c1
29 changed files with 3961 additions and 1076 deletions
+38 -16
View File
@@ -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 }}"
+62 -33
View File
@@ -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"
}
]
},
+22
View File
@@ -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-* / <name>` check required for its
declared tier. This repository cannot configure server-side protection by
+28
View File
@@ -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
@@ -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.
+79 -20
View File
@@ -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<typeof verifyPromotionInputs>
@@ -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<ReturnType<typeof createReleaseCandidateManifest>>,
source: Readonly<{ revision: string; sourceSetSha256: string }>,
overrides: Readonly<{ distSha256?: string }> = {},
): Promise<NodeJS.ProcessEnv> {
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<string, unknown>,
keyId: string,
publicKey: ReturnType<typeof generateKeyPairSync>["publicKey"],
privateKey: ReturnType<typeof generateKeyPairSync>["privateKey"],
) {
return {
@@ -259,6 +317,7 @@ function signedEvidence(
signature: {
algorithm: "Ed25519",
keyId,
publicKeyFingerprint: providerPublicKeyFingerprint(publicKey),
value: sign(
null,
providerEvidenceSignaturePayload(value),
+25
View File
@@ -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");
+51 -36
View File
@@ -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
) {
+12 -6
View File
@@ -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}`),
);
+125
View File
@@ -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),
+10
View File
@@ -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({
+26
View File
@@ -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)}`,
+56
View File
@@ -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<CapturedCandidateArchive> {
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<T>(input: Readonly<{
captured: CapturedCandidateArchive;
verify: (view: Readonly<{
extractionRoot: string;
manifest: ReleaseCandidateManifest;
}>) => Promise<T>;
}>): Promise<T> {
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;
+506 -2
View File
@@ -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<z.infer<typeof localEvidenceAssessmentArtifactSchema>> {
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<Readonly<{
checks: Readonly<Record<LocalCheckName, "PASS" | "FAIL">>;
failures: readonly string[];
}>> {
const checks: Record<LocalCheckName, "PASS" | "FAIL"> = {
release: "PASS",
supplyChain: "PASS",
dependencyPolicy: "PASS",
licensePolicy: "PASS",
vulnerabilityPolicy: "PASS",
secretScan: "PASS",
};
const failures: string[] = [];
const evaluate = async (
check: LocalCheckName,
operation: () => Promise<readonly string[]>,
): Promise<void> => {
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<Readonly<{ path: string; bytes: number; sha256: string }>> {
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<Readonly<{
status: "PASS" | "FAIL";
identity: null | Readonly<{
sourceRevision: string;
sourceSetSha256: string;
assessmentSha256: string;
}>;
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<typeof localEvidenceAssessmentArtifactSchema> | 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<Readonly<{
buildRevision: unknown;
releaseRevision: unknown;
provenanceSourceSetSha256: unknown;
supplySourceSetSha256: unknown;
}>> {
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<Readonly<{
+360 -219
View File
@@ -1,40 +1,54 @@
import { createHash, createPublicKey, randomUUID } from "node:crypto";
import {
createHash,
createPublicKey,
randomBytes as cryptoRandomBytes,
} from "node:crypto";
import { constants } from "node:fs";
import { lstat, mkdtemp, open, rename, rm } from "node:fs/promises";
import {
lstat,
mkdir,
open,
rm,
stat,
} from "node:fs/promises";
import path from "node:path";
import {
evaluatePromotionEvidence,
providerVerificationArtifactSchema,
provenanceProviderAttestationSchema,
vulnerabilityProviderReportSchema,
} from "./provider-evidence.ts";
import { verifyCapturedCiCandidateArchive } from "./ci-candidate-archive.ts";
import { verifyArchivedLocalEvidence } from "./local-release-evidence.ts";
import { verifyReleaseCandidate } from "./release-candidate.ts";
import { readBoundedRegularFile } from "./ci-artifact-validator.ts";
PROMOTED_FILE_NAMES,
type PromotedFileName,
} from "../contracts/promotion-artifacts.ts";
import {
assertSafePublishLeaf,
ensureSafePublishDirectory,
} from "./ci-gate-log.ts";
import { PROMOTED_STAGING_PATHS } from "../contracts/promotion-artifacts.ts";
export { PROMOTED_STAGING_PATHS };
type PromotionSource = Readonly<{
sourcePath: string;
destinationName: string;
maxBytes: number;
validate: (bytes: Buffer) => 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<void>;
beforePublishRename?: () => Promise<void>;
}> = {}): Promise<ReadonlyArray<Readonly<{ path: string; sha256: string }>>> {
beforePublish?: () => Promise<void>;
afterStagingWrite?: () => Promise<void>;
}> = {}): Promise<FinalizedPromotion> {
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<void>;
}> = {}): Promise<void> {
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<void>,
): Promise<FinalizedPromotion> {
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<Buffer> {
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<boolean> {
async function syncDirectory(directory: string): Promise<void> {
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<void> {
const handle = await open(directory, constants.O_RDONLY);
async function syncHandle(handle: Awaited<ReturnType<typeof open>>): Promise<void> {
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;
}
}
+155 -27
View File
@@ -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<ProviderTrust | null> {
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;
+317 -118
View File
@@ -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`);
}
}
+143
View File
@@ -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<Record<string, string>>;
}>;
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<void>;
captureReport: () => Promise<Buffer>;
}>, dependencies: Readonly<{
captureArchive?: typeof captureCiCandidateArchive;
withVerifiedCandidate?: typeof withVerifiedCapturedCandidate;
verifyLocalEvidence?: typeof verifyArchivedLocalEvidence;
validateUpload?: typeof validateProviderUpload;
randomBytes?: (bytes: number) => Buffer;
nowEpochMs?: () => number;
}> = {}): Promise<Readonly<{
evidence: unknown;
invocationNonce: string;
expectedContext: ExpectedPromotionContext;
}>> {
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<Record<string, string>> {
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<CapturedCandidateArchive>;
+40 -55
View File
@@ -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<unknown> {
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;
}
+3
View File
@@ -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",
+56 -30
View File
@@ -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<Record<string, string>>,
): 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<void> {
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,
);
+25 -3
View File
@@ -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`,
);
+4 -1
View File
@@ -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`,
@@ -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,
);
@@ -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 }}"
File diff suppressed because it is too large Load Diff
+25 -7
View File
@@ -151,16 +151,19 @@ describe("CI gate contract", () => {
["candidate output identity drift", (value: Record<string, any>) => { 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<string, any>) => (value.stages[0].needs = ["release"]), /stage dependency cycle/i],
["provider adapter target drift", (value: Record<string, any>) => (value.providerAdapter = "package.json"), /canonical generated workflow/i],
["workflow root extraction", (value: Record<string, any>) => (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<string, any>) => 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<string, any>) => (value.jobs[0].steps.find((step: any) => step.kind === "upload").paths = ["foo/.."]), /unsafe workflow path/i],
["immutable archive field drift", (value: Record<string, any>) => (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<string, any>) => (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<string, any>) => (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<string, any>) => (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<string, any>) => (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<string, any>) => (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<string, any>) => 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<string, any>) => (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<string, any>) => (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<string, any>) => { 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<string, any>) => (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<string, any>) => (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<string, any>) => (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<string, any>) => (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<string, any>) => { 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<string, any>;
@@ -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(
+863
View File
@@ -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<readonly [
string,
(value: Record<string, any>) => Record<string, any>,
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<string, any>) => ({
...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<Record<string, string>> | 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<string, unknown>,
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<typeof providerExpectedContext>,
): Record<string, any> {
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<string, Buffer>([
["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) };
}
+201 -195
View File
@@ -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<string, unknown>,
keyId: string,
privateKey: ReturnType<typeof generateKeyPairSync>["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<typeof generateKeyPairSync>;
provenanceKeys: ReturnType<typeof generateKeyPairSync>;
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<typeof generateKeyPairSync>["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<ReturnType<typeof createReleaseCandidateManifest>>,
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<typeof verifyPromotionInputs>[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");
});