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