Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

sbom.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

sbom.tsBlame218 lines · 1 contributor
b426348Claude1/**
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}