From e0c88449fff2d6e3b499a464c68c846c8f6c0a62 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 03:07:18 +0000 Subject: [PATCH] fix(diff): detect upgrades when purls include the version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real-world SBOM generators (syft, cdxgen, trivy) embed the version in the purl, e.g. "pkg:npm/lodash@4.17.21". buildComponentMap keyed components on the full purl, so an upgraded package received two different keys and was reported as a spurious removed + added pair instead of an upgrade — leaving the `upgraded` list effectively always empty for real SBOMs, which defeats the tool's core purpose. Key components on a version-independent purl coordinate (purl with the `@version` segment stripped, bounded by any ?qualifiers/#subpath), so the same package matches across SBOMs and genuine version changes are detected. Handles scoped npm namespaces (%40) and purl qualifiers. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TbSanSfxx5hSGA6prTYxfs --- src/__tests__/diff.test.ts | 29 ++++++++++++++++++++++++----- src/diff.ts | 32 +++++++++++++++++++++++++++++--- 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/src/__tests__/diff.test.ts b/src/__tests__/diff.test.ts index 0f8587c..4fa4592 100644 --- a/src/__tests__/diff.test.ts +++ b/src/__tests__/diff.test.ts @@ -42,14 +42,33 @@ describe('diff', () => { expect(report.removed[0].name).toBe('moment'); }); - it('detects version upgrades', () => { + it('detects version upgrades when matched by versioned purl', () => { + // Real SBOM generators embed the version in the purl. The upgrade must be + // matched on the version-independent coordinate, not surfaced 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'); + }); + + it('matches versioned purls with qualifiers and scoped namespaces', () => { + const a = makesbom([ + { name: '@angular/core', version: '15.0.0', purl: 'pkg:npm/%40angular/core@15.0.0' }, + { name: 'zlib', version: '1.2.13', purl: 'pkg:generic/zlib@1.2.13?download_url=https://example.com' }, + ]); + const b = makesbom([ + { name: '@angular/core', version: '16.0.0', purl: 'pkg:npm/%40angular/core@16.0.0' }, + { name: 'zlib', version: '1.3.0', purl: 'pkg:generic/zlib@1.3.0?download_url=https://example.com' }, + ]); + const report = diff(a, b); + expect(report.added).toHaveLength(0); + expect(report.removed).toHaveLength(0); + expect(report.upgraded).toHaveLength(2); + expect(report.upgraded.find(u => u.component.name === '@angular/core')?.isMajorBump).toBe(true); }); it('detects version upgrades when matched by name (no purl)', () => { diff --git a/src/diff.ts b/src/diff.ts index 6540b50..b8165f5 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; } +/** + * A version-independent identity for a component. + * + * Real-world SBOMs (syft, cdxgen, trivy, …) embed the version in the purl + * (e.g. "pkg:npm/lodash@4.17.21"), so keying on the raw purl would give an + * upgraded package two different keys — surfacing it as a spurious + * remove + add pair instead of an upgrade. Strip the version so the same + * package matches across SBOMs and real upgrades are detected. + */ +function componentKey(comp: Component): string { + if (comp.purl) return stripPurlVersion(comp.purl); + return comp.name; +} + +/** + * Remove the `@version` segment from a purl, leaving the coordinate. + * + * purl layout: `pkg:type/namespace/name@version?qualifiers#subpath`. The + * version is delimited by the last unescaped `@` (scoped npm namespaces + * encode their leading `@` as `%40`, so it never collides), bounded by any + * `?qualifiers` / `#subpath` suffix. + */ +function stripPurlVersion(purl: string): string { + const core = purl.split('?')[0].split('#')[0]; + const at = core.lastIndexOf('@'); + return at > 0 ? core.slice(0, at) : core; +} + /** * Returns true if the major version changed (semver-style). * Handles versions like "1.2.3", "2.0.0-beta", etc.