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