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
+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);
}