Skip to content
Merged
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
6 changes: 3 additions & 3 deletions src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ describe('gateFailures', () => {
analysisState?: string,
): CVEEntry => ({
id,
affects: 'pkg:npm/example',
affects: ['pkg:npm/example'],
severity,
analysisState,
});
Expand Down Expand Up @@ -160,7 +160,7 @@ describe('gateFailures', () => {
describe('isSuppressed', () => {
const withState = (analysisState?: string): CVEEntry => ({
id: 'CVE-x',
affects: 'pkg:npm/example',
affects: ['pkg:npm/example'],
analysisState,
});

Expand All @@ -183,7 +183,7 @@ describe('gateWarning', () => {
components: [],
vulnerabilities,
});
const withCve: SBOM = sbom([{ id: 'CVE-1', affects: 'pkg:npm/example', severity: 'high' }]);
const withCve: SBOM = sbom([{ id: 'CVE-1', affects: ['pkg:npm/example'], severity: 'high' }]);

it('returns null when the gate is off, even without vulnerability data', () => {
expect(gateWarning(sbom(), sbom(), 'none')).toBeNull();
Expand Down
60 changes: 48 additions & 12 deletions src/__tests__/diff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ describe('diff', () => {
});

it('detects new CVEs', () => {
const cve = { id: 'CVE-2021-44228', affects: 'pkg:npm/log4j@2.14.1', severity: 'critical' as const };
const cve = { id: 'CVE-2021-44228', affects: ['pkg:npm/log4j@2.14.1'], severity: 'critical' as const };
const a = makesbom([]);
const b = makesbom([], [cve]);
const report = diff(a, b);
Expand All @@ -148,7 +148,7 @@ describe('diff', () => {
});

it('detects fixed CVEs', () => {
const cve = { id: 'CVE-2021-44228', affects: 'pkg:npm/log4j@2.14.1', severity: 'critical' as const };
const cve = { id: 'CVE-2021-44228', affects: ['pkg:npm/log4j@2.14.1'], severity: 'critical' as const };
const a = makesbom([], [cve]);
const b = makesbom([]);
const report = diff(a, b);
Expand Down Expand Up @@ -204,10 +204,10 @@ describe('diff ordering', () => {
it('orders new CVEs by severity (most severe first), then by id', () => {
const a = makesbom([]);
const b = makesbom([], [
{ id: 'CVE-2023-0002', affects: 'x', severity: 'low' },
{ id: 'CVE-2023-0003', affects: 'y', severity: 'critical' },
{ id: 'CVE-2023-0001', affects: 'z', severity: 'critical' },
{ id: 'CVE-2023-0004', affects: 'w', severity: 'medium' },
{ id: 'CVE-2023-0002', affects: ['x'], severity: 'low' },
{ id: 'CVE-2023-0003', affects: ['y'], severity: 'critical' },
{ id: 'CVE-2023-0001', affects: ['z'], severity: 'critical' },
{ id: 'CVE-2023-0004', affects: ['w'], severity: 'medium' },
]);
const report = diff(a, b);
expect(report.newCVEs.map(v => v.id)).toEqual([
Expand Down Expand Up @@ -241,12 +241,12 @@ describe('diff ordering', () => {

it('detects a CVE whose severity was re-scored between scans (issue #46)', () => {
const a = makesbom([], [
{ id: 'CVE-2021-44228', affects: 'pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1', severity: 'medium', cvssScore: 6.0 },
{ id: 'CVE-2023-0001', affects: 'pkg:npm/foo@1.0.0', severity: 'high', cvssScore: 8.0 },
{ id: 'CVE-2021-44228', affects: ['pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1'], severity: 'medium', cvssScore: 6.0 },
{ id: 'CVE-2023-0001', affects: ['pkg:npm/foo@1.0.0'], severity: 'high', cvssScore: 8.0 },
]);
const b = makesbom([], [
{ id: 'CVE-2021-44228', affects: 'pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1', severity: 'critical', cvssScore: 10.0 },
{ id: 'CVE-2023-0001', affects: 'pkg:npm/foo@1.0.0', severity: 'low', cvssScore: 3.0 },
{ id: 'CVE-2021-44228', affects: ['pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1'], severity: 'critical', cvssScore: 10.0 },
{ id: 'CVE-2023-0001', affects: ['pkg:npm/foo@1.0.0'], severity: 'low', cvssScore: 3.0 },
]);
const report = diff(a, b);
// The escalated CVE is in neither newCVEs nor fixedCVEs.
Expand All @@ -265,10 +265,10 @@ describe('diff ordering', () => {

it('flags a CVSS score rise even when the severity label is unchanged', () => {
const a = makesbom([], [
{ id: 'CVE-2024-0001', affects: 'pkg:npm/a@1.0.0', severity: 'high', cvssScore: 7.0 },
{ id: 'CVE-2024-0001', affects: ['pkg:npm/a@1.0.0'], severity: 'high', cvssScore: 7.0 },
]);
const b = makesbom([], [
{ id: 'CVE-2024-0001', affects: 'pkg:npm/a@1.0.0', severity: 'high', cvssScore: 9.0 },
{ id: 'CVE-2024-0001', affects: ['pkg:npm/a@1.0.0'], severity: 'high', cvssScore: 9.0 },
]);
const report = diff(a, b);
expect(report.severityEscalations).toHaveLength(1);
Expand Down Expand Up @@ -307,4 +307,40 @@ describe('diff ordering', () => {
expect(report.upgraded).toHaveLength(1);
expect(report.hashChanges).toHaveLength(0);
});

it('reports components that share a key instead of silently dropping them (issue #50)', () => {
// Two purl-less components with the same name coexist in the same SBOM
// (common in OS-package / container SBOMs). The old last-write-wins map
// silently discarded the first, hiding a real removal.
const a = makesbom([
{ name: 'kernel', version: '5.15.0' },
{ name: 'kernel', version: '5.19.0' },
]);
const b = makesbom([
{ name: 'kernel', version: '5.15.0' },
]);
const report = diff(a, b);
// Both entries survived: one matches (unchanged), the other is a removal.
expect(report.removed).toHaveLength(1);
expect(report.removed[0].version).toBe('5.19.0');
});

it('matches same-key components across SBOMs by occurrence order', () => {
const a = makesbom([
{ name: 'dup', version: '1.0.0' },
{ name: 'dup', version: '2.0.0' },
]);
const b = makesbom([
{ name: 'dup', version: '1.0.0' },
{ name: 'dup', version: '3.0.0' },
]);
const report = diff(a, b);
// First occurrence pairs 1.0.0<->1.0.0 (unchanged); second pairs
// 2.0.0<->3.0.0 (upgrade). No add/remove false positives.
expect(report.upgraded).toHaveLength(1);
expect(report.upgraded[0].from).toBe('2.0.0');
expect(report.upgraded[0].to).toBe('3.0.0');
expect(report.added).toHaveLength(0);
expect(report.removed).toHaveLength(0);
});
});
23 changes: 23 additions & 0 deletions src/__tests__/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,29 @@ describe('parse (CycloneDX)', () => {
expect(sbom.vulnerabilities![0].severity).toBe('critical');
});

it('keeps every affected component, not just the first (issue #30)', () => {
// Log4Shell hits both log4j-core and log4j-api; the old parser kept only
// affects[0], hiding the blast radius.
const sbom = parse({
bomFormat: 'CycloneDX',
specVersion: '1.4',
components: [],
vulnerabilities: [
{
id: 'CVE-2021-44228',
affects: [
{ ref: 'pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1' },
{ ref: 'pkg:maven/org.apache.logging.log4j/log4j-api@2.14.1' },
],
},
],
});
expect(sbom.vulnerabilities![0].affects).toEqual([
'pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1',
'pkg:maven/org.apache.logging.log4j/log4j-api@2.14.1',
]);
});

it('reports the highest severity when a CVE has multiple ratings', () => {
const sbom = parse({
bomFormat: 'CycloneDX',
Expand Down
8 changes: 4 additions & 4 deletions src/__tests__/reporter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ const sampleReport: ChangeReport = {
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, isDowngrade: 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' }],
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'] }],
severityEscalations: [],
hashChanges: [],
summary: { totalAdded: 1, totalRemoved: 1, totalUpgraded: 1, totalLicenseChanges: 1, totalDowngraded: 0, totalNewCVEs: 1, totalFixedCVEs: 1, totalSeverityEscalations: 0, totalHashChanges: 0 },
Expand Down Expand Up @@ -67,7 +67,7 @@ it('escapes pipes and newlines in markdown cells so the table stays well-formed'
removed: [],
upgraded: [],
licenseChanges: [],
newCVEs: [{ id: 'CVE-2024-0001', affects: 'pkg:npm/a | b', severity: 'high', description: 'line1\nline2' }],
newCVEs: [{ id: 'CVE-2024-0001', affects: ['pkg:npm/a | b'], severity: 'high', description: 'line1\nline2' }],
fixedCVEs: [],
severityEscalations: [],
hashChanges: [],
Expand Down Expand Up @@ -137,7 +137,7 @@ it('annotates the VEX analysis state on new CVEs in text and markdown', () => {
const report: ChangeReport = {
...sampleReport,
newCVEs: [
{ id: 'CVE-2024-2000', affects: 'pkg:npm/foo@1.0.0', severity: 'critical', analysisState: 'not_affected' },
{ id: 'CVE-2024-2000', affects: ['pkg:npm/foo@1.0.0'], severity: 'critical', analysisState: 'not_affected' },
],
};
expect(renderReport(report, 'text')).toContain('(VEX: not_affected)');
Expand Down
20 changes: 19 additions & 1 deletion src/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,10 +153,28 @@ function toIdentity(sbom: SBOM): SBOMIdentity {
};
}

/**
* Build a component lookup map, disambiguating components that share a key.
*
* buildComponentMap previously used last-write-wins: when a single SBOM
* contained two components mapping to the same `purl ?? name` key, every entry
* but the last was silently discarded *before* the diff ran, so added/removed
* packages could vanish from the report entirely (issue #50).
*
* Instead, every component gets a unique key: the first occurrence keeps the
* bare key, subsequent collisions get a `#2`, `#3`, … suffix. All entries
* survive into the map and are compared. Ordering is deterministic (stable
* input order) so the same SBOM always yields the same keys.
*/
function buildComponentMap(components: Component[]): Map<string, Component> {
const map = new Map<string, Component>();
const seen = new Map<string, number>();
for (const comp of components) {
map.set(componentKey(comp), comp);
const base = componentKey(comp);
const count = seen.get(base) ?? 0;
seen.set(base, count + 1);
const key = count === 0 ? base : `${base}#${count + 1}`;
map.set(key, comp);
}
return map;
}
Expand Down
14 changes: 10 additions & 4 deletions src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,11 +244,17 @@ function extractCycloneDXSupplier(c: Record<string, unknown>): string | undefine
return typeof supplier.name === 'string' ? supplier.name : undefined;
}

function extractCycloneDXAffects(v: Record<string, unknown>): string {
function extractCycloneDXAffects(v: Record<string, unknown>): string[] {
const affects = v.affects;
if (!Array.isArray(affects) || affects.length === 0) return 'unknown';
const ref = affects[0] as Record<string, unknown>;
return typeof ref.ref === 'string' ? ref.ref : 'unknown';
if (!Array.isArray(affects) || affects.length === 0) return ['unknown'];
const refs: string[] = [];
for (const entry of affects) {
if (typeof entry === 'object' && entry !== null) {
const ref = (entry as Record<string, unknown>).ref;
if (typeof ref === 'string') refs.push(ref);
}
}
return refs.length > 0 ? refs : ['unknown'];
}

/** Severity ordering, lowest to highest, for selecting the most severe rating. */
Expand Down
17 changes: 11 additions & 6 deletions src/reporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ function vexNote(v: CVEEntry): string {
return v.analysisState ? ` (VEX: ${v.analysisState})` : '';
}

/** Join a CVE's affected components into a display string (blast radius). */
function joinAffects(v: CVEEntry): string {
return (v.affects ?? ['unknown']).join(', ');
}

/**
* Render a ChangeReport to a human-readable string.
*
Expand Down Expand Up @@ -95,14 +100,14 @@ function renderText(r: ChangeReport): string {
lines.push('\u26a0 New CVEs:');
for (const v of r.newCVEs) {
const score = v.cvssScore !== undefined ? `, CVSS ${v.cvssScore}` : '';
lines.push(` ! ${v.id} [${v.severity ?? 'unknown'}${score}]${vexNote(v)} — ${v.affects}`);
lines.push(` ! ${v.id} [${v.severity ?? 'unknown'}${score}]${vexNote(v)} — ${joinAffects(v)}`);
}
lines.push('');
}
if (r.fixedCVEs.length > 0) {
lines.push('\u2713 Fixed CVEs:');
for (const v of r.fixedCVEs) {
lines.push(` \u2713 ${v.id} \u2014 ${v.affects}`);
lines.push(` \u2713 ${v.id} \u2014 ${joinAffects(v)}`);
}
}
if (r.severityEscalations.length > 0) {
Expand All @@ -113,7 +118,7 @@ function renderText(r: ChangeReport): string {
const score = e.toScore !== undefined && e.fromScore !== undefined
? ` (CVSS ${e.fromScore} \u2192 ${e.toScore})`
: '';
lines.push(` \u26a0 ${e.cve.id} [${from} \u2192 ${to}${score}] \u2014 ${e.cve.affects}`);
lines.push(` \u26a0 ${e.cve.id} [${from} \u2192 ${to}${score}] \u2014 ${joinAffects(e.cve)}`);
}
lines.push('');
}
Expand Down Expand Up @@ -217,22 +222,22 @@ lines.push('| CVE ID | Severity | CVSS | Affects |');
lines.push('|--------|----------|------|---------|');
for (const v of r.newCVEs) {
const score = v.cvssScore !== undefined ? String(v.cvssScore) : '—';
lines.push(`| ${escapeCell(v.id)} | ${escapeCell(v.severity)}${vexNote(v)} | ${escapeCell(score)} | ${escapeCell(v.affects)} |`);
lines.push(`| ${escapeCell(v.id)} | ${escapeCell(v.severity)}${vexNote(v)} | ${escapeCell(score)} | ${escapeCell(joinAffects(v))} |`);
}
lines.push('');
}
if (r.fixedCVEs.length > 0) {
lines.push('## \u2705 Fixed CVEs', '');
lines.push('| CVE ID | Affects |');
lines.push('|--------|---------|');
for (const v of r.fixedCVEs) lines.push(`| ${escapeCell(v.id)} | ${escapeCell(v.affects)} |`);
for (const v of r.fixedCVEs) lines.push(`| ${escapeCell(v.id)} | ${escapeCell(joinAffects(v))} |`);
}
if (r.severityEscalations.length > 0) {
lines.push('## \u26a0\ufe0f Severity Escalations', '');
lines.push('| CVE ID | From | To | CVSS | Affects |');
lines.push('|--------|------|----|------|---------|');
for (const e of r.severityEscalations) {
lines.push(`| ${escapeCell(e.cve.id)} | ${escapeCell(e.fromSeverity ?? 'none')} | ${escapeCell(e.toSeverity ?? 'none')} | ${escapeCell(e.fromScore !== undefined && e.toScore !== undefined ? `${e.fromScore} \u2192 ${e.toScore}` : undefined)} | ${escapeCell(e.cve.affects)} |`);
lines.push(`| ${escapeCell(e.cve.id)} | ${escapeCell(e.fromSeverity ?? 'none')} | ${escapeCell(e.toSeverity ?? 'none')} | ${escapeCell(e.fromScore !== undefined && e.toScore !== undefined ? `${e.fromScore} \u2192 ${e.toScore}` : undefined)} | ${escapeCell(joinAffects(e.cve))} |`);
}
}
if (r.hashChanges.length > 0) {
Expand Down
4 changes: 2 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ export interface Component {
export interface CVEEntry {
/** CVE ID, e.g. "CVE-2021-44228" */
id: string;
/** Affected component purl or name */
affects: string;
/** Affected component purl(s) or name(s) — a CVE may hit multiple packages */
affects: string[];
/** Severity: none, low, medium, high, critical */
severity?: 'none' | 'low' | 'medium' | 'high' | 'critical';
/** CVSS score 0.0–10.0 */
Expand Down