Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit7a1c00eunknown_key

feat: add license compliance scanner with copyleft detection

feat: add license compliance scanner with copyleft detection

Scans dependencies for GPL/AGPL/SSPL copyleft contamination risk.
Classifies licenses as high/medium/low/unknown risk with clear reasons.
Visual compliance page at /:owner/:repo/dependencies/licenses plus
JSON API at /api/repos/:owner/:repo/licenses. Includes 60+ known
package licenses to avoid registry lookups for common packages.

https://claude.ai/code/session_01M6EgoTaZ6HAZiVu77xp11g
Claude committed on April 16, 2026Parent: b426348
2 files changed+36907a1c00e4a5c6e5bae99f386b42ede3f31433ccc8
2 changed files+369−0
Addedsrc/lib/license-scan.ts+227−0View fileUnifiedSplit
1/**
2 * License compliance scanner.
3 *
4 * Analyzes repo dependencies for license compatibility issues. Flags
5 * copyleft licenses (GPL, AGPL) that may contaminate proprietary codebases,
6 * and unknown/missing licenses that need manual review.
7 */
8
9import { listDependenciesForRepo } from "./deps";
10import type { RepoDependency } from "../db/schema";
11
12export type LicenseRisk = "none" | "low" | "medium" | "high" | "unknown";
13
14export interface LicenseInfo {
15 name: string;
16 ecosystem: string;
17 version: string;
18 license: string;
19 risk: LicenseRisk;
20 reason?: string;
21}
22
23export interface LicenseReport {
24 totalDeps: number;
25 scanned: number;
26 risks: {
27 high: LicenseInfo[];
28 medium: LicenseInfo[];
29 low: LicenseInfo[];
30 unknown: LicenseInfo[];
31 };
32 compliant: boolean;
33 summary: string;
34}
35
36const COPYLEFT_HIGH: string[] = [
37 "AGPL-3.0",
38 "AGPL-3.0-only",
39 "AGPL-3.0-or-later",
40 "GPL-3.0",
41 "GPL-3.0-only",
42 "GPL-3.0-or-later",
43 "GPL-2.0",
44 "GPL-2.0-only",
45 "GPL-2.0-or-later",
46 "SSPL-1.0",
47 "EUPL-1.2",
48];
49
50const COPYLEFT_MEDIUM: string[] = [
51 "LGPL-3.0",
52 "LGPL-3.0-only",
53 "LGPL-3.0-or-later",
54 "LGPL-2.1",
55 "LGPL-2.1-only",
56 "LGPL-2.1-or-later",
57 "MPL-2.0",
58 "EPL-2.0",
59 "EPL-1.0",
60 "CDDL-1.0",
61 "OSL-3.0",
62];
63
64const PERMISSIVE: string[] = [
65 "MIT",
66 "Apache-2.0",
67 "BSD-2-Clause",
68 "BSD-3-Clause",
69 "ISC",
70 "0BSD",
71 "Unlicense",
72 "CC0-1.0",
73 "CC-BY-4.0",
74 "CC-BY-3.0",
75 "Zlib",
76 "BSL-1.0",
77 "PSF-2.0",
78 "Python-2.0",
79 "BlueOak-1.0.0",
80];
81
82// Well-known package licenses (avoids registry lookups for the most common packages)
83const KNOWN_LICENSES: Record<string, string> = {
84 "react": "MIT",
85 "react-dom": "MIT",
86 "typescript": "Apache-2.0",
87 "express": "MIT",
88 "lodash": "MIT",
89 "axios": "MIT",
90 "next": "MIT",
91 "vue": "MIT",
92 "angular": "MIT",
93 "hono": "MIT",
94 "drizzle-orm": "Apache-2.0",
95 "esbuild": "MIT",
96 "bun": "MIT",
97 "tailwindcss": "MIT",
98 "postcss": "MIT",
99 "webpack": "MIT",
100 "vite": "MIT",
101 "eslint": "MIT",
102 "prettier": "MIT",
103 "jest": "MIT",
104 "@types/node": "MIT",
105 "@types/react": "MIT",
106 "zod": "MIT",
107 "prisma": "Apache-2.0",
108 "@prisma/client": "Apache-2.0",
109 "pg": "MIT",
110 "mysql2": "MIT",
111 "mongoose": "MIT",
112 "cors": "MIT",
113 "dotenv": "BSD-2-Clause",
114 "chalk": "MIT",
115 "commander": "MIT",
116 "inquirer": "MIT",
117 "glob": "ISC",
118 "rimraf": "ISC",
119 "uuid": "MIT",
120 "moment": "MIT",
121 "dayjs": "MIT",
122 "date-fns": "MIT",
123 "sharp": "Apache-2.0",
124 "bcrypt": "MIT",
125 "jsonwebtoken": "MIT",
126 "socket.io": "MIT",
127 "redis": "MIT",
128 "ioredis": "MIT",
129 "aws-sdk": "Apache-2.0",
130 "@aws-sdk/client-s3": "Apache-2.0",
131 "stripe": "MIT",
132 "nodemailer": "MIT",
133 "highlight.js": "BSD-3-Clause",
134 "marked": "MIT",
135 "@anthropic-ai/sdk": "MIT",
136 "openai": "Apache-2.0",
137 "mysql": "MIT",
138 "sqlite3": "BSD-3-Clause",
139 "better-sqlite3": "MIT",
140 "ffmpeg": "LGPL-2.1",
141 "readline-sync": "MIT",
142 "puppeteer": "Apache-2.0",
143 "playwright": "Apache-2.0",
144};
145
146function classifyLicense(spdxId: string): { risk: LicenseRisk; reason?: string } {
147 const normalized = spdxId.trim();
148
149 if (COPYLEFT_HIGH.some((l) => normalized.includes(l))) {
150 return { risk: "high", reason: `Copyleft license (${normalized}) — may require source disclosure` };
151 }
152 if (COPYLEFT_MEDIUM.some((l) => normalized.includes(l))) {
153 return { risk: "medium", reason: `Weak copyleft (${normalized}) — review linking requirements` };
154 }
155 if (PERMISSIVE.some((l) => normalized.includes(l))) {
156 return { risk: "none" };
157 }
158 return { risk: "low", reason: `Uncommon license (${normalized}) — verify compatibility` };
159}
160
161function lookupLicense(name: string): string | null {
162 return KNOWN_LICENSES[name] ?? null;
163}
164
165export async function scanLicenses(repositoryId: string): Promise<LicenseReport> {
166 const deps = await listDependenciesForRepo(repositoryId);
167
168 const results: LicenseInfo[] = [];
169 const risks: LicenseReport["risks"] = { high: [], medium: [], low: [], unknown: [] };
170
171 for (const dep of deps) {
172 const license = lookupLicense(dep.name);
173 const version = dep.versionSpec?.replace(/^[~^>=<!\s]+/, "") || "unknown";
174
175 if (!license) {
176 const info: LicenseInfo = {
177 name: dep.name,
178 ecosystem: dep.ecosystem,
179 version,
180 license: "UNKNOWN",
181 risk: "unknown",
182 reason: "License not in local database — verify manually",
183 };
184 results.push(info);
185 risks.unknown.push(info);
186 continue;
187 }
188
189 const { risk, reason } = classifyLicense(license);
190 const info: LicenseInfo = {
191 name: dep.name,
192 ecosystem: dep.ecosystem,
193 version,
194 license,
195 risk,
196 reason,
197 };
198 results.push(info);
199
200 if (risk === "high") risks.high.push(info);
201 else if (risk === "medium") risks.medium.push(info);
202 else if (risk === "low") risks.low.push(info);
203 }
204
205 const highCount = risks.high.length;
206 const medCount = risks.medium.length;
207 const unknownCount = risks.unknown.length;
208
209 let summary: string;
210 if (highCount > 0) {
211 summary = `${highCount} high-risk copyleft license${highCount === 1 ? "" : "s"} detected — review required before shipping.`;
212 } else if (medCount > 0) {
213 summary = `${medCount} weak copyleft license${medCount === 1 ? "" : "s"} found — verify linking requirements.`;
214 } else if (unknownCount > 0) {
215 summary = `All known licenses are permissive. ${unknownCount} package${unknownCount === 1 ? "" : "s"} with unknown licenses need manual review.`;
216 } else {
217 summary = "All dependencies use permissive licenses. No compliance issues detected.";
218 }
219
220 return {
221 totalDeps: deps.length,
222 scanned: results.length,
223 risks,
224 compliant: highCount === 0,
225 summary,
226 };
227}
Modifiedsrc/routes/deps.tsx+142−0View fileUnifiedSplit
1919 summarizeDependencies,
2020} from "../lib/deps";
2121import { generateSpdx, generateCycloneDx } from "../lib/sbom";
22import { scanLicenses, type LicenseReport, type LicenseRisk } from "../lib/license-scan";
2223
2324const deps = new Hono<AuthEnv>();
2425deps.use("*", softAuth);
8081 <div style="display:flex;gap:8px;align-items:center">
8182 {all.length > 0 && (
8283 <>
84 <a href={`/${ownerName}/${repoName}/dependencies/licenses`} class="btn btn-sm" style="text-decoration:none">
85 License Scan
86 </a>
8387 <a href={`/${ownerName}/${repoName}/dependencies/sbom/spdx`} class="btn btn-sm" style="text-decoration:none">
8488 SPDX
8589 </a>
244248 );
245249});
246250
251// ---------- License Compliance ----------
252
253deps.get("/:owner/:repo/dependencies/licenses", async (c) => {
254 const user = c.get("user");
255 const { owner: ownerName, repo: repoName } = c.req.param();
256 const ctx = await loadRepo(ownerName, repoName);
257 if (!ctx) return c.notFound();
258 const { repo } = ctx;
259 if (repo.isPrivate && (!user || user.id !== repo.ownerId)) return c.notFound();
260
261 const report = await scanLicenses(repo.id);
262
263 const riskColor = (risk: LicenseRisk): string => {
264 if (risk === "high") return "#f85149";
265 if (risk === "medium") return "#d29922";
266 if (risk === "low") return "#58a6ff";
267 if (risk === "unknown") return "#8b949e";
268 return "#3fb950";
269 };
270
271 const riskBadge = (risk: LicenseRisk) => (
272 <span style={`font-size:11px;padding:2px 8px;border-radius:3px;background:${riskColor(risk)}22;color:${riskColor(risk)};font-weight:600;text-transform:uppercase`}>
273 {risk}
274 </span>
275 );
276
277 return c.html(
278 <Layout title={`License Compliance — ${ownerName}/${repoName}`} user={user}>
279 <RepoHeader owner={ownerName} repo={repoName} />
280 <RepoNav owner={ownerName} repo={repoName} active="code" />
281 <div class="settings-container">
282 <div style="display:flex;justify-content:space-between;align-items:center">
283 <h2 style="margin:0">License Compliance</h2>
284 <a href={`/${ownerName}/${repoName}/dependencies`} class="btn btn-sm" style="text-decoration:none">
285 Back to Dependencies
286 </a>
287 </div>
288
289 <div class={`panel ${report.compliant ? "" : "auth-error"}`} style="padding:16px;margin-top:16px">
290 <div style="font-weight:600;font-size:16px;margin-bottom:4px">
291 {report.compliant ? "Compliant" : "Action Required"}
292 </div>
293 <div style="color:var(--text-muted)">{report.summary}</div>
294 <div style="font-size:12px;color:var(--text-muted);margin-top:8px">
295 {report.scanned} of {report.totalDeps} dependencies scanned
296 </div>
297 </div>
298
299 {report.risks.high.length > 0 && (
300 <>
301 <h3 style="margin-top:24px;color:#f85149">High Risk ({report.risks.high.length})</h3>
302 <div class="panel">
303 {report.risks.high.map((d) => (
304 <div class="panel-item" style="justify-content:space-between;flex-wrap:wrap;gap:6px">
305 <div>
306 <span style="font-family:var(--font-mono);font-weight:600">{d.name}</span>
307 <span style="margin-left:8px;font-size:12px;color:var(--text-muted)">{d.version}</span>
308 {riskBadge(d.risk)}
309 </div>
310 <div style="font-size:12px;color:var(--text-muted)">{d.license} — {d.reason}</div>
311 </div>
312 ))}
313 </div>
314 </>
315 )}
316
317 {report.risks.medium.length > 0 && (
318 <>
319 <h3 style="margin-top:24px;color:#d29922">Medium Risk ({report.risks.medium.length})</h3>
320 <div class="panel">
321 {report.risks.medium.map((d) => (
322 <div class="panel-item" style="justify-content:space-between;flex-wrap:wrap;gap:6px">
323 <div>
324 <span style="font-family:var(--font-mono);font-weight:600">{d.name}</span>
325 <span style="margin-left:8px;font-size:12px;color:var(--text-muted)">{d.version}</span>
326 {riskBadge(d.risk)}
327 </div>
328 <div style="font-size:12px;color:var(--text-muted)">{d.license} — {d.reason}</div>
329 </div>
330 ))}
331 </div>
332 </>
333 )}
334
335 {report.risks.unknown.length > 0 && (
336 <>
337 <h3 style="margin-top:24px;color:#8b949e">Unknown License ({report.risks.unknown.length})</h3>
338 <div class="panel">
339 {report.risks.unknown.map((d) => (
340 <div class="panel-item" style="justify-content:space-between;flex-wrap:wrap;gap:6px">
341 <div>
342 <span style="font-family:var(--font-mono);font-weight:600">{d.name}</span>
343 <span style="margin-left:8px;font-size:12px;color:var(--text-muted)">{d.version}</span>
344 {riskBadge(d.risk)}
345 </div>
346 <div style="font-size:12px;color:var(--text-muted)">{d.ecosystem} — {d.reason}</div>
347 </div>
348 ))}
349 </div>
350 </>
351 )}
352
353 {report.risks.low.length > 0 && (
354 <>
355 <h3 style="margin-top:24px">Other ({report.risks.low.length})</h3>
356 <div class="panel">
357 {report.risks.low.map((d) => (
358 <div class="panel-item" style="justify-content:space-between;flex-wrap:wrap;gap:6px">
359 <div>
360 <span style="font-family:var(--font-mono);font-weight:600">{d.name}</span>
361 <span style="margin-left:8px;font-size:12px;color:var(--text-muted)">{d.version}</span>
362 {riskBadge(d.risk)}
363 </div>
364 <div style="font-size:12px;color:var(--text-muted)">{d.license} — {d.reason}</div>
365 </div>
366 ))}
367 </div>
368 </>
369 )}
370 </div>
371 </Layout>
372 );
373});
374
375// ---------- License API ----------
376
377deps.get("/api/repos/:owner/:repo/licenses", softAuth, async (c) => {
378 const user = c.get("user");
379 const { owner: ownerName, repo: repoName } = c.req.param();
380 const ctx = await loadRepo(ownerName, repoName);
381 if (!ctx) return c.json({ error: "Not found" }, 404);
382 const { repo } = ctx;
383 if (repo.isPrivate && (!user || user.id !== repo.ownerId)) {
384 return c.json({ error: "Not found" }, 404);
385 }
386 return c.json(await scanLicenses(repo.id));
387});
388
247389// ---------- SBOM Export (SPDX) ----------
248390
249391deps.get("/:owner/:repo/dependencies/sbom/spdx", async (c) => {
250392