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
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` — `gateWarning()`: when `--fail-on` is armed but neither compared SBOM contains vulnerability data, the CLI now prints a warning to `stderr` explaining the CVE gate has nothing to evaluate and will pass. Guards against a silent fail-open (SPDX 2.x has no vulnerability field; default CycloneDX output omits one), so a green gate is never mistaken for "no new CVEs".
- `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`
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,15 @@ your pipeline stops on risky changes:
run: npx @hailbytes/sbom-diff sbom.base.json sbom.pr.json --fail-on high
```

> **Note:** the gate can only see vulnerabilities that are **embedded in the
> SBOMs** you compare. SPDX 2.x has no vulnerability field, and the default
> output of common CycloneDX generators omits one, so `--fail-on` has nothing to
> evaluate for those inputs and will pass. When a gate is armed but neither SBOM
> carries vulnerability data, the CLI prints a warning to `stderr` so a green
> result is never mistaken for "no new CVEs". To enable CVE gating, feed SBOMs
> that include a CycloneDX 1.4+ `vulnerabilities` list (e.g. from a scan/VEX
> step).

### Programmatic
```ts
import { readFile } from 'node:fs/promises';
Expand Down
42 changes: 40 additions & 2 deletions src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import { parseArgs, gateFailures } from '../cli.js';
import type { ChangeReport, CVEEntry } from '../types.js';
import { parseArgs, gateFailures, gateWarning } from '../cli.js';
import type { ChangeReport, CVEEntry, SBOM } from '../types.js';

describe('parseArgs', () => {
it('defaults to text format when no flag is given', () => {
Expand Down Expand Up @@ -120,3 +120,41 @@ describe('gateFailures', () => {
expect(gateFailures(report, 'any').map(v => v.id)).toEqual(['CVE-unknown']);
});
});

describe('gateWarning', () => {
const sbom = (vulnerabilities: SBOM['vulnerabilities'] = []): SBOM => ({
format: 'cyclonedx',
components: [],
vulnerabilities,
});
const withCve: SBOM = sbom([{ id: 'CVE-1', affects: 'pkg:npm/example', severity: 'high' }]);

it('returns null when the gate is off, even without vulnerability data', () => {
expect(gateWarning(sbom(), sbom(), 'none')).toBeNull();
});

it('warns when a gate is armed but neither SBOM carries vulnerability data', () => {
const warning = gateWarning(sbom(), sbom(), 'high');
expect(warning).toMatch(/--fail-on "high"/);
expect(warning).toMatch(/neither SBOM contains vulnerability data/);
});

it('warns for the "any" policy too', () => {
expect(gateWarning(sbom(), sbom(), 'any')).toMatch(/neither SBOM contains vulnerability data/);
});

it('stays silent when the old SBOM carries vulnerability data', () => {
expect(gateWarning(withCve, sbom(), 'critical')).toBeNull();
});

it('stays silent when the new SBOM carries vulnerability data', () => {
expect(gateWarning(sbom(), withCve, 'critical')).toBeNull();
});

it('treats a missing vulnerabilities field as no data', () => {
const noField: SBOM = { format: 'spdx', components: [] };
expect(gateWarning(noField, noField, 'medium')).toMatch(
/neither SBOM contains vulnerability data/,
);
});
});
33 changes: 32 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { pathToFileURL } from 'node:url';
import { parse } from './parser.js';
import { diff } from './diff.js';
import { renderReport } from './reporter.js';
import type { ChangeReport, CVEEntry, ReportFormat } from './types.js';
import type { ChangeReport, CVEEntry, ReportFormat, SBOM } from './types.js';

const USAGE =
'Usage: sbom-diff <old.json> <new.json> [--format text|json|markdown] [--fail-on none|any|low|medium|high|critical]';
Expand Down Expand Up @@ -111,6 +111,34 @@ export function gateFailures(report: ChangeReport, failOn: FailOn): CVEEntry[] {
);
}

/**
* Guard against a silently fail-open CVE gate.
*
* `--fail-on` can only trip on vulnerabilities that are *embedded in the SBOMs*
* being compared (`diff()` derives `newCVEs` from each SBOM's `vulnerabilities`
* list). Most SBOMs carry no such data: SPDX 2.x has no vulnerability field at
* all, and the default output of common CycloneDX generators omits it —
* vulnerabilities are usually attached by a separate scan/VEX step. When a gate
* is armed but neither input carries vulnerability data, the gate has nothing to
* evaluate and always passes, which in CI reads as "no new CVEs" when the truth
* is "CVEs were never checked".
*
* Returns a human-readable warning for that fail-open case, or `null` when the
* gate is off (`none`) or at least one SBOM actually carries vulnerability data.
*/
export function gateWarning(oldSBOM: SBOM, newSBOM: SBOM, failOn: FailOn): string | null {
if (failOn === 'none') return null;
const hasVulnData =
(oldSBOM.vulnerabilities?.length ?? 0) > 0 || (newSBOM.vulnerabilities?.length ?? 0) > 0;
if (hasVulnData) return null;
return (
`Warning: --fail-on "${failOn}" is set, but neither SBOM contains vulnerability data, ` +
'so the CVE gate has nothing to evaluate and will pass. Most SBOMs do not embed ' +
'vulnerabilities (all SPDX 2.x, and the default output of common CycloneDX generators); ' +
'attach a scan/VEX step that emits a CycloneDX 1.4+ "vulnerabilities" list to enable CVE gating.'
);
}

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

Expand All @@ -132,6 +160,9 @@ async function main(): Promise<void> {

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

const warning = gateWarning(oldSBOM, newSBOM, failOn);
if (warning) console.error(warning);

const failures = gateFailures(report, failOn);
if (failures.length > 0) {
const label = failOn === 'any' ? 'new CVE(s)' : `new CVE(s) at or above "${failOn}" severity`;
Expand Down
Loading