import console from "node:console"; import { createHash } from "node:crypto"; import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const rootManifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); const components = []; function npmPurl(name, version) { if (name.startsWith("@")) { const slash = name.indexOf("/"); return `pkg:npm/%40${encodeURIComponent(name.slice(1, slash))}/${encodeURIComponent(name.slice(slash + 1))}@${version}`; } return `pkg:npm/${encodeURIComponent(name)}@${version}`; } for (const entry of readdirSync(join(root, "packages"), { withFileTypes: true })) { if (!entry.isDirectory()) continue; const file = join(root, "packages", entry.name, "package.json"); try { const manifest = JSON.parse(readFileSync(file, "utf8")); if (!manifest.name?.startsWith("@wrnexus/")) continue; components.push({ type: "library", name: manifest.name, version: manifest.version, "bom-ref": npmPurl(manifest.name, manifest.version), purl: npmPurl(manifest.name, manifest.version), properties: [ { name: "wrnexus:workspace", value: "true" }, { name: "wrnexus:private", value: String(manifest.publishConfig?.access !== "public") }, ], }); } catch { // Not a workspace package. } } // Bun's text lockfile is JSON with optional trailing commas. Package tuples use // ["name@version", resolved, metadata, integrity], which is enough to include // every resolved transitive dependency without requiring node_modules. try { const lockSource = readFileSync(join(root, "bun.lock"), "utf8").replace(/,\s*([}\]])/g, "$1"); const lock = JSON.parse(lockSource); const external = new Map(); for (const value of Object.values(lock.packages ?? {})) { if (!Array.isArray(value) || typeof value[0] !== "string") continue; const specifier = value[0]; const split = specifier.lastIndexOf("@"); if (split <= 0) continue; const name = specifier.slice(0, split); const version = specifier.slice(split + 1); if (!name || !version || name.startsWith("@wrnexus/")) continue; const ref = npmPurl(name, version); const component = { type: "library", name, version, "bom-ref": ref, purl: ref, properties: [{ name: "wrnexus:dependency-source", value: "bun.lock" }], }; const integrity = typeof value[3] === "string" ? value[3] : ""; if (integrity.startsWith("sha512-")) { component.hashes = [{ alg: "SHA-512", content: integrity.slice("sha512-".length) }]; } external.set(ref, component); } components.push(...external.values()); } catch (error) { throw new Error( `Unable to parse bun.lock for the SBOM: ${error instanceof Error ? error.message : String(error)}`, { cause: error }, ); } components.sort((a, b) => { const left = `${a.name}@${a.version}`; const right = `${b.name}@${b.version}`; return left < right ? -1 : left > right ? 1 : 0; }); function deterministicUuid(value) { const hash = createHash("sha256").update(value).digest("hex").slice(0, 32).split(""); hash[12] = "5"; hash[16] = ((Number.parseInt(hash[16], 16) & 0x3) | 0x8).toString(16); const hex = hash.join(""); return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; } const version = String(rootManifest.version); const identity = JSON.stringify({ framework: "WRNexusJS", version, components }); const document = { bomFormat: "CycloneDX", specVersion: "1.5", serialNumber: `urn:uuid:${deterministicUuid(identity)}`, version: 1, metadata: { component: { type: "framework", name: "WRNexusJS", version, "bom-ref": `pkg:generic/WRNexusJS@${version}`, }, }, components, }; const reportDir = join(root, ".wrnexus", "reports"); mkdirSync(reportDir, { recursive: true }); const file = join(reportDir, `SBOM-${version}.cdx.json`); writeFileSync(file, `${JSON.stringify(document, null, 2)}\n`); console.log(`Generated ${file} with ${components.length} workspace and transitive components.`);