From 65f6967788919e0b1a40a2decac58659a4db4943 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 03:10:58 +0000 Subject: [PATCH] feat(diff): detect license changes between SBOMs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tool is positioned for supply-chain and compliance auditing, yet diff() ignored the license field entirely — a dependency silently switching from a permissive license (MIT) to a copyleft one (GPL-3.0) produced no signal at all, even though the reader already parses per-component licenses. Add license-change detection: - New LicenseChange type and ChangeReport.licenseChanges / summary.totalLicenseChanges - diff() flags components present in both SBOMs whose declared license differs. Only fires when both sides declare a license, so newly-added or dropped license metadata is not mistaken for a relicense. Independent of version, so an in-place relicense is caught even without a version bump. - Text and Markdown reports render a License Changes section; JSON gains the field automatically - Tests for detection, the version-unchanged case, the metadata-only case, and the no-change case; README documents the new report field Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_012JxAhzwjGGfRAJrv1FCC6c --- README.md | 11 ++++++----- src/__tests__/diff.test.ts | 33 +++++++++++++++++++++++++++++++++ src/__tests__/reporter.test.ts | 7 ++++++- src/diff.ts | 18 +++++++++++++++--- src/index.ts | 1 + src/reporter.ts | 16 ++++++++++++++++ src/types.ts | 12 ++++++++++++ 7 files changed, 89 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 6fe9bef..d93ff6b 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ ## What it does -Compare two CycloneDX or SPDX SBOM files and instantly see what changed: added packages, removed packages, version upgrades, and newly introduced CVEs. Output as human-readable text, JSON, or Markdown — perfect for CI/CD gates and audit trails. +Compare two CycloneDX or SPDX SBOM files and instantly see what changed: added packages, removed packages, version upgrades, license changes (e.g. a dependency that switched from MIT to GPL-3.0), and newly introduced CVEs. Output as human-readable text, JSON, or Markdown — perfect for CI/CD gates and audit trails. --- @@ -54,10 +54,11 @@ const newSBOM = parse(await readFile('new.cdx.json', 'utf-8')); const report = diff(oldSBOM, newSBOM); -console.log(report.added); // Component[] — newly added packages -console.log(report.removed); // Component[] — packages removed -console.log(report.upgraded); // VersionChange[] — { component, from, to, isMajorBump } -console.log(report.newCVEs); // CVEEntry[] — vulnerabilities new in the latest SBOM +console.log(report.added); // Component[] — newly added packages +console.log(report.removed); // Component[] — packages removed +console.log(report.upgraded); // VersionChange[] — { component, from, to, isMajorBump } +console.log(report.licenseChanges); // LicenseChange[] — { component, from, to } (e.g. MIT → GPL-3.0) +console.log(report.newCVEs); // CVEEntry[] — vulnerabilities new in the latest SBOM // Or render a ready-made report in text, JSON, or markdown: console.log(renderReport(report, 'markdown')); diff --git a/src/__tests__/diff.test.ts b/src/__tests__/diff.test.ts index 0f8587c..3035e32 100644 --- a/src/__tests__/diff.test.ts +++ b/src/__tests__/diff.test.ts @@ -85,4 +85,37 @@ describe('diff', () => { const report = diff(a, b); expect(report.fixedCVEs).toHaveLength(1); }); + + it('detects a license change on a component present in both SBOMs', () => { + const a = makesbom([{ name: 'chalk', version: '5.3.0', license: 'MIT' }]); + const b = makesbom([{ name: 'chalk', version: '5.3.0', license: 'GPL-3.0' }]); + const report = diff(a, b); + expect(report.licenseChanges).toHaveLength(1); + expect(report.licenseChanges[0].component.name).toBe('chalk'); + expect(report.licenseChanges[0].from).toBe('MIT'); + expect(report.licenseChanges[0].to).toBe('GPL-3.0'); + expect(report.summary.totalLicenseChanges).toBe(1); + }); + + it('reports a license change even when the version is unchanged', () => { + const a = makesbom([{ name: 'left-pad', version: '1.3.0', purl: 'pkg:npm/left-pad@1.3.0', license: 'WTFPL' }]); + const b = makesbom([{ name: 'left-pad', version: '1.3.0', purl: 'pkg:npm/left-pad@1.3.0', license: 'MIT' }]); + const report = diff(a, b); + expect(report.upgraded).toHaveLength(0); + expect(report.licenseChanges).toHaveLength(1); + expect(report.licenseChanges[0].to).toBe('MIT'); + }); + + it('does not report a change when license metadata is only added or dropped', () => { + const a = makesbom([{ name: 'lodash', version: '4.17.21' }]); + const b = makesbom([{ name: 'lodash', version: '4.17.21', license: 'MIT' }]); + expect(diff(a, b).licenseChanges).toHaveLength(0); + expect(diff(b, a).licenseChanges).toHaveLength(0); + }); + + it('reports no license change for identical licenses', () => { + const a = makesbom([{ name: 'react', version: '18.2.0', license: 'MIT' }]); + const b = makesbom([{ name: 'react', version: '18.2.0', license: 'MIT' }]); + expect(diff(a, b).licenseChanges).toHaveLength(0); + }); }); diff --git a/src/__tests__/reporter.test.ts b/src/__tests__/reporter.test.ts index 7cb1553..c06d48e 100644 --- a/src/__tests__/reporter.test.ts +++ b/src/__tests__/reporter.test.ts @@ -6,9 +6,10 @@ const sampleReport: ChangeReport = { added: [{ name: 'express', version: '4.18.2', ecosystem: 'npm' }], removed: [{ name: 'moment', version: '2.29.4' }], upgraded: [{ component: { name: 'lodash', version: '4.17.21' }, from: '4.17.20', to: '4.17.21', isMajorBump: false }], + licenseChanges: [{ component: { name: 'chalk', version: '5.3.0' }, from: 'MIT', to: 'GPL-3.0' }], newCVEs: [{ id: 'CVE-2023-1234', affects: 'pkg:npm/foo@1.0.0', severity: 'high' }], fixedCVEs: [{ id: 'CVE-2022-9999', affects: 'pkg:npm/bar@0.9.0' }], - summary: { totalAdded: 1, totalRemoved: 1, totalUpgraded: 1, totalNewCVEs: 1, totalFixedCVEs: 1 }, + summary: { totalAdded: 1, totalRemoved: 1, totalUpgraded: 1, totalLicenseChanges: 1, totalNewCVEs: 1, totalFixedCVEs: 1 }, }; describe('renderReport', () => { @@ -19,6 +20,8 @@ describe('renderReport', () => { expect(out).toContain('moment'); expect(out).toContain('CVE-2023-1234'); expect(out).toContain('CVE-2022-9999'); + expect(out).toContain('License Changes'); + expect(out).toContain('chalk: MIT'); }); it('renders JSON format', () => { @@ -32,6 +35,8 @@ describe('renderReport', () => { expect(out).toContain('# SBOM Diff Report'); expect(out).toContain('| express |'); expect(out).toContain('CVE-2023-1234'); + expect(out).toContain('## ⚖️ License Changes'); + expect(out).toContain('| chalk | MIT | GPL-3.0 |'); }); it('throws on unsupported format', () => { diff --git a/src/diff.ts b/src/diff.ts index 6540b50..8418c53 100644 --- a/src/diff.ts +++ b/src/diff.ts @@ -1,4 +1,4 @@ -import type { SBOM, Component, CVEEntry, ChangeReport, VersionChange } from './types.js'; +import type { SBOM, Component, CVEEntry, ChangeReport, VersionChange, LicenseChange } from './types.js'; /** * Compare two parsed SBOMs and produce a ChangeReport. @@ -14,13 +14,16 @@ export function diff(a: SBOM, b: SBOM): ChangeReport { const added: Component[] = []; const removed: Component[] = []; const upgraded: VersionChange[] = []; + const licenseChanges: LicenseChange[] = []; - // Find added and upgraded + // Find added, upgraded, and relicensed for (const [key, bComp] of bMap) { const aComp = aMap.get(key); if (!aComp) { added.push(bComp); - } else if (aComp.version !== bComp.version && aComp.version && bComp.version) { + continue; + } + if (aComp.version !== bComp.version && aComp.version && bComp.version) { upgraded.push({ component: bComp, from: aComp.version, @@ -28,6 +31,13 @@ export function diff(a: SBOM, b: SBOM): ChangeReport { isMajorBump: isMajorVersionBump(aComp.version, bComp.version), }); } + // A relicensing (e.g. MIT -> GPL-3.0) is a compliance-relevant event even when + // the version is unchanged. Only surface it when both SBOMs declare a license + // and they differ — treating newly-added or dropped license metadata as a + // "change" would produce noise rather than a real relicense signal. + if (aComp.license && bComp.license && aComp.license !== bComp.license) { + licenseChanges.push({ component: bComp, from: aComp.license, to: bComp.license }); + } } // Find removed @@ -48,12 +58,14 @@ export function diff(a: SBOM, b: SBOM): ChangeReport { added, removed, upgraded, + licenseChanges, newCVEs, fixedCVEs, summary: { totalAdded: added.length, totalRemoved: removed.length, totalUpgraded: upgraded.length, + totalLicenseChanges: licenseChanges.length, totalNewCVEs: newCVEs.length, totalFixedCVEs: fixedCVEs.length, }, diff --git a/src/index.ts b/src/index.ts index b68829e..01a62ab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,7 @@ export type { CVEEntry, ChangeReport, VersionChange, + LicenseChange, SBOMFormat, ReportFormat, } from './types.js'; diff --git a/src/reporter.ts b/src/reporter.ts index 973b21d..3dd6149 100644 --- a/src/reporter.ts +++ b/src/reporter.ts @@ -21,6 +21,7 @@ function renderText(r: ChangeReport): string { lines.push(` Added: ${r.summary.totalAdded}`); lines.push(` Removed: ${r.summary.totalRemoved}`); lines.push(` Upgraded: ${r.summary.totalUpgraded}`); + lines.push(` Licenses: ${r.summary.totalLicenseChanges}`); lines.push(` New CVEs: ${r.summary.totalNewCVEs}`); lines.push(` Fixed CVEs: ${r.summary.totalFixedCVEs}`); lines.push(''); @@ -43,6 +44,13 @@ function renderText(r: ChangeReport): string { } lines.push(''); } + if (r.licenseChanges.length > 0) { + lines.push('\u2696 License Changes:'); + for (const l of r.licenseChanges) { + lines.push(` ~ ${l.component.name}: ${l.from} \u2192 ${l.to}`); + } + lines.push(''); + } if (r.newCVEs.length > 0) { lines.push('\u26a0 New CVEs:'); for (const v of r.newCVEs) { @@ -71,6 +79,7 @@ function renderMarkdown(r: ChangeReport): string { `| Added components | ${r.summary.totalAdded} |`, `| Removed components | ${r.summary.totalRemoved} |`, `| Upgraded components | ${r.summary.totalUpgraded} |`, + `| License changes | ${r.summary.totalLicenseChanges} |`, `| New CVEs | ${r.summary.totalNewCVEs} |`, `| Fixed CVEs | ${r.summary.totalFixedCVEs} |`, '', @@ -99,6 +108,13 @@ function renderMarkdown(r: ChangeReport): string { } lines.push(''); } + if (r.licenseChanges.length > 0) { + lines.push('## \u2696\ufe0f License Changes', ''); + lines.push('| Component | From | To |'); + lines.push('|-----------|------|----|'); + for (const l of r.licenseChanges) lines.push(`| ${l.component.name} | ${l.from} | ${l.to} |`); + lines.push(''); + } if (r.newCVEs.length > 0) { lines.push('## \ud83d\udea8 New CVEs', ''); lines.push('| CVE ID | Severity | Affects |'); diff --git a/src/types.ts b/src/types.ts index ede0ac5..a00a8a9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -66,6 +66,15 @@ export interface VersionChange { isMajorBump: boolean; } +/** A license change for a component present in both SBOMs (e.g. MIT -> GPL-3.0) */ +export interface LicenseChange { + component: Component; + /** License declared in the old SBOM */ + from: string; + /** License declared in the new SBOM */ + to: string; +} + /** The full result of diffing two SBOMs */ export interface ChangeReport { /** Components in B but not in A */ @@ -74,6 +83,8 @@ export interface ChangeReport { removed: Component[]; /** Components where the version changed */ upgraded: VersionChange[]; + /** Components present in both SBOMs whose declared license changed */ + licenseChanges: LicenseChange[]; /** Vulnerabilities in B but not in A */ newCVEs: CVEEntry[]; /** Vulnerabilities in A but not in B (fixed) */ @@ -82,6 +93,7 @@ export interface ChangeReport { totalAdded: number; totalRemoved: number; totalUpgraded: number; + totalLicenseChanges: number; totalNewCVEs: number; totalFixedCVEs: number; };