From 60629cd19d69f40e7c5b9e7053ff08cc9616cca4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 03:12:02 +0000 Subject: [PATCH] feat(cli): warn when --fail-on gate can't see any vulnerability data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The --fail-on gate only evaluates vulnerabilities embedded in the compared SBOMs (diff() derives newCVEs from each SBOM's `vulnerabilities` list). Most SBOMs carry none: SPDX 2.x has no vulnerability field, and the default output of common CycloneDX generators omits one. In that case the gate has nothing to evaluate and always passes — a silent fail-open that reads as "no new CVEs" in CI when the truth is "CVEs were never checked". Add gateWarning(oldSBOM, newSBOM, failOn): when a gate is armed (failOn !== 'none') but neither SBOM carries vulnerability data, main() prints a warning to stderr explaining why the gate can't fire and how to enable it. The report still goes to stdout and the exit code is unchanged, so nothing breaks for callers whose SBOMs do embed vulnerabilities. Covered by six new unit tests; README --fail-on section and CHANGELOG updated. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0142u9rtNkQzJmMuxzjuDbZV --- CHANGELOG.md | 1 + README.md | 9 +++++++++ src/__tests__/cli.test.ts | 42 +++++++++++++++++++++++++++++++++++++-- src/cli.ts | 33 +++++++++++++++++++++++++++++- 4 files changed, 82 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d08f8fc..0f13e87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` diff --git a/README.md b/README.md index a6d817d..7fc5f91 100644 --- a/README.md +++ b/README.md @@ -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'; diff --git a/src/__tests__/cli.test.ts b/src/__tests__/cli.test.ts index 23df1e1..7bbd677 100644 --- a/src/__tests__/cli.test.ts +++ b/src/__tests__/cli.test.ts @@ -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', () => { @@ -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/, + ); + }); +}); diff --git a/src/cli.ts b/src/cli.ts index 4af54ba..8f1f27a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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 [--format text|json|markdown] [--fail-on none|any|low|medium|high|critical]'; @@ -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 { const { positional, format, failOn } = parseArgs(process.argv.slice(2)); @@ -132,6 +160,9 @@ async function main(): Promise { 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`;