Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down Expand Up @@ -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'));
Expand Down
33 changes: 33 additions & 0 deletions src/__tests__/diff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
7 changes: 6 additions & 1 deletion src/__tests__/reporter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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', () => {
Expand All @@ -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', () => {
Expand Down
18 changes: 15 additions & 3 deletions src/diff.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -14,20 +14,30 @@ 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,
to: bComp.version,
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
Expand All @@ -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,
},
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export type {
CVEEntry,
ChangeReport,
VersionChange,
LicenseChange,
SBOMFormat,
ReportFormat,
} from './types.js';
16 changes: 16 additions & 0 deletions src/reporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('');
Expand All @@ -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) {
Expand Down Expand Up @@ -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} |`,
'',
Expand Down Expand Up @@ -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 |');
Expand Down
12 changes: 12 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -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) */
Expand All @@ -82,6 +93,7 @@ export interface ChangeReport {
totalAdded: number;
totalRemoved: number;
totalUpgraded: number;
totalLicenseChanges: number;
totalNewCVEs: number;
totalFixedCVEs: number;
};
Expand Down
Loading