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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
50 changes: 45 additions & 5 deletions src/__tests__/diff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand Down
41 changes: 38 additions & 3 deletions src/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,48 @@ 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;
}

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