188 lines
5.5 KiB
TypeScript
188 lines
5.5 KiB
TypeScript
#!/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<string, string>;
|
|
dir: string;
|
|
}
|
|
|
|
async function getPackages(specificPackage?: string): Promise<PackageInfo[]> {
|
|
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);
|