From 2a383398161b1845b6c43cd036823a8c02ffeba5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 03:08:42 +0000 Subject: [PATCH] fix(diff): match components on version-independent purl key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Component matching keyed on the raw purl, but real-world purls embed the version (e.g. "pkg:npm/lodash@4.17.21"), so an upgraded package received a different key in the old vs. new SBOM. Every upgrade was therefore reported as one removed + one added component, and the `upgraded` report — a headline feature — was always empty for SBOMs produced by real tooling (syft, cdxgen, etc.). It only worked in the synthetic no-purl case. Derive a version-independent identity key by stripping the version segment from the purl while preserving type, namespace, name, qualifiers, and subpath. Uses the last '@' so unencoded scoped-npm purls ("pkg:npm/@angular/core@12.0.0") keep the scope intact. Update the previously-misleading upgrade test (which rationalised the broken behaviour) and add coverage for versioned-purl upgrades, major bumps, qualifiers, unencoded npm scopes, and the different-package add/remove case. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LucDjPM6yokJEqBnf6XwvK --- CHANGELOG.md | 3 +++ src/__tests__/diff.test.ts | 50 ++++++++++++++++++++++++++++++++++---- src/diff.ts | 41 ++++++++++++++++++++++++++++--- 3 files changed, 86 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d08f8fc..dec19ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +### Fixed +- `src/diff.ts` — Version upgrades are now detected for real-world SBOMs. Component matching keyed on the raw purl, but purls embed the version (e.g. `pkg:npm/lodash@4.17.21`), so every upgrade was misreported as one removed + one added component and the `upgraded` section was always empty. Components are now matched on a version-independent purl key (type/namespace/name plus qualifiers/subpath), correctly surfacing upgrades. Handles unencoded scoped-npm purls and qualifiers. + ### Added - `src/cli.ts` — `--fail-on none|any|low|medium|high|critical` flag: turns the diff into a CI/CD gate that exits with code `3` when new CVEs meet the chosen severity policy (default `none` preserves prior always-exit-`0` behaviour) - Real devDependencies: `typescript`, `vitest`, `@vitest/coverage-v8`, `typescript-eslint`, `@types/node` diff --git a/src/__tests__/diff.test.ts b/src/__tests__/diff.test.ts index 0f8587c..95ee4e8 100644 --- a/src/__tests__/diff.test.ts +++ b/src/__tests__/diff.test.ts @@ -42,14 +42,54 @@ describe('diff', () => { expect(report.removed[0].name).toBe('moment'); }); - it('detects version upgrades', () => { + it('detects version upgrades even when the purl embeds the version', () => { + // Real-world purls include the version, so a naive purl key would report an + // upgrade as one removed + one added. The versionless key must recognise it + // as the same package being upgraded. 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('detects a major bump matched by versioned purl', () => { + const a = makesbom([{ name: 'react', version: '17.0.2', purl: 'pkg:npm/react@17.0.2' }]); + const b = makesbom([{ name: 'react', version: '18.2.0', purl: 'pkg:npm/react@18.2.0' }]); + const report = diff(a, b); + expect(report.upgraded).toHaveLength(1); + expect(report.upgraded[0].isMajorBump).toBe(true); + }); + + it('matches versioned purls with qualifiers and unencoded npm scopes', () => { + const a = makesbom([ + { name: '@angular/core', version: '12.0.0', purl: 'pkg:npm/@angular/core@12.0.0' }, + { name: 'commons', version: '1.0', purl: 'pkg:maven/org.apache/commons@1.0?type=jar' }, + ]); + const b = makesbom([ + { name: '@angular/core', version: '13.0.0', purl: 'pkg:npm/@angular/core@13.0.0' }, + { name: 'commons', version: '2.0', purl: 'pkg:maven/org.apache/commons@2.0?type=jar' }, + ]); + const report = diff(a, b); + expect(report.added).toHaveLength(0); + expect(report.removed).toHaveLength(0); + expect(report.upgraded).toHaveLength(2); + expect(report.upgraded.map(u => u.component.name).sort()).toEqual([ + '@angular/core', + 'commons', + ]); + }); + + it('still treats different packages as add/remove', () => { + const a = makesbom([{ name: 'lodash', version: '4.17.21', purl: 'pkg:npm/lodash@4.17.21' }]); + const b = makesbom([{ name: 'underscore', version: '1.13.6', purl: 'pkg:npm/underscore@1.13.6' }]); + const report = diff(a, b); + expect(report.added.map(c => c.name)).toEqual(['underscore']); + expect(report.removed.map(c => c.name)).toEqual(['lodash']); + expect(report.upgraded).toHaveLength(0); }); it('detects version upgrades when matched by name (no purl)', () => { diff --git a/src/diff.ts b/src/diff.ts index 6540b50..dd3168f 100644 --- a/src/diff.ts +++ b/src/diff.ts @@ -63,13 +63,48 @@ 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; } +/** + * Derive a version-independent identity key for a component so the same package + * at two different versions maps to the same key (which is what makes upgrade + * detection work). + * + * Real-world purls embed the version — e.g. "pkg:npm/lodash@4.17.21" — so keying + * on the raw purl would give the old and new versions different keys, making + * every upgrade look like a removal plus an addition. We strip the version + * segment while preserving type, namespace, name, qualifiers, and subpath. + * Components without a purl fall back to matching by name. + */ +export function componentKey(comp: Component): string { + if (!comp.purl) return `name:${comp.name}`; + return stripPurlVersion(comp.purl); +} + +/** + * Remove the version from a Package URL, keeping everything else intact. + * + * purl layout: scheme:type/namespace/name@version?qualifiers#subpath + * The version is introduced by the last '@' before any '?' or '#'. Using the + * last '@' keeps unencoded scoped-npm purls like "pkg:npm/@angular/core@12.0.0" + * correct — only "@12.0.0" is stripped, not the "@angular" scope. + */ +function stripPurlVersion(purl: string): string { + const subpathIdx = purl.indexOf('#'); + const subpath = subpathIdx >= 0 ? purl.slice(subpathIdx) : ''; + const withoutSubpath = subpathIdx >= 0 ? purl.slice(0, subpathIdx) : purl; + + const qualIdx = withoutSubpath.indexOf('?'); + const qualifiers = qualIdx >= 0 ? withoutSubpath.slice(qualIdx) : ''; + const coord = qualIdx >= 0 ? withoutSubpath.slice(0, qualIdx) : withoutSubpath; + + const versionless = coord.replace(/@[^@]*$/, ''); + return versionless + qualifiers + subpath; +} + /** * Returns true if the major version changed (semver-style). * Handles versions like "1.2.3", "2.0.0-beta", etc.