SysDeck/klanker-gate/scripts/generate_sbom.ts

598 lines
18 KiB
TypeScript
Executable File

import { fromFileUrl } from "@std/path";
const docsRoot = new URL("../docs/reference/", import.meta.url);
const sbomJsonPath = new URL("sbom/sbom.cyclonedx.json", docsRoot);
const sbomMdPath = new URL("sbom.md", docsRoot);
type Scope = "required" | "dev" | "optional";
type Directness = "Direct" | "Transitive";
interface ComponentRow {
bomRef: string;
type: string;
name: string;
version: string;
ecosystem: string;
purl: string;
license: string;
directness: Directness;
scope: Scope;
}
interface CycloneComponent {
"bom-ref": string;
type: string;
name: string;
version: string;
purl: string;
licenses?: Array<{ license: { id?: string; name?: string } }>;
}
interface LockLike {
version: string;
specifiers: Record<string, string>;
jsr?: Record<string, { dependencies?: string[] }>;
npm?: Record<string, { dependencies?: string[] }>;
}
function stripJsonc(input: string): string {
return input.replace(/(^|\n)\s*\/\/.*(?=\n|$)/g, "$1");
}
function readJson<T>(path: URL): T {
return JSON.parse(Deno.readTextFileSync(path)) as T;
}
function readJsonc<T>(path: URL): T {
return JSON.parse(stripJsonc(Deno.readTextFileSync(path))) as T;
}
function extractVersion(): string {
const source = Deno.readTextFileSync(
new URL("../apps/gateway/context.ts", import.meta.url),
);
const match = source.match(/export const VERSION = "([^"]+)"/);
if (!match) {
throw new Error("Could not extract VERSION from apps/gateway/context.ts");
}
return match[1];
}
function encodePurlName(name: string): string {
return name
.split("/")
.map((part) => encodeURIComponent(part))
.join("/");
}
function licenseBlock(license: string) {
return license === "Apache-2.0"
? [{ license: { id: license } }]
: [{ license: { name: license } }];
}
function parseNpmKey(key: string): { name: string; version: string } {
const at = key.startsWith("@")
? key.indexOf("@", key.indexOf("/") + 1)
: key.indexOf("@");
const name = key.slice(0, at);
const version = key.slice(at + 1).split("_")[0];
return { name, version };
}
function parseJsrKey(key: string): { name: string; version: string } {
const at = key.startsWith("@")
? key.indexOf("@", key.indexOf("/") + 1)
: key.indexOf("@");
return { name: key.slice(0, at), version: key.slice(at + 1) };
}
function parseJsrSpecifier(specifier: string): {
name: string;
versionSpec?: string;
} {
const raw = specifier.replace(/^jsr:/, "");
const at = raw.startsWith("@")
? raw.indexOf("@", raw.indexOf("/") + 1)
: raw.indexOf("@");
if (at === -1) {
return { name: raw };
}
return { name: raw.slice(0, at), versionSpec: raw.slice(at + 1) };
}
function jsrPurl(name: string, version: string): string {
return `pkg:jsr/${encodePurlName(name)}@${encodeURIComponent(version)}`;
}
function npmPurl(name: string, version: string): string {
return `pkg:npm/${encodePurlName(name)}@${encodeURIComponent(version)}`;
}
function dockerPurl(name: string, version: string): string {
return `pkg:docker/${name}@${encodeURIComponent(version)}`;
}
function candidateNpmKeys(
index: Map<string, string[]>,
depName: string,
): string[] {
return index.get(depName) ?? [];
}
function resolveNpmKey(
lock: LockLike,
index: Map<string, string[]>,
name: string,
declaredSpec: string,
): string | undefined {
const resolved = lock.specifiers[`npm:${name}@${declaredSpec}`];
const candidates = candidateNpmKeys(index, name);
if (resolved) {
const exact = candidates.find((key) =>
parseNpmKey(key).version === resolved
);
if (exact) {
return exact;
}
}
return candidates[0];
}
function resolveJsrKey(
lock: LockLike,
keys: string[],
specifier: string,
): string | undefined {
const resolved = lock.specifiers[specifier];
const { name: rawName } = parseJsrSpecifier(specifier);
if (resolved) {
const exact = keys.find((key) => {
const parsed = parseJsrKey(key);
return parsed.name === rawName && parsed.version === resolved;
});
if (exact) {
return exact;
}
}
return keys.find((key) => parseJsrKey(key).name === rawName);
}
function crawl<T>(
roots: Iterable<T>,
next: (node: T) => Iterable<T>,
): Set<T> {
const seen = new Set<T>();
const queue = [...roots];
while (queue.length > 0) {
const current = queue.shift()!;
if (seen.has(current)) {
continue;
}
seen.add(current);
for (const child of next(current)) {
if (!seen.has(child)) {
queue.push(child);
}
}
}
return seen;
}
function escapeMd(value: string): string {
return value.replace(/\|/g, "\\|");
}
function tableFor(rows: ComponentRow[]): string[] {
const lines = [
"| Component | Version | Type | Ecosystem | PURL | License | Direct/Transitive | Scope |",
"| --- | --- | --- | --- | --- | --- | --- | --- |",
];
for (const row of rows) {
lines.push(
`| ${escapeMd(row.name)} | ${
escapeMd(row.version)
} | ${row.type} | ${row.ecosystem} | ${
escapeMd(row.purl)
} | ${row.license} | ${row.directness} | ${row.scope} |`,
);
}
return lines;
}
function main() {
const timestamp = new Date().toISOString();
const version = extractVersion();
const lock = readJson<LockLike>(new URL("../deno.lock", import.meta.url));
const rootJson = readJsonc<{
imports?: Record<string, string>;
}>(new URL("../deno.jsonc", import.meta.url));
const uiPackage = readJson<{
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
}>(new URL("../apps/control-ui/package.json", import.meta.url));
const browserPackage = readJson<{
devDependencies?: Record<string, string>;
}>(new URL("../tests/browser/package.json", import.meta.url));
const jsrKeys = Object.keys(lock.jsr ?? {});
const npmKeys = Object.keys(lock.npm ?? {});
const npmIndex = new Map<string, string[]>();
for (const key of npmKeys) {
const parsed = parseNpmKey(key);
const bucket = npmIndex.get(parsed.name) ?? [];
bucket.push(key);
npmIndex.set(parsed.name, bucket);
}
const directRequiredJsr = new Set<string>();
const directDevJsr = new Set<string>();
for (const [name, spec] of Object.entries(rootJson.imports ?? {})) {
if (!spec.startsWith("jsr:")) continue;
const resolved = resolveJsrKey(lock, jsrKeys, spec);
if (!resolved) continue;
if (name === "@std/assert") {
directDevJsr.add(resolved);
} else {
directRequiredJsr.add(resolved);
}
}
const rootRuntimeNpm = ["zod", "postgres"];
const uiRuntimeNpm = Object.entries(uiPackage.dependencies ?? {});
const uiDevNpm = Object.entries(uiPackage.devDependencies ?? {});
const browserDevNpm = Object.entries(browserPackage.devDependencies ?? {});
const directRequiredNpm = new Set<string>();
const directDevNpm = new Set<string>();
for (const name of rootRuntimeNpm) {
const spec = name === "zod" ? "4" : "3";
const resolved = resolveNpmKey(lock, npmIndex, name, spec);
if (resolved) directRequiredNpm.add(resolved);
}
for (const [name, spec] of uiRuntimeNpm) {
const resolved = resolveNpmKey(lock, npmIndex, name, spec);
if (resolved) directRequiredNpm.add(resolved);
}
for (const [name, spec] of uiDevNpm) {
const resolved = resolveNpmKey(lock, npmIndex, name, spec);
if (resolved) directDevNpm.add(resolved);
}
const jsrRequiredReachable = crawl(directRequiredJsr, (key) => {
const deps = lock.jsr?.[key]?.dependencies ?? [];
return deps
.map((dep) =>
resolveJsrKey(
lock,
jsrKeys,
dep.startsWith("jsr:") ? dep : `jsr:${dep}`,
)
)
.filter((dep): dep is string => Boolean(dep));
});
const jsrDevReachable = crawl(directDevJsr, (key) => {
const deps = lock.jsr?.[key]?.dependencies ?? [];
return deps
.map((dep) =>
resolveJsrKey(
lock,
jsrKeys,
dep.startsWith("jsr:") ? dep : `jsr:${dep}`,
)
)
.filter((dep): dep is string => Boolean(dep));
});
const npmRequiredReachable = crawl(directRequiredNpm, (key) => {
const deps = lock.npm?.[key]?.dependencies ?? [];
return deps.flatMap((dep) => candidateNpmKeys(npmIndex, dep));
});
const npmDevReachable = crawl(directDevNpm, (key) => {
const deps = lock.npm?.[key]?.dependencies ?? [];
return deps.flatMap((dep) => candidateNpmKeys(npmIndex, dep));
});
const componentRows: ComponentRow[] = [];
const rootComponent: ComponentRow = {
bomRef: `pkg:generic/frosty-deno@${version}`,
type: "application",
name: "frosty-deno",
version,
ecosystem: "Application",
purl: `pkg:generic/frosty-deno@${version}`,
license: "Apache-2.0",
directness: "Direct",
scope: "required",
};
componentRows.push(rootComponent);
for (const key of jsrKeys.sort()) {
const parsed = parseJsrKey(key);
const directness: Directness =
directRequiredJsr.has(key) || directDevJsr.has(key)
? "Direct"
: "Transitive";
const scope: Scope = jsrRequiredReachable.has(key)
? "required"
: jsrDevReachable.has(key)
? "dev"
: "dev";
componentRows.push({
bomRef: jsrPurl(parsed.name, parsed.version),
type: "library",
name: parsed.name,
version: parsed.version,
ecosystem: "JSR",
purl: jsrPurl(parsed.name, parsed.version),
license: "NOASSERTION",
directness,
scope,
});
}
for (const key of npmKeys.sort()) {
const parsed = parseNpmKey(key);
const directness: Directness =
directRequiredNpm.has(key) || directDevNpm.has(key)
? "Direct"
: "Transitive";
const scope: Scope = npmRequiredReachable.has(key)
? "required"
: npmDevReachable.has(key)
? "dev"
: "dev";
componentRows.push({
bomRef: npmPurl(parsed.name, parsed.version),
type: "library",
name: parsed.name,
version: parsed.version,
ecosystem: "npm",
purl: npmPurl(parsed.name, parsed.version),
license: "NOASSERTION",
directness,
scope,
});
}
for (const [name, spec] of browserDevNpm) {
componentRows.push({
bomRef: npmPurl(name, spec),
type: "library",
name,
version: spec,
ecosystem: "npm",
purl: npmPurl(name, spec),
license: "NOASSERTION",
directness: "Direct",
scope: "dev",
});
}
const dockerImages = [
["denoland/deno", "2.9.3"],
["denoland/deno", "alpine-2.9.3"],
["pgvector/pgvector", "0.8.5-pg18"],
["edoburu/pgbouncer", "v1.24.1-p1"],
["prom/prometheus", "v2.53.0"],
["grafana/grafana", "11.1.0"],
["curlimages/curl", "8.11.1"],
["otel/opentelemetry-collector-contrib", "0.109.0"],
["minio/minio", "RELEASE.2025-09-07T16-13-09Z"],
["minio/mc", "RELEASE.2025-08-13T08-35-41Z"],
["grafana/tempo", "2.9.0"],
] as const;
for (const [name, imageVersion] of dockerImages) {
componentRows.push({
bomRef: dockerPurl(name, imageVersion),
type: "container",
name,
version: imageVersion,
ecosystem: "Docker",
purl: dockerPurl(name, imageVersion),
license: "NOASSERTION",
directness: "Direct",
scope: "required",
});
}
const components = componentRows.map<CycloneComponent>((row) => ({
"bom-ref": row.bomRef,
type: row.type,
name: row.name,
version: row.version,
purl: row.purl,
licenses: licenseBlock(row.license),
}));
const dependencyEdges = new Map<string, Set<string>>();
const addEdge = (from: string, to: string) => {
const bucket = dependencyEdges.get(from) ?? new Set<string>();
bucket.add(to);
dependencyEdges.set(from, bucket);
};
const directRootRefs = componentRows
.filter((row) => row.directness === "Direct" && row.name !== "frosty-deno")
.map((row) => row.bomRef);
for (const ref of directRootRefs) {
addEdge(rootComponent.bomRef, ref);
}
for (const key of jsrKeys) {
const from = componentRows.find((row) =>
row.bomRef === jsrPurl(parseJsrKey(key).name, parseJsrKey(key).version)
);
if (!from) continue;
for (const dep of lock.jsr?.[key]?.dependencies ?? []) {
const resolved = resolveJsrKey(
lock,
jsrKeys,
dep.startsWith("jsr:") ? dep : `jsr:${dep}`,
);
if (!resolved) continue;
const parsed = parseJsrKey(resolved);
addEdge(from.bomRef, jsrPurl(parsed.name, parsed.version));
}
}
for (const key of npmKeys) {
const parsed = parseNpmKey(key);
const fromRef = npmPurl(parsed.name, parsed.version);
for (const dep of lock.npm?.[key]?.dependencies ?? []) {
for (const resolved of candidateNpmKeys(npmIndex, dep)) {
const depParsed = parseNpmKey(resolved);
addEdge(fromRef, npmPurl(depParsed.name, depParsed.version));
}
}
}
const cyclone = {
bomFormat: "CycloneDX",
specVersion: "1.5",
serialNumber: `urn:uuid:${crypto.randomUUID()}`,
version: 1,
metadata: {
timestamp,
tools: {
components: [{
type: "application",
name: "GitHub Copilot",
version: "GPT-5.4",
}],
},
component: components[0],
},
components,
dependencies: [...dependencyEdges.entries()].map(([ref, dependsOn]) => ({
ref,
dependsOn: [...dependsOn].sort(),
})),
};
Deno.mkdirSync(new URL("sbom/", docsRoot), { recursive: true });
Deno.writeTextFileSync(sbomJsonPath, JSON.stringify(cyclone, null, 2) + "\n");
const licenseCounts = new Map<string, number>();
for (const row of componentRows) {
licenseCounts.set(row.license, (licenseCounts.get(row.license) ?? 0) + 1);
}
const byEcosystem = new Map<string, ComponentRow[]>();
for (const row of componentRows) {
const bucket = byEcosystem.get(row.ecosystem) ?? [];
bucket.push(row);
byEcosystem.set(row.ecosystem, bucket);
}
const requiredDirect =
componentRows.filter((row) =>
row.directness === "Direct" && row.scope === "required"
).length - 1;
const devDirect =
componentRows.filter((row) =>
row.directness === "Direct" && row.scope === "dev"
).length;
const transitive =
componentRows.filter((row) => row.directness === "Transitive").length;
const md: string[] = [];
md.push("# Software Bill of Materials (SBOM)", "");
md.push("## Document Metadata", "");
md.push(`- Project name: Frosty Deno`);
md.push(`- Project version: ${version}`);
md.push(
`- Description: Deno 2 + TypeScript LLM gateway with a same-origin React control plane and PostgreSQL-backed durable state.`,
);
md.push(`- SBOM timestamp: ${timestamp}`);
md.push(
`- Author/tool: GitHub Copilot (GPT-5.4) using the checked-in manifests and lockfile only.`,
);
md.push(`- Format: CycloneDX JSON 1.5 plus this human-readable summary.`);
md.push(`- Lifecycle phase: source / pre-build`, "");
md.push("## Component Inventory", "");
for (const ecosystem of ["Application", "JSR", "npm", "Docker"] as const) {
const rows = (byEcosystem.get(ecosystem) ?? []).sort((a, b) => {
if (a.directness !== b.directness) {
return a.directness === "Direct" ? -1 : 1;
}
return a.name.localeCompare(b.name) || a.version.localeCompare(b.version);
});
if (rows.length === 0) continue;
md.push(`### ${ecosystem}`, "");
md.push(...tableFor(rows), "");
}
md.push("## License Summary", "");
md.push("| License | Count | Review Note |", "| --- | --- | --- |");
for (
const [license, count] of [...licenseCounts.entries()].sort((a, b) =>
a[0].localeCompare(b[0])
)
) {
const note = license === "NOASSERTION"
? "License could not be derived from checked-in manifests or lockfiles."
: license === "Apache-2.0"
? "Repository root license."
: "Review required.";
md.push(`| ${license} | ${count} | ${note} |`);
}
md.push("");
md.push("## Dependency Relationships", "");
md.push(`- Direct required components: ${requiredDirect}`);
md.push(`- Direct development components: ${devDirect}`);
md.push(`- Transitive components: ${transitive}`);
md.push(`- Locked JSR components from deno.lock: ${jsrKeys.length}`);
md.push(`- Locked npm components from deno.lock: ${npmKeys.length}`);
md.push(
`- Browser harness note: tests/browser declares \`@playwright/test\` in package.json but does not ship its own lockfile, so the SBOM records the declared range rather than an exact resolved version.`,
"",
);
md.push("## Known Vulnerabilities", "");
md.push(
"Vulnerability scanning was not performed as part of this documentation pass. Recommended follow-up commands:",
"",
);
md.push("- `deno run -A scripts/generate_sbom.ts`");
md.push("- `osv-scanner --lockfile=deno.lock`");
md.push("- `npm audit --prefix tests/browser`");
md.push("- `trivy fs .`");
md.push("- `docker scout cves denoland/deno:alpine-2.9.3`", "");
md.push("## Generation and Maintenance", "");
md.push(
"- Regenerate the machine-readable and human-readable SBOM with `deno run -A scripts/generate_sbom.ts`.",
);
md.push(
"- Regenerate on every dependency change and every release candidate or release cut.",
);
md.push(
"- Add the SBOM generation command and the vulnerability scans above to CI if the repository later adopts automation.",
);
Deno.writeTextFileSync(sbomMdPath, md.join("\n") + "\n");
const fmt = new Deno.Command(Deno.execPath(), {
args: ["fmt", fromFileUrl(sbomMdPath)],
stdout: "null",
stderr: "null",
}).outputSync();
if (!fmt.success) {
throw new Error("Failed to format generated sbom.md");
}
console.log(`Wrote ${sbomMdPath.pathname}`);
console.log(`Wrote ${sbomJsonPath.pathname}`);
}
if (import.meta.main) {
main();
}