diff --git a/review-enrichment/src/analyzers/license-check.ts b/review-enrichment/src/analyzers/license-check.ts new file mode 100644 index 000000000..24863d156 --- /dev/null +++ b/review-enrichment/src/analyzers/license-check.ts @@ -0,0 +1,65 @@ +// SPDX license policy analyzer (#1475). For each dependency a PR adds/upgrades, resolves its SPDX license via +// deps.dev (free, no key, covers npm/PyPI/Go) and flags the ones a maintainer should eyeball: copyleft (may be +// incompatible with a permissive project) or unresolved/unknown. Permissive licenses (MIT/BSD/Apache/…) are not +// flagged. The no-checkout reviewer can't resolve a dependency's published license — this can. +import type { EnrichRequest, LicenseFinding } from "../types.js"; +import { extractDependencyChanges } from "./dependency-scan.js"; + +// REES ecosystem label → deps.dev system path segment. +const SYSTEM: Record = { npm: "npm", PyPI: "pypi", Go: "go" }; + +// Strong/weak copyleft families worth a compatibility check against a permissive project. +const COPYLEFT = /^(A?GPL|LGPL|MPL|EPL|CDDL|EUPL|OSL|SSPL|CPAL|CECILL)/i; + +function classify(licenses: string[]): LicenseFinding["classification"] | null { + const resolved = licenses.filter( + (license) => license && !/^NOASSERTION$/i.test(license), + ); + if (!resolved.length) return "unknown"; + if (resolved.some((license) => COPYLEFT.test(license))) return "copyleft"; + return null; // permissive / otherwise-known → not flagged +} + +// null ⇒ couldn't determine (don't flag); [] / ["NOASSERTION"] ⇒ resolved-but-unknown (flag). +async function fetchLicenses( + system: string, + name: string, + version: string, + fetchImpl: typeof fetch, +): Promise { + const url = `https://api.deps.dev/v3/systems/${system}/packages/${encodeURIComponent(name)}/versions/${encodeURIComponent(version)}`; + const response = await fetchImpl(url); + if (!response.ok) return null; + const data = (await response.json()) as { licenses?: string[] }; + return Array.isArray(data.licenses) ? data.licenses : []; +} + +/** Analyzer entrypoint: changed deps → deps.dev license → only the copyleft/unknown ones. */ +export async function scanLicenses( + req: EnrichRequest, + fetchImpl: typeof fetch = fetch, +): Promise { + const findings: LicenseFinding[] = []; + for (const change of extractDependencyChanges(req.files ?? [])) { + const system = SYSTEM[change.ecosystem]; + if (!system) continue; + const licenses = await fetchLicenses( + system, + change.package, + change.to, + fetchImpl, + ); + if (licenses === null) continue; // resolution failed — don't false-flag + const classification = classify(licenses); + if (classification) { + findings.push({ + ecosystem: change.ecosystem, + package: change.package, + version: change.to, + licenses, + classification, + }); + } + } + return findings; +} diff --git a/review-enrichment/src/brief.ts b/review-enrichment/src/brief.ts index 5b5c4f16e..52cb0275d 100644 --- a/review-enrichment/src/brief.ts +++ b/review-enrichment/src/brief.ts @@ -9,6 +9,7 @@ import type { } from "./types.js"; import { scanDependencies } from "./analyzers/dependency-scan.js"; import { scanSecrets } from "./analyzers/secret-scan.js"; +import { scanLicenses } from "./analyzers/license-check.js"; import { renderBrief } from "./render.js"; type AnalyzerFn = (req: EnrichRequest) => Promise; @@ -17,6 +18,7 @@ type AnalyzerFn = (req: EnrichRequest) => Promise; const ANALYZERS: Record = { dependency: (req) => scanDependencies(req), secret: (req) => scanSecrets(req), + license: (req) => scanLicenses(req), }; function withTimeout(promise: Promise, ms: number): Promise { diff --git a/review-enrichment/src/render.ts b/review-enrichment/src/render.ts index 5614dae7a..ca1ba1dcc 100644 --- a/review-enrichment/src/render.ts +++ b/review-enrichment/src/render.ts @@ -47,6 +47,16 @@ export function renderBrief( } } + const licenses = findings.license ?? []; + if (licenses.length) { + lines.push("### Dependency licenses (verify compatibility)"); + for (const lic of licenses) { + lines.push( + `- \`${lic.package}@${lic.version}\` (${lic.ecosystem}): ${lic.licenses.join("/") || "none"} — **${lic.classification}**`, + ); + } + } + if (!lines.length) return { promptSection: "", systemSuffix: "" }; const header = diff --git a/review-enrichment/src/types.ts b/review-enrichment/src/types.ts index 0fedfdd2d..6656bb30a 100644 --- a/review-enrichment/src/types.ts +++ b/review-enrichment/src/types.ts @@ -50,10 +50,20 @@ export interface SecretFinding { confidence: "high" | "medium"; } -/** Structured analyzer output. Each analyzer fills its own key; more land as analyzers ship (#1475/#1477/#1478). */ +/** A newly-added/upgraded dependency whose license warrants a compatibility check. */ +export interface LicenseFinding { + ecosystem: string; + package: string; + version: string; + licenses: string[]; + classification: "copyleft" | "unknown"; +} + +/** Structured analyzer output. Each analyzer fills its own key; more land as analyzers ship (#1477/#1478). */ export interface BriefFindings { dependency?: DependencyFinding[]; secret?: SecretFinding[]; + license?: LicenseFinding[]; } export type AnalyzerStatus = "ok" | "degraded" | "skipped"; diff --git a/review-enrichment/test/enrichment.test.ts b/review-enrichment/test/enrichment.test.ts index d5364e55c..e15094faa 100644 --- a/review-enrichment/test/enrichment.test.ts +++ b/review-enrichment/test/enrichment.test.ts @@ -8,6 +8,19 @@ import { import { renderBrief } from "../dist/render.js"; import { buildBrief } from "../dist/brief.js"; import { scanPatch, scanSecrets } from "../dist/analyzers/secret-scan.js"; +import { scanLicenses } from "../dist/analyzers/license-check.js"; + +const licFetch = + (licenses, ok = true) => + async () => ({ + ok, + json: async () => ({ licenses }), + }); +const pkgPatch = (name) => ({ + repoFullName: "o/r", + prNumber: 1, + files: [{ path: "package.json", patch: `+ "${name}": "1.0.0",` }], +}); const okFetch = (vulns) => async () => ({ ok: true, @@ -254,3 +267,53 @@ test("buildBrief: dependency + secret analyzers both run", async () => { globalThis.fetch = realFetch; } }); + +test("scanLicenses: flags copyleft + unknown, skips permissive + fetch-fail", async () => { + const gpl = await scanLicenses( + pkgPatch("gpl-pkg"), + licFetch(["GPL-3.0-or-later"]), + ); + assert.equal(gpl.length, 1); + assert.equal(gpl[0].classification, "copyleft"); + const mit = await scanLicenses(pkgPatch("mit-pkg"), licFetch(["MIT"])); + assert.equal(mit.length, 0); + const unknown = await scanLicenses(pkgPatch("nolic"), licFetch([])); + assert.equal(unknown[0].classification, "unknown"); + const na = await scanLicenses(pkgPatch("na"), licFetch(["NOASSERTION"])); + assert.equal(na[0].classification, "unknown"); + const failed = await scanLicenses(pkgPatch("x"), licFetch([], false)); + assert.equal(failed.length, 0); +}); + +test("renderBrief: renders the license block", () => { + const r = renderBrief({ + license: [ + { + ecosystem: "npm", + package: "g", + version: "1", + licenses: ["GPL-3.0"], + classification: "copyleft", + }, + ], + }); + assert.match(r.promptSection, /Dependency licenses/); + assert.match(r.promptSection, /`g@1` \(npm\): GPL-3\.0 — \*\*copyleft\*\*/); +}); + +test("buildBrief: license analyzer runs alongside the others", async () => { + const realFetch = globalThis.fetch; + globalThis.fetch = async (url) => + String(url).includes("deps.dev") + ? { ok: true, json: async () => ({ licenses: ["AGPL-3.0"] }) } + : { ok: true, json: async () => ({ vulns: [] }) }; + try { + const brief = await buildBrief(pkgPatch("agpl-pkg")); + assert.equal(brief.analyzerStatus.license, "ok"); + assert.equal(brief.findings.license.length, 1); + assert.equal(brief.findings.license[0].classification, "copyleft"); + assert.match(brief.promptSection, /AGPL-3.0/); + } finally { + globalThis.fetch = realFetch; + } +});