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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

## [Unreleased]

### Fixed
- `diff()` now matches components by a version-independent identity key, so a
version change on a component that carries a version-qualified purl (e.g.
`pkg:npm/lodash@4.17.20` → `pkg:npm/lodash@4.17.21`) is reported as an
**upgrade** instead of a spurious remove + add. Previously the raw purl —
which embeds the version — was used as the match key, so upgrade detection
(the tool's headline feature) never triggered for real-world SBOMs, which
almost always emit version-qualified purls. Scoped npm purls (`%40`-encoded)
and purls with `?qualifiers`/`#subpath` are handled correctly.

### Added
- Real devDependencies: `typescript`, `vitest`, `@vitest/coverage-v8`, `typescript-eslint`, `@types/node`
- `src/types.ts` — Full domain model: `SBOM`, `Component`, `CVEEntry`, `ChangeReport`, `VersionChange`, `SBOMFormat`, `ReportFormat`
Expand Down
30 changes: 25 additions & 5 deletions src/__tests__/diff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,34 @@ describe('diff', () => {
expect(report.removed[0].name).toBe('moment');
});

it('detects version upgrades', () => {
it('detects version upgrades when matched by version-qualified purl', () => {
// Real SBOMs emit version-qualified purls. The identity key must ignore the
// "@version" segment so this is reported as an upgrade, not remove + add.
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 scoped npm purls across versions (leading @ is percent-encoded)', () => {
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.1.0', purl: 'pkg:npm/%40angular/core@16.1.0' }]);
const report = diff(a, b);
expect(report.upgraded).toHaveLength(1);
expect(report.upgraded[0].isMajorBump).toBe(true);
});

it('matches purls with qualifiers across versions', () => {
const a = makesbom([{ name: 'openssl', version: '1.1.1', purl: 'pkg:deb/debian/openssl@1.1.1?arch=amd64' }]);
const b = makesbom([{ name: 'openssl', version: '3.0.2', purl: 'pkg:deb/debian/openssl@3.0.2?arch=amd64' }]);
const report = diff(a, b);
expect(report.upgraded).toHaveLength(1);
expect(report.added).toHaveLength(0);
expect(report.removed).toHaveLength(0);
});

it('detects version upgrades when matched by name (no purl)', () => {
Expand Down
35 changes: 32 additions & 3 deletions src/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,42 @@ export function diff(a: SBOM, b: SBOM): ChangeReport {
function buildComponentMap(components: Component[]): Map<string, Component> {
const map = new Map<string, Component>();
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;
}

/**
* Compute a version-independent identity key for a component.
*
* A component's identity must be stable across version changes so that an
* upgrade (lodash 4.17.20 -> 4.17.21) matches the same key in both SBOMs
* and is reported as an upgrade rather than a remove + add. Because purls
* embed the version (e.g. "pkg:npm/lodash@4.17.21"), keying on the raw purl
* would give every version a distinct key and break upgrade detection —
* the tool's headline feature. So we strip the "@version" segment while
* preserving any qualifiers/subpath that follow it.
*/
function componentKey(comp: Component): string {
if (comp.purl) return purlIdentity(comp.purl);
// No purl: fall back to ecosystem-qualified name (or bare name).
return comp.ecosystem ? `${comp.ecosystem}:${comp.name}` : comp.name;
}

/**
* Strip the version from a purl, keeping the type/namespace/name and any
* trailing ?qualifiers or #subpath. purl format:
* pkg:type/namespace/name@version?qualifiers#subpath
* The version separator is the first unescaped "@" (scoped npm names encode
* their leading "@" as "%40", so indexOf finds the version's "@").
*/
function purlIdentity(purl: string): string {
const atIdx = purl.indexOf('@');
if (atIdx === -1) return purl;
const tail = purl.slice(atIdx).match(/[?#].*$/);
return purl.slice(0, atIdx) + (tail ? tail[0] : '');
}

/**
* Returns true if the major version changed (semver-style).
* Handles versions like "1.2.3", "2.0.0-beta", etc.
Expand Down
Loading