feat: verify frontend supply chain
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { gzipSync } from "node:zlib";
|
||||
import {
|
||||
@@ -9,47 +10,404 @@ import {
|
||||
} 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) {
|
||||
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();
|
||||
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 lockfile = await readFile("pnpm-lock.yaml");
|
||||
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,
|
||||
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 dependencies = {
|
||||
...packageJson.dependencies,
|
||||
...packageJson.devDependencies,
|
||||
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,
|
||||
};
|
||||
const inventory = Object.entries(dependencies)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([name, version]) => ({ name, version, direct: true }));
|
||||
|
||||
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(
|
||||
@@ -59,7 +417,8 @@ await writeFile(
|
||||
context: {
|
||||
nodeVersion: process.version,
|
||||
packageManager: packageJson.packageManager,
|
||||
runnerImage: process.env.CI_RUNNER_IMAGE ?? `${process.platform}-${process.arch}`,
|
||||
runnerImage:
|
||||
process.env.CI_RUNNER_IMAGE ?? `${process.platform}-${process.arch}`,
|
||||
},
|
||||
outputs,
|
||||
},
|
||||
@@ -67,36 +426,66 @@ await writeFile(
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
|
||||
await writeFile(
|
||||
"artifacts/release/dependency-inventory.json",
|
||||
`${JSON.stringify(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
lockfileSha256: createHash("sha256").update(lockfile).digest("hex"),
|
||||
dependencies: inventory,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
`${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: 1,
|
||||
reviewStatus: "local-baseline",
|
||||
directDependencies: inventory.length,
|
||||
highRiskUnreviewed: [],
|
||||
lockfileSha256: createHash("sha256").update(lockfile).digest("hex"),
|
||||
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`,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user