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` — 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`
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 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)', () => {
Expand Down
30 changes: 27 additions & 3 deletions src/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,37 @@ 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.
*
* 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.
Expand Down
Loading