diff --git a/CHANGELOG.md b/CHANGELOG.md index d08f8fc..eeed56f 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` — Upgrade detection now works for versioned purls. Component identity was keyed on the full purl (which embeds the version, e.g. `pkg:npm/lodash@4.17.21`), so a version bump was reported as a removal + addition and `upgraded` stayed empty for essentially every real-world SBOM. Identity is now derived from the version-independent purl coordinates. + ### 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..ad781d4 100644 --- a/src/__tests__/diff.test.ts +++ b/src/__tests__/diff.test.ts @@ -42,14 +42,34 @@ 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, so identity must be + // derived from the version-independent purl coordinates — otherwise a bump + // looks like a remove + add rather than an upgrade. 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 across purl qualifiers and subpaths', () => { + const a = makesbom([{ name: 'lodash', version: '4.17.20', purl: 'pkg:npm/lodash@4.17.20?arch=x64' }]); + const b = makesbom([{ name: 'lodash', version: '4.17.21', purl: 'pkg:npm/lodash@4.17.21?arch=x64' }]); + const report = diff(a, b); + expect(report.upgraded).toHaveLength(1); + expect(report.upgraded[0].isMajorBump).toBe(false); + }); + + it('matches scoped npm purls (percent-encoded @ in the scope)', () => { + const a = makesbom([{ name: '@angular/core', version: '15.0.0', purl: 'pkg:npm/%40angular/core@15.0.0' }]); + const b = makesbom([{ name: '@angular/core', version: '16.0.0', purl: 'pkg:npm/%40angular/core@16.0.0' }]); + const report = diff(a, b); + expect(report.upgraded).toHaveLength(1); + expect(report.upgraded[0].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..3aa8b76 100644 --- a/src/diff.ts +++ b/src/diff.ts @@ -63,13 +63,37 @@ 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. + * + * A purl normally embeds the component's version (e.g. "pkg:npm/lodash@4.17.21"), + * so keying on the raw purl makes an upgraded dependency look like a removal plus + * an addition instead of an upgrade — defeating upgrade detection for essentially + * every real-world SBOM, since generators (syft, cdxgen, Trivy, …) emit versioned + * purls. Strip the version so the same package across two SBOMs shares a key and + * version changes surface as upgrades. Falls back to the name when no purl exists. + */ +function componentKey(comp: Component): string { + return comp.purl ? purlWithoutVersion(comp.purl) : comp.name; +} + +/** + * Strip the `@version` suffix (plus any `?qualifiers` / `#subpath`) from a purl, + * leaving the version-independent coordinates. Per the purl spec any literal `@` + * within the name must be percent-encoded, so the last `@` unambiguously marks + * the version separator. + */ +function purlWithoutVersion(purl: string): string { + 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.