382 lines
12 KiB
TypeScript
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 {
|
|
bundlePerformanceArtifactSchema,
|
|
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:clean-architecture-frontend-template" },
|
|
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: bundlePerformanceArtifactSchema,
|
|
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`,
|
|
);
|