Files
clean-architecture-frontend…/scripts/verify-supply-chain-artifacts.ts
T

133 lines
4.0 KiB
TypeScript

import { createHash } from "node:crypto";
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import {
isValidSha512Integrity,
parsePnpmLockfilePackages,
supplyChainDigest,
verifySupplyChainCoherence,
} from "./lib/supply-chain.ts";
type Document = Record<string, unknown>;
function isRecord(value: unknown): value is Document {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function parseDocument(text: string, label: string): Document {
const parsed: unknown = JSON.parse(text);
if (!isRecord(parsed)) throw new Error(`${label} must be a JSON object`);
return parsed;
}
function recordRows(value: unknown): Document[] {
return Array.isArray(value) ? value.filter(isRecord) : [];
}
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",
);
const sbom = await readDocument("artifacts/release/sbom.cdx.json");
const provenance = await readDocument("artifacts/release/provenance.json");
const verification = await readDocument(
"artifacts/security/supply-chain-verification.json",
);
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 coherence = verifySupplyChainCoherence(
sbom,
inventory,
provenance,
distDigest,
);
const failures: string[] = [...coherence.failures];
if (
inventory.lockfileSha256 !== lockfileSha256 ||
verification.lockfileSha256 !== lockfileSha256
) {
failures.push("inventory/verification lockfile digest mismatch");
}
if (
verification.distSha256 !== distDigest ||
verification.sbomSha256 !== supplyChainDigest(sbom)
) {
failures.push("verification digest set is incoherent");
}
const lockRows = parsePnpmLockfilePackages(lockfileText);
const inventoryRows = recordRows(inventory.dependencies);
const inventoryByIdentity = new Map<string, Document>(
inventoryRows.map(
(entry) => [
`${String(entry.name ?? "")}@${String(entry.version ?? "")}`,
entry,
] as const,
),
);
if (lockRows.length !== inventoryRows.length) {
failures.push("transitive dependency count differs from lockfile");
}
for (const lockRow of lockRows) {
const identity = `${lockRow.name}@${lockRow.version}`;
const dependency = inventoryByIdentity.get(identity);
if (
!dependency ||
dependency.integrity !== lockRow.integrity ||
!isValidSha512Integrity(lockRow.integrity)
) {
failures.push(`lockfile inventory integrity mismatch: ${identity}`);
}
}
const report = {
schemaVersion: 1,
status: failures.length === 0 ? "PASS" : "FAIL",
dependencyCount: inventoryRows.length,
lockfileSha256,
distSha256: distDigest,
sbomSha256: supplyChainDigest(sbom),
failures,
};
await mkdir("artifacts/security", { recursive: true });
await writeFile(
"artifacts/security/supply-chain-coherence.json",
`${JSON.stringify(report, null, 2)}\n`,
);
if (failures.length > 0) {
process.stderr.write(
`Supply-chain artifact coherence failed:\n- ${failures.join("\n- ")}\n`,
);
process.exit(1);
}
process.stdout.write(
`Supply-chain artifact coherence: PASS (${inventoryRows.length} dependencies)\n`,
);