Files
tech-log-frontend/scripts/generate-supply-chain.ts
T

382 lines
12 KiB
TypeScript

import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import {
mkdir,
readFile,
writeFile,
} from "node:fs/promises";
import {
bundleOutputInventoryArtifactSchema,
buildManifestArtifactSchema,
dependencyDiffArtifactSchema,
dependencyInventoryArtifactSchema,
licenseReportArtifactSchema,
provenanceArtifactSchema,
sbomArtifactSchema,
supplyChainVerificationArtifactSchema,
vulnerabilityReportArtifactSchema,
} from "./contracts/release-artifacts.ts";
import {
flattenPnpmDependencyTree,
isValidSha512Integrity,
parsePnpmLockfilePackages,
supplyChainDigest,
verifySupplyChainCoherence,
} from "./lib/supply-chain.ts";
import {
createLocalVulnerabilityReport,
distChecksumsText,
LOCAL_SUPPLY_CHAIN_UNVERIFIED_DEFAULTS,
recomputeDependencyEvidence,
recomputeLicenseEvidence,
} from "./lib/local-policy-evidence.ts";
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
import { digestReleaseInputFiles } from "./lib/release-input-evidence.ts";
import {
buildRepositoryFileInventory,
parseRepositoryFileInventoryPolicy,
} from "./lib/repository-file-inventory.ts";
import { collectDistOutputs, distSha256 } from "./lib/release-candidate.ts";
import { deterministicSupplyChainGeneratedAt } from "./lib/supply-chain-time.ts";
type Document = Record<string, unknown>;
function isRecord(value: unknown): value is Document {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function documentValue(value: unknown, label: string): Document {
if (!isRecord(value)) throw new Error(`${label} must be a JSON object`);
return value;
}
function stringMap(value: unknown): Record<string, string> {
if (!isRecord(value)) return {};
return Object.fromEntries(
Object.entries(value).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
);
}
async function jsonDocument(file: string): Promise<Document> {
const parsed: unknown = JSON.parse(await readFile(file, "utf8"));
return documentValue(parsed, file);
}
async function sha256File(file: string): Promise<string> {
return createHash("sha256").update(await readFile(file)).digest("hex");
}
async function optionalJson(file: string): Promise<Document | null> {
try {
const parsed: unknown = JSON.parse(await readFile(file, "utf8"));
return isRecord(parsed) ? parsed : null;
} catch {
return null;
}
}
export async function buildDependencyInventory() {
const packageJson = await jsonDocument("package.json");
const lockfileText = await readFile("pnpm-lock.yaml", "utf8");
const lockfileSha256 = createHash("sha256")
.update(lockfileText)
.digest("hex");
const listed = spawnSync(
"corepack",
["pnpm", "list", "--json", "--depth", "Infinity"],
{
encoding: "utf8",
maxBuffer: 32 * 1024 * 1024,
},
);
if (listed.status !== 0) {
throw new Error(`pnpm dependency graph failed: ${listed.stderr}`);
}
const roots: unknown = JSON.parse(listed.stdout);
const root = Array.isArray(roots) && isRecord(roots[0]) ? roots[0] : null;
if (!root) throw new Error("pnpm dependency graph root is invalid");
const flattened = await flattenPnpmDependencyTree(
root,
stringMap(packageJson.dependencies),
stringMap(packageJson.devDependencies),
);
const lockRows = parsePnpmLockfilePackages(lockfileText);
const lockByIdentity = new Map(
lockRows.map((row) => [`${row.name}@${row.version}`, row]),
);
const failures: string[] = [];
const dependencies = flattened.map((dependency) => {
const identity = `${dependency.name}@${dependency.version}`;
const lockRow = lockByIdentity.get(identity);
if (!lockRow) failures.push(`dependency missing from lockfile: ${identity}`);
if (lockRow && !isValidSha512Integrity(lockRow.integrity)) {
failures.push(`dependency has invalid sha512 integrity: ${identity}`);
}
return {
...dependency,
integrity: lockRow?.integrity ?? "missing",
};
});
const inventoryIds = new Set(
dependencies.map((dependency) => `${dependency.name}@${dependency.version}`),
);
for (const lockRow of lockRows) {
const identity = `${lockRow.name}@${lockRow.version}`;
if (!inventoryIds.has(identity)) {
failures.push(`transitive lockfile dependency omitted: ${identity}`);
}
}
if (failures.length > 0) {
throw new Error(failures.join("\n"));
}
return {
schemaVersion: 2,
packageManager: String(packageJson.packageManager ?? ""),
lockfileSha256,
dependencyCount: dependencies.length,
directDependencyCount: dependencies.filter((entry) => entry.direct).length,
dependencies,
};
}
const packageJson = await jsonDocument("package.json");
const buildManifest = buildManifestArtifactSchema.parse(
await jsonDocument("artifacts/release/build-manifest.json"),
);
const secretScanPolicy = documentValue(
JSON.parse(await readFile("config/security/secret-scan-policy.json", "utf8")),
"secret scan policy",
);
const inventoryPolicy = parseRepositoryFileInventoryPolicy(secretScanPolicy);
const repositoryInventory = await buildRepositoryFileInventory({
trackedRoots: inventoryPolicy.trackedRoots,
generatedRoots: inventoryPolicy.generatedRoots,
optionalRoots: inventoryPolicy.optionalRoots,
});
const outputs = await collectDistOutputs();
const distDigest = distSha256(outputs);
const inventory = await buildDependencyInventory();
const licensePolicy = JSON.parse(
await readFile("config/security/dependency-policy.json", "utf8"),
);
const baseline = await optionalJson(
"config/security/dependency-baseline.json",
);
const baselineApproval = await optionalJson(
"config/security/dependency-baseline.approval.json",
);
const dependencyEvidence = JSON.parse(
await readFile(
"config/security/dependency-change-evidence.json",
"utf8",
),
);
const skipsBaseline = process.argv.includes("--no-baseline");
const dependencyPolicy = recomputeDependencyEvidence({
inventory,
baseline,
baselineApproval,
dependencyChangeEvidence: dependencyEvidence,
skipBaseline: skipsBaseline,
});
const licenseEvidence = recomputeLicenseEvidence({
inventory,
policy: licensePolicy,
});
const vulnerabilityReport = createLocalVulnerabilityReport(
inventory.lockfileSha256,
);
const sourceFiles = [...repositoryInventory.trackedFiles];
const sourceSetSha256 = await digestReleaseInputFiles(sourceFiles);
const components = inventory.dependencies.map((dependency) => ({
type: "library",
"bom-ref": `pkg:npm/${encodeURIComponent(dependency.name)}@${dependency.version}`,
name: dependency.name,
version: dependency.version,
scope: dependency.optional ? "optional" : "required",
hashes: [
{
alg: "SHA-512",
content: dependency.integrity.slice("sha512-".length),
},
],
licenses:
dependency.license === "NOASSERTION"
? [{ expression: "NOASSERTION" }]
: [{ expression: dependency.license }],
properties: [
{ name: "ca:direct", value: String(dependency.direct) },
{ name: "ca:scope", value: dependency.scope },
],
}));
const serialSeed = supplyChainDigest({
lockfileSha256: inventory.lockfileSha256,
components: components.map((component) => component["bom-ref"]),
});
const sbom = {
bomFormat: "CycloneDX",
specVersion: "1.6",
serialNumber: `urn:uuid:${serialSeed.slice(0, 8)}-${serialSeed.slice(8, 12)}-${serialSeed.slice(12, 16)}-${serialSeed.slice(16, 20)}-${serialSeed.slice(20, 32)}`,
version: 1,
metadata: {
component: {
type: "application",
name: String(packageJson.name ?? ""),
version: String(packageJson.version ?? ""),
},
properties: [
{
name: "ca:lockfileSha256",
value: inventory.lockfileSha256,
},
],
},
components,
dependencies: inventory.dependencies.map((dependency) => ({
ref: `pkg:npm/${encodeURIComponent(dependency.name)}@${dependency.version}`,
dependsOn: dependency.dependencies.map((identity) => {
const separator = identity.lastIndexOf("@");
return `pkg:npm/${encodeURIComponent(identity.slice(0, separator))}@${identity.slice(separator + 1)}`;
}),
})),
};
const provenance = {
_type: "https://in-toto.io/Statement/v1",
subject: [{ name: "dist", digest: { sha256: distDigest } }],
predicateType: "https://slsa.dev/provenance/v1",
predicate: {
buildDefinition: {
buildType: "https://vite.dev/build/v1",
externalParameters: {
nodeVersion: process.version,
packageManager: String(packageJson.packageManager ?? ""),
},
internalParameters: {
sourceSetSha256,
},
resolvedDependencies: [
{
uri: "pnpm-lock.yaml",
digest: { sha256: inventory.lockfileSha256 },
},
],
},
runDetails: {
builder: { id: "local:tech-log-frontend" },
metadata: { invocationId: "LOCAL_UNSIGNED" },
},
materials: {
lockfileSha256: inventory.lockfileSha256,
sourceSetSha256,
sbomSha256: supplyChainDigest(sbom),
},
},
};
const coherence = verifySupplyChainCoherence(
sbom,
inventory,
provenance,
distDigest,
);
const localFailures = [
...licenseEvidence.failures,
...dependencyPolicy.failures,
...coherence.failures,
];
const localPassed = localFailures.length === 0;
const verification = {
schemaVersion: 1,
localStatus: localPassed ? "PASS" : "FAIL",
...LOCAL_SUPPLY_CHAIN_UNVERIFIED_DEFAULTS,
lockfileSha256: inventory.lockfileSha256,
sourceSetSha256,
distSha256: distDigest,
sbomSha256: supplyChainDigest(sbom),
dependencyDiff: dependencyPolicy.dependencyDiff,
highRiskReview: dependencyPolicy.highRisk,
failures: localFailures,
};
const bundleReport = {
schemaVersion: 1,
generatedAt: deterministicSupplyChainGeneratedAt({
generatedAt: buildManifest.generatedAt,
sourceDateEpoch: buildManifest.buildContext.sourceDateEpoch,
}),
context: {
nodeVersion: process.version,
packageManager: String(packageJson.packageManager ?? ""),
runnerImage:
process.env.CI_RUNNER_IMAGE ?? `${process.platform}-${process.arch}`,
},
outputs,
};
const dependencyDiffReport = dependencyPolicy.report;
const licenseReport = licenseEvidence.report;
await mkdir("artifacts/performance", { recursive: true });
await mkdir("artifacts/release", { recursive: true });
await mkdir("artifacts/security", { recursive: true });
await writeValidatedJsonArtifact({
path: "artifacts/performance/bundle.json",
schema: bundleOutputInventoryArtifactSchema,
value: bundleReport,
});
await writeValidatedJsonArtifact({
path: "artifacts/release/dependency-inventory.json",
schema: dependencyInventoryArtifactSchema,
value: inventory,
});
await writeValidatedJsonArtifact({
path: "artifacts/release/sbom.cdx.json",
schema: sbomArtifactSchema,
value: sbom,
});
await writeValidatedJsonArtifact({
path: "artifacts/release/provenance.json",
schema: provenanceArtifactSchema,
value: provenance,
});
await writeFile(
"artifacts/release/checksums.txt",
distChecksumsText(outputs),
);
await writeValidatedJsonArtifact({
path: "artifacts/security/dependency-diff.json",
schema: dependencyDiffArtifactSchema,
value: dependencyDiffReport,
});
await writeValidatedJsonArtifact({
path: "artifacts/security/license-report.json",
schema: licenseReportArtifactSchema,
value: licenseReport,
});
await writeValidatedJsonArtifact({
path: "artifacts/security/vulnerability-report.json",
schema: vulnerabilityReportArtifactSchema,
value: vulnerabilityReport,
});
await writeValidatedJsonArtifact({
path: "artifacts/security/supply-chain-verification.json",
schema: supplyChainVerificationArtifactSchema,
value: verification,
});
if (!localPassed) {
process.stderr.write(
`Local supply-chain verification failed:\n- ${localFailures.join("\n- ")}\n`,
);
process.exit(1);
}
process.stdout.write(
`Supply chain: LOCAL PASS (${inventory.dependencyCount} dependencies); promotion=${verification.promotionStatus}\n`,
);