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
51 changes: 51 additions & 0 deletions src/__tests__/diff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
});
});
40 changes: 40 additions & 0 deletions src/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<NonNullable<CVEEntry['severity']>, 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);
}
Loading