fix: promote immutable verified release bundles
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
import { createPublicKey } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import {
|
||||
evaluatePromotionEvidence,
|
||||
type ProviderTrust,
|
||||
} from "./provider-evidence.ts";
|
||||
import {
|
||||
RELEASE_CANDIDATE_MANIFEST_PATH,
|
||||
releaseCandidateManifestSchema,
|
||||
verifyReleaseCandidate,
|
||||
} from "./release-candidate.ts";
|
||||
|
||||
export async function verifyPromotionInputs(
|
||||
environment: NodeJS.ProcessEnv = process.env,
|
||||
) {
|
||||
const manifestDocument = await requiredJson(RELEASE_CANDIDATE_MANIFEST_PATH);
|
||||
const manifest = releaseCandidateManifestSchema.parse(manifestDocument);
|
||||
const candidate = await verifyReleaseCandidate(manifestDocument);
|
||||
const localVerification = await requiredJson(
|
||||
"artifacts/security/supply-chain-verification.json",
|
||||
);
|
||||
const vulnerabilityReport = await optionalJson(
|
||||
environment.VULNERABILITY_REPORT_PATH,
|
||||
);
|
||||
const provenanceAttestation = await optionalJson(
|
||||
environment.PROVENANCE_ATTESTATION_PATH,
|
||||
);
|
||||
const result = evaluatePromotionEvidence({
|
||||
candidate: manifest,
|
||||
currentDistSha256: candidate.currentDistSha256 ?? "",
|
||||
localStatus: localVerification.localStatus,
|
||||
vulnerabilityReport,
|
||||
provenanceAttestation,
|
||||
vulnerabilityTrust: await readTrust(
|
||||
environment.VULNERABILITY_PUBLIC_KEY_PATH,
|
||||
environment.VULNERABILITY_KEY_ID,
|
||||
),
|
||||
provenanceTrust: await readTrust(
|
||||
environment.PROVENANCE_PUBLIC_KEY_PATH,
|
||||
environment.PROVENANCE_KEY_ID,
|
||||
),
|
||||
});
|
||||
const failures = [...candidate.failures, ...result.failures];
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
status:
|
||||
failures.length === 0 && result.status === "PASS"
|
||||
? ("PASS" as const)
|
||||
: ("FAIL_UNVERIFIED" as const),
|
||||
vulnerabilityStatus: result.vulnerabilityStatus,
|
||||
provenanceAttestationStatus: result.provenanceAttestationStatus,
|
||||
lockfileSha256: manifest.lockfileSha256,
|
||||
distSha256: manifest.distSha256,
|
||||
failures: Object.freeze(failures),
|
||||
});
|
||||
}
|
||||
|
||||
async function readTrust(
|
||||
publicKeyPath: string | undefined,
|
||||
keyId: string | undefined,
|
||||
): Promise<ProviderTrust | null> {
|
||||
if (!publicKeyPath || !keyId?.trim()) return null;
|
||||
try {
|
||||
return Object.freeze({
|
||||
keyId,
|
||||
publicKey: createPublicKey(await readFile(publicKeyPath, "utf8")),
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function optionalJson(file: string | undefined): Promise<unknown> {
|
||||
if (!file) return null;
|
||||
try {
|
||||
return JSON.parse(await readFile(file, "utf8")) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function requiredJson(file: string): Promise<Record<string, unknown>> {
|
||||
const value: unknown = JSON.parse(await readFile(file, "utf8"));
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new TypeError(`${file} must be a JSON object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { verify, type KeyLike } from "node:crypto";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { canonicalizeSupplyChainValue } from "./supply-chain.ts";
|
||||
|
||||
const sha256 = z.string().regex(/^[a-f0-9]{64}$/u);
|
||||
const nonEmptyString = z.string().trim().min(1);
|
||||
const signatureSchema = z
|
||||
.object({
|
||||
algorithm: z.literal("Ed25519"),
|
||||
keyId: nonEmptyString,
|
||||
value: z.string().regex(/^[A-Za-z0-9+/]+={0,2}$/u),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const vulnerabilityProviderReportSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(1),
|
||||
provider: nonEmptyString,
|
||||
generatedAt: z.iso.datetime(),
|
||||
scannedLockfileSha256: sha256,
|
||||
scannedDistSha256: sha256,
|
||||
findings: z.array(z.record(z.string(), z.json())),
|
||||
signature: signatureSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const provenanceProviderAttestationSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(1),
|
||||
provider: nonEmptyString,
|
||||
signer: nonEmptyString,
|
||||
generatedAt: z.iso.datetime(),
|
||||
subject: z
|
||||
.object({
|
||||
name: z.literal("dist"),
|
||||
digest: z.object({ sha256 }).strict(),
|
||||
})
|
||||
.strict(),
|
||||
signature: signatureSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const providerVerificationArtifactSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(1),
|
||||
status: z.enum(["PASS", "FAIL_UNVERIFIED"]),
|
||||
vulnerabilityStatus: z.enum(["PASS", "FAIL_UNVERIFIED"]),
|
||||
provenanceAttestationStatus: z.enum(["PASS", "FAIL_UNVERIFIED"]),
|
||||
lockfileSha256: sha256,
|
||||
distSha256: sha256,
|
||||
failures: z.array(z.string()),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type ProviderTrust = Readonly<{
|
||||
keyId: string;
|
||||
publicKey: KeyLike;
|
||||
}>;
|
||||
|
||||
export type PromotionEvidenceResult = Readonly<{
|
||||
status: "PASS" | "FAIL_UNVERIFIED";
|
||||
vulnerabilityStatus: "PASS" | "FAIL_UNVERIFIED";
|
||||
provenanceAttestationStatus: "PASS" | "FAIL_UNVERIFIED";
|
||||
failures: readonly string[];
|
||||
}>;
|
||||
|
||||
export function providerEvidenceSignaturePayload(value: unknown): Buffer {
|
||||
if (!isRecord(value)) return Buffer.from("null", "utf8");
|
||||
const { signature: _signature, ...payload } = value;
|
||||
return Buffer.from(
|
||||
JSON.stringify(canonicalizeSupplyChainValue(payload)),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
export function evaluatePromotionEvidence(input: Readonly<{
|
||||
candidate: Readonly<{ distSha256: string; lockfileSha256: string }>;
|
||||
currentDistSha256: string;
|
||||
localStatus: unknown;
|
||||
vulnerabilityReport: unknown;
|
||||
provenanceAttestation: unknown;
|
||||
vulnerabilityTrust: ProviderTrust | null;
|
||||
provenanceTrust: ProviderTrust | null;
|
||||
}>): PromotionEvidenceResult {
|
||||
const failures: string[] = [];
|
||||
let vulnerabilityStatus: "PASS" | "FAIL_UNVERIFIED" = "FAIL_UNVERIFIED";
|
||||
let provenanceAttestationStatus: "PASS" | "FAIL_UNVERIFIED" =
|
||||
"FAIL_UNVERIFIED";
|
||||
|
||||
if (input.localStatus !== "PASS") {
|
||||
failures.push("local supply-chain evidence is not PASS");
|
||||
}
|
||||
if (input.currentDistSha256 !== input.candidate.distSha256) {
|
||||
failures.push("candidate dist bytes changed after immutable build");
|
||||
}
|
||||
|
||||
const vulnerability = vulnerabilityProviderReportSchema.safeParse(
|
||||
input.vulnerabilityReport,
|
||||
);
|
||||
if (!vulnerability.success) {
|
||||
failures.push("external vulnerability provider report is missing or invalid");
|
||||
} else {
|
||||
if (
|
||||
vulnerability.data.scannedLockfileSha256 !==
|
||||
input.candidate.lockfileSha256
|
||||
) {
|
||||
failures.push("vulnerability report lockfile digest mismatch");
|
||||
}
|
||||
if (
|
||||
vulnerability.data.scannedDistSha256 !== input.candidate.distSha256
|
||||
) {
|
||||
failures.push("vulnerability report dist digest mismatch");
|
||||
}
|
||||
if (vulnerability.data.findings.length > 0) {
|
||||
failures.push("vulnerability report contains findings");
|
||||
}
|
||||
const signaturePassed = signatureMatches(
|
||||
vulnerability.data,
|
||||
input.vulnerabilityTrust,
|
||||
);
|
||||
if (!signaturePassed) {
|
||||
failures.push("vulnerability report signature verification failed");
|
||||
}
|
||||
if (
|
||||
vulnerability.data.scannedLockfileSha256 ===
|
||||
input.candidate.lockfileSha256 &&
|
||||
vulnerability.data.scannedDistSha256 === input.candidate.distSha256 &&
|
||||
vulnerability.data.findings.length === 0 &&
|
||||
input.currentDistSha256 === input.candidate.distSha256 &&
|
||||
input.localStatus === "PASS" &&
|
||||
signaturePassed
|
||||
) {
|
||||
vulnerabilityStatus = "PASS";
|
||||
}
|
||||
}
|
||||
|
||||
const provenance = provenanceProviderAttestationSchema.safeParse(
|
||||
input.provenanceAttestation,
|
||||
);
|
||||
if (!provenance.success) {
|
||||
failures.push("external signed provenance attestation is missing or invalid");
|
||||
} else {
|
||||
if (provenance.data.subject.digest.sha256 !== input.candidate.distSha256) {
|
||||
failures.push("provenance attestation dist digest mismatch");
|
||||
}
|
||||
const signaturePassed = signatureMatches(
|
||||
provenance.data,
|
||||
input.provenanceTrust,
|
||||
);
|
||||
if (!signaturePassed) {
|
||||
failures.push("provenance attestation signature verification failed");
|
||||
}
|
||||
if (
|
||||
provenance.data.subject.digest.sha256 === input.candidate.distSha256 &&
|
||||
input.currentDistSha256 === input.candidate.distSha256 &&
|
||||
input.localStatus === "PASS" &&
|
||||
signaturePassed
|
||||
) {
|
||||
provenanceAttestationStatus = "PASS";
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
status:
|
||||
failures.length === 0 &&
|
||||
vulnerabilityStatus === "PASS" &&
|
||||
provenanceAttestationStatus === "PASS"
|
||||
? "PASS"
|
||||
: "FAIL_UNVERIFIED",
|
||||
vulnerabilityStatus,
|
||||
provenanceAttestationStatus,
|
||||
failures: Object.freeze(failures),
|
||||
});
|
||||
}
|
||||
|
||||
function signatureMatches(
|
||||
evidence: z.infer<
|
||||
| typeof vulnerabilityProviderReportSchema
|
||||
| typeof provenanceProviderAttestationSchema
|
||||
>,
|
||||
trust: ProviderTrust | null,
|
||||
): boolean {
|
||||
if (!trust || evidence.signature.keyId !== trust.keyId) return false;
|
||||
try {
|
||||
return verify(
|
||||
null,
|
||||
providerEvidenceSignaturePayload(evidence),
|
||||
trust.publicKey,
|
||||
Buffer.from(evidence.signature.value, "base64"),
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { lstat, readFile, readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { gzipSync } from "node:zlib";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { supplyChainDigest } from "./supply-chain.ts";
|
||||
|
||||
const sha256 = z.string().regex(/^[a-f0-9]{64}$/u);
|
||||
const candidateFileSchema = z
|
||||
.object({
|
||||
path: z.string().min(1),
|
||||
bytes: z.int().nonnegative(),
|
||||
sha256,
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const releaseCandidateManifestSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(1),
|
||||
distSha256: sha256,
|
||||
lockfileSha256: sha256,
|
||||
bundleSha256: sha256,
|
||||
files: z.array(candidateFileSchema).min(1),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type ReleaseCandidateManifest = z.infer<
|
||||
typeof releaseCandidateManifestSchema
|
||||
>;
|
||||
|
||||
export const RELEASE_CANDIDATE_MANIFEST_PATH =
|
||||
"artifacts/release/release-candidate.json";
|
||||
|
||||
export const RELEASE_CANDIDATE_EVIDENCE_PATHS = Object.freeze([
|
||||
"artifacts/performance/bundle.json",
|
||||
"artifacts/quality/vite-module-inventory.json",
|
||||
"artifacts/release/build-manifest.json",
|
||||
"artifacts/release/checksums.txt",
|
||||
"artifacts/release/dependency-inventory.json",
|
||||
"artifacts/release/provenance.json",
|
||||
"artifacts/release/verification.json",
|
||||
"artifacts/release/sbom.cdx.json",
|
||||
"artifacts/security/dependency-diff.json",
|
||||
"artifacts/security/license-report.json",
|
||||
"artifacts/security/scan.sarif",
|
||||
"artifacts/security/supply-chain-coherence.json",
|
||||
"artifacts/security/supply-chain-verification.json",
|
||||
"artifacts/security/vulnerability-report.json",
|
||||
]);
|
||||
|
||||
export type DistOutput = Readonly<{
|
||||
path: string;
|
||||
bytes: number;
|
||||
gzipBytes: number;
|
||||
sha256: string;
|
||||
}>;
|
||||
|
||||
export async function collectDistOutputs(
|
||||
repositoryRoot = process.cwd(),
|
||||
): Promise<DistOutput[]> {
|
||||
const distRoot = path.resolve(repositoryRoot, "dist");
|
||||
const files = await regularFilesWithin(distRoot);
|
||||
if (files.length === 0) {
|
||||
throw new Error("dist is missing or empty; run the production build first");
|
||||
}
|
||||
return Promise.all(
|
||||
files.map(async (absolutePath) => {
|
||||
const content = await readFile(absolutePath);
|
||||
return Object.freeze({
|
||||
path: path
|
||||
.relative(repositoryRoot, absolutePath)
|
||||
.replaceAll(path.sep, "/"),
|
||||
bytes: content.byteLength,
|
||||
gzipBytes: gzipSync(content).byteLength,
|
||||
sha256: createHash("sha256").update(content).digest("hex"),
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function distSha256(outputs: readonly DistOutput[]): string {
|
||||
return supplyChainDigest(
|
||||
outputs.map(({ path: outputPath, bytes, sha256 }) => ({
|
||||
path: outputPath,
|
||||
bytes,
|
||||
sha256,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
export async function createReleaseCandidateManifest(
|
||||
repositoryRoot = process.cwd(),
|
||||
): Promise<ReleaseCandidateManifest> {
|
||||
const outputs = await collectDistOutputs(repositoryRoot);
|
||||
const evidence = await Promise.all(
|
||||
RELEASE_CANDIDATE_EVIDENCE_PATHS.map((file) =>
|
||||
digestRequiredFile(repositoryRoot, file),
|
||||
),
|
||||
);
|
||||
const files = [
|
||||
...outputs.map(({ path: outputPath, bytes, sha256 }) => ({
|
||||
path: outputPath,
|
||||
bytes,
|
||||
sha256,
|
||||
})),
|
||||
...evidence,
|
||||
].sort((left, right) => left.path.localeCompare(right.path));
|
||||
const dependencyInventory = JSON.parse(
|
||||
await readFile(
|
||||
path.resolve(repositoryRoot, "artifacts/release/dependency-inventory.json"),
|
||||
"utf8",
|
||||
),
|
||||
) as { lockfileSha256?: unknown };
|
||||
return releaseCandidateManifestSchema.parse({
|
||||
schemaVersion: 1,
|
||||
distSha256: distSha256(outputs),
|
||||
lockfileSha256: dependencyInventory.lockfileSha256,
|
||||
bundleSha256: supplyChainDigest(files),
|
||||
files,
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyReleaseCandidate(
|
||||
value: unknown,
|
||||
repositoryRoot = process.cwd(),
|
||||
): Promise<Readonly<{
|
||||
manifest: ReleaseCandidateManifest | null;
|
||||
currentDistSha256: string | null;
|
||||
failures: readonly string[];
|
||||
}>> {
|
||||
const parsed = releaseCandidateManifestSchema.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
return Object.freeze({
|
||||
manifest: null,
|
||||
currentDistSha256: null,
|
||||
failures: Object.freeze(["release candidate manifest schema mismatch"]),
|
||||
});
|
||||
}
|
||||
const failures: string[] = [];
|
||||
let actual: ReleaseCandidateManifest | null = null;
|
||||
try {
|
||||
actual = await createReleaseCandidateManifest(repositoryRoot);
|
||||
} catch (error) {
|
||||
failures.push(
|
||||
`release candidate inputs unreadable: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
if (actual) {
|
||||
if (parsed.data.distSha256 !== actual.distSha256) {
|
||||
failures.push("release candidate dist digest mismatch");
|
||||
}
|
||||
if (parsed.data.lockfileSha256 !== actual.lockfileSha256) {
|
||||
failures.push("release candidate lockfile digest mismatch");
|
||||
}
|
||||
if (parsed.data.bundleSha256 !== actual.bundleSha256) {
|
||||
failures.push("release candidate bundle digest mismatch");
|
||||
}
|
||||
if (JSON.stringify(parsed.data.files) !== JSON.stringify(actual.files)) {
|
||||
failures.push("release candidate file set or file digest mismatch");
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
manifest: parsed.data,
|
||||
currentDistSha256: actual?.distSha256 ?? null,
|
||||
failures: Object.freeze(failures),
|
||||
});
|
||||
}
|
||||
|
||||
async function digestRequiredFile(repositoryRoot: string, file: string) {
|
||||
const absolutePath = path.resolve(repositoryRoot, file);
|
||||
const relative = path.relative(repositoryRoot, absolutePath);
|
||||
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||
throw new Error(`candidate path escapes repository root: ${file}`);
|
||||
}
|
||||
const metadata = await lstat(absolutePath);
|
||||
if (!metadata.isFile()) {
|
||||
throw new Error(`candidate input is not a regular file: ${file}`);
|
||||
}
|
||||
const content = await readFile(absolutePath);
|
||||
return Object.freeze({
|
||||
path: file,
|
||||
bytes: content.byteLength,
|
||||
sha256: createHash("sha256").update(content).digest("hex"),
|
||||
});
|
||||
}
|
||||
|
||||
async function regularFilesWithin(directory: string): Promise<string[]> {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const files: string[] = [];
|
||||
for (const entry of entries.sort((left, right) =>
|
||||
left.name.localeCompare(right.name),
|
||||
)) {
|
||||
const target = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await regularFilesWithin(target)));
|
||||
} else if (entry.isFile()) {
|
||||
files.push(target);
|
||||
} else {
|
||||
throw new Error(`dist contains a non-regular entry: ${target}`);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
Reference in New Issue
Block a user