Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]

### 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`
- `src/types.ts` — Full domain model: `SBOM`, `Component`, `CVEEntry`, `ChangeReport`, `VersionChange`, `SBOMFormat`, `ReportFormat`
- `src/parser.ts` — `parse()` / `parseCycloneDX()` / `parseSPDX()`: auto-detect and parse CycloneDX + SPDX JSON SBOMs, extracts purls, ecosystems, licenses, suppliers, CVEs
Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,27 @@ npx @hailbytes/sbom-diff old.json new.json --format json

# Output as Markdown (great for PR comments)
npx @hailbytes/sbom-diff old.json new.json --format markdown

# Fail the build (exit code 3) if any new high or critical CVE appears
npx @hailbytes/sbom-diff old.json new.json --fail-on high
```

### CI/CD gate

Use `--fail-on` to turn the diff into a pass/fail gate. When the policy is
triggered, the report is still printed and the process exits with code `3`, so
your pipeline stops on risky changes:

| `--fail-on` | Fails when… |
|-------------|-------------|
| `none` *(default)* | never — always exits `0` |
| `any` | any new CVE is introduced |
| `low` / `medium` / `high` / `critical` | a new CVE appears at or above that severity |

```yaml
# GitHub Actions example
- name: Gate on new high-severity CVEs
run: npx @hailbytes/sbom-diff sbom.base.json sbom.pr.json --fail-on high
```

### Programmatic
Expand Down
86 changes: 84 additions & 2 deletions src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { describe, it, expect } from 'vitest';
import { parseArgs } from '../cli.js';
import { parseArgs, gateFailures } from '../cli.js';
import type { ChangeReport, CVEEntry } from '../types.js';

describe('parseArgs', () => {
it('defaults to text format when no flag is given', () => {
const { positional, format } = parseArgs(['old.json', 'new.json']);
const { positional, format, failOn } = parseArgs(['old.json', 'new.json']);
expect(positional).toEqual(['old.json', 'new.json']);
expect(format).toBe('text');
expect(failOn).toBe('none');
});

it('parses --format=json', () => {
Expand Down Expand Up @@ -37,4 +39,84 @@ describe('parseArgs', () => {
it('throws on an unknown option', () => {
expect(() => parseArgs(['old.json', 'new.json', '--bogus'])).toThrow(/Unknown option/);
});

it('parses --fail-on high', () => {
const { failOn } = parseArgs(['old.json', 'new.json', '--fail-on', 'high']);
expect(failOn).toBe('high');
});

it('parses --fail-on=any', () => {
const { failOn } = parseArgs(['old.json', 'new.json', '--fail-on=any']);
expect(failOn).toBe('any');
});

it('throws on an unsupported --fail-on value', () => {
expect(() => parseArgs(['old.json', 'new.json', '--fail-on=sometimes'])).toThrow(
/Invalid --fail-on/,
);
});

it('throws when --fail-on is given without a value', () => {
expect(() => parseArgs(['old.json', 'new.json', '--fail-on'])).toThrow(/Invalid --fail-on/);
});
});

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

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

it('never fails under the "none" policy, even with new CVEs', () => {
const report = reportWith([cve('CVE-1', 'critical')]);
expect(gateFailures(report, 'none')).toEqual([]);
});

it('fails on any new CVE under the "any" policy', () => {
const report = reportWith([cve('CVE-1', 'low'), cve('CVE-2')]);
expect(gateFailures(report, 'any').map(v => v.id)).toEqual(['CVE-1', 'CVE-2']);
});

it('passes when there are no new CVEs', () => {
expect(gateFailures(reportWith([]), 'any')).toEqual([]);
expect(gateFailures(reportWith([]), 'critical')).toEqual([]);
});

it('fails only on new CVEs at or above the severity threshold', () => {
const report = reportWith([
cve('CVE-low', 'low'),
cve('CVE-med', 'medium'),
cve('CVE-high', 'high'),
cve('CVE-crit', 'critical'),
]);
expect(gateFailures(report, 'high').map(v => v.id)).toEqual(['CVE-high', 'CVE-crit']);
expect(gateFailures(report, 'medium').map(v => v.id)).toEqual([
'CVE-med',
'CVE-high',
'CVE-crit',
]);
});

it('does not trip a severity threshold on unknown-severity CVEs', () => {
const report = reportWith([cve('CVE-unknown')]);
expect(gateFailures(report, 'critical')).toEqual([]);
// ...but "any" still catches them.
expect(gateFailures(report, 'any').map(v => v.id)).toEqual(['CVE-unknown']);
});
});
80 changes: 71 additions & 9 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,50 +3,80 @@
* @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> [--format text|json|markdown] [--fail-on <level>]
*/

import { readFile } from 'node:fs/promises';
import { pathToFileURL } from 'node:url';
import { parse } from './parser.js';
import { diff } from './diff.js';
import { renderReport } from './reporter.js';
import type { ReportFormat } from './types.js';
import type { ChangeReport, CVEEntry, ReportFormat } from './types.js';

const USAGE = 'Usage: sbom-diff <old.json> <new.json> [--format text|json|markdown]';
const USAGE =
'Usage: sbom-diff <old.json> <new.json> [--format text|json|markdown] [--fail-on none|any|low|medium|high|critical]';
const VALID_FORMATS: ReportFormat[] = ['text', 'json', 'markdown'];

/**
* CI/CD gate policy. Determines whether the CLI exits non-zero.
* - `none`: never fail (default; preserves prior behaviour)
* - `any`: fail if any new CVE is introduced, regardless of severity
* - a severity: fail if any new CVE meets or exceeds that severity
*/
export type FailOn = 'none' | 'any' | 'low' | 'medium' | 'high' | 'critical';
const VALID_FAIL_ON: FailOn[] = ['none', 'any', 'low', 'medium', 'high', 'critical'];

/** Exit code used when a `--fail-on` gate is triggered (distinct from usage/runtime errors). */
export const GATE_FAILURE_EXIT_CODE = 3;

/** Severity ordering, lowest to highest, for threshold comparisons. */
const SEVERITY_RANK: Record<NonNullable<CVEEntry['severity']>, number> = {
none: 0,
low: 1,
medium: 2,
high: 3,
critical: 4,
};

export interface ParsedArgs {
positional: string[];
format: ReportFormat;
failOn: FailOn;
}

/**
* Parse CLI arguments into positional paths and the requested output format.
* Parse CLI arguments into positional paths, the requested output format, and
* the CI/CD gate policy.
*
* Supports `--format text`, `--format=text`, and flags appearing in any
* position relative to the positional file paths. Defaults to `text`.
* Supports `--format text`, `--format=text`, `--fail-on high`, `--fail-on=high`,
* and flags appearing in any position relative to the positional file paths.
* Defaults to `text` format and a `none` gate policy.
*
* @throws if an unknown flag or unsupported format value is supplied.
* @throws if an unknown flag or unsupported flag value is supplied.
*/
export function parseArgs(argv: string[]): ParsedArgs {
const positional: string[] = [];
let format: ReportFormat = 'text';
let failOn: FailOn = 'none';

for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--format') {
format = assertFormat(argv[++i]);
} else if (arg.startsWith('--format=')) {
format = assertFormat(arg.slice('--format='.length));
} else if (arg === '--fail-on') {
failOn = assertFailOn(argv[++i]);
} else if (arg.startsWith('--fail-on=')) {
failOn = assertFailOn(arg.slice('--fail-on='.length));
} else if (arg.startsWith('-')) {
throw new Error(`Unknown option: ${arg}\n${USAGE}`);
} else {
positional.push(arg);
}
}

return { positional, format };
return { positional, format, failOn };
}

function assertFormat(value: string | undefined): ReportFormat {
Expand All @@ -58,8 +88,31 @@ function assertFormat(value: string | undefined): ReportFormat {
);
}

function assertFailOn(value: string | undefined): FailOn {
if (value !== undefined && (VALID_FAIL_ON as string[]).includes(value)) {
return value as FailOn;
}
throw new Error(
`Invalid --fail-on value: ${value ?? '(none)'}. Expected one of: ${VALID_FAIL_ON.join(', ')}`,
);
}

/**
* Evaluate the CI/CD gate against a diff. Returns the new CVEs that trip the
* gate (empty when the gate passes). A new CVE with an unknown severity only
* trips the `any` gate, since it cannot be compared against a severity threshold.
*/
export function gateFailures(report: ChangeReport, failOn: FailOn): CVEEntry[] {
if (failOn === 'none') return [];
if (failOn === 'any') return report.newCVEs;
const threshold = SEVERITY_RANK[failOn];
return report.newCVEs.filter(
v => v.severity !== undefined && SEVERITY_RANK[v.severity] >= threshold,
);
}

async function main(): Promise<void> {
const { positional, format } = parseArgs(process.argv.slice(2));
const { positional, format, failOn } = parseArgs(process.argv.slice(2));

if (positional.length < 2) {
console.error(USAGE);
Expand All @@ -78,6 +131,15 @@ async function main(): Promise<void> {
const report = diff(oldSBOM, newSBOM);

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

const failures = gateFailures(report, failOn);
if (failures.length > 0) {
const label = failOn === 'any' ? 'new CVE(s)' : `new CVE(s) at or above "${failOn}" severity`;
console.error(
`\nGate failed: ${failures.length} ${label}: ${failures.map(v => v.id).join(', ')}`,
);
process.exit(GATE_FAILURE_EXIT_CODE);
}
}

// Only run when invoked directly (not when imported by tests).
Expand Down
Loading