Commitb426348unknown_key
feat: add SBOM export in SPDX 2.3 and CycloneDX 1.5 formats
feat: add SBOM export in SPDX 2.3 and CycloneDX 1.5 formats Free supply chain transparency that GitHub gates behind enterprise. Generates compliant SPDX and CycloneDX documents from the existing repo_dependencies table. Download buttons on the dependencies page, plus a JSON API at /api/repos/:owner/:repo/sbom?format=spdx|cyclonedx. https://claude.ai/code/session_01M6EgoTaZ6HAZiVu77xp11g
2 files changed+293−10b426348ed0febe42505418413467003134de992d
2 changed files+293−10
Addedsrc/lib/sbom.ts+218−0View fileUnifiedSplit
@@ -0,0 +1,218 @@
1/**
2 * SBOM (Software Bill of Materials) export.
3 *
4 * Generates SPDX 2.3 and CycloneDX 1.5 documents from the repo_dependencies
5 * table. This is a key differentiator — GitHub charges enterprise prices for
6 * what we ship free.
7 */
8
9import { listDependenciesForRepo } from "./deps";
10import type { RepoDependency } from "../db/schema";
11
12const ECOSYSTEM_PURL_TYPE: Record<string, string> = {
13 npm: "npm",
14 pypi: "pypi",
15 go: "golang",
16 cargo: "cargo",
17 rubygems: "gem",
18 composer: "composer",
19};
20
21function toPurl(dep: RepoDependency): string {
22 const type = ECOSYSTEM_PURL_TYPE[dep.ecosystem] ?? dep.ecosystem;
23 const version = dep.versionSpec?.replace(/^[~^>=<!\s]+/, "") || "unknown";
24 return `pkg:${type}/${encodeURIComponent(dep.name)}@${encodeURIComponent(version)}`;
25}
26
27// ── SPDX 2.3 ───────────────────────────────────────────────────────────────
28
29interface SpdxDocument {
30 spdxVersion: string;
31 dataLicense: string;
32 SPDXID: string;
33 name: string;
34 documentNamespace: string;
35 creationInfo: {
36 created: string;
37 creators: string[];
38 licenseListVersion: string;
39 };
40 packages: SpdxPackage[];
41 relationships: SpdxRelationship[];
42}
43
44interface SpdxPackage {
45 SPDXID: string;
46 name: string;
47 versionInfo: string;
48 downloadLocation: string;
49 filesAnalyzed: boolean;
50 externalRefs: Array<{
51 referenceCategory: string;
52 referenceType: string;
53 referenceLocator: string;
54 }>;
55 primaryPackagePurpose?: string;
56}
57
58interface SpdxRelationship {
59 spdxElementId: string;
60 relatedSpdxElement: string;
61 relationshipType: string;
62}
63
64export async function generateSpdx(
65 repositoryId: string,
66 repoFullName: string
67): Promise<SpdxDocument> {
68 const deps = await listDependenciesForRepo(repositoryId);
69
70 const rootId = "SPDXRef-DOCUMENT";
71 const rootPkgId = "SPDXRef-RootPackage";
72
73 const packages: SpdxPackage[] = [
74 {
75 SPDXID: rootPkgId,
76 name: repoFullName,
77 versionInfo: "HEAD",
78 downloadLocation: `https://gluecron.com/${repoFullName}`,
79 filesAnalyzed: false,
80 externalRefs: [],
81 primaryPackagePurpose: "APPLICATION",
82 },
83 ];
84
85 const relationships: SpdxRelationship[] = [
86 {
87 spdxElementId: rootId,
88 relatedSpdxElement: rootPkgId,
89 relationshipType: "DESCRIBES",
90 },
91 ];
92
93 for (let i = 0; i < deps.length; i++) {
94 const dep = deps[i];
95 const pkgId = `SPDXRef-Package-${i}`;
96 const version = dep.versionSpec?.replace(/^[~^>=<!\s]+/, "") || "NOASSERTION";
97
98 packages.push({
99 SPDXID: pkgId,
100 name: dep.name,
101 versionInfo: version,
102 downloadLocation: "NOASSERTION",
103 filesAnalyzed: false,
104 externalRefs: [
105 {
106 referenceCategory: "PACKAGE-MANAGER",
107 referenceType: "purl",
108 referenceLocator: toPurl(dep),
109 },
110 ],
111 });
112
113 relationships.push({
114 spdxElementId: rootPkgId,
115 relatedSpdxElement: pkgId,
116 relationshipType: dep.isDev ? "DEV_DEPENDENCY_OF" : "DEPENDENCY_OF",
117 });
118 }
119
120 return {
121 spdxVersion: "SPDX-2.3",
122 dataLicense: "CC0-1.0",
123 SPDXID: rootId,
124 name: `SBOM for ${repoFullName}`,
125 documentNamespace: `https://gluecron.com/spdx/${repoFullName}/${Date.now()}`,
126 creationInfo: {
127 created: new Date().toISOString(),
128 creators: ["Tool: GlueCron-SBOM-1.0"],
129 licenseListVersion: "3.22",
130 },
131 packages,
132 relationships,
133 };
134}
135
136// ── CycloneDX 1.5 ──────────────────────────────────────────────────────────
137
138interface CycloneDxBom {
139 bomFormat: string;
140 specVersion: string;
141 serialNumber: string;
142 version: number;
143 metadata: {
144 timestamp: string;
145 tools: Array<{ vendor: string; name: string; version: string }>;
146 component: {
147 type: string;
148 name: string;
149 version: string;
150 "bom-ref": string;
151 };
152 };
153 components: CycloneDxComponent[];
154 dependencies: Array<{ ref: string; dependsOn: string[] }>;
155}
156
157interface CycloneDxComponent {
158 type: string;
159 name: string;
160 version: string;
161 purl: string;
162 "bom-ref": string;
163 scope?: string;
164 properties?: Array<{ name: string; value: string }>;
165}
166
167export async function generateCycloneDx(
168 repositoryId: string,
169 repoFullName: string
170): Promise<CycloneDxBom> {
171 const deps = await listDependenciesForRepo(repositoryId);
172
173 const rootRef = `pkg:gluecron/${encodeURIComponent(repoFullName)}`;
174 const components: CycloneDxComponent[] = [];
175 const depRefs: string[] = [];
176
177 for (const dep of deps) {
178 const purl = toPurl(dep);
179 const version = dep.versionSpec?.replace(/^[~^>=<!\s]+/, "") || "unknown";
180
181 components.push({
182 type: "library",
183 name: dep.name,
184 version,
185 purl,
186 "bom-ref": purl,
187 scope: dep.isDev ? "optional" : "required",
188 properties: [
189 { name: "gluecron:ecosystem", value: dep.ecosystem },
190 { name: "gluecron:manifest", value: dep.manifestPath },
191 ],
192 });
193
194 depRefs.push(purl);
195 }
196
197 return {
198 bomFormat: "CycloneDX",
199 specVersion: "1.5",
200 serialNumber: `urn:uuid:${crypto.randomUUID()}`,
201 version: 1,
202 metadata: {
203 timestamp: new Date().toISOString(),
204 tools: [{ vendor: "GlueCron", name: "SBOM Generator", version: "1.0.0" }],
205 component: {
206 type: "application",
207 name: repoFullName,
208 version: "HEAD",
209 "bom-ref": rootRef,
210 },
211 },
212 components,
213 dependencies: [
214 { ref: rootRef, dependsOn: depRefs },
215 ...components.map((c) => ({ ref: c["bom-ref"], dependsOn: [] })),
216 ],
217 };
218}
Modifiedsrc/routes/deps.tsx+75−10View fileUnifiedSplit
@@ -18,6 +18,7 @@ import {
1818 listDependenciesForRepo,
1919 summarizeDependencies,
2020} from "../lib/deps";
21import { generateSpdx, generateCycloneDx } from "../lib/sbom";
2122
2223const deps = new Hono<AuthEnv>();
2324deps.use("*", softAuth);
@@ -76,16 +77,29 @@ deps.get("/:owner/:repo/dependencies", async (c) => {
7677 <div class="settings-container">
7778 <div style="display:flex;justify-content:space-between;align-items:center">
7879 <h2 style="margin:0">Dependencies</h2>
79 {isOwner && (
80 <form
81 method="POST"
82 action={`/${ownerName}/${repoName}/dependencies/reindex`}
83 >
84 <button type="submit" class="btn btn-primary btn-sm">
85 Reindex
86 </button>
87 </form>
88 )}
80 <div style="display:flex;gap:8px;align-items:center">
81 {all.length > 0 && (
82 <>
83 <a href={`/${ownerName}/${repoName}/dependencies/sbom/spdx`} class="btn btn-sm" style="text-decoration:none">
84 SPDX
85 </a>
86 <a href={`/${ownerName}/${repoName}/dependencies/sbom/cyclonedx`} class="btn btn-sm" style="text-decoration:none">
87 CycloneDX
88 </a>
89 </>
90 )}
91 {isOwner && (
92 <form
93 method="POST"
94 action={`/${ownerName}/${repoName}/dependencies/reindex`}
95 style="margin:0"
96 >
97 <button type="submit" class="btn btn-primary btn-sm">
98 Reindex
99 </button>
100 </form>
101 )}
102 </div>
89103 </div>
90104 {message && (
91105 <div class="auth-success" style="margin-top:12px">
@@ -230,4 +244,55 @@ deps.post("/:owner/:repo/dependencies/reindex", requireAuth, async (c) => {
230244 );
231245});
232246
247// ---------- SBOM Export (SPDX) ----------
248
249deps.get("/:owner/:repo/dependencies/sbom/spdx", async (c) => {
250 const user = c.get("user");
251 const { owner: ownerName, repo: repoName } = c.req.param();
252 const ctx = await loadRepo(ownerName, repoName);
253 if (!ctx) return c.notFound();
254 const { repo } = ctx;
255 if (repo.isPrivate && (!user || user.id !== repo.ownerId)) return c.notFound();
256
257 const spdx = await generateSpdx(repo.id, `${ownerName}/${repoName}`);
258 return c.json(spdx, 200, {
259 "Content-Disposition": `attachment; filename="${repoName}-sbom-spdx.json"`,
260 });
261});
262
263// ---------- SBOM Export (CycloneDX) ----------
264
265deps.get("/:owner/:repo/dependencies/sbom/cyclonedx", async (c) => {
266 const user = c.get("user");
267 const { owner: ownerName, repo: repoName } = c.req.param();
268 const ctx = await loadRepo(ownerName, repoName);
269 if (!ctx) return c.notFound();
270 const { repo } = ctx;
271 if (repo.isPrivate && (!user || user.id !== repo.ownerId)) return c.notFound();
272
273 const bom = await generateCycloneDx(repo.id, `${ownerName}/${repoName}`);
274 return c.json(bom, 200, {
275 "Content-Disposition": `attachment; filename="${repoName}-sbom-cyclonedx.json"`,
276 });
277});
278
279// ---------- SBOM API (JSON) ----------
280
281deps.get("/api/repos/:owner/:repo/sbom", softAuth, async (c) => {
282 const user = c.get("user");
283 const { owner: ownerName, repo: repoName } = c.req.param();
284 const format = c.req.query("format") || "cyclonedx";
285 const ctx = await loadRepo(ownerName, repoName);
286 if (!ctx) return c.json({ error: "Not found" }, 404);
287 const { repo } = ctx;
288 if (repo.isPrivate && (!user || user.id !== repo.ownerId)) {
289 return c.json({ error: "Not found" }, 404);
290 }
291
292 if (format === "spdx") {
293 return c.json(await generateSpdx(repo.id, `${ownerName}/${repoName}`));
294 }
295 return c.json(await generateCycloneDx(repo.id, `${ownerName}/${repoName}`));
296});
297
233298export default deps;
234299