Blame · Line-by-line history
license-scan.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.
| 7a1c00e | 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 | ||
| 9 | import { listDependenciesForRepo } from "./deps"; | |
| 10 | import type { RepoDependency } from "../db/schema"; | |
| 11 | ||
| 12 | export type LicenseRisk = "none" | "low" | "medium" | "high" | "unknown"; | |
| 13 | ||
| 14 | export interface LicenseInfo { | |
| 15 | name: string; | |
| 16 | ecosystem: string; | |
| 17 | version: string; | |
| 18 | license: string; | |
| 19 | risk: LicenseRisk; | |
| 20 | reason?: string; | |
| 21 | } | |
| 22 | ||
| 23 | export 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 | ||
| 36 | const 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 | ||
| 50 | const 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 | ||
| 64 | const 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) | |
| 83 | const 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 | ||
| 146 | function 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 | ||
| 161 | function lookupLicense(name: string): string | null { | |
| 162 | return KNOWN_LICENSES[name] ?? null; | |
| 163 | } | |
| 164 | ||
| 165 | export 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 | } |