133 lines
4.0 KiB
TypeScript
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`,
|
|
);
|