From f3ff7f0637ca2959ae0c7c97d033410596143653 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 03:05:25 +0000 Subject: [PATCH] feat(diff): order report deterministically by risk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SBOM scanners emit components and vulnerabilities in arbitrary order, so today's reports are unstable run-to-run. For the headline use cases — committed audit trails and PR-comment diffs — that produces noisy diffs even when nothing actually changed, and a `critical` CVE can be listed below several `low` ones. Sort the report before returning from diff() so all three render formats and direct JSON consumers benefit: - added/removed components: by name, then version - upgraded: major bumps first (highest risk), then by name - new/fixed CVEs: by severity (most severe first), then by id Purely a reordering — the ChangeReport shape is unchanged and the change is fully backward compatible. Adds tests covering stable ordering and severity/major-bump prioritisation. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01GT9LrpLo5a1XkHqrUKtCaY --- src/__tests__/diff.test.ts | 51 ++++++++++++++++++++++++++++++++++++++ src/diff.ts | 40 ++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/src/__tests__/diff.test.ts b/src/__tests__/diff.test.ts index 0f8587c..7dcca65 100644 --- a/src/__tests__/diff.test.ts +++ b/src/__tests__/diff.test.ts @@ -86,3 +86,54 @@ describe('diff', () => { expect(report.fixedCVEs).toHaveLength(1); }); }); + +describe('diff ordering', () => { + it('sorts added/removed components by name regardless of input order', () => { + const a = makesbom([]); + const b = makesbom([ + { name: 'zod', version: '3.0.0' }, + { name: 'axios', version: '1.0.0' }, + { name: 'lodash', version: '4.0.0' }, + ]); + const report = diff(a, b); + expect(report.added.map(c => c.name)).toEqual(['axios', 'lodash', 'zod']); + }); + + 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' }, + ]); + const report = diff(a, b); + expect(report.newCVEs.map(v => v.id)).toEqual([ + 'CVE-2023-0001', // critical (id breaks the tie) + 'CVE-2023-0003', // critical + 'CVE-2023-0004', // medium + 'CVE-2023-0002', // low + ]); + }); + + it('sorts upgrades with major bumps first', () => { + const a = makesbom([ + { name: 'patch-pkg', version: '1.0.0' }, + { name: 'major-pkg', version: '1.0.0' }, + ]); + const b = makesbom([ + { name: 'patch-pkg', version: '1.0.1' }, + { name: 'major-pkg', version: '2.0.0' }, + ]); + const report = diff(a, b); + expect(report.upgraded.map(u => u.component.name)).toEqual(['major-pkg', 'patch-pkg']); + expect(report.upgraded[0].isMajorBump).toBe(true); + }); + + it('produces identical output for the same components in different input order', () => { + const order1 = makesbom([{ name: 'b', version: '1' }, { name: 'a', version: '1' }]); + const order2 = makesbom([{ name: 'a', version: '1' }, { name: 'b', version: '1' }]); + const empty = makesbom([]); + expect(diff(empty, order1)).toEqual(diff(empty, order2)); + }); +}); diff --git a/src/diff.ts b/src/diff.ts index 6540b50..4cf1f99 100644 --- a/src/diff.ts +++ b/src/diff.ts @@ -44,6 +44,17 @@ export function diff(a: SBOM, b: SBOM): ChangeReport { const newCVEs = [...bVulns.values()].filter(v => !aVulns.has(v.id)); const fixedCVEs = [...aVulns.values()].filter(v => !bVulns.has(v.id)); + // Order the report deterministically so it is reproducible regardless of the + // (arbitrary) order in which the source SBOM listed its components/vulns. + // Stable output matters for the headline use cases: committed audit trails and + // PR-comment diffs stay quiet unless something *actually* changed, and the + // highest-risk findings (critical CVEs, major bumps) surface at the top. + added.sort(compareComponents); + removed.sort(compareComponents); + upgraded.sort(compareVersionChanges); + newCVEs.sort(compareCVEs); + fixedCVEs.sort(compareCVEs); + return { added, removed, @@ -80,3 +91,32 @@ function isMajorVersionBump(from: string, to: string): boolean { if (isNaN(fromMajor) || isNaN(toMajor)) return false; return toMajor > fromMajor; } + +// --- Deterministic ordering --- + +/** Severity ordering, highest to lowest, for sorting CVEs by risk. */ +const CVE_SEVERITY_ORDER: Record, number> = { + critical: 0, + high: 1, + medium: 2, + low: 3, + none: 4, +}; + +/** Sort components by name, then version, for stable add/remove listings. */ +function compareComponents(a: Component, b: Component): number { + return a.name.localeCompare(b.name) || (a.version ?? '').localeCompare(b.version ?? ''); +} + +/** Sort upgrades with major bumps first (highest risk), then by name. */ +function compareVersionChanges(a: VersionChange, b: VersionChange): number { + if (a.isMajorBump !== b.isMajorBump) return a.isMajorBump ? -1 : 1; + return compareComponents(a.component, b.component); +} + +/** Sort CVEs by severity (most severe first), then by ID for stability. */ +function compareCVEs(a: CVEEntry, b: CVEEntry): number { + const rankA = a.severity ? CVE_SEVERITY_ORDER[a.severity] : 5; + const rankB = b.severity ? CVE_SEVERITY_ORDER[b.severity] : 5; + return rankA - rankB || a.id.localeCompare(b.id); +}