fix: harden provider and promotion evidence
This commit is contained in:
@@ -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
@@ -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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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),
|
||||
|
||||
@@ -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");
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
@@ -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}`),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)}`,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user