fix: promote immutable verified release bundles

This commit is contained in:
DongHyeonka
2026-08-02 06:08:06 +09:00
parent 100a3bb6ba
commit 7c5ed80407
17 changed files with 1203 additions and 263 deletions
+163 -2
View File
@@ -88,7 +88,6 @@ jobs:
include:
- { gate: FE-GATE-012, name: bundle, 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-026, name: lab-performance, browser: true }
steps:
@@ -113,9 +112,171 @@ jobs:
path: artifacts/
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:
name: ${{ matrix.gate }} / ${{ matrix.name }}
needs: release_gate
needs: promotion
if: ${{ gitea.event_name == 'workflow_dispatch' && (inputs.stage == 'production' || inputs.stage == 'field') }}
runs-on: ubuntu-latest
timeout-minutes: 45
+6 -6
View File
@@ -268,8 +268,8 @@
"name": "security",
"steps": [
{ "script": "verify:reproducible-build", "expect": "pass" },
{ "script": "build:release", "expect": "pass" },
{ "script": "verify:supply-chain", "expect": "pass" },
{ "script": "build:release-candidate", "expect": "pass" },
{ "script": "verify:local-evidence", "expect": "pass" },
{ "script": "check:supply-chain:fixtures", "expect": "pass" },
{
"script": "check:supply-chain:provider-fixtures",
@@ -305,14 +305,14 @@
"FE-GATE-015": {
"name": "release-coherence",
"steps": [
{ "script": "build:release", "expect": "pass" },
{ "script": "verify:release", "expect": "pass" },
{ "script": "verify:supply-chain:promotion", "expect": "pass" }
{ "script": "build:release-candidate", "expect": "pass" },
{ "script": "verify:local-evidence", "expect": "pass" }
],
"logPath": "artifacts/quality/gates/FE-GATE-015.txt",
"evidence": [
"artifacts/release/release-candidate.json",
"artifacts/release/verification.json",
"artifacts/security/promotion-verification.json"
"artifacts/security/supply-chain-coherence.json"
],
"retentionClass": "release-coherence"
},
+12 -9
View File
@@ -19,8 +19,8 @@ The canonical commands are:
```bash
corepack pnpm verify:lockfile
corepack pnpm verify:reproducible-build
corepack pnpm build:release
corepack pnpm verify:supply-chain
corepack pnpm build:release-candidate
corepack pnpm verify:local-evidence
corepack pnpm check:supply-chain:fixtures
```
@@ -31,16 +31,19 @@ rejected.
## External promotion controls
The vulnerability adapter reads the file named by
`VULNERABILITY_REPORT_PATH`. It requires a provider, the exact lockfile digest,
severity findings and valid independent, unexpired exception evidence.
`PROVENANCE_ATTESTATION_PATH` must name a provider, signer and the exact built
dist subject digest.
Promotion reads the provider files named by `VULNERABILITY_REPORT_PATH` and
`PROVENANCE_ATTESTATION_PATH`. The vulnerability report must bind both the
exact lockfile digest and candidate `distSha256`; the provenance attestation
must name `dist` with that same digest. Both documents use strict schemas and
Ed25519 signatures verified with separately configured trusted public keys and
key IDs.
If either provider input is absent, local verification remains meaningful but
`artifacts/security/supply-chain-verification.json` records
`promotionStatus: FAIL_UNVERIFIED`. `verify:supply-chain:promotion` then exits
non-zero. Scanner or signing outages are not converted to an empty PASS.
`promotionStatus: FAIL_UNVERIFIED`. `verify:provider-evidence` and
`verify:promotion` then exit non-zero. Promotion recomputes the candidate file
set and digests and never rebuilds it. Scanner or signing outages are not
converted to an empty PASS.
Approved vulnerability exceptions require vulnerability/package identity,
owner, a different reviewer, reason and expiry. Expired or self-approved
+4 -3
View File
@@ -11,7 +11,7 @@
"scripts": {
"dev": "vite",
"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",
"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",
@@ -73,11 +73,12 @@
"generate:artifact-schemas": "node scripts/generate-artifact-schemas.ts",
"check:artifact-schemas": "node scripts/generate-artifact-schemas.ts --check",
"generate:supply-chain": "node scripts/generate-supply-chain.ts",
"verify: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",
"check:supply-chain:fixtures": "node scripts/check-supply-chain-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",
"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",
+61 -1
View File
@@ -22,6 +22,10 @@ const document = parseCiContractDocument(
JSON.parse(await readFile("config/ci/gates.json", "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 gateRunner = await readFile("scripts/run-ci-gate.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;
if (jobTimeoutCount !== 5) {
if (jobTimeoutCount !== 9) {
failures.push("every CI gate job must declare timeout-minutes: 45");
}
if (/if-no-files-found:\s*warn/.test(workflow)) {
@@ -166,9 +170,16 @@ for (const requiredToken of [
"production_gate:",
"field_gate:",
"documentation_gate:",
"immutable_build:",
"vulnerability_provider:",
"provenance_provider:",
"promotion:",
"needs: merge_gate",
"needs: release_gate",
"needs: production_gate",
"needs: immutable_build",
"needs: [immutable_build, vulnerability_provider, provenance_provider]",
"actions/download-artifact@v4",
"actions/upload-artifact@v4",
"if: always()",
"permissions:",
@@ -178,11 +189,60 @@ for (const requiredToken of [
'VITE_COMMIT_SHA: "${{ gitea.sha }}"',
'RELEASE_ID: "${{ gitea.ref }}-${{ gitea.run_id }}-${{ gitea.run_attempt }}"',
'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)) {
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 [
"ciCheckoutIdentityFailures",
"ciBuildEnvironmentFailures",
+107 -100
View File
@@ -1,103 +1,118 @@
import { spawnSync } from "node:child_process";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { generateKeyPairSync, sign } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
type Document = Record<string, unknown>;
import {
evaluatePromotionEvidence,
providerEvidenceSignaturePayload,
} from "./lib/provider-evidence.ts";
function isRecord(value: unknown): value is Document {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
async function readDocument(file: string): Promise<Document> {
const parsed: unknown = JSON.parse(await readFile(file, "utf8"));
if (!isRecord(parsed)) throw new Error(`${file} must be a JSON object`);
return parsed;
}
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",
const candidateDistSha256 = "1".repeat(64);
const lockfileSha256 = "2".repeat(64);
const vulnerabilityKeys = generateKeyPairSync("ed25519");
const provenanceKeys = generateKeyPairSync("ed25519");
const trust = {
vulnerabilityTrust: {
keyId: "fixture-vulnerability-key",
publicKey: vulnerabilityKeys.publicKey,
},
);
let promotionStatus = "MISSING";
if (providerRun.status === 0) {
const providerVerification = await readDocument(
"artifacts/security/supply-chain-verification.json",
);
promotionStatus =
typeof providerVerification.promotionStatus === "string"
? providerVerification.promotionStatus
: "MISSING";
provenanceTrust: {
keyId: "fixture-provenance-key",
publicKey: provenanceKeys.publicKey,
},
};
function signedEvidence(
value: Record<string, unknown>,
keyId: string,
privateKey: typeof vulnerabilityKeys.privateKey,
) {
return {
...value,
signature: {
algorithm: "Ed25519",
keyId,
value: sign(
null,
providerEvidenceSignaturePayload(value),
privateKey,
).toString("base64"),
},
};
}
const restore = spawnSync(
"node",
["scripts/generate-supply-chain.ts"],
{ encoding: "utf8" },
);
await rm(fixtureDirectory, { recursive: true, force: true });
function evidenceFor(distDigest: string) {
return {
vulnerabilityReport: signedEvidence(
{
schemaVersion: 1,
provider: "fixture-vulnerability-provider",
generatedAt: "2026-08-01T00:00:00.000Z",
scannedLockfileSha256: lockfileSha256,
scannedDistSha256: distDigest,
findings: [],
},
"fixture-vulnerability-key",
vulnerabilityKeys.privateKey,
),
provenanceAttestation: signedEvidence(
{
schemaVersion: 1,
provider: "fixture-provenance-provider",
signer: "fixture-workload-identity",
generatedAt: "2026-08-01T00:00:00.000Z",
subject: { name: "dist", digest: { sha256: distDigest } },
},
"fixture-provenance-key",
provenanceKeys.privateKey,
),
};
}
const base = {
candidate: { distSha256: candidateDistSha256, lockfileSha256 },
currentDistSha256: candidateDistSha256,
localStatus: "PASS",
...trust,
};
const validEvidence = evidenceFor(candidateDistSha256);
const fixtures = {
absent: evaluatePromotionEvidence({
...base,
vulnerabilityReport: null,
provenanceAttestation: null,
}),
validImmutable: evaluatePromotionEvidence({ ...base, ...validEvidence }),
wrongDigest: evaluatePromotionEvidence({
...base,
...evidenceFor("3".repeat(64)),
}),
postAttestationMutation: evaluatePromotionEvidence({
...base,
...validEvidence,
currentDistSha256: "4".repeat(64),
}),
};
const passed =
providerRun.status === 0 &&
promotionStatus === "PASS" &&
restore.status === 0;
fixtures.validImmutable.status === "PASS" &&
fixtures.absent.status === "FAIL_UNVERIFIED" &&
fixtures.wrongDigest.status === "FAIL_UNVERIFIED" &&
fixtures.postAttestationMutation.status === "FAIL_UNVERIFIED";
await mkdir("artifacts/security", { recursive: true });
await writeFile(
"artifacts/security/supply-chain-provider-fixtures.json",
`${JSON.stringify(
{
schemaVersion: 1,
providerAccepted: providerRun.status === 0,
promotionStatus,
unverifiedDefaultRestored: restore.status === 0,
fixtures: Object.fromEntries(
Object.entries(fixtures).map(([name, result]) => [
name,
{ status: result.status, failures: result.failures },
]),
),
passingFixtureCount: Object.values(fixtures).filter(
(result) => result.status === "PASS",
).length,
status: passed ? "PASS" : "FAIL",
},
null,
@@ -105,17 +120,9 @@ await writeFile(
)}\n`,
);
if (!passed) {
const detail =
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.stderr.write("Supply-chain provider fixtures failed closed incorrectly\n");
process.exit(1);
}
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",
);
+19
View File
@@ -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`,
);
+12 -92
View File
@@ -1,13 +1,10 @@
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { gzipSync } from "node:zlib";
import {
mkdir,
readFile,
stat,
writeFile,
} from "node:fs/promises";
import path from "node:path";
import {
bundlePerformanceArtifactSchema,
@@ -27,7 +24,6 @@ import {
supplyChainDigest,
validateDependencyReview,
validateLicensePolicy,
validateVulnerabilityReport,
verifySupplyChainCoherence,
type DependencyInventoryDiff,
} from "./lib/supply-chain.ts";
@@ -37,6 +33,7 @@ import {
buildRepositoryFileInventory,
parseRepositoryFileInventoryPolicy,
} from "./lib/repository-file-inventory.ts";
import { collectDistOutputs, distSha256 } from "./lib/release-candidate.ts";
type Document = Record<string, unknown>;
@@ -151,31 +148,8 @@ const repositoryInventory = await buildRepositoryFileInventory({
generatedRoots: inventoryPolicy.generatedRoots,
optionalRoots: inventoryPolicy.optionalRoots,
});
const outputFiles = repositoryInventory.generatedFiles.filter(
(file) => file === "dist" || file.startsWith("dist/"),
);
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 outputs = await collectDistOutputs();
const distDigest = distSha256(outputs);
const inventory = await buildDependencyInventory();
const licensePolicy = JSON.parse(
await readFile("config/security/dependency-policy.json", "utf8"),
@@ -229,44 +203,15 @@ if (baseline && baselineApproval) {
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 = {
schemaVersion: 1,
provider: vulnerabilityInput?.provider ?? "UNCONFIGURED",
scannedLockfileSha256:
vulnerabilityInput?.scannedLockfileSha256 ?? inventory.lockfileSha256,
status: vulnerabilityInput
? vulnerabilityResult.passed
? "PASS"
: "FAIL"
: "FAIL_UNVERIFIED",
findings: vulnerabilityInput?.findings ?? [],
exceptionsApplied:
vulnerabilityInput && vulnerabilityResult.passed
? vulnerabilityExceptions.exceptions
: [],
failures: vulnerabilityResult.failures,
blocking: vulnerabilityResult.blocking,
provider: "UNCONFIGURED",
scannedLockfileSha256: inventory.lockfileSha256,
status: "FAIL_UNVERIFIED",
findings: [],
exceptionsApplied: [],
failures: ["external vulnerability provider report is missing"],
blocking: [],
};
const sourceFiles = [...repositoryInventory.trackedFiles];
@@ -364,40 +309,17 @@ const coherence = verifySupplyChainCoherence(
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 = [
...licenseResult.failures,
...baselineFailures,
...reviewResult.failures,
...coherence.failures,
];
if (vulnerabilityInput && !vulnerabilityResult.passed) {
localFailures.push(
...vulnerabilityResult.failures,
...vulnerabilityResult.blocking,
);
}
const localPassed = localFailures.length === 0;
const promotionPassed =
localPassed && vulnerabilityResult.passed && attestationPassed;
const verification = {
schemaVersion: 1,
localStatus: localPassed ? "PASS" : "FAIL",
promotionStatus: promotionPassed ? "PASS" : "FAIL_UNVERIFIED",
promotionStatus: "FAIL_UNVERIFIED",
lockfileSha256: inventory.lockfileSha256,
sourceSetSha256,
distSha256: distDigest,
@@ -405,9 +327,7 @@ const verification = {
dependencyDiff,
highRiskReview: reviewResult.highRisk,
vulnerabilityStatus: vulnerabilityReport.status,
provenanceAttestationStatus: attestationPassed
? "PASS"
: "FAIL_UNVERIFIED",
provenanceAttestationStatus: "FAIL_UNVERIFIED",
failures: localFailures,
};
const bundleReport = {
+89
View File
@@ -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>;
}
+200
View File
@@ -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);
}
+205
View File
@@ -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;
}
+20
View File
@@ -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");
+25
View File
@@ -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`,
);
+1 -1
View File
@@ -212,7 +212,7 @@ const artifact = {
const passed = artifact.compatible && fixtures.every((fixture) => fixture.passed);
const report = {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
generatedAt: release.builtAt,
artifact,
fixtures,
passed,
+11 -25
View File
@@ -1,6 +1,5 @@
import { createHash } from "node:crypto";
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import {
isValidSha512Integrity,
@@ -9,6 +8,10 @@ import {
verifySupplyChainCoherence,
} from "./lib/supply-chain.ts";
import { assertMatchesJsonSchema } from "./lib/json-schema.ts";
import {
collectDistOutputs,
distSha256,
} from "./lib/release-candidate.ts";
type Document = Record<string, unknown>;
@@ -30,17 +33,6 @@ async function readDocument(file: string): Promise<Document> {
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(
"artifacts/release/dependency-inventory.json",
);
@@ -72,24 +64,18 @@ const lockfileText = await readFile("pnpm-lock.yaml", "utf8");
const lockfileSha256 = createHash("sha256")
.update(lockfileText)
.digest("hex");
const outputs = await Promise.all(
(await filesWithin("dist")).map(async (file) => {
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 outputs = await collectDistOutputs();
const distDigest = distSha256(outputs);
const coherence = verifySupplyChainCoherence(
sbom,
inventory,
provenance,
distDigest,
);
const failures: string[] = [...artifactSchemaFailures, ...coherence.failures];
const failures: string[] = [
...artifactSchemaFailures,
...coherence.failures,
];
if (
inventory.lockfileSha256 !== lockfileSha256 ||
verification.lockfileSha256 !== lockfileSha256
+13 -23
View File
@@ -1,29 +1,19 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { mkdir } from "node:fs/promises";
const verification = JSON.parse(
await readFile(
"artifacts/security/supply-chain-verification.json",
"utf8",
),
);
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,
};
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 writeFile(
"artifacts/security/promotion-verification.json",
`${JSON.stringify(report, null, 2)}\n`,
);
if (!passed) {
await writeValidatedJsonArtifact({
path: "artifacts/security/promotion-verification.json",
schema: providerVerificationArtifactSchema,
value: report,
});
if (report.status !== "PASS") {
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);
}
+255 -1
View File
@@ -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";
@@ -17,6 +20,15 @@ import {
selectIncludedInventoryFiles,
} from "../../scripts/lib/secret-scan-policy.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 dependency = {
@@ -30,7 +42,249 @@ const dependency = {
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", () => {
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([
["empty", []],
["empty entry", [""]],