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
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,27 @@ npx @hailbytes/sbom-diff old.json new.json --format json
npx @hailbytes/sbom-diff old.json new.json --format markdown
```

### CI/CD gate

Use `--fail-on` to exit with a non-zero status when the diff matches one or
more conditions — turning the diff into a build gate. Conditions are
comma-separated:

| Condition | Fails when… |
|-----------|-------------|
| `any` | any component is added, removed, or upgraded, or any new CVE appears |
| `added` / `removed` / `upgraded` | any component is added / removed / upgraded |
| `major` | any major version bump occurs |
| `new-cves` | any new CVE is introduced |
| `low` / `medium` / `high` / `critical` | any new CVE at or above that severity |

```bash
# Fail the pipeline if a new critical CVE or a major version bump appears
npx @hailbytes/sbom-diff old.json new.json --fail-on critical,major
```

Exit codes: `0` = no gated changes, `1` = gate triggered (or invalid input).

### Programmatic
```ts
import { diff } from '@hailbytes/sbom-diff';
Expand All @@ -54,6 +75,16 @@ console.log(report.upgraded); // { from: Component, to: Component }[]
console.log(report.newCVEs); // CVE[] — vulnerabilities in new packages
```

```ts
import { parse, diff, evaluateGate } from '@hailbytes/sbom-diff';

const report = diff(parse(oldJSON), parse(newJSON));
const gate = evaluateGate(report, ['critical', 'major']);
if (gate.shouldFail) {
throw new Error(`SBOM gate failed: ${gate.reasons.join('; ')}`);
}
```

---

## Who Is This For
Expand Down
120 changes: 120 additions & 0 deletions src/__tests__/gate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { describe, it, expect } from 'vitest';
import { evaluateGate, parseGateConditions, ALL_GATE_CONDITIONS } from '../gate.js';
import type { ChangeReport, CVEEntry } from '../types.js';

const emptyReport = (): ChangeReport => ({
added: [],
removed: [],
upgraded: [],
newCVEs: [],
fixedCVEs: [],
summary: {
totalAdded: 0,
totalRemoved: 0,
totalUpgraded: 0,
totalNewCVEs: 0,
totalFixedCVEs: 0,
},
});

const cve = (id: string, severity: CVEEntry['severity']): CVEEntry => ({
id,
affects: 'pkg:npm/example@1.0.0',
severity,
});

describe('parseGateConditions', () => {
it('parses a comma-separated list', () => {
expect(parseGateConditions('critical,major')).toEqual(['critical', 'major']);
});

it('is case-insensitive and trims whitespace', () => {
expect(parseGateConditions(' New-CVEs , HIGH ')).toEqual(['new-cves', 'high']);
});

it('ignores empty tokens', () => {
expect(parseGateConditions('any,,')).toEqual(['any']);
});

it('throws on unknown conditions', () => {
expect(() => parseGateConditions('bogus')).toThrow(/Unknown --fail-on condition/);
});

it('accepts every documented condition', () => {
expect(parseGateConditions(ALL_GATE_CONDITIONS.join(','))).toEqual(ALL_GATE_CONDITIONS);
});
});

describe('evaluateGate', () => {
it('does not fail an empty report', () => {
const result = evaluateGate(emptyReport(), ['any', 'critical', 'major', 'new-cves']);
expect(result.shouldFail).toBe(false);
expect(result.reasons).toEqual([]);
});

it('fails on "any" when there are changes', () => {
const r = emptyReport();
r.summary.totalAdded = 2;
const result = evaluateGate(r, ['any']);
expect(result.shouldFail).toBe(true);
expect(result.reasons[0]).toMatch(/2 change/);
});

it('fails on added/removed/upgraded counts', () => {
const r = emptyReport();
r.summary.totalRemoved = 1;
expect(evaluateGate(r, ['added']).shouldFail).toBe(false);
expect(evaluateGate(r, ['removed']).shouldFail).toBe(true);
});

it('fails on major bumps only when a major bump exists', () => {
const r = emptyReport();
r.upgraded = [
{ component: { name: 'a' }, from: '1.0.0', to: '1.2.0', isMajorBump: false },
];
r.summary.totalUpgraded = 1;
expect(evaluateGate(r, ['major']).shouldFail).toBe(false);
expect(evaluateGate(r, ['upgraded']).shouldFail).toBe(true);

r.upgraded.push({ component: { name: 'b' }, from: '1.0.0', to: '2.0.0', isMajorBump: true });
r.summary.totalUpgraded = 2;
expect(evaluateGate(r, ['major']).shouldFail).toBe(true);
});

it('fails on new CVEs', () => {
const r = emptyReport();
r.newCVEs = [cve('CVE-1', 'low')];
r.summary.totalNewCVEs = 1;
expect(evaluateGate(r, ['new-cves']).shouldFail).toBe(true);
});

it('applies severity thresholds (at or above)', () => {
const r = emptyReport();
r.newCVEs = [cve('CVE-low', 'low'), cve('CVE-high', 'high')];
r.summary.totalNewCVEs = 2;

expect(evaluateGate(r, ['critical']).shouldFail).toBe(false);
expect(evaluateGate(r, ['high']).shouldFail).toBe(true);
expect(evaluateGate(r, ['medium']).shouldFail).toBe(true);
expect(evaluateGate(r, ['low']).shouldFail).toBe(true);
});

it('ignores CVEs with unknown severity for severity thresholds', () => {
const r = emptyReport();
r.newCVEs = [cve('CVE-x', undefined)];
r.summary.totalNewCVEs = 1;
expect(evaluateGate(r, ['low']).shouldFail).toBe(false);
// ...but still counts under the generic new-cves condition
expect(evaluateGate(r, ['new-cves']).shouldFail).toBe(true);
});

it('reports a reason for each triggered condition', () => {
const r = emptyReport();
r.summary.totalAdded = 1;
r.newCVEs = [cve('CVE-1', 'critical')];
r.summary.totalNewCVEs = 1;
const result = evaluateGate(r, ['added', 'critical']);
expect(result.shouldFail).toBe(true);
expect(result.reasons).toHaveLength(2);
});
});
103 changes: 97 additions & 6 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,111 @@
* @hailbytes/sbom-diff CLI
*
* Usage:
* npx @hailbytes/sbom-diff <old.json> <new.json> [--format text|json|markdown]
* npx @hailbytes/sbom-diff <old.json> <new.json> [options]
*/

import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { parse } from './parser.js';
import { diff } from './diff.js';
import { renderReport } from './reporter.js';
import { parseGateConditions, evaluateGate, ALL_GATE_CONDITIONS } from './gate.js';
import type { ReportFormat } from './types.js';

const VALID_FORMATS: ReportFormat[] = ['text', 'json', 'markdown'];

const HELP = `sbom-diff — diff two CycloneDX or SPDX SBOMs

Usage:
sbom-diff <old.json> <new.json> [options]

Options:
--format <fmt> Output format: text | json | markdown (default: text)
--fail-on <conds> Exit non-zero when the diff matches the given,
comma-separated condition(s). Use as a CI/CD gate.
Conditions: ${ALL_GATE_CONDITIONS.join(', ')}
-h, --help Show this help and exit
-v, --version Show version and exit

Examples:
sbom-diff old.json new.json
sbom-diff old.json new.json --format markdown
sbom-diff old.json new.json --fail-on critical,major
`;

/** Flags that consume the following token as their value (space-separated form). */
const VALUE_FLAGS = new Set(['--format', '--fail-on']);

function getFlag(args: string[], name: string): string | undefined {
const eq = args.find(a => a.startsWith(`${name}=`));
if (eq) return eq.slice(name.length + 1);
const idx = args.indexOf(name);
if (idx !== -1 && idx + 1 < args.length) return args[idx + 1];
return undefined;
}

/** Collect positional arguments, skipping flags and their consumed values. */
function getPositionals(args: string[]): string[] {
const positionals: string[] = [];
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg.startsWith('-')) {
// Space-separated value flag (e.g. "--format markdown") consumes next token.
if (VALUE_FLAGS.has(arg) && i + 1 < args.length) i++;
continue;
}
positionals.push(arg);
}
return positionals;
}

async function readVersion(): Promise<string> {
try {
const here = dirname(fileURLToPath(import.meta.url));
const pkg = JSON.parse(await readFile(join(here, '..', 'package.json'), 'utf-8'));
return typeof pkg.version === 'string' ? pkg.version : 'unknown';
} catch {
return 'unknown';
}
}

async function main(): Promise<void> {
const args = process.argv.slice(2);

const positional = args.filter(a => !a.startsWith('--'));
if (args.includes('-h') || args.includes('--help')) {
console.log(HELP);
return;
}
if (args.includes('-v') || args.includes('--version')) {
console.log(await readVersion());
return;
}

const positional = getPositionals(args);
if (positional.length < 2) {
console.error('Usage: sbom-diff <old.json> <new.json> [--format text|json|markdown]');
console.error('Usage: sbom-diff <old.json> <new.json> [--format text|json|markdown] [--fail-on <conditions>]');
console.error('Run "sbom-diff --help" for details.');
process.exit(1);
}

const [oldPath, newPath] = positional;
const formatArg = args.find(a => a.startsWith('--format='))?.split('=')[1]
?? args[args.indexOf('--format') + 1];
const format: ReportFormat = (formatArg as ReportFormat) ?? 'text';

const format = (getFlag(args, '--format') ?? 'text') as ReportFormat;
if (!VALID_FORMATS.includes(format)) {
console.error(`Error: unknown --format "${format}". Valid values: ${VALID_FORMATS.join(', ')}.`);
process.exit(1);
}

const failOnRaw = getFlag(args, '--fail-on');
let gateConditions;
try {
gateConditions = failOnRaw != null ? parseGateConditions(failOnRaw) : [];
} catch (err) {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
return;
}

const [oldRaw, newRaw] = await Promise.all([
readFile(oldPath, 'utf-8'),
Expand All @@ -36,6 +119,14 @@ async function main(): Promise<void> {
const report = diff(oldSBOM, newSBOM);

console.log(renderReport(report, format));

if (gateConditions.length > 0) {
const gate = evaluateGate(report, gateConditions);
if (gate.shouldFail) {
console.error(`\nGate failed (--fail-on ${failOnRaw}): ${gate.reasons.join('; ')}.`);
process.exit(1);
}
}
}

main().catch(err => {
Expand Down
Loading
Loading