149 lines
5.8 KiB
JavaScript
149 lines
5.8 KiB
JavaScript
#!/usr/bin/env node
|
|
// Generates a lightweight SBOM-style dependency manifest. Docker builds point
|
|
// it at the already-pruned runtime node_modules tree so the document describes
|
|
// files that actually ship, rather than the full development workspace.
|
|
//
|
|
// Why pnpm-list instead of @cyclonedx/cyclonedx-npm:
|
|
// - cyclonedx-npm is opinionated about lockfile shape and tends to mis-handle
|
|
// pnpm workspaces (apps/*, packages/*). pnpm's own resolver is the source of
|
|
// truth, so we capture its output verbatim.
|
|
// - The artifact is consumed for audit traceability (which version of which
|
|
// package shipped in this image), not for CycloneDX validator gating. JSON
|
|
// tree is sufficient.
|
|
//
|
|
// The script is invoked from the Docker `build` stage and the resulting JSON
|
|
// is COPYed into each runtime stage so that the SBOM ships alongside the app.
|
|
|
|
import { spawnSync } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import process from "node:process";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
const ROOT = path.resolve(HERE, "..");
|
|
const DOCUMENT_ROOT = process.env.SBOM_ROOT_PATH ? path.resolve(process.env.SBOM_ROOT_PATH) : ROOT;
|
|
const RUNTIME_NODE_MODULES_PATH = process.env.SBOM_NODE_MODULES_PATH
|
|
? path.resolve(process.env.SBOM_NODE_MODULES_PATH)
|
|
: undefined;
|
|
const OUTPUT_PATH = process.env.SBOM_OUTPUT_PATH
|
|
? path.resolve(process.env.SBOM_OUTPUT_PATH)
|
|
: path.join(ROOT, "sbom-pnpm.json");
|
|
|
|
function runPnpmList() {
|
|
const result = spawnSync(
|
|
"pnpm",
|
|
["list", "-r", "--json", "--depth=Infinity"],
|
|
{ cwd: ROOT, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 },
|
|
);
|
|
if (result.error) {
|
|
throw result.error;
|
|
}
|
|
if (result.status !== 0) {
|
|
const stderr = result.stderr?.toString() ?? "";
|
|
throw new Error(`pnpm list exited with status ${result.status}: ${stderr}`);
|
|
}
|
|
return result.stdout?.toString() ?? "";
|
|
}
|
|
|
|
function parseTree(raw) {
|
|
if (!raw.trim()) {
|
|
return [];
|
|
}
|
|
try {
|
|
return JSON.parse(raw);
|
|
} catch (err) {
|
|
throw new Error(`pnpm list output was not valid JSON: ${(err && err.message) || err}`);
|
|
}
|
|
}
|
|
|
|
function repoVersion(root = DOCUMENT_ROOT) {
|
|
try {
|
|
const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
|
|
return { name: pkg.name ?? null, version: pkg.version ?? null };
|
|
} catch {
|
|
return { name: null, version: null };
|
|
}
|
|
}
|
|
|
|
function packageDirectories(packageRoot) {
|
|
if (!fs.existsSync(packageRoot)) return [];
|
|
const directories = [];
|
|
for (const entry of fs.readdirSync(packageRoot, { withFileTypes: true })) {
|
|
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
|
|
if (entry.name.startsWith("@")) {
|
|
const scopeRoot = path.join(packageRoot, entry.name);
|
|
if (!fs.existsSync(scopeRoot)) continue;
|
|
for (const scopedEntry of fs.readdirSync(scopeRoot, { withFileTypes: true })) {
|
|
if (scopedEntry.isDirectory() || scopedEntry.isSymbolicLink()) {
|
|
directories.push(path.join(scopeRoot, scopedEntry.name));
|
|
}
|
|
}
|
|
} else {
|
|
directories.push(path.join(packageRoot, entry.name));
|
|
}
|
|
}
|
|
return directories;
|
|
}
|
|
|
|
export function collectInstalledRuntimePackages(nodeModulesPath) {
|
|
const virtualStore = path.join(nodeModulesPath, ".pnpm");
|
|
if (!fs.existsSync(virtualStore)) {
|
|
throw new Error(`runtime pnpm virtual store not found: ${virtualStore}`);
|
|
}
|
|
const packages = new Map();
|
|
for (const entry of fs.readdirSync(virtualStore, { withFileTypes: true })) {
|
|
if (!entry.isDirectory() || entry.name === "node_modules") continue;
|
|
const packageRoot = path.join(virtualStore, entry.name, "node_modules");
|
|
for (const directory of packageDirectories(packageRoot)) {
|
|
const manifestPath = path.join(directory, "package.json");
|
|
if (!fs.existsSync(manifestPath)) continue;
|
|
try {
|
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
if (typeof manifest.name !== "string" || typeof manifest.version !== "string") continue;
|
|
const key = `${manifest.name}@${manifest.version}`;
|
|
packages.set(key, {
|
|
name: manifest.name,
|
|
version: manifest.version,
|
|
license: typeof manifest.license === "string" ? manifest.license : null,
|
|
});
|
|
} catch {
|
|
// A malformed third-party manifest is omitted here and will remain
|
|
// visible to the separate fail-closed license inventory gate.
|
|
}
|
|
}
|
|
}
|
|
return [...packages.values()].sort((left, right) => `${left.name}@${left.version}`.localeCompare(`${right.name}@${right.version}`));
|
|
}
|
|
|
|
function generatedAt() {
|
|
const sourceDateEpoch = process.env.SOURCE_DATE_EPOCH;
|
|
if (!sourceDateEpoch || !/^\d+$/.test(sourceDateEpoch)) return null;
|
|
return new Date(Number(sourceDateEpoch) * 1000).toISOString();
|
|
}
|
|
|
|
function main() {
|
|
const runtimePackages = RUNTIME_NODE_MODULES_PATH ? collectInstalledRuntimePackages(RUNTIME_NODE_MODULES_PATH) : undefined;
|
|
const tree = runtimePackages ? undefined : parseTree(runPnpmList());
|
|
const repo = repoVersion();
|
|
const document = {
|
|
schema: "hivegrid.sbom-pnpm/v2",
|
|
generatedAt: generatedAt(),
|
|
generator: "scripts/generate-sbom.mjs",
|
|
source: runtimePackages ? "installed pruned pnpm runtime tree" : "pnpm list -r --json --depth=Infinity",
|
|
target: process.env.SBOM_TARGET ?? null,
|
|
repo,
|
|
...(runtimePackages ? { packageCount: runtimePackages.length, packages: runtimePackages } : { workspaces: tree }),
|
|
};
|
|
fs.mkdirSync(path.dirname(OUTPUT_PATH), { recursive: true });
|
|
fs.writeFileSync(OUTPUT_PATH, JSON.stringify(document, null, 2));
|
|
// eslint-disable-next-line no-console
|
|
console.log(
|
|
`SBOM written: ${OUTPUT_PATH} (${runtimePackages ? `${runtimePackages.length} runtime packages` : `${tree.length} workspace entries`})`,
|
|
);
|
|
}
|
|
|
|
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
|
main();
|
|
}
|