Initial Commit
This commit is contained in:
187
scripts/generate-sbom.ts
Normal file
187
scripts/generate-sbom.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
#!/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);
|
||||
Reference in New Issue
Block a user