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
35 changes: 30 additions & 5 deletions src/__tests__/diff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand Down
32 changes: 29 additions & 3 deletions src/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,39 @@ 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;
}

/**
* 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.
Expand Down
Loading