diff --git a/src/__tests__/cli.test.ts b/src/__tests__/cli.test.ts index 351a78d..0d0e520 100644 --- a/src/__tests__/cli.test.ts +++ b/src/__tests__/cli.test.ts @@ -71,7 +71,7 @@ describe('gateFailures', () => { analysisState?: string, ): CVEEntry => ({ id, - affects: 'pkg:npm/example', + affects: ['pkg:npm/example'], severity, analysisState, }); @@ -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, }); @@ -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(); diff --git a/src/__tests__/diff.test.ts b/src/__tests__/diff.test.ts index 82a72ee..b1c59d6 100644 --- a/src/__tests__/diff.test.ts +++ b/src/__tests__/diff.test.ts @@ -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); @@ -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); @@ -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([ @@ -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. @@ -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); @@ -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); + }); }); diff --git a/src/__tests__/parser.test.ts b/src/__tests__/parser.test.ts index b92c7a1..1ccfe0f 100644 --- a/src/__tests__/parser.test.ts +++ b/src/__tests__/parser.test.ts @@ -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', diff --git a/src/__tests__/reporter.test.ts b/src/__tests__/reporter.test.ts index e047609..6fbebcd 100644 --- a/src/__tests__/reporter.test.ts +++ b/src/__tests__/reporter.test.ts @@ -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 }, @@ -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: [], @@ -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)'); diff --git a/src/diff.ts b/src/diff.ts index 54555a1..1d75411 100644 --- a/src/diff.ts +++ b/src/diff.ts @@ -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 { const map = new Map(); + const seen = new Map(); 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; } diff --git a/src/parser.ts b/src/parser.ts index f158b8d..0b5accd 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -244,11 +244,17 @@ function extractCycloneDXSupplier(c: Record): string | undefine return typeof supplier.name === 'string' ? supplier.name : undefined; } -function extractCycloneDXAffects(v: Record): string { +function extractCycloneDXAffects(v: Record): string[] { const affects = v.affects; - if (!Array.isArray(affects) || affects.length === 0) return 'unknown'; - const ref = affects[0] as Record; - 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).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. */ diff --git a/src/reporter.ts b/src/reporter.ts index 34d0294..e99a859 100644 --- a/src/reporter.ts +++ b/src/reporter.ts @@ -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. * @@ -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) { @@ -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(''); } @@ -217,7 +222,7 @@ 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(''); } @@ -225,14 +230,14 @@ lines.push('| CVE ID | Severity | CVSS | Affects |'); 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) { diff --git a/src/types.ts b/src/types.ts index 485236c..51b634f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 */