#!/usr/bin/env bun /** * SBOM (Software Bill of Materials) generation script * * Generates CycloneDX 1.5 SBOMs for all (or specific) packages in the monorepo. * SBOMs are written to each package's dist/ directory alongside the build output. * * Usage: * bun scripts/generate-sbom.ts # All packages * bun scripts/generate-sbom.ts --package @suaveplan/utils # Single package * bun scripts/generate-sbom.ts --output-dir ./sboms # Custom output dir */ import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { CONFIG } from "./lib/config.js"; import { discoverPackageDirs } from "./lib/discover-packages.js"; const ROOT_DIR = CONFIG.paths.root; interface CliOptions { specificPackage?: string; outputDir?: string; } function parseArgs(): CliOptions { const args = process.argv.slice(2); const options: CliOptions = {}; for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg === "--package" && i + 1 < args.length) { options.specificPackage = args[++i]; } else if (arg.startsWith("--package=")) { options.specificPackage = arg.slice(10); } else if (arg === "--output-dir" && i + 1 < args.length) { options.outputDir = args[++i]; } else if (arg.startsWith("--output-dir=")) { options.outputDir = arg.slice(13); } } return options; } interface PackageInfo { name: string; version: string; description?: string; license?: string; dependencies: Record; dir: string; } async function getPackages(specificPackage?: string): Promise { const pkgDirs = await discoverPackageDirs(); const packages: PackageInfo[] = []; for (const pkgDir of pkgDirs) { const pkgJsonPath = join(pkgDir, "package.json"); const content = await readFile(pkgJsonPath, "utf-8"); const pkg = JSON.parse(content); if (pkg.private) continue; if (specificPackage && pkg.name !== specificPackage) continue; packages.push({ name: pkg.name, version: pkg.version, description: pkg.description, license: pkg.license, dependencies: pkg.dependencies ?? {}, dir: pkgDir, }); } return packages; } function generateCycloneDxSbom(pkg: PackageInfo): object { const components = Object.entries(pkg.dependencies).map( ([name, version]) => ({ type: "library", name, version: resolveVersion(version), purl: buildPurl(name, resolveVersion(version)), }) ); return { bomFormat: "CycloneDX", specVersion: "1.5", serialNumber: `urn:uuid:${crypto.randomUUID()}`, version: 1, metadata: { timestamp: new Date().toISOString(), tools: [{ name: "@suaveplan/slsa", version: "1.0.0" }], component: { type: "library", name: pkg.name, version: pkg.version, purl: buildPurl(pkg.name, pkg.version), description: pkg.description, license: pkg.license, }, }, components, }; } function buildPurl(name: string, version: string): string { if (name.startsWith("@")) { const [scope, pkg] = name.split("/"); return `pkg:npm/${encodeURIComponent(scope!)}/${pkg}@${version}`; } return `pkg:npm/${name}@${version}`; } function resolveVersion(version: string): string { if (version.startsWith("workspace:")) { return version.replace("workspace:", ""); } if (version.startsWith("catalog:")) { return version.replace("catalog:", "catalog-"); } return version; } async function main() { const options = parseArgs(); console.log("=".repeat(60)); console.log("SBOM Generation (CycloneDX 1.5)"); if (options.specificPackage) { console.log(`Package: ${options.specificPackage}`); } console.log("=".repeat(60)); const packages = await getPackages(options.specificPackage); console.log(`Found ${packages.length} packages\n`); let generated = 0; let failed = 0; for (const pkg of packages) { try { const sbom = generateCycloneDxSbom(pkg); const json = JSON.stringify(sbom, null, 2); const outputDir = options.outputDir ? join(ROOT_DIR, options.outputDir) : join(pkg.dir, "dist"); try { await stat(outputDir); } catch { await mkdir(outputDir, { recursive: true }); } const filename = "sbom.cdx.json"; const outputPath = join(outputDir, filename); await writeFile(outputPath, json); const depCount = Object.keys(pkg.dependencies).length; console.log( ` ${pkg.name}@${pkg.version} - ${depCount} deps -> ${outputPath}` ); generated++; } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); console.error(` ${pkg.name} - FAILED: ${message}`); failed++; } } console.log(); console.log("=".repeat(60)); console.log(`Generated: ${generated} | Failed: ${failed}`); console.log("=".repeat(60)); if (failed > 0) { process.exit(1); } } main().catch(console.error);