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.mjs"; /** @param {string} directory @returns {Promise} */ async function filesWithin(directory) { const entries = await readdir(directory, { withFileTypes: true }); const nested = /** @type {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 = JSON.parse( await readFile("artifacts/release/dependency-inventory.json", "utf8"), ); const sbom = JSON.parse( await readFile("artifacts/release/sbom.cdx.json", "utf8"), ); const provenance = JSON.parse( await readFile("artifacts/release/provenance.json", "utf8"), ); const verification = JSON.parse( await readFile( "artifacts/security/supply-chain-verification.json", "utf8", ), ); 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 = [...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 = /** @type {Array>} */ ( inventory.dependencies ?? [] ); const inventoryByIdentity = new Map( inventoryRows.map((entry) => [ `${entry.name}@${entry.version}`, entry, ]), ); 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`, );