Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/scripts/generate-sbom.mjs
T

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();
}