Files
WRNexusJS/scripts/generate-sbom.mjs
T
2026-08-01 10:04:42 +05:30

113 lines
4.1 KiB
JavaScript

import console from "node:console";
import { createHash } from "node:crypto";
import { 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 file = join(root, `SBOM-${version}.cdx.json`);
writeFileSync(file, `${JSON.stringify(document, null, 2)}\n`);
console.log(`Generated ${file} with ${components.length} workspace and transitive components.`);