492 lines
14 KiB
JavaScript
492 lines
14 KiB
JavaScript
import { spawnSync } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import { gzipSync } from "node:zlib";
|
|
import {
|
|
mkdir,
|
|
readFile,
|
|
readdir,
|
|
stat,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import path from "node:path";
|
|
|
|
import {
|
|
diffDependencyInventories,
|
|
flattenPnpmDependencyTree,
|
|
isValidSha512Integrity,
|
|
parsePnpmLockfilePackages,
|
|
supplyChainDigest,
|
|
validateDependencyReview,
|
|
validateLicensePolicy,
|
|
validateVulnerabilityReport,
|
|
verifySupplyChainCoherence,
|
|
} from "./lib/supply-chain.mjs";
|
|
|
|
/** @param {string} directory @returns {Promise<string[]>} */
|
|
async function filesWithin(directory) {
|
|
try {
|
|
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();
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/** @param {string} file */
|
|
async function sha256File(file) {
|
|
return createHash("sha256").update(await readFile(file)).digest("hex");
|
|
}
|
|
|
|
/** @param {string[]} files */
|
|
async function digestFileSet(files) {
|
|
const rows = await Promise.all(
|
|
files.sort().map(async (file) => ({
|
|
path: file.replaceAll("\\", "/"),
|
|
sha256: await sha256File(file),
|
|
})),
|
|
);
|
|
return supplyChainDigest(rows);
|
|
}
|
|
|
|
/** @param {string} file @returns {Promise<Record<string, unknown> | null>} */
|
|
async function optionalJson(file) {
|
|
try {
|
|
return JSON.parse(await readFile(file, "utf8"));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function buildDependencyInventory() {
|
|
const packageJson = JSON.parse(await readFile("package.json", "utf8"));
|
|
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 = JSON.parse(listed.stdout);
|
|
const root = roots[0];
|
|
const flattened = await flattenPnpmDependencyTree(
|
|
root,
|
|
packageJson.dependencies ?? {},
|
|
packageJson.devDependencies ?? {},
|
|
);
|
|
const lockRows = parsePnpmLockfilePackages(lockfileText);
|
|
const lockByIdentity = new Map(
|
|
lockRows.map((row) => [`${row.name}@${row.version}`, row]),
|
|
);
|
|
const failures = [];
|
|
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: packageJson.packageManager,
|
|
lockfileSha256,
|
|
dependencyCount: dependencies.length,
|
|
directDependencyCount: dependencies.filter((entry) => entry.direct).length,
|
|
dependencies,
|
|
};
|
|
}
|
|
|
|
const packageJson = JSON.parse(await readFile("package.json", "utf8"));
|
|
const outputFiles = await filesWithin("dist");
|
|
if (outputFiles.length === 0) {
|
|
throw new Error("dist is missing; run the production build first");
|
|
}
|
|
const outputs = await Promise.all(
|
|
outputFiles.map(async (outputFile) => {
|
|
const content = await readFile(outputFile);
|
|
const metadata = await stat(outputFile);
|
|
return {
|
|
path: outputFile.replaceAll("\\", "/"),
|
|
bytes: metadata.size,
|
|
gzipBytes: gzipSync(content).byteLength,
|
|
sha256: createHash("sha256").update(content).digest("hex"),
|
|
};
|
|
}),
|
|
);
|
|
const distDigest = supplyChainDigest(
|
|
outputs.map(({ path: outputPath, bytes, sha256 }) => ({
|
|
path: outputPath,
|
|
bytes,
|
|
sha256,
|
|
})),
|
|
);
|
|
const inventory = await buildDependencyInventory();
|
|
const licensePolicy = JSON.parse(
|
|
await readFile("config/security/dependency-policy.json", "utf8"),
|
|
);
|
|
const licenseResult = validateLicensePolicy(inventory, licensePolicy);
|
|
|
|
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 baselineFailures = [];
|
|
let dependencyDiff =
|
|
/** @type {ReturnType<typeof diffDependencyInventories>} */ ({
|
|
added: [],
|
|
removed: [],
|
|
changed: [],
|
|
upgrades: [],
|
|
});
|
|
let reviewResult =
|
|
/** @type {ReturnType<typeof validateDependencyReview>} */ ({
|
|
passed: skipsBaseline,
|
|
highRisk: [],
|
|
failures: skipsBaseline ? [] : ["dependency baseline unavailable"],
|
|
});
|
|
if (baseline && baselineApproval) {
|
|
const actualBaselineDigest = supplyChainDigest(baseline);
|
|
if (
|
|
baselineApproval.schemaVersion !== 1 ||
|
|
baselineApproval.snapshotDigest !== actualBaselineDigest ||
|
|
typeof baselineApproval.owner !== "string" ||
|
|
!baselineApproval.owner
|
|
) {
|
|
baselineFailures.push("dependency baseline approval digest mismatch");
|
|
}
|
|
dependencyDiff = diffDependencyInventories(baseline, inventory);
|
|
reviewResult = validateDependencyReview(
|
|
dependencyDiff,
|
|
inventory,
|
|
dependencyEvidence,
|
|
);
|
|
} else if (!skipsBaseline) {
|
|
baselineFailures.push("dependency baseline and approval are required");
|
|
}
|
|
|
|
const vulnerabilityPolicy = JSON.parse(
|
|
await readFile("config/security/vulnerability-policy.json", "utf8"),
|
|
);
|
|
const vulnerabilityExceptions = JSON.parse(
|
|
await readFile("config/security/vulnerability-exceptions.json", "utf8"),
|
|
);
|
|
const vulnerabilityInput = process.env.VULNERABILITY_REPORT_PATH
|
|
? await optionalJson(process.env.VULNERABILITY_REPORT_PATH)
|
|
: null;
|
|
const vulnerabilityResult = vulnerabilityInput
|
|
? validateVulnerabilityReport(
|
|
vulnerabilityInput,
|
|
vulnerabilityPolicy,
|
|
vulnerabilityExceptions,
|
|
inventory.lockfileSha256,
|
|
)
|
|
: {
|
|
passed: false,
|
|
failures: ["external vulnerability provider report is missing"],
|
|
blocking: [],
|
|
};
|
|
const vulnerabilityReport = {
|
|
schemaVersion: 1,
|
|
provider: vulnerabilityInput?.provider ?? "UNCONFIGURED",
|
|
scannedLockfileSha256:
|
|
vulnerabilityInput?.scannedLockfileSha256 ?? inventory.lockfileSha256,
|
|
status: vulnerabilityInput
|
|
? vulnerabilityResult.passed
|
|
? "PASS"
|
|
: "FAIL"
|
|
: "FAIL_UNVERIFIED",
|
|
findings: vulnerabilityInput?.findings ?? [],
|
|
exceptionsApplied:
|
|
vulnerabilityInput && vulnerabilityResult.passed
|
|
? vulnerabilityExceptions.exceptions
|
|
: [],
|
|
failures: vulnerabilityResult.failures,
|
|
blocking: vulnerabilityResult.blocking,
|
|
};
|
|
|
|
const sourceFiles = (
|
|
await Promise.all(
|
|
[
|
|
"src",
|
|
"scripts",
|
|
"config",
|
|
"public",
|
|
"schemas",
|
|
"package.json",
|
|
"pnpm-lock.yaml",
|
|
"vite.config.js",
|
|
].map(async (target) => {
|
|
try {
|
|
const metadata = await stat(target);
|
|
return metadata.isDirectory() ? filesWithin(target) : [target];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}),
|
|
)
|
|
).flat();
|
|
const sourceSetSha256 = await digestFileSet(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: packageJson.name,
|
|
version: 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: 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 attestationInput = process.env.PROVENANCE_ATTESTATION_PATH
|
|
? await optionalJson(process.env.PROVENANCE_ATTESTATION_PATH)
|
|
: null;
|
|
const attestationSubject =
|
|
/** @type {Record<string, unknown>} */ (
|
|
/** @type {Record<string, unknown>} */ (
|
|
attestationInput?.subject ?? {}
|
|
).digest ?? {}
|
|
);
|
|
const attestationPassed =
|
|
attestationSubject.sha256 === distDigest &&
|
|
typeof attestationInput?.provider === "string" &&
|
|
Boolean(attestationInput.provider) &&
|
|
typeof attestationInput?.signer === "string" &&
|
|
Boolean(attestationInput.signer);
|
|
const localFailures = [
|
|
...licenseResult.failures,
|
|
...baselineFailures,
|
|
...reviewResult.failures,
|
|
...coherence.failures,
|
|
];
|
|
if (vulnerabilityInput && !vulnerabilityResult.passed) {
|
|
localFailures.push(
|
|
...vulnerabilityResult.failures,
|
|
...vulnerabilityResult.blocking,
|
|
);
|
|
}
|
|
const localPassed = localFailures.length === 0;
|
|
const promotionPassed =
|
|
localPassed && vulnerabilityResult.passed && attestationPassed;
|
|
const verification = {
|
|
schemaVersion: 1,
|
|
localStatus: localPassed ? "PASS" : "FAIL",
|
|
promotionStatus: promotionPassed ? "PASS" : "FAIL_UNVERIFIED",
|
|
lockfileSha256: inventory.lockfileSha256,
|
|
sourceSetSha256,
|
|
distSha256: distDigest,
|
|
sbomSha256: supplyChainDigest(sbom),
|
|
dependencyDiff,
|
|
highRiskReview: reviewResult.highRisk,
|
|
vulnerabilityStatus: vulnerabilityReport.status,
|
|
provenanceAttestationStatus: attestationPassed
|
|
? "PASS"
|
|
: "FAIL_UNVERIFIED",
|
|
failures: localFailures,
|
|
};
|
|
|
|
await mkdir("artifacts/performance", { recursive: true });
|
|
await mkdir("artifacts/release", { recursive: true });
|
|
await mkdir("artifacts/security", { recursive: true });
|
|
await writeFile(
|
|
"artifacts/performance/bundle.json",
|
|
`${JSON.stringify(
|
|
{
|
|
schemaVersion: 1,
|
|
generatedAt: new Date().toISOString(),
|
|
context: {
|
|
nodeVersion: process.version,
|
|
packageManager: packageJson.packageManager,
|
|
runnerImage:
|
|
process.env.CI_RUNNER_IMAGE ?? `${process.platform}-${process.arch}`,
|
|
},
|
|
outputs,
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
);
|
|
await writeFile(
|
|
"artifacts/release/dependency-inventory.json",
|
|
`${JSON.stringify(inventory, null, 2)}\n`,
|
|
);
|
|
await writeFile(
|
|
"artifacts/release/sbom.cdx.json",
|
|
`${JSON.stringify(sbom, null, 2)}\n`,
|
|
);
|
|
await writeFile(
|
|
"artifacts/release/provenance.json",
|
|
`${JSON.stringify(provenance, null, 2)}\n`,
|
|
);
|
|
await writeFile(
|
|
"artifacts/release/checksums.txt",
|
|
`${outputs.map((output) => `${output.sha256} ${output.path}`).join("\n")}\n`,
|
|
);
|
|
await writeFile(
|
|
"artifacts/security/dependency-diff.json",
|
|
`${JSON.stringify(
|
|
{
|
|
schemaVersion: 2,
|
|
baselineDigest: baseline ? supplyChainDigest(baseline) : null,
|
|
currentDigest: supplyChainDigest(inventory),
|
|
...dependencyDiff,
|
|
highRisk: reviewResult.highRisk,
|
|
reviewFailures: reviewResult.failures,
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
);
|
|
await writeFile(
|
|
"artifacts/security/license-report.json",
|
|
`${JSON.stringify(
|
|
{
|
|
schemaVersion: 1,
|
|
status: licenseResult.passed ? "PASS" : "FAIL",
|
|
dependencyCount: inventory.dependencyCount,
|
|
results: licenseResult.results,
|
|
failures: licenseResult.failures,
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
);
|
|
await writeFile(
|
|
"artifacts/security/vulnerability-report.json",
|
|
`${JSON.stringify(vulnerabilityReport, null, 2)}\n`,
|
|
);
|
|
await writeFile(
|
|
"artifacts/security/supply-chain-verification.json",
|
|
`${JSON.stringify(verification, null, 2)}\n`,
|
|
);
|
|
|
|
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`,
|
|
);
|