fix: promote immutable verified release bundles
This commit is contained in:
@@ -88,7 +88,6 @@ jobs:
|
|||||||
include:
|
include:
|
||||||
- { gate: FE-GATE-012, name: bundle, browser: false }
|
- { gate: FE-GATE-012, name: bundle, browser: false }
|
||||||
- { gate: FE-GATE-014, name: config-compatibility, browser: false }
|
- { gate: FE-GATE-014, name: config-compatibility, browser: false }
|
||||||
- { gate: FE-GATE-015, name: release-coherence, browser: false }
|
|
||||||
- { gate: FE-GATE-019, name: hosting-header, browser: false }
|
- { gate: FE-GATE-019, name: hosting-header, browser: false }
|
||||||
- { gate: FE-GATE-026, name: lab-performance, browser: true }
|
- { gate: FE-GATE-026, name: lab-performance, browser: true }
|
||||||
steps:
|
steps:
|
||||||
@@ -113,9 +112,171 @@ jobs:
|
|||||||
path: artifacts/
|
path: artifacts/
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
|
immutable_build:
|
||||||
|
name: FE-GATE-015 / immutable-release-candidate
|
||||||
|
needs: release_gate
|
||||||
|
if: ${{ startsWith(gitea.ref, 'refs/tags/v') || (gitea.event_name == 'workflow_dispatch' && (inputs.stage == 'release' || inputs.stage == 'production' || inputs.stage == 'field')) }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
|
outputs:
|
||||||
|
dist_sha256: ${{ steps.candidate.outputs.dist_sha256 }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version-file: .nvmrc
|
||||||
|
- name: Frozen install
|
||||||
|
run: |
|
||||||
|
corepack enable
|
||||||
|
corepack pnpm install --frozen-lockfile
|
||||||
|
- name: Build candidate once and verify local evidence
|
||||||
|
run: corepack pnpm ci:gate -- FE-GATE-015
|
||||||
|
- name: Archive the exact candidate file set
|
||||||
|
id: candidate
|
||||||
|
run: |
|
||||||
|
mkdir -p .release
|
||||||
|
tar --sort=name --mtime="@0" --owner=0 --group=0 --numeric-owner -czf ".release/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz" \
|
||||||
|
dist \
|
||||||
|
artifacts/performance/bundle.json \
|
||||||
|
artifacts/quality/vite-module-inventory.json \
|
||||||
|
artifacts/release/build-manifest.json \
|
||||||
|
artifacts/release/checksums.txt \
|
||||||
|
artifacts/release/dependency-inventory.json \
|
||||||
|
artifacts/release/provenance.json \
|
||||||
|
artifacts/release/release-candidate.json \
|
||||||
|
artifacts/release/verification.json \
|
||||||
|
artifacts/release/sbom.cdx.json \
|
||||||
|
artifacts/security/dependency-diff.json \
|
||||||
|
artifacts/security/license-report.json \
|
||||||
|
artifacts/security/scan.sarif \
|
||||||
|
artifacts/security/supply-chain-coherence.json \
|
||||||
|
artifacts/security/supply-chain-verification.json \
|
||||||
|
artifacts/security/vulnerability-report.json
|
||||||
|
node -e "const m=require('./artifacts/release/release-candidate.json'); process.stdout.write('dist_sha256='+m.distSha256+'\\n')" >> "$GITHUB_OUTPUT"
|
||||||
|
- name: Upload immutable candidate archive
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}
|
||||||
|
path: .release/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
vulnerability_provider:
|
||||||
|
name: external-vulnerability-provider
|
||||||
|
needs: immutable_build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
|
env:
|
||||||
|
CANDIDATE_DIST_SHA256: ${{ needs.immutable_build.outputs.dist_sha256 }}
|
||||||
|
VULNERABILITY_PROVIDER_COMMAND: ${{ vars.VULNERABILITY_PROVIDER_COMMAND }}
|
||||||
|
VULNERABILITY_REPORT_PATH: provider-evidence/vulnerability-report.json
|
||||||
|
steps:
|
||||||
|
- name: Download immutable candidate archive
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}
|
||||||
|
path: .release/vulnerability-candidate
|
||||||
|
- name: Extract the provider input candidate
|
||||||
|
run: tar -xzf ".release/vulnerability-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz"
|
||||||
|
- name: Run configured external vulnerability provider
|
||||||
|
run: |
|
||||||
|
test -n "$VULNERABILITY_PROVIDER_COMMAND"
|
||||||
|
mkdir -p provider-evidence
|
||||||
|
sh -eu -c "$VULNERABILITY_PROVIDER_COMMAND"
|
||||||
|
test -s "$VULNERABILITY_REPORT_PATH"
|
||||||
|
- name: Upload external vulnerability report
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: vulnerability-provider-${{ gitea.run_id }}-${{ gitea.run_attempt }}
|
||||||
|
path: provider-evidence/vulnerability-report.json
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
provenance_provider:
|
||||||
|
name: external-provenance-provider
|
||||||
|
needs: immutable_build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
|
env:
|
||||||
|
CANDIDATE_DIST_SHA256: ${{ needs.immutable_build.outputs.dist_sha256 }}
|
||||||
|
PROVENANCE_PROVIDER_COMMAND: ${{ vars.PROVENANCE_PROVIDER_COMMAND }}
|
||||||
|
PROVENANCE_ATTESTATION_PATH: provider-evidence/provenance-attestation.json
|
||||||
|
steps:
|
||||||
|
- name: Download immutable candidate archive
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}
|
||||||
|
path: .release/provenance-candidate
|
||||||
|
- name: Extract the provider input candidate
|
||||||
|
run: tar -xzf ".release/provenance-candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz"
|
||||||
|
- name: Run configured external provenance provider
|
||||||
|
run: |
|
||||||
|
test -n "$PROVENANCE_PROVIDER_COMMAND"
|
||||||
|
mkdir -p provider-evidence
|
||||||
|
sh -eu -c "$PROVENANCE_PROVIDER_COMMAND"
|
||||||
|
test -s "$PROVENANCE_ATTESTATION_PATH"
|
||||||
|
- name: Upload external provenance attestation
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: provenance-provider-${{ gitea.run_id }}-${{ gitea.run_attempt }}
|
||||||
|
path: provider-evidence/provenance-attestation.json
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
promotion:
|
||||||
|
name: promote-verified-immutable-candidate
|
||||||
|
needs: [immutable_build, vulnerability_provider, provenance_provider]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
|
env:
|
||||||
|
VULNERABILITY_REPORT_PATH: .release/vulnerability/vulnerability-report.json
|
||||||
|
PROVENANCE_ATTESTATION_PATH: .release/provenance/provenance-attestation.json
|
||||||
|
VULNERABILITY_PUBLIC_KEY_PATH: ${{ vars.VULNERABILITY_PUBLIC_KEY_PATH }}
|
||||||
|
VULNERABILITY_KEY_ID: ${{ vars.VULNERABILITY_KEY_ID }}
|
||||||
|
PROVENANCE_PUBLIC_KEY_PATH: ${{ vars.PROVENANCE_PUBLIC_KEY_PATH }}
|
||||||
|
PROVENANCE_KEY_ID: ${{ vars.PROVENANCE_KEY_ID }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version-file: .nvmrc
|
||||||
|
- name: Frozen install
|
||||||
|
run: |
|
||||||
|
corepack enable
|
||||||
|
corepack pnpm install --frozen-lockfile
|
||||||
|
- name: Download immutable candidate archive
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}
|
||||||
|
path: .release/candidate
|
||||||
|
- name: Download vulnerability provider evidence
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: vulnerability-provider-${{ gitea.run_id }}-${{ gitea.run_attempt }}
|
||||||
|
path: .release/vulnerability
|
||||||
|
- name: Download provenance provider evidence
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: provenance-provider-${{ gitea.run_id }}-${{ gitea.run_attempt }}
|
||||||
|
path: .release/provenance
|
||||||
|
- name: Extract unchanged candidate
|
||||||
|
run: tar -xzf ".release/candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz"
|
||||||
|
- name: Verify provider signatures and candidate digests
|
||||||
|
run: |
|
||||||
|
corepack pnpm verify:provider-evidence
|
||||||
|
corepack pnpm verify:promotion
|
||||||
|
- name: Upload promoted unchanged candidate
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: promoted-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}
|
||||||
|
path: |
|
||||||
|
.release/candidate/release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tar.gz
|
||||||
|
.release/vulnerability/vulnerability-report.json
|
||||||
|
.release/provenance/provenance-attestation.json
|
||||||
|
artifacts/security/provider-verification.json
|
||||||
|
artifacts/security/promotion-verification.json
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
production_gate:
|
production_gate:
|
||||||
name: ${{ matrix.gate }} / ${{ matrix.name }}
|
name: ${{ matrix.gate }} / ${{ matrix.name }}
|
||||||
needs: release_gate
|
needs: promotion
|
||||||
if: ${{ gitea.event_name == 'workflow_dispatch' && (inputs.stage == 'production' || inputs.stage == 'field') }}
|
if: ${{ gitea.event_name == 'workflow_dispatch' && (inputs.stage == 'production' || inputs.stage == 'field') }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 45
|
timeout-minutes: 45
|
||||||
|
|||||||
@@ -268,8 +268,8 @@
|
|||||||
"name": "security",
|
"name": "security",
|
||||||
"steps": [
|
"steps": [
|
||||||
{ "script": "verify:reproducible-build", "expect": "pass" },
|
{ "script": "verify:reproducible-build", "expect": "pass" },
|
||||||
{ "script": "build:release", "expect": "pass" },
|
{ "script": "build:release-candidate", "expect": "pass" },
|
||||||
{ "script": "verify:supply-chain", "expect": "pass" },
|
{ "script": "verify:local-evidence", "expect": "pass" },
|
||||||
{ "script": "check:supply-chain:fixtures", "expect": "pass" },
|
{ "script": "check:supply-chain:fixtures", "expect": "pass" },
|
||||||
{
|
{
|
||||||
"script": "check:supply-chain:provider-fixtures",
|
"script": "check:supply-chain:provider-fixtures",
|
||||||
@@ -305,14 +305,14 @@
|
|||||||
"FE-GATE-015": {
|
"FE-GATE-015": {
|
||||||
"name": "release-coherence",
|
"name": "release-coherence",
|
||||||
"steps": [
|
"steps": [
|
||||||
{ "script": "build:release", "expect": "pass" },
|
{ "script": "build:release-candidate", "expect": "pass" },
|
||||||
{ "script": "verify:release", "expect": "pass" },
|
{ "script": "verify:local-evidence", "expect": "pass" }
|
||||||
{ "script": "verify:supply-chain:promotion", "expect": "pass" }
|
|
||||||
],
|
],
|
||||||
"logPath": "artifacts/quality/gates/FE-GATE-015.txt",
|
"logPath": "artifacts/quality/gates/FE-GATE-015.txt",
|
||||||
"evidence": [
|
"evidence": [
|
||||||
|
"artifacts/release/release-candidate.json",
|
||||||
"artifacts/release/verification.json",
|
"artifacts/release/verification.json",
|
||||||
"artifacts/security/promotion-verification.json"
|
"artifacts/security/supply-chain-coherence.json"
|
||||||
],
|
],
|
||||||
"retentionClass": "release-coherence"
|
"retentionClass": "release-coherence"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ The canonical commands are:
|
|||||||
```bash
|
```bash
|
||||||
corepack pnpm verify:lockfile
|
corepack pnpm verify:lockfile
|
||||||
corepack pnpm verify:reproducible-build
|
corepack pnpm verify:reproducible-build
|
||||||
corepack pnpm build:release
|
corepack pnpm build:release-candidate
|
||||||
corepack pnpm verify:supply-chain
|
corepack pnpm verify:local-evidence
|
||||||
corepack pnpm check:supply-chain:fixtures
|
corepack pnpm check:supply-chain:fixtures
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -31,16 +31,19 @@ rejected.
|
|||||||
|
|
||||||
## External promotion controls
|
## External promotion controls
|
||||||
|
|
||||||
The vulnerability adapter reads the file named by
|
Promotion reads the provider files named by `VULNERABILITY_REPORT_PATH` and
|
||||||
`VULNERABILITY_REPORT_PATH`. It requires a provider, the exact lockfile digest,
|
`PROVENANCE_ATTESTATION_PATH`. The vulnerability report must bind both the
|
||||||
severity findings and valid independent, unexpired exception evidence.
|
exact lockfile digest and candidate `distSha256`; the provenance attestation
|
||||||
`PROVENANCE_ATTESTATION_PATH` must name a provider, signer and the exact built
|
must name `dist` with that same digest. Both documents use strict schemas and
|
||||||
dist subject digest.
|
Ed25519 signatures verified with separately configured trusted public keys and
|
||||||
|
key IDs.
|
||||||
|
|
||||||
If either provider input is absent, local verification remains meaningful but
|
If either provider input is absent, local verification remains meaningful but
|
||||||
`artifacts/security/supply-chain-verification.json` records
|
`artifacts/security/supply-chain-verification.json` records
|
||||||
`promotionStatus: FAIL_UNVERIFIED`. `verify:supply-chain:promotion` then exits
|
`promotionStatus: FAIL_UNVERIFIED`. `verify:provider-evidence` and
|
||||||
non-zero. Scanner or signing outages are not converted to an empty PASS.
|
`verify:promotion` then exit non-zero. Promotion recomputes the candidate file
|
||||||
|
set and digests and never rebuilds it. Scanner or signing outages are not
|
||||||
|
converted to an empty PASS.
|
||||||
|
|
||||||
Approved vulnerability exceptions require vulnerability/package identity,
|
Approved vulnerability exceptions require vulnerability/package identity,
|
||||||
owner, a different reviewer, reason and expiry. Expired or self-approved
|
owner, a different reviewer, reason and expiry. Expired or self-approved
|
||||||
|
|||||||
+4
-3
@@ -11,7 +11,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "node scripts/build-frontend.ts",
|
"build": "node scripts/build-frontend.ts",
|
||||||
"build:release": "corepack pnpm build && corepack pnpm generate:supply-chain && corepack pnpm scan:security",
|
"build:release-candidate": "corepack pnpm build && corepack pnpm generate:supply-chain && corepack pnpm scan:security && corepack pnpm verify:release && node scripts/verify-supply-chain-artifacts.ts && node scripts/create-release-candidate.ts",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint": "eslint src scripts tests recipes .storybook vite.config.ts vitest.config.ts playwright*.config.ts --max-warnings=0",
|
"lint": "eslint src scripts tests recipes .storybook vite.config.ts vitest.config.ts playwright*.config.ts --max-warnings=0",
|
||||||
"check:architecture": "node scripts/check-architecture.ts",
|
"check:architecture": "node scripts/check-architecture.ts",
|
||||||
@@ -73,11 +73,12 @@
|
|||||||
"generate:artifact-schemas": "node scripts/generate-artifact-schemas.ts",
|
"generate:artifact-schemas": "node scripts/generate-artifact-schemas.ts",
|
||||||
"check:artifact-schemas": "node scripts/generate-artifact-schemas.ts --check",
|
"check:artifact-schemas": "node scripts/generate-artifact-schemas.ts --check",
|
||||||
"generate:supply-chain": "node scripts/generate-supply-chain.ts",
|
"generate:supply-chain": "node scripts/generate-supply-chain.ts",
|
||||||
"verify:supply-chain": "node scripts/verify-supply-chain-artifacts.ts",
|
"verify:local-evidence": "node scripts/verify-release-candidate.ts && node scripts/verify-release.ts && node scripts/verify-supply-chain-artifacts.ts && node scripts/verify-release-candidate.ts",
|
||||||
|
"verify:provider-evidence": "node scripts/verify-provider-evidence.ts",
|
||||||
|
"verify:promotion": "node scripts/verify-supply-chain-promotion.ts",
|
||||||
"update:dependency-baseline": "node scripts/update-dependency-baseline.ts",
|
"update:dependency-baseline": "node scripts/update-dependency-baseline.ts",
|
||||||
"check:supply-chain:fixtures": "node scripts/check-supply-chain-fixtures.ts",
|
"check:supply-chain:fixtures": "node scripts/check-supply-chain-fixtures.ts",
|
||||||
"check:supply-chain:provider-fixtures": "node scripts/check-supply-chain-provider-fixtures.ts",
|
"check:supply-chain:provider-fixtures": "node scripts/check-supply-chain-provider-fixtures.ts",
|
||||||
"verify:supply-chain:promotion": "node scripts/verify-supply-chain-promotion.ts",
|
|
||||||
"verify:reproducible-build": "node scripts/verify-reproducible-build.ts",
|
"verify:reproducible-build": "node scripts/verify-reproducible-build.ts",
|
||||||
"scan:security": "node scripts/security-scan.ts",
|
"scan:security": "node scripts/security-scan.ts",
|
||||||
"scan:security:fixture": "node scripts/security-scan.ts --policy tests/fixtures/security/secret-detection/forbidden-policy.json --artifact artifacts/security/scan-fixture.sarif",
|
"scan:security:fixture": "node scripts/security-scan.ts --policy tests/fixtures/security/secret-detection/forbidden-policy.json --artifact artifacts/security/scan-fixture.sarif",
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ const document = parseCiContractDocument(
|
|||||||
JSON.parse(await readFile("config/ci/gates.json", "utf8")),
|
JSON.parse(await readFile("config/ci/gates.json", "utf8")),
|
||||||
);
|
);
|
||||||
const workflow = await readFile(document.providerAdapter, "utf8");
|
const workflow = await readFile(document.providerAdapter, "utf8");
|
||||||
|
const packageDocument = JSON.parse(await readFile("package.json", "utf8")) as {
|
||||||
|
scripts?: Record<string, string>;
|
||||||
|
};
|
||||||
|
const packageScripts = packageDocument.scripts ?? {};
|
||||||
const nodeVersion = (await readFile(".nvmrc", "utf8")).trim();
|
const nodeVersion = (await readFile(".nvmrc", "utf8")).trim();
|
||||||
const gateRunner = await readFile("scripts/run-ci-gate.ts", "utf8");
|
const gateRunner = await readFile("scripts/run-ci-gate.ts", "utf8");
|
||||||
const drillRunner = await readFile("scripts/drill-runbook.ts", "utf8");
|
const drillRunner = await readFile("scripts/drill-runbook.ts", "utf8");
|
||||||
@@ -154,7 +158,7 @@ for (const pattern of forbiddenWorkflowPatterns) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const jobTimeoutCount = workflow.match(/timeout-minutes:\s*45/g)?.length ?? 0;
|
const jobTimeoutCount = workflow.match(/timeout-minutes:\s*45/g)?.length ?? 0;
|
||||||
if (jobTimeoutCount !== 5) {
|
if (jobTimeoutCount !== 9) {
|
||||||
failures.push("every CI gate job must declare timeout-minutes: 45");
|
failures.push("every CI gate job must declare timeout-minutes: 45");
|
||||||
}
|
}
|
||||||
if (/if-no-files-found:\s*warn/.test(workflow)) {
|
if (/if-no-files-found:\s*warn/.test(workflow)) {
|
||||||
@@ -166,9 +170,16 @@ for (const requiredToken of [
|
|||||||
"production_gate:",
|
"production_gate:",
|
||||||
"field_gate:",
|
"field_gate:",
|
||||||
"documentation_gate:",
|
"documentation_gate:",
|
||||||
|
"immutable_build:",
|
||||||
|
"vulnerability_provider:",
|
||||||
|
"provenance_provider:",
|
||||||
|
"promotion:",
|
||||||
"needs: merge_gate",
|
"needs: merge_gate",
|
||||||
"needs: release_gate",
|
"needs: release_gate",
|
||||||
"needs: production_gate",
|
"needs: production_gate",
|
||||||
|
"needs: immutable_build",
|
||||||
|
"needs: [immutable_build, vulnerability_provider, provenance_provider]",
|
||||||
|
"actions/download-artifact@v4",
|
||||||
"actions/upload-artifact@v4",
|
"actions/upload-artifact@v4",
|
||||||
"if: always()",
|
"if: always()",
|
||||||
"permissions:",
|
"permissions:",
|
||||||
@@ -178,11 +189,60 @@ for (const requiredToken of [
|
|||||||
'VITE_COMMIT_SHA: "${{ gitea.sha }}"',
|
'VITE_COMMIT_SHA: "${{ gitea.sha }}"',
|
||||||
'RELEASE_ID: "${{ gitea.ref }}-${{ gitea.run_id }}-${{ gitea.run_attempt }}"',
|
'RELEASE_ID: "${{ gitea.ref }}-${{ gitea.run_id }}-${{ gitea.run_attempt }}"',
|
||||||
'CI_RUNNER_IMAGE: "${{ vars.RUNNER_IMAGE_DIGEST }}"',
|
'CI_RUNNER_IMAGE: "${{ vars.RUNNER_IMAGE_DIGEST }}"',
|
||||||
|
"VULNERABILITY_REPORT_PATH:",
|
||||||
|
"PROVENANCE_ATTESTATION_PATH:",
|
||||||
|
"VULNERABILITY_PUBLIC_KEY_PATH:",
|
||||||
|
"PROVENANCE_PUBLIC_KEY_PATH:",
|
||||||
|
"release-candidate-${{ gitea.run_id }}-${{ gitea.run_attempt }}",
|
||||||
|
"corepack pnpm verify:provider-evidence",
|
||||||
|
"corepack pnpm verify:promotion",
|
||||||
]) {
|
]) {
|
||||||
if (!workflow.includes(requiredToken)) {
|
if (!workflow.includes(requiredToken)) {
|
||||||
failures.push(`workflow missing ${requiredToken}`);
|
failures.push(`workflow missing ${requiredToken}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (const script of [
|
||||||
|
"build:release-candidate",
|
||||||
|
"verify:local-evidence",
|
||||||
|
"verify:provider-evidence",
|
||||||
|
"verify:promotion",
|
||||||
|
]) {
|
||||||
|
if (!packageScripts[script]) failures.push(`package script missing ${script}`);
|
||||||
|
}
|
||||||
|
for (const removedScript of [
|
||||||
|
"build:release",
|
||||||
|
"verify:supply-chain",
|
||||||
|
"verify:supply-chain:promotion",
|
||||||
|
]) {
|
||||||
|
if (packageScripts[removedScript]) {
|
||||||
|
failures.push(`legacy package script remains ${removedScript}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (/\b(?:build|rebuild)(?::[\w-]+)?\b/u.test(packageScripts["verify:promotion"] ?? "")) {
|
||||||
|
failures.push("verify:promotion must not build or rebuild candidate bytes");
|
||||||
|
}
|
||||||
|
const promotionWorkflow = workflow.match(
|
||||||
|
/\n {2}promotion:\n(?<body>[\s\S]*?)\n {2}production_gate:/u,
|
||||||
|
)?.groups?.body;
|
||||||
|
if (!promotionWorkflow) {
|
||||||
|
failures.push("workflow promotion job is missing or misplaced");
|
||||||
|
} else if (
|
||||||
|
/\b(?:build|build:[\w-]+|rebuild)\b/u.test(
|
||||||
|
promotionWorkflow.replaceAll("immutable_build", ""),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
failures.push("workflow promotion job must not build or rebuild candidate bytes");
|
||||||
|
}
|
||||||
|
const immutableGateSteps = document.gates["FE-GATE-015"]?.steps;
|
||||||
|
if (
|
||||||
|
JSON.stringify(immutableGateSteps) !==
|
||||||
|
JSON.stringify([
|
||||||
|
{ script: "build:release-candidate", expect: "pass" },
|
||||||
|
{ script: "verify:local-evidence", expect: "pass" },
|
||||||
|
])
|
||||||
|
) {
|
||||||
|
failures.push("FE-GATE-015 must build the candidate once and verify local evidence only");
|
||||||
|
}
|
||||||
for (const requiredToken of [
|
for (const requiredToken of [
|
||||||
"ciCheckoutIdentityFailures",
|
"ciCheckoutIdentityFailures",
|
||||||
"ciBuildEnvironmentFailures",
|
"ciBuildEnvironmentFailures",
|
||||||
|
|||||||
@@ -1,103 +1,118 @@
|
|||||||
import { spawnSync } from "node:child_process";
|
import { generateKeyPairSync, sign } from "node:crypto";
|
||||||
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
type Document = Record<string, unknown>;
|
import {
|
||||||
|
evaluatePromotionEvidence,
|
||||||
|
providerEvidenceSignaturePayload,
|
||||||
|
} from "./lib/provider-evidence.ts";
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Document {
|
const candidateDistSha256 = "1".repeat(64);
|
||||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
const lockfileSha256 = "2".repeat(64);
|
||||||
}
|
const vulnerabilityKeys = generateKeyPairSync("ed25519");
|
||||||
|
const provenanceKeys = generateKeyPairSync("ed25519");
|
||||||
async function readDocument(file: string): Promise<Document> {
|
const trust = {
|
||||||
const parsed: unknown = JSON.parse(await readFile(file, "utf8"));
|
vulnerabilityTrust: {
|
||||||
if (!isRecord(parsed)) throw new Error(`${file} must be a JSON object`);
|
keyId: "fixture-vulnerability-key",
|
||||||
return parsed;
|
publicKey: vulnerabilityKeys.publicKey,
|
||||||
}
|
|
||||||
|
|
||||||
const fixtureDirectory = path.resolve(".tmp/supply-chain-provider-fixture");
|
|
||||||
await rm(fixtureDirectory, { recursive: true, force: true });
|
|
||||||
await mkdir(fixtureDirectory, { recursive: true });
|
|
||||||
const inventory = await readDocument(
|
|
||||||
"artifacts/release/dependency-inventory.json",
|
|
||||||
);
|
|
||||||
const verification = await readDocument(
|
|
||||||
"artifacts/security/supply-chain-verification.json",
|
|
||||||
);
|
|
||||||
const vulnerabilityPath = path.join(
|
|
||||||
fixtureDirectory,
|
|
||||||
"vulnerability-report.json",
|
|
||||||
);
|
|
||||||
const attestationPath = path.join(fixtureDirectory, "attestation.json");
|
|
||||||
await writeFile(
|
|
||||||
vulnerabilityPath,
|
|
||||||
`${JSON.stringify(
|
|
||||||
{
|
|
||||||
schemaVersion: 1,
|
|
||||||
provider: "fixture-scanner",
|
|
||||||
scannedLockfileSha256: inventory.lockfileSha256,
|
|
||||||
generatedAt: "2026-07-26T00:00:00.000Z",
|
|
||||||
findings: [],
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
)}\n`,
|
|
||||||
);
|
|
||||||
await writeFile(
|
|
||||||
attestationPath,
|
|
||||||
`${JSON.stringify(
|
|
||||||
{
|
|
||||||
schemaVersion: 1,
|
|
||||||
provider: "fixture-attestor",
|
|
||||||
signer: "fixture-workload-identity",
|
|
||||||
subject: {
|
|
||||||
name: "dist",
|
|
||||||
digest: { sha256: verification.distSha256 },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
)}\n`,
|
|
||||||
);
|
|
||||||
const providerRun = spawnSync(
|
|
||||||
"node",
|
|
||||||
["scripts/generate-supply-chain.ts"],
|
|
||||||
{
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
VULNERABILITY_REPORT_PATH: vulnerabilityPath,
|
|
||||||
PROVENANCE_ATTESTATION_PATH: attestationPath,
|
|
||||||
},
|
|
||||||
encoding: "utf8",
|
|
||||||
},
|
},
|
||||||
);
|
provenanceTrust: {
|
||||||
let promotionStatus = "MISSING";
|
keyId: "fixture-provenance-key",
|
||||||
if (providerRun.status === 0) {
|
publicKey: provenanceKeys.publicKey,
|
||||||
const providerVerification = await readDocument(
|
},
|
||||||
"artifacts/security/supply-chain-verification.json",
|
};
|
||||||
);
|
|
||||||
promotionStatus =
|
function signedEvidence(
|
||||||
typeof providerVerification.promotionStatus === "string"
|
value: Record<string, unknown>,
|
||||||
? providerVerification.promotionStatus
|
keyId: string,
|
||||||
: "MISSING";
|
privateKey: typeof vulnerabilityKeys.privateKey,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
...value,
|
||||||
|
signature: {
|
||||||
|
algorithm: "Ed25519",
|
||||||
|
keyId,
|
||||||
|
value: sign(
|
||||||
|
null,
|
||||||
|
providerEvidenceSignaturePayload(value),
|
||||||
|
privateKey,
|
||||||
|
).toString("base64"),
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const restore = spawnSync(
|
|
||||||
"node",
|
function evidenceFor(distDigest: string) {
|
||||||
["scripts/generate-supply-chain.ts"],
|
return {
|
||||||
{ encoding: "utf8" },
|
vulnerabilityReport: signedEvidence(
|
||||||
);
|
{
|
||||||
await rm(fixtureDirectory, { recursive: true, force: true });
|
schemaVersion: 1,
|
||||||
|
provider: "fixture-vulnerability-provider",
|
||||||
|
generatedAt: "2026-08-01T00:00:00.000Z",
|
||||||
|
scannedLockfileSha256: lockfileSha256,
|
||||||
|
scannedDistSha256: distDigest,
|
||||||
|
findings: [],
|
||||||
|
},
|
||||||
|
"fixture-vulnerability-key",
|
||||||
|
vulnerabilityKeys.privateKey,
|
||||||
|
),
|
||||||
|
provenanceAttestation: signedEvidence(
|
||||||
|
{
|
||||||
|
schemaVersion: 1,
|
||||||
|
provider: "fixture-provenance-provider",
|
||||||
|
signer: "fixture-workload-identity",
|
||||||
|
generatedAt: "2026-08-01T00:00:00.000Z",
|
||||||
|
subject: { name: "dist", digest: { sha256: distDigest } },
|
||||||
|
},
|
||||||
|
"fixture-provenance-key",
|
||||||
|
provenanceKeys.privateKey,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
candidate: { distSha256: candidateDistSha256, lockfileSha256 },
|
||||||
|
currentDistSha256: candidateDistSha256,
|
||||||
|
localStatus: "PASS",
|
||||||
|
...trust,
|
||||||
|
};
|
||||||
|
const validEvidence = evidenceFor(candidateDistSha256);
|
||||||
|
const fixtures = {
|
||||||
|
absent: evaluatePromotionEvidence({
|
||||||
|
...base,
|
||||||
|
vulnerabilityReport: null,
|
||||||
|
provenanceAttestation: null,
|
||||||
|
}),
|
||||||
|
validImmutable: evaluatePromotionEvidence({ ...base, ...validEvidence }),
|
||||||
|
wrongDigest: evaluatePromotionEvidence({
|
||||||
|
...base,
|
||||||
|
...evidenceFor("3".repeat(64)),
|
||||||
|
}),
|
||||||
|
postAttestationMutation: evaluatePromotionEvidence({
|
||||||
|
...base,
|
||||||
|
...validEvidence,
|
||||||
|
currentDistSha256: "4".repeat(64),
|
||||||
|
}),
|
||||||
|
};
|
||||||
const passed =
|
const passed =
|
||||||
providerRun.status === 0 &&
|
fixtures.validImmutable.status === "PASS" &&
|
||||||
promotionStatus === "PASS" &&
|
fixtures.absent.status === "FAIL_UNVERIFIED" &&
|
||||||
restore.status === 0;
|
fixtures.wrongDigest.status === "FAIL_UNVERIFIED" &&
|
||||||
|
fixtures.postAttestationMutation.status === "FAIL_UNVERIFIED";
|
||||||
|
|
||||||
|
await mkdir("artifacts/security", { recursive: true });
|
||||||
await writeFile(
|
await writeFile(
|
||||||
"artifacts/security/supply-chain-provider-fixtures.json",
|
"artifacts/security/supply-chain-provider-fixtures.json",
|
||||||
`${JSON.stringify(
|
`${JSON.stringify(
|
||||||
{
|
{
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
providerAccepted: providerRun.status === 0,
|
fixtures: Object.fromEntries(
|
||||||
promotionStatus,
|
Object.entries(fixtures).map(([name, result]) => [
|
||||||
unverifiedDefaultRestored: restore.status === 0,
|
name,
|
||||||
|
{ status: result.status, failures: result.failures },
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
passingFixtureCount: Object.values(fixtures).filter(
|
||||||
|
(result) => result.status === "PASS",
|
||||||
|
).length,
|
||||||
status: passed ? "PASS" : "FAIL",
|
status: passed ? "PASS" : "FAIL",
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
@@ -105,17 +120,9 @@ await writeFile(
|
|||||||
)}\n`,
|
)}\n`,
|
||||||
);
|
);
|
||||||
if (!passed) {
|
if (!passed) {
|
||||||
const detail =
|
process.stderr.write("Supply-chain provider fixtures failed closed incorrectly\n");
|
||||||
providerRun.stderr ||
|
|
||||||
restore.stderr ||
|
|
||||||
providerRun.stdout ||
|
|
||||||
restore.stdout ||
|
|
||||||
`providerStatus=${String(providerRun.status)}, promotionStatus=${promotionStatus}, restoreStatus=${String(restore.status)}`;
|
|
||||||
process.stderr.write(
|
|
||||||
`Supply-chain provider fixture failed: ${detail}\n`,
|
|
||||||
);
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
process.stdout.write(
|
process.stdout.write(
|
||||||
"Supply-chain provider fixture: verified PASS and unconfigured default restored\n",
|
"Supply-chain provider fixtures: only the valid immutable fixture PASS\n",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { mkdir } from "node:fs/promises";
|
||||||
|
|
||||||
|
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
||||||
|
import {
|
||||||
|
createReleaseCandidateManifest,
|
||||||
|
RELEASE_CANDIDATE_MANIFEST_PATH,
|
||||||
|
releaseCandidateManifestSchema,
|
||||||
|
} from "./lib/release-candidate.ts";
|
||||||
|
|
||||||
|
const manifest = await createReleaseCandidateManifest();
|
||||||
|
await mkdir("artifacts/release", { recursive: true });
|
||||||
|
await writeValidatedJsonArtifact({
|
||||||
|
path: RELEASE_CANDIDATE_MANIFEST_PATH,
|
||||||
|
schema: releaseCandidateManifestSchema,
|
||||||
|
value: manifest,
|
||||||
|
});
|
||||||
|
process.stdout.write(
|
||||||
|
`Immutable release candidate: ${manifest.distSha256} (${manifest.files.length} files)\n`,
|
||||||
|
);
|
||||||
@@ -1,13 +1,10 @@
|
|||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { gzipSync } from "node:zlib";
|
|
||||||
import {
|
import {
|
||||||
mkdir,
|
mkdir,
|
||||||
readFile,
|
readFile,
|
||||||
stat,
|
|
||||||
writeFile,
|
writeFile,
|
||||||
} from "node:fs/promises";
|
} from "node:fs/promises";
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
bundlePerformanceArtifactSchema,
|
bundlePerformanceArtifactSchema,
|
||||||
@@ -27,7 +24,6 @@ import {
|
|||||||
supplyChainDigest,
|
supplyChainDigest,
|
||||||
validateDependencyReview,
|
validateDependencyReview,
|
||||||
validateLicensePolicy,
|
validateLicensePolicy,
|
||||||
validateVulnerabilityReport,
|
|
||||||
verifySupplyChainCoherence,
|
verifySupplyChainCoherence,
|
||||||
type DependencyInventoryDiff,
|
type DependencyInventoryDiff,
|
||||||
} from "./lib/supply-chain.ts";
|
} from "./lib/supply-chain.ts";
|
||||||
@@ -37,6 +33,7 @@ import {
|
|||||||
buildRepositoryFileInventory,
|
buildRepositoryFileInventory,
|
||||||
parseRepositoryFileInventoryPolicy,
|
parseRepositoryFileInventoryPolicy,
|
||||||
} from "./lib/repository-file-inventory.ts";
|
} from "./lib/repository-file-inventory.ts";
|
||||||
|
import { collectDistOutputs, distSha256 } from "./lib/release-candidate.ts";
|
||||||
|
|
||||||
type Document = Record<string, unknown>;
|
type Document = Record<string, unknown>;
|
||||||
|
|
||||||
@@ -151,31 +148,8 @@ const repositoryInventory = await buildRepositoryFileInventory({
|
|||||||
generatedRoots: inventoryPolicy.generatedRoots,
|
generatedRoots: inventoryPolicy.generatedRoots,
|
||||||
optionalRoots: inventoryPolicy.optionalRoots,
|
optionalRoots: inventoryPolicy.optionalRoots,
|
||||||
});
|
});
|
||||||
const outputFiles = repositoryInventory.generatedFiles.filter(
|
const outputs = await collectDistOutputs();
|
||||||
(file) => file === "dist" || file.startsWith("dist/"),
|
const distDigest = distSha256(outputs);
|
||||||
);
|
|
||||||
if (outputFiles.length === 0) {
|
|
||||||
throw new Error("dist is missing; run the production build first");
|
|
||||||
}
|
|
||||||
const outputs = await Promise.all(
|
|
||||||
outputFiles.map(async (outputFile) => {
|
|
||||||
const content = await readFile(outputFile);
|
|
||||||
const metadata = await stat(outputFile);
|
|
||||||
return {
|
|
||||||
path: outputFile.replaceAll("\\", "/"),
|
|
||||||
bytes: metadata.size,
|
|
||||||
gzipBytes: gzipSync(content).byteLength,
|
|
||||||
sha256: createHash("sha256").update(content).digest("hex"),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const distDigest = supplyChainDigest(
|
|
||||||
outputs.map(({ path: outputPath, bytes, sha256 }) => ({
|
|
||||||
path: outputPath,
|
|
||||||
bytes,
|
|
||||||
sha256,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
const inventory = await buildDependencyInventory();
|
const inventory = await buildDependencyInventory();
|
||||||
const licensePolicy = JSON.parse(
|
const licensePolicy = JSON.parse(
|
||||||
await readFile("config/security/dependency-policy.json", "utf8"),
|
await readFile("config/security/dependency-policy.json", "utf8"),
|
||||||
@@ -229,44 +203,15 @@ if (baseline && baselineApproval) {
|
|||||||
baselineFailures.push("dependency baseline and approval are required");
|
baselineFailures.push("dependency baseline and approval are required");
|
||||||
}
|
}
|
||||||
|
|
||||||
const vulnerabilityPolicy = JSON.parse(
|
|
||||||
await readFile("config/security/vulnerability-policy.json", "utf8"),
|
|
||||||
);
|
|
||||||
const vulnerabilityExceptions = JSON.parse(
|
|
||||||
await readFile("config/security/vulnerability-exceptions.json", "utf8"),
|
|
||||||
);
|
|
||||||
const vulnerabilityInput = process.env.VULNERABILITY_REPORT_PATH
|
|
||||||
? await optionalJson(process.env.VULNERABILITY_REPORT_PATH)
|
|
||||||
: null;
|
|
||||||
const vulnerabilityResult = vulnerabilityInput
|
|
||||||
? validateVulnerabilityReport(
|
|
||||||
vulnerabilityInput,
|
|
||||||
vulnerabilityPolicy,
|
|
||||||
vulnerabilityExceptions,
|
|
||||||
inventory.lockfileSha256,
|
|
||||||
)
|
|
||||||
: {
|
|
||||||
passed: false,
|
|
||||||
failures: ["external vulnerability provider report is missing"],
|
|
||||||
blocking: [],
|
|
||||||
};
|
|
||||||
const vulnerabilityReport = {
|
const vulnerabilityReport = {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
provider: vulnerabilityInput?.provider ?? "UNCONFIGURED",
|
provider: "UNCONFIGURED",
|
||||||
scannedLockfileSha256:
|
scannedLockfileSha256: inventory.lockfileSha256,
|
||||||
vulnerabilityInput?.scannedLockfileSha256 ?? inventory.lockfileSha256,
|
status: "FAIL_UNVERIFIED",
|
||||||
status: vulnerabilityInput
|
findings: [],
|
||||||
? vulnerabilityResult.passed
|
exceptionsApplied: [],
|
||||||
? "PASS"
|
failures: ["external vulnerability provider report is missing"],
|
||||||
: "FAIL"
|
blocking: [],
|
||||||
: "FAIL_UNVERIFIED",
|
|
||||||
findings: vulnerabilityInput?.findings ?? [],
|
|
||||||
exceptionsApplied:
|
|
||||||
vulnerabilityInput && vulnerabilityResult.passed
|
|
||||||
? vulnerabilityExceptions.exceptions
|
|
||||||
: [],
|
|
||||||
failures: vulnerabilityResult.failures,
|
|
||||||
blocking: vulnerabilityResult.blocking,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const sourceFiles = [...repositoryInventory.trackedFiles];
|
const sourceFiles = [...repositoryInventory.trackedFiles];
|
||||||
@@ -364,40 +309,17 @@ const coherence = verifySupplyChainCoherence(
|
|||||||
distDigest,
|
distDigest,
|
||||||
);
|
);
|
||||||
|
|
||||||
const attestationInput = process.env.PROVENANCE_ATTESTATION_PATH
|
|
||||||
? await optionalJson(process.env.PROVENANCE_ATTESTATION_PATH)
|
|
||||||
: null;
|
|
||||||
const attestation = isRecord(attestationInput?.subject)
|
|
||||||
? attestationInput.subject
|
|
||||||
: {};
|
|
||||||
const attestationSubject = isRecord(attestation.digest)
|
|
||||||
? attestation.digest
|
|
||||||
: {};
|
|
||||||
const attestationPassed =
|
|
||||||
attestationSubject.sha256 === distDigest &&
|
|
||||||
typeof attestationInput?.provider === "string" &&
|
|
||||||
Boolean(attestationInput.provider) &&
|
|
||||||
typeof attestationInput?.signer === "string" &&
|
|
||||||
Boolean(attestationInput.signer);
|
|
||||||
const localFailures = [
|
const localFailures = [
|
||||||
...licenseResult.failures,
|
...licenseResult.failures,
|
||||||
...baselineFailures,
|
...baselineFailures,
|
||||||
...reviewResult.failures,
|
...reviewResult.failures,
|
||||||
...coherence.failures,
|
...coherence.failures,
|
||||||
];
|
];
|
||||||
if (vulnerabilityInput && !vulnerabilityResult.passed) {
|
|
||||||
localFailures.push(
|
|
||||||
...vulnerabilityResult.failures,
|
|
||||||
...vulnerabilityResult.blocking,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const localPassed = localFailures.length === 0;
|
const localPassed = localFailures.length === 0;
|
||||||
const promotionPassed =
|
|
||||||
localPassed && vulnerabilityResult.passed && attestationPassed;
|
|
||||||
const verification = {
|
const verification = {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
localStatus: localPassed ? "PASS" : "FAIL",
|
localStatus: localPassed ? "PASS" : "FAIL",
|
||||||
promotionStatus: promotionPassed ? "PASS" : "FAIL_UNVERIFIED",
|
promotionStatus: "FAIL_UNVERIFIED",
|
||||||
lockfileSha256: inventory.lockfileSha256,
|
lockfileSha256: inventory.lockfileSha256,
|
||||||
sourceSetSha256,
|
sourceSetSha256,
|
||||||
distSha256: distDigest,
|
distSha256: distDigest,
|
||||||
@@ -405,9 +327,7 @@ const verification = {
|
|||||||
dependencyDiff,
|
dependencyDiff,
|
||||||
highRiskReview: reviewResult.highRisk,
|
highRiskReview: reviewResult.highRisk,
|
||||||
vulnerabilityStatus: vulnerabilityReport.status,
|
vulnerabilityStatus: vulnerabilityReport.status,
|
||||||
provenanceAttestationStatus: attestationPassed
|
provenanceAttestationStatus: "FAIL_UNVERIFIED",
|
||||||
? "PASS"
|
|
||||||
: "FAIL_UNVERIFIED",
|
|
||||||
failures: localFailures,
|
failures: localFailures,
|
||||||
};
|
};
|
||||||
const bundleReport = {
|
const bundleReport = {
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { createPublicKey } from "node:crypto";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
|
||||||
|
import {
|
||||||
|
evaluatePromotionEvidence,
|
||||||
|
type ProviderTrust,
|
||||||
|
} from "./provider-evidence.ts";
|
||||||
|
import {
|
||||||
|
RELEASE_CANDIDATE_MANIFEST_PATH,
|
||||||
|
releaseCandidateManifestSchema,
|
||||||
|
verifyReleaseCandidate,
|
||||||
|
} from "./release-candidate.ts";
|
||||||
|
|
||||||
|
export async function verifyPromotionInputs(
|
||||||
|
environment: NodeJS.ProcessEnv = process.env,
|
||||||
|
) {
|
||||||
|
const manifestDocument = await requiredJson(RELEASE_CANDIDATE_MANIFEST_PATH);
|
||||||
|
const manifest = releaseCandidateManifestSchema.parse(manifestDocument);
|
||||||
|
const candidate = await verifyReleaseCandidate(manifestDocument);
|
||||||
|
const localVerification = await requiredJson(
|
||||||
|
"artifacts/security/supply-chain-verification.json",
|
||||||
|
);
|
||||||
|
const vulnerabilityReport = await optionalJson(
|
||||||
|
environment.VULNERABILITY_REPORT_PATH,
|
||||||
|
);
|
||||||
|
const provenanceAttestation = await optionalJson(
|
||||||
|
environment.PROVENANCE_ATTESTATION_PATH,
|
||||||
|
);
|
||||||
|
const result = evaluatePromotionEvidence({
|
||||||
|
candidate: manifest,
|
||||||
|
currentDistSha256: candidate.currentDistSha256 ?? "",
|
||||||
|
localStatus: localVerification.localStatus,
|
||||||
|
vulnerabilityReport,
|
||||||
|
provenanceAttestation,
|
||||||
|
vulnerabilityTrust: await readTrust(
|
||||||
|
environment.VULNERABILITY_PUBLIC_KEY_PATH,
|
||||||
|
environment.VULNERABILITY_KEY_ID,
|
||||||
|
),
|
||||||
|
provenanceTrust: await readTrust(
|
||||||
|
environment.PROVENANCE_PUBLIC_KEY_PATH,
|
||||||
|
environment.PROVENANCE_KEY_ID,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const failures = [...candidate.failures, ...result.failures];
|
||||||
|
return Object.freeze({
|
||||||
|
schemaVersion: 1 as const,
|
||||||
|
status:
|
||||||
|
failures.length === 0 && result.status === "PASS"
|
||||||
|
? ("PASS" as const)
|
||||||
|
: ("FAIL_UNVERIFIED" as const),
|
||||||
|
vulnerabilityStatus: result.vulnerabilityStatus,
|
||||||
|
provenanceAttestationStatus: result.provenanceAttestationStatus,
|
||||||
|
lockfileSha256: manifest.lockfileSha256,
|
||||||
|
distSha256: manifest.distSha256,
|
||||||
|
failures: Object.freeze(failures),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readTrust(
|
||||||
|
publicKeyPath: string | undefined,
|
||||||
|
keyId: string | undefined,
|
||||||
|
): Promise<ProviderTrust | null> {
|
||||||
|
if (!publicKeyPath || !keyId?.trim()) return null;
|
||||||
|
try {
|
||||||
|
return Object.freeze({
|
||||||
|
keyId,
|
||||||
|
publicKey: createPublicKey(await readFile(publicKeyPath, "utf8")),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function optionalJson(file: string | undefined): Promise<unknown> {
|
||||||
|
if (!file) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(await readFile(file, "utf8")) as unknown;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requiredJson(file: string): Promise<Record<string, unknown>> {
|
||||||
|
const value: unknown = JSON.parse(await readFile(file, "utf8"));
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
throw new TypeError(`${file} must be a JSON object`);
|
||||||
|
}
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { verify, type KeyLike } from "node:crypto";
|
||||||
|
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { canonicalizeSupplyChainValue } from "./supply-chain.ts";
|
||||||
|
|
||||||
|
const sha256 = z.string().regex(/^[a-f0-9]{64}$/u);
|
||||||
|
const nonEmptyString = z.string().trim().min(1);
|
||||||
|
const signatureSchema = z
|
||||||
|
.object({
|
||||||
|
algorithm: z.literal("Ed25519"),
|
||||||
|
keyId: nonEmptyString,
|
||||||
|
value: z.string().regex(/^[A-Za-z0-9+/]+={0,2}$/u),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export const vulnerabilityProviderReportSchema = z
|
||||||
|
.object({
|
||||||
|
schemaVersion: z.literal(1),
|
||||||
|
provider: nonEmptyString,
|
||||||
|
generatedAt: z.iso.datetime(),
|
||||||
|
scannedLockfileSha256: sha256,
|
||||||
|
scannedDistSha256: sha256,
|
||||||
|
findings: z.array(z.record(z.string(), z.json())),
|
||||||
|
signature: signatureSchema,
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export const provenanceProviderAttestationSchema = z
|
||||||
|
.object({
|
||||||
|
schemaVersion: z.literal(1),
|
||||||
|
provider: nonEmptyString,
|
||||||
|
signer: nonEmptyString,
|
||||||
|
generatedAt: z.iso.datetime(),
|
||||||
|
subject: z
|
||||||
|
.object({
|
||||||
|
name: z.literal("dist"),
|
||||||
|
digest: z.object({ sha256 }).strict(),
|
||||||
|
})
|
||||||
|
.strict(),
|
||||||
|
signature: signatureSchema,
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export const providerVerificationArtifactSchema = z
|
||||||
|
.object({
|
||||||
|
schemaVersion: z.literal(1),
|
||||||
|
status: z.enum(["PASS", "FAIL_UNVERIFIED"]),
|
||||||
|
vulnerabilityStatus: z.enum(["PASS", "FAIL_UNVERIFIED"]),
|
||||||
|
provenanceAttestationStatus: z.enum(["PASS", "FAIL_UNVERIFIED"]),
|
||||||
|
lockfileSha256: sha256,
|
||||||
|
distSha256: sha256,
|
||||||
|
failures: z.array(z.string()),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export type ProviderTrust = Readonly<{
|
||||||
|
keyId: string;
|
||||||
|
publicKey: KeyLike;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type PromotionEvidenceResult = Readonly<{
|
||||||
|
status: "PASS" | "FAIL_UNVERIFIED";
|
||||||
|
vulnerabilityStatus: "PASS" | "FAIL_UNVERIFIED";
|
||||||
|
provenanceAttestationStatus: "PASS" | "FAIL_UNVERIFIED";
|
||||||
|
failures: readonly string[];
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export function providerEvidenceSignaturePayload(value: unknown): Buffer {
|
||||||
|
if (!isRecord(value)) return Buffer.from("null", "utf8");
|
||||||
|
const { signature: _signature, ...payload } = value;
|
||||||
|
return Buffer.from(
|
||||||
|
JSON.stringify(canonicalizeSupplyChainValue(payload)),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evaluatePromotionEvidence(input: Readonly<{
|
||||||
|
candidate: Readonly<{ distSha256: string; lockfileSha256: string }>;
|
||||||
|
currentDistSha256: string;
|
||||||
|
localStatus: unknown;
|
||||||
|
vulnerabilityReport: unknown;
|
||||||
|
provenanceAttestation: unknown;
|
||||||
|
vulnerabilityTrust: ProviderTrust | null;
|
||||||
|
provenanceTrust: ProviderTrust | null;
|
||||||
|
}>): PromotionEvidenceResult {
|
||||||
|
const failures: string[] = [];
|
||||||
|
let vulnerabilityStatus: "PASS" | "FAIL_UNVERIFIED" = "FAIL_UNVERIFIED";
|
||||||
|
let provenanceAttestationStatus: "PASS" | "FAIL_UNVERIFIED" =
|
||||||
|
"FAIL_UNVERIFIED";
|
||||||
|
|
||||||
|
if (input.localStatus !== "PASS") {
|
||||||
|
failures.push("local supply-chain evidence is not PASS");
|
||||||
|
}
|
||||||
|
if (input.currentDistSha256 !== input.candidate.distSha256) {
|
||||||
|
failures.push("candidate dist bytes changed after immutable build");
|
||||||
|
}
|
||||||
|
|
||||||
|
const vulnerability = vulnerabilityProviderReportSchema.safeParse(
|
||||||
|
input.vulnerabilityReport,
|
||||||
|
);
|
||||||
|
if (!vulnerability.success) {
|
||||||
|
failures.push("external vulnerability provider report is missing or invalid");
|
||||||
|
} else {
|
||||||
|
if (
|
||||||
|
vulnerability.data.scannedLockfileSha256 !==
|
||||||
|
input.candidate.lockfileSha256
|
||||||
|
) {
|
||||||
|
failures.push("vulnerability report lockfile digest mismatch");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
vulnerability.data.scannedDistSha256 !== input.candidate.distSha256
|
||||||
|
) {
|
||||||
|
failures.push("vulnerability report dist digest mismatch");
|
||||||
|
}
|
||||||
|
if (vulnerability.data.findings.length > 0) {
|
||||||
|
failures.push("vulnerability report contains findings");
|
||||||
|
}
|
||||||
|
const signaturePassed = signatureMatches(
|
||||||
|
vulnerability.data,
|
||||||
|
input.vulnerabilityTrust,
|
||||||
|
);
|
||||||
|
if (!signaturePassed) {
|
||||||
|
failures.push("vulnerability report signature verification failed");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
vulnerability.data.scannedLockfileSha256 ===
|
||||||
|
input.candidate.lockfileSha256 &&
|
||||||
|
vulnerability.data.scannedDistSha256 === input.candidate.distSha256 &&
|
||||||
|
vulnerability.data.findings.length === 0 &&
|
||||||
|
input.currentDistSha256 === input.candidate.distSha256 &&
|
||||||
|
input.localStatus === "PASS" &&
|
||||||
|
signaturePassed
|
||||||
|
) {
|
||||||
|
vulnerabilityStatus = "PASS";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const provenance = provenanceProviderAttestationSchema.safeParse(
|
||||||
|
input.provenanceAttestation,
|
||||||
|
);
|
||||||
|
if (!provenance.success) {
|
||||||
|
failures.push("external signed provenance attestation is missing or invalid");
|
||||||
|
} else {
|
||||||
|
if (provenance.data.subject.digest.sha256 !== input.candidate.distSha256) {
|
||||||
|
failures.push("provenance attestation dist digest mismatch");
|
||||||
|
}
|
||||||
|
const signaturePassed = signatureMatches(
|
||||||
|
provenance.data,
|
||||||
|
input.provenanceTrust,
|
||||||
|
);
|
||||||
|
if (!signaturePassed) {
|
||||||
|
failures.push("provenance attestation signature verification failed");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
provenance.data.subject.digest.sha256 === input.candidate.distSha256 &&
|
||||||
|
input.currentDistSha256 === input.candidate.distSha256 &&
|
||||||
|
input.localStatus === "PASS" &&
|
||||||
|
signaturePassed
|
||||||
|
) {
|
||||||
|
provenanceAttestationStatus = "PASS";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
status:
|
||||||
|
failures.length === 0 &&
|
||||||
|
vulnerabilityStatus === "PASS" &&
|
||||||
|
provenanceAttestationStatus === "PASS"
|
||||||
|
? "PASS"
|
||||||
|
: "FAIL_UNVERIFIED",
|
||||||
|
vulnerabilityStatus,
|
||||||
|
provenanceAttestationStatus,
|
||||||
|
failures: Object.freeze(failures),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function signatureMatches(
|
||||||
|
evidence: z.infer<
|
||||||
|
| typeof vulnerabilityProviderReportSchema
|
||||||
|
| typeof provenanceProviderAttestationSchema
|
||||||
|
>,
|
||||||
|
trust: ProviderTrust | null,
|
||||||
|
): boolean {
|
||||||
|
if (!trust || evidence.signature.keyId !== trust.keyId) return false;
|
||||||
|
try {
|
||||||
|
return verify(
|
||||||
|
null,
|
||||||
|
providerEvidenceSignaturePayload(evidence),
|
||||||
|
trust.publicKey,
|
||||||
|
Buffer.from(evidence.signature.value, "base64"),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { lstat, readFile, readdir } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { gzipSync } from "node:zlib";
|
||||||
|
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { supplyChainDigest } from "./supply-chain.ts";
|
||||||
|
|
||||||
|
const sha256 = z.string().regex(/^[a-f0-9]{64}$/u);
|
||||||
|
const candidateFileSchema = z
|
||||||
|
.object({
|
||||||
|
path: z.string().min(1),
|
||||||
|
bytes: z.int().nonnegative(),
|
||||||
|
sha256,
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export const releaseCandidateManifestSchema = z
|
||||||
|
.object({
|
||||||
|
schemaVersion: z.literal(1),
|
||||||
|
distSha256: sha256,
|
||||||
|
lockfileSha256: sha256,
|
||||||
|
bundleSha256: sha256,
|
||||||
|
files: z.array(candidateFileSchema).min(1),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export type ReleaseCandidateManifest = z.infer<
|
||||||
|
typeof releaseCandidateManifestSchema
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const RELEASE_CANDIDATE_MANIFEST_PATH =
|
||||||
|
"artifacts/release/release-candidate.json";
|
||||||
|
|
||||||
|
export const RELEASE_CANDIDATE_EVIDENCE_PATHS = Object.freeze([
|
||||||
|
"artifacts/performance/bundle.json",
|
||||||
|
"artifacts/quality/vite-module-inventory.json",
|
||||||
|
"artifacts/release/build-manifest.json",
|
||||||
|
"artifacts/release/checksums.txt",
|
||||||
|
"artifacts/release/dependency-inventory.json",
|
||||||
|
"artifacts/release/provenance.json",
|
||||||
|
"artifacts/release/verification.json",
|
||||||
|
"artifacts/release/sbom.cdx.json",
|
||||||
|
"artifacts/security/dependency-diff.json",
|
||||||
|
"artifacts/security/license-report.json",
|
||||||
|
"artifacts/security/scan.sarif",
|
||||||
|
"artifacts/security/supply-chain-coherence.json",
|
||||||
|
"artifacts/security/supply-chain-verification.json",
|
||||||
|
"artifacts/security/vulnerability-report.json",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type DistOutput = Readonly<{
|
||||||
|
path: string;
|
||||||
|
bytes: number;
|
||||||
|
gzipBytes: number;
|
||||||
|
sha256: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export async function collectDistOutputs(
|
||||||
|
repositoryRoot = process.cwd(),
|
||||||
|
): Promise<DistOutput[]> {
|
||||||
|
const distRoot = path.resolve(repositoryRoot, "dist");
|
||||||
|
const files = await regularFilesWithin(distRoot);
|
||||||
|
if (files.length === 0) {
|
||||||
|
throw new Error("dist is missing or empty; run the production build first");
|
||||||
|
}
|
||||||
|
return Promise.all(
|
||||||
|
files.map(async (absolutePath) => {
|
||||||
|
const content = await readFile(absolutePath);
|
||||||
|
return Object.freeze({
|
||||||
|
path: path
|
||||||
|
.relative(repositoryRoot, absolutePath)
|
||||||
|
.replaceAll(path.sep, "/"),
|
||||||
|
bytes: content.byteLength,
|
||||||
|
gzipBytes: gzipSync(content).byteLength,
|
||||||
|
sha256: createHash("sha256").update(content).digest("hex"),
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function distSha256(outputs: readonly DistOutput[]): string {
|
||||||
|
return supplyChainDigest(
|
||||||
|
outputs.map(({ path: outputPath, bytes, sha256 }) => ({
|
||||||
|
path: outputPath,
|
||||||
|
bytes,
|
||||||
|
sha256,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createReleaseCandidateManifest(
|
||||||
|
repositoryRoot = process.cwd(),
|
||||||
|
): Promise<ReleaseCandidateManifest> {
|
||||||
|
const outputs = await collectDistOutputs(repositoryRoot);
|
||||||
|
const evidence = await Promise.all(
|
||||||
|
RELEASE_CANDIDATE_EVIDENCE_PATHS.map((file) =>
|
||||||
|
digestRequiredFile(repositoryRoot, file),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const files = [
|
||||||
|
...outputs.map(({ path: outputPath, bytes, sha256 }) => ({
|
||||||
|
path: outputPath,
|
||||||
|
bytes,
|
||||||
|
sha256,
|
||||||
|
})),
|
||||||
|
...evidence,
|
||||||
|
].sort((left, right) => left.path.localeCompare(right.path));
|
||||||
|
const dependencyInventory = JSON.parse(
|
||||||
|
await readFile(
|
||||||
|
path.resolve(repositoryRoot, "artifacts/release/dependency-inventory.json"),
|
||||||
|
"utf8",
|
||||||
|
),
|
||||||
|
) as { lockfileSha256?: unknown };
|
||||||
|
return releaseCandidateManifestSchema.parse({
|
||||||
|
schemaVersion: 1,
|
||||||
|
distSha256: distSha256(outputs),
|
||||||
|
lockfileSha256: dependencyInventory.lockfileSha256,
|
||||||
|
bundleSha256: supplyChainDigest(files),
|
||||||
|
files,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyReleaseCandidate(
|
||||||
|
value: unknown,
|
||||||
|
repositoryRoot = process.cwd(),
|
||||||
|
): Promise<Readonly<{
|
||||||
|
manifest: ReleaseCandidateManifest | null;
|
||||||
|
currentDistSha256: string | null;
|
||||||
|
failures: readonly string[];
|
||||||
|
}>> {
|
||||||
|
const parsed = releaseCandidateManifestSchema.safeParse(value);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return Object.freeze({
|
||||||
|
manifest: null,
|
||||||
|
currentDistSha256: null,
|
||||||
|
failures: Object.freeze(["release candidate manifest schema mismatch"]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const failures: string[] = [];
|
||||||
|
let actual: ReleaseCandidateManifest | null = null;
|
||||||
|
try {
|
||||||
|
actual = await createReleaseCandidateManifest(repositoryRoot);
|
||||||
|
} catch (error) {
|
||||||
|
failures.push(
|
||||||
|
`release candidate inputs unreadable: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (actual) {
|
||||||
|
if (parsed.data.distSha256 !== actual.distSha256) {
|
||||||
|
failures.push("release candidate dist digest mismatch");
|
||||||
|
}
|
||||||
|
if (parsed.data.lockfileSha256 !== actual.lockfileSha256) {
|
||||||
|
failures.push("release candidate lockfile digest mismatch");
|
||||||
|
}
|
||||||
|
if (parsed.data.bundleSha256 !== actual.bundleSha256) {
|
||||||
|
failures.push("release candidate bundle digest mismatch");
|
||||||
|
}
|
||||||
|
if (JSON.stringify(parsed.data.files) !== JSON.stringify(actual.files)) {
|
||||||
|
failures.push("release candidate file set or file digest mismatch");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
manifest: parsed.data,
|
||||||
|
currentDistSha256: actual?.distSha256 ?? null,
|
||||||
|
failures: Object.freeze(failures),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function digestRequiredFile(repositoryRoot: string, file: string) {
|
||||||
|
const absolutePath = path.resolve(repositoryRoot, file);
|
||||||
|
const relative = path.relative(repositoryRoot, absolutePath);
|
||||||
|
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||||
|
throw new Error(`candidate path escapes repository root: ${file}`);
|
||||||
|
}
|
||||||
|
const metadata = await lstat(absolutePath);
|
||||||
|
if (!metadata.isFile()) {
|
||||||
|
throw new Error(`candidate input is not a regular file: ${file}`);
|
||||||
|
}
|
||||||
|
const content = await readFile(absolutePath);
|
||||||
|
return Object.freeze({
|
||||||
|
path: file,
|
||||||
|
bytes: content.byteLength,
|
||||||
|
sha256: createHash("sha256").update(content).digest("hex"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function regularFilesWithin(directory: string): Promise<string[]> {
|
||||||
|
const entries = await readdir(directory, { withFileTypes: true });
|
||||||
|
const files: string[] = [];
|
||||||
|
for (const entry of entries.sort((left, right) =>
|
||||||
|
left.name.localeCompare(right.name),
|
||||||
|
)) {
|
||||||
|
const target = path.join(directory, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
files.push(...(await regularFilesWithin(target)));
|
||||||
|
} else if (entry.isFile()) {
|
||||||
|
files.push(target);
|
||||||
|
} else {
|
||||||
|
throw new Error(`dist contains a non-regular entry: ${target}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { mkdir } from "node:fs/promises";
|
||||||
|
|
||||||
|
import { providerVerificationArtifactSchema } from "./lib/provider-evidence.ts";
|
||||||
|
import { verifyPromotionInputs } from "./lib/promotion-verifier.ts";
|
||||||
|
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
||||||
|
|
||||||
|
const report = await verifyPromotionInputs();
|
||||||
|
await mkdir("artifacts/security", { recursive: true });
|
||||||
|
await writeValidatedJsonArtifact({
|
||||||
|
path: "artifacts/security/provider-verification.json",
|
||||||
|
schema: providerVerificationArtifactSchema,
|
||||||
|
value: report,
|
||||||
|
});
|
||||||
|
if (report.status !== "PASS") {
|
||||||
|
process.stderr.write(
|
||||||
|
`Provider evidence is FAIL_UNVERIFIED:\n- ${report.failures.join("\n- ")}\n`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
process.stdout.write("Provider evidence: PASS\n");
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
|
||||||
|
import {
|
||||||
|
RELEASE_CANDIDATE_MANIFEST_PATH,
|
||||||
|
verifyReleaseCandidate,
|
||||||
|
} from "./lib/release-candidate.ts";
|
||||||
|
|
||||||
|
let document: unknown = null;
|
||||||
|
try {
|
||||||
|
document = JSON.parse(
|
||||||
|
await readFile(RELEASE_CANDIDATE_MANIFEST_PATH, "utf8"),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// The verifier reports a single fail-closed schema error below.
|
||||||
|
}
|
||||||
|
const result = await verifyReleaseCandidate(document);
|
||||||
|
if (result.failures.length > 0) {
|
||||||
|
process.stderr.write(
|
||||||
|
`Release candidate verification failed:\n- ${result.failures.join("\n- ")}\n`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
process.stdout.write(
|
||||||
|
`Release candidate verification: PASS (${result.manifest!.distSha256})\n`,
|
||||||
|
);
|
||||||
@@ -212,7 +212,7 @@ const artifact = {
|
|||||||
const passed = artifact.compatible && fixtures.every((fixture) => fixture.passed);
|
const passed = artifact.compatible && fixtures.every((fixture) => fixture.passed);
|
||||||
const report = {
|
const report = {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt: release.builtAt,
|
||||||
artifact,
|
artifact,
|
||||||
fixtures,
|
fixtures,
|
||||||
passed,
|
passed,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
isValidSha512Integrity,
|
isValidSha512Integrity,
|
||||||
@@ -9,6 +8,10 @@ import {
|
|||||||
verifySupplyChainCoherence,
|
verifySupplyChainCoherence,
|
||||||
} from "./lib/supply-chain.ts";
|
} from "./lib/supply-chain.ts";
|
||||||
import { assertMatchesJsonSchema } from "./lib/json-schema.ts";
|
import { assertMatchesJsonSchema } from "./lib/json-schema.ts";
|
||||||
|
import {
|
||||||
|
collectDistOutputs,
|
||||||
|
distSha256,
|
||||||
|
} from "./lib/release-candidate.ts";
|
||||||
|
|
||||||
type Document = Record<string, unknown>;
|
type Document = Record<string, unknown>;
|
||||||
|
|
||||||
@@ -30,17 +33,6 @@ async function readDocument(file: string): Promise<Document> {
|
|||||||
return parseDocument(await readFile(file, "utf8"), file);
|
return parseDocument(await readFile(file, "utf8"), file);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function filesWithin(directory: string): Promise<string[]> {
|
|
||||||
const entries = await readdir(directory, { withFileTypes: true });
|
|
||||||
const nested: string[][] = await Promise.all(
|
|
||||||
entries.map((entry) => {
|
|
||||||
const target = path.join(directory, entry.name);
|
|
||||||
return entry.isDirectory() ? filesWithin(target) : [target];
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return nested.flat().sort();
|
|
||||||
}
|
|
||||||
|
|
||||||
const inventory = await readDocument(
|
const inventory = await readDocument(
|
||||||
"artifacts/release/dependency-inventory.json",
|
"artifacts/release/dependency-inventory.json",
|
||||||
);
|
);
|
||||||
@@ -72,24 +64,18 @@ const lockfileText = await readFile("pnpm-lock.yaml", "utf8");
|
|||||||
const lockfileSha256 = createHash("sha256")
|
const lockfileSha256 = createHash("sha256")
|
||||||
.update(lockfileText)
|
.update(lockfileText)
|
||||||
.digest("hex");
|
.digest("hex");
|
||||||
const outputs = await Promise.all(
|
const outputs = await collectDistOutputs();
|
||||||
(await filesWithin("dist")).map(async (file) => {
|
const distDigest = distSha256(outputs);
|
||||||
const content = await readFile(file);
|
|
||||||
return {
|
|
||||||
path: file.replaceAll("\\", "/"),
|
|
||||||
bytes: (await stat(file)).size,
|
|
||||||
sha256: createHash("sha256").update(content).digest("hex"),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const distDigest = supplyChainDigest(outputs);
|
|
||||||
const coherence = verifySupplyChainCoherence(
|
const coherence = verifySupplyChainCoherence(
|
||||||
sbom,
|
sbom,
|
||||||
inventory,
|
inventory,
|
||||||
provenance,
|
provenance,
|
||||||
distDigest,
|
distDigest,
|
||||||
);
|
);
|
||||||
const failures: string[] = [...artifactSchemaFailures, ...coherence.failures];
|
const failures: string[] = [
|
||||||
|
...artifactSchemaFailures,
|
||||||
|
...coherence.failures,
|
||||||
|
];
|
||||||
if (
|
if (
|
||||||
inventory.lockfileSha256 !== lockfileSha256 ||
|
inventory.lockfileSha256 !== lockfileSha256 ||
|
||||||
verification.lockfileSha256 !== lockfileSha256
|
verification.lockfileSha256 !== lockfileSha256
|
||||||
|
|||||||
@@ -1,29 +1,19 @@
|
|||||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
import { mkdir } from "node:fs/promises";
|
||||||
|
|
||||||
const verification = JSON.parse(
|
import { providerVerificationArtifactSchema } from "./lib/provider-evidence.ts";
|
||||||
await readFile(
|
import { verifyPromotionInputs } from "./lib/promotion-verifier.ts";
|
||||||
"artifacts/security/supply-chain-verification.json",
|
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
||||||
"utf8",
|
|
||||||
),
|
const report = await verifyPromotionInputs();
|
||||||
);
|
|
||||||
const passed = verification.promotionStatus === "PASS";
|
|
||||||
const report = {
|
|
||||||
schemaVersion: 1,
|
|
||||||
status: passed ? "PASS" : "FAIL_UNVERIFIED",
|
|
||||||
vulnerabilityStatus: verification.vulnerabilityStatus,
|
|
||||||
provenanceAttestationStatus:
|
|
||||||
verification.provenanceAttestationStatus,
|
|
||||||
lockfileSha256: verification.lockfileSha256,
|
|
||||||
distSha256: verification.distSha256,
|
|
||||||
};
|
|
||||||
await mkdir("artifacts/security", { recursive: true });
|
await mkdir("artifacts/security", { recursive: true });
|
||||||
await writeFile(
|
await writeValidatedJsonArtifact({
|
||||||
"artifacts/security/promotion-verification.json",
|
path: "artifacts/security/promotion-verification.json",
|
||||||
`${JSON.stringify(report, null, 2)}\n`,
|
schema: providerVerificationArtifactSchema,
|
||||||
);
|
value: report,
|
||||||
if (!passed) {
|
});
|
||||||
|
if (report.status !== "PASS") {
|
||||||
process.stderr.write(
|
process.stderr.write(
|
||||||
"Supply-chain promotion is FAIL_UNVERIFIED: external vulnerability and signed provenance evidence are required.\n",
|
`Supply-chain promotion is FAIL_UNVERIFIED:\n- ${report.failures.join("\n- ")}\n`,
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { readFile } from "node:fs/promises";
|
import { generateKeyPairSync, sign } from "node:crypto";
|
||||||
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
@@ -17,6 +20,15 @@ import {
|
|||||||
selectIncludedInventoryFiles,
|
selectIncludedInventoryFiles,
|
||||||
} from "../../scripts/lib/secret-scan-policy.ts";
|
} from "../../scripts/lib/secret-scan-policy.ts";
|
||||||
import { checkSecurityFixtures } from "../../scripts/lib/security-fixture-check.ts";
|
import { checkSecurityFixtures } from "../../scripts/lib/security-fixture-check.ts";
|
||||||
|
import {
|
||||||
|
evaluatePromotionEvidence,
|
||||||
|
providerEvidenceSignaturePayload,
|
||||||
|
} from "../../scripts/lib/provider-evidence.ts";
|
||||||
|
import {
|
||||||
|
createReleaseCandidateManifest,
|
||||||
|
RELEASE_CANDIDATE_EVIDENCE_PATHS,
|
||||||
|
verifyReleaseCandidate,
|
||||||
|
} from "../../scripts/lib/release-candidate.ts";
|
||||||
|
|
||||||
const integrity = `sha512-${Buffer.alloc(64, 7).toString("base64")}`;
|
const integrity = `sha512-${Buffer.alloc(64, 7).toString("base64")}`;
|
||||||
const dependency = {
|
const dependency = {
|
||||||
@@ -30,7 +42,249 @@ const dependency = {
|
|||||||
dependencies: [],
|
dependencies: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const candidateDistSha256 = "1".repeat(64);
|
||||||
|
const lockfileSha256 = "2".repeat(64);
|
||||||
|
|
||||||
|
function signedProviderEvidence(
|
||||||
|
value: Record<string, unknown>,
|
||||||
|
keyId: string,
|
||||||
|
privateKey: ReturnType<typeof generateKeyPairSync>["privateKey"],
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
...value,
|
||||||
|
signature: {
|
||||||
|
algorithm: "Ed25519",
|
||||||
|
keyId,
|
||||||
|
value: sign(
|
||||||
|
null,
|
||||||
|
providerEvidenceSignaturePayload(value),
|
||||||
|
privateKey,
|
||||||
|
).toString("base64"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("supply-chain policy", () => {
|
describe("supply-chain policy", () => {
|
||||||
|
it("rejects release candidate dist bytes changed after manifest creation", async () => {
|
||||||
|
const root = await mkdtemp(path.join(tmpdir(), "release-candidate-"));
|
||||||
|
try {
|
||||||
|
await mkdir(path.join(root, "dist/.vite"), { recursive: true });
|
||||||
|
await writeFile(path.join(root, "dist/app.js"), "immutable\n");
|
||||||
|
await writeFile(path.join(root, "dist/.vite/metadata.json"), "{}\n");
|
||||||
|
for (const file of RELEASE_CANDIDATE_EVIDENCE_PATHS) {
|
||||||
|
await mkdir(path.dirname(path.join(root, file)), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
path.join(root, file),
|
||||||
|
file === "artifacts/release/dependency-inventory.json"
|
||||||
|
? `${JSON.stringify({ lockfileSha256 })}\n`
|
||||||
|
: `${file}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const manifest = await createReleaseCandidateManifest(root);
|
||||||
|
expect((await verifyReleaseCandidate(manifest, root)).failures).toEqual(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
await writeFile(path.join(root, "dist/app.js"), "mutated\n");
|
||||||
|
expect(
|
||||||
|
(await verifyReleaseCandidate(manifest, root)).failures,
|
||||||
|
).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
"release candidate dist digest mismatch",
|
||||||
|
"release candidate bundle digest mismatch",
|
||||||
|
"release candidate file set or file digest mismatch",
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails promotion when external provider evidence is absent", () => {
|
||||||
|
const result = evaluatePromotionEvidence({
|
||||||
|
candidate: {
|
||||||
|
distSha256: candidateDistSha256,
|
||||||
|
lockfileSha256,
|
||||||
|
},
|
||||||
|
currentDistSha256: candidateDistSha256,
|
||||||
|
localStatus: "PASS",
|
||||||
|
vulnerabilityReport: null,
|
||||||
|
provenanceAttestation: null,
|
||||||
|
vulnerabilityTrust: null,
|
||||||
|
provenanceTrust: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe("FAIL_UNVERIFIED");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes only signed provider evidence for the exact immutable candidate", () => {
|
||||||
|
const vulnerabilityKeys = generateKeyPairSync("ed25519");
|
||||||
|
const provenanceKeys = generateKeyPairSync("ed25519");
|
||||||
|
const vulnerabilityReport = signedProviderEvidence(
|
||||||
|
{
|
||||||
|
schemaVersion: 1,
|
||||||
|
provider: "fixture-vulnerability-provider",
|
||||||
|
generatedAt: "2026-08-01T00:00:00.000Z",
|
||||||
|
scannedLockfileSha256: lockfileSha256,
|
||||||
|
scannedDistSha256: candidateDistSha256,
|
||||||
|
findings: [],
|
||||||
|
},
|
||||||
|
"fixture-vulnerability-key",
|
||||||
|
vulnerabilityKeys.privateKey,
|
||||||
|
);
|
||||||
|
const provenanceAttestation = signedProviderEvidence(
|
||||||
|
{
|
||||||
|
schemaVersion: 1,
|
||||||
|
provider: "fixture-provenance-provider",
|
||||||
|
signer: "fixture-workload-identity",
|
||||||
|
generatedAt: "2026-08-01T00:00:00.000Z",
|
||||||
|
subject: {
|
||||||
|
name: "dist",
|
||||||
|
digest: { sha256: candidateDistSha256 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"fixture-provenance-key",
|
||||||
|
provenanceKeys.privateKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = evaluatePromotionEvidence({
|
||||||
|
candidate: {
|
||||||
|
distSha256: candidateDistSha256,
|
||||||
|
lockfileSha256,
|
||||||
|
},
|
||||||
|
currentDistSha256: candidateDistSha256,
|
||||||
|
localStatus: "PASS",
|
||||||
|
vulnerabilityReport,
|
||||||
|
provenanceAttestation,
|
||||||
|
vulnerabilityTrust: {
|
||||||
|
keyId: "fixture-vulnerability-key",
|
||||||
|
publicKey: vulnerabilityKeys.publicKey,
|
||||||
|
},
|
||||||
|
provenanceTrust: {
|
||||||
|
keyId: "fixture-provenance-key",
|
||||||
|
publicKey: provenanceKeys.publicKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
status: "PASS",
|
||||||
|
vulnerabilityStatus: "PASS",
|
||||||
|
provenanceAttestationStatus: "PASS",
|
||||||
|
failures: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects correctly signed provider evidence for a different digest", () => {
|
||||||
|
const vulnerabilityKeys = generateKeyPairSync("ed25519");
|
||||||
|
const provenanceKeys = generateKeyPairSync("ed25519");
|
||||||
|
const wrongDistSha256 = "3".repeat(64);
|
||||||
|
const vulnerabilityReport = signedProviderEvidence(
|
||||||
|
{
|
||||||
|
schemaVersion: 1,
|
||||||
|
provider: "fixture-vulnerability-provider",
|
||||||
|
generatedAt: "2026-08-01T00:00:00.000Z",
|
||||||
|
scannedLockfileSha256: lockfileSha256,
|
||||||
|
scannedDistSha256: wrongDistSha256,
|
||||||
|
findings: [],
|
||||||
|
},
|
||||||
|
"fixture-vulnerability-key",
|
||||||
|
vulnerabilityKeys.privateKey,
|
||||||
|
);
|
||||||
|
const provenanceAttestation = signedProviderEvidence(
|
||||||
|
{
|
||||||
|
schemaVersion: 1,
|
||||||
|
provider: "fixture-provenance-provider",
|
||||||
|
signer: "fixture-workload-identity",
|
||||||
|
generatedAt: "2026-08-01T00:00:00.000Z",
|
||||||
|
subject: { name: "dist", digest: { sha256: wrongDistSha256 } },
|
||||||
|
},
|
||||||
|
"fixture-provenance-key",
|
||||||
|
provenanceKeys.privateKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = evaluatePromotionEvidence({
|
||||||
|
candidate: {
|
||||||
|
distSha256: candidateDistSha256,
|
||||||
|
lockfileSha256,
|
||||||
|
},
|
||||||
|
currentDistSha256: candidateDistSha256,
|
||||||
|
localStatus: "PASS",
|
||||||
|
vulnerabilityReport,
|
||||||
|
provenanceAttestation,
|
||||||
|
vulnerabilityTrust: {
|
||||||
|
keyId: "fixture-vulnerability-key",
|
||||||
|
publicKey: vulnerabilityKeys.publicKey,
|
||||||
|
},
|
||||||
|
provenanceTrust: {
|
||||||
|
keyId: "fixture-provenance-key",
|
||||||
|
publicKey: provenanceKeys.publicKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe("FAIL_UNVERIFIED");
|
||||||
|
expect(result.failures).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
"vulnerability report dist digest mismatch",
|
||||||
|
"provenance attestation dist digest mismatch",
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects candidate bytes changed after provider attestation", () => {
|
||||||
|
const vulnerabilityKeys = generateKeyPairSync("ed25519");
|
||||||
|
const provenanceKeys = generateKeyPairSync("ed25519");
|
||||||
|
const vulnerabilityReport = signedProviderEvidence(
|
||||||
|
{
|
||||||
|
schemaVersion: 1,
|
||||||
|
provider: "fixture-vulnerability-provider",
|
||||||
|
generatedAt: "2026-08-01T00:00:00.000Z",
|
||||||
|
scannedLockfileSha256: lockfileSha256,
|
||||||
|
scannedDistSha256: candidateDistSha256,
|
||||||
|
findings: [],
|
||||||
|
},
|
||||||
|
"fixture-vulnerability-key",
|
||||||
|
vulnerabilityKeys.privateKey,
|
||||||
|
);
|
||||||
|
const provenanceAttestation = signedProviderEvidence(
|
||||||
|
{
|
||||||
|
schemaVersion: 1,
|
||||||
|
provider: "fixture-provenance-provider",
|
||||||
|
signer: "fixture-workload-identity",
|
||||||
|
generatedAt: "2026-08-01T00:00:00.000Z",
|
||||||
|
subject: {
|
||||||
|
name: "dist",
|
||||||
|
digest: { sha256: candidateDistSha256 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"fixture-provenance-key",
|
||||||
|
provenanceKeys.privateKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = evaluatePromotionEvidence({
|
||||||
|
candidate: {
|
||||||
|
distSha256: candidateDistSha256,
|
||||||
|
lockfileSha256,
|
||||||
|
},
|
||||||
|
currentDistSha256: "4".repeat(64),
|
||||||
|
localStatus: "PASS",
|
||||||
|
vulnerabilityReport,
|
||||||
|
provenanceAttestation,
|
||||||
|
vulnerabilityTrust: {
|
||||||
|
keyId: "fixture-vulnerability-key",
|
||||||
|
publicKey: vulnerabilityKeys.publicKey,
|
||||||
|
},
|
||||||
|
provenanceTrust: {
|
||||||
|
keyId: "fixture-provenance-key",
|
||||||
|
publicKey: provenanceKeys.publicKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe("FAIL_UNVERIFIED");
|
||||||
|
expect(result.failures).toContain(
|
||||||
|
"candidate dist bytes changed after immutable build",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
["empty", []],
|
["empty", []],
|
||||||
["empty entry", [""]],
|
["empty entry", [""]],
|
||||||
|
|||||||
Reference in New Issue
Block a user