From f46826227ef06ceec7807ee987a45e27a55b60de Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 03:03:56 +0000 Subject: [PATCH] fix(diff): detect upgrades for version-qualified purls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Component map keys were the raw purl, which embeds the version (e.g. pkg:npm/lodash@4.17.21). Real-world SBOM generators (Syft, Trivy, cdxgen) almost always emit version-qualified purls, so every version got its own key and an upgrade was reported as a remove + add instead of an upgrade — defeating the tool's flagship "upgraded dependencies" feature. Key components by a version-independent purl identity (subpath, qualifiers, and @version stripped), falling back to the name. Add tests covering qualified/subpath purls and guarding against false upgrades between distinct packages. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BPc7gpi66iTEvhzh9b3rNf --- src/__tests__/diff.test.ts | 35 ++++++++++++++++++++++++++++++----- src/diff.ts | 32 +++++++++++++++++++++++++++++--- 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/src/__tests__/diff.test.ts b/src/__tests__/diff.test.ts index 0f8587c..1875661 100644 --- a/src/__tests__/diff.test.ts +++ b/src/__tests__/diff.test.ts @@ -42,14 +42,39 @@ describe('diff', () => { expect(report.removed[0].name).toBe('moment'); }); - it('detects version upgrades', () => { + it('detects version upgrades when matched by version-qualified purl', () => { + // Real-world SBOMs embed the version in the purl. The same package at two + // versions must still match as an upgrade, not be reported as add + remove. const a = makesbom([{ name: 'lodash', version: '4.17.20', purl: 'pkg:npm/lodash@4.17.20' }]); const b = makesbom([{ name: 'lodash', version: '4.17.21', purl: 'pkg:npm/lodash@4.17.21' }]); const report = diff(a, b); - // Different purl = treated as add/remove (purl includes version) - // With our current purl-based key: 4.17.20 -> removed, 4.17.21 -> added - // This is correct behavior — different purls are different packages - expect(report.added.length + report.removed.length + report.upgraded.length).toBeGreaterThan(0); + expect(report.added).toHaveLength(0); + expect(report.removed).toHaveLength(0); + expect(report.upgraded).toHaveLength(1); + expect(report.upgraded[0].from).toBe('4.17.20'); + expect(report.upgraded[0].to).toBe('4.17.21'); + expect(report.upgraded[0].isMajorBump).toBe(false); + }); + + it('matches purls with qualifiers and subpaths regardless of version', () => { + const a = makesbom([ + { name: 'lodash', version: '4.17.20', purl: 'pkg:npm/lodash@4.17.20?arch=x64#sub' }, + ]); + const b = makesbom([ + { name: 'lodash', version: '4.17.21', purl: 'pkg:npm/lodash@4.17.21?arch=x64#sub' }, + ]); + const report = diff(a, b); + expect(report.upgraded).toHaveLength(1); + expect(report.upgraded[0].to).toBe('4.17.21'); + }); + + it('keeps distinct packages distinct (no false upgrades)', () => { + const a = makesbom([{ name: 'lodash', version: '4.17.21', purl: 'pkg:npm/lodash@4.17.21' }]); + const b = makesbom([{ name: 'express', version: '4.18.2', purl: 'pkg:npm/express@4.18.2' }]); + const report = diff(a, b); + expect(report.upgraded).toHaveLength(0); + expect(report.added).toHaveLength(1); + expect(report.removed).toHaveLength(1); }); it('detects version upgrades when matched by name (no purl)', () => { diff --git a/src/diff.ts b/src/diff.ts index 6540b50..c19238e 100644 --- a/src/diff.ts +++ b/src/diff.ts @@ -63,13 +63,39 @@ export function diff(a: SBOM, b: SBOM): ChangeReport { function buildComponentMap(components: Component[]): Map { const map = new Map(); for (const comp of components) { - // Prefer purl as key, fall back to name - const key = comp.purl ?? comp.name; - map.set(key, comp); + map.set(componentKey(comp), comp); } return map; } +/** + * Build a version-independent identity for a component so the same package at + * two different versions matches across SBOMs (and is reported as an upgrade + * rather than a remove + add). + * + * A purl typically embeds the version (e.g. "pkg:npm/lodash@4.17.21"), which + * real-world generators (Syft, Trivy, cdxgen, ...) almost always include. + * Keying on the raw purl would therefore give every version its own key and + * defeat upgrade detection entirely, so we strip the subpath, qualifiers, and + * @version before using the purl as a key. Falls back to the package name. + */ +function componentKey(comp: Component): string { + if (comp.purl) { + return purlIdentity(comp.purl); + } + return comp.name; +} + +function purlIdentity(purl: string): string { + // pkg:type/namespace/name@version?qualifiers#subpath + // Drop #subpath and ?qualifiers, then the trailing @version. An npm scope + // (e.g. %40scope) is percent-encoded, so a literal '@' only ever separates + // the version. + const base = purl.split('#')[0].split('?')[0]; + const at = base.lastIndexOf('@'); + return at > 0 ? base.slice(0, at) : base; +} + /** * Returns true if the major version changed (semver-style). * Handles versions like "1.2.3", "2.0.0-beta", etc.