diff --git a/README.md b/README.md index a6d817d..7347fb0 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,12 @@ your pipeline stops on risky changes: | `any` | any new CVE is introduced | | `low` / `medium` / `high` / `critical` | a new CVE appears at or above that severity | +**VEX-aware:** CycloneDX vulnerabilities whose `analysis.state` is `not_affected` +or `false_positive` are treated as documented suppressions and never trip the +gate — that is the whole point of VEX. They still appear in the report (tagged +`(VEX: not_affected)`) so the audit trail is complete, but they won't fail your +build. + ```yaml # GitHub Actions example - name: Gate on new high-severity CVEs diff --git a/src/__tests__/cli.test.ts b/src/__tests__/cli.test.ts index 23df1e1..8ce5da4 100644 --- a/src/__tests__/cli.test.ts +++ b/src/__tests__/cli.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { parseArgs, gateFailures } from '../cli.js'; +import { parseArgs, gateFailures, isSuppressed } from '../cli.js'; import type { ChangeReport, CVEEntry } from '../types.js'; describe('parseArgs', () => { @@ -62,10 +62,15 @@ describe('parseArgs', () => { }); describe('gateFailures', () => { - const cve = (id: string, severity?: CVEEntry['severity']): CVEEntry => ({ + const cve = ( + id: string, + severity?: CVEEntry['severity'], + analysisState?: string, + ): CVEEntry => ({ id, affects: 'pkg:npm/example', severity, + analysisState, }); const reportWith = (newCVEs: CVEEntry[]): ChangeReport => ({ @@ -119,4 +124,43 @@ describe('gateFailures', () => { // ...but "any" still catches them. expect(gateFailures(report, 'any').map(v => v.id)).toEqual(['CVE-unknown']); }); + + it('ignores VEX-suppressed CVEs (not_affected / false_positive) under every policy', () => { + const report = reportWith([ + cve('CVE-not-affected', 'critical', 'not_affected'), + cve('CVE-false-pos', 'critical', 'false_positive'), + ]); + expect(gateFailures(report, 'any')).toEqual([]); + expect(gateFailures(report, 'critical')).toEqual([]); + }); + + it('still fails on actionable CVEs alongside suppressed ones', () => { + const report = reportWith([ + cve('CVE-suppressed', 'critical', 'not_affected'), + cve('CVE-real', 'high'), + cve('CVE-triage', 'critical', 'in_triage'), + ]); + // The suppressed one drops out; the real and still-under-triage ones remain. + expect(gateFailures(report, 'high').map(v => v.id)).toEqual(['CVE-real', 'CVE-triage']); + }); +}); + +describe('isSuppressed', () => { + const withState = (analysisState?: string): CVEEntry => ({ + id: 'CVE-x', + affects: 'pkg:npm/example', + analysisState, + }); + + it('is true only for not_affected and false_positive', () => { + expect(isSuppressed(withState('not_affected'))).toBe(true); + expect(isSuppressed(withState('false_positive'))).toBe(true); + }); + + it('is false for active, in-progress, or absent states', () => { + expect(isSuppressed(withState('exploitable'))).toBe(false); + expect(isSuppressed(withState('in_triage'))).toBe(false); + expect(isSuppressed(withState('resolved'))).toBe(false); + expect(isSuppressed(withState(undefined))).toBe(false); + }); }); diff --git a/src/__tests__/parser.test.ts b/src/__tests__/parser.test.ts index e0378fb..a884723 100644 --- a/src/__tests__/parser.test.ts +++ b/src/__tests__/parser.test.ts @@ -103,6 +103,28 @@ describe('parse (CycloneDX)', () => { expect(sbom.vulnerabilities![0].severity).toBe('medium'); }); + it('parses the VEX analysis state and lowercases it', () => { + const sbom = parse({ + bomFormat: 'CycloneDX', + specVersion: '1.5', + components: [], + vulnerabilities: [ + { + id: 'CVE-2024-1000', + affects: [{ ref: 'pkg:npm/foo@1.0.0' }], + ratings: [{ severity: 'critical' }], + analysis: { state: 'Not_Affected' }, + }, + ], + }); + expect(sbom.vulnerabilities![0].analysisState).toBe('not_affected'); + }); + + it('leaves analysisState undefined when no analysis block is present', () => { + const sbom = parse(cyclonedxFixture); + expect(sbom.vulnerabilities![0].analysisState).toBeUndefined(); + }); + it('parses metadata name and version', () => { const sbom = parse(cyclonedxFixture); expect(sbom.name).toBe('my-app'); diff --git a/src/__tests__/reporter.test.ts b/src/__tests__/reporter.test.ts index 7cb1553..7f80998 100644 --- a/src/__tests__/reporter.test.ts +++ b/src/__tests__/reporter.test.ts @@ -37,4 +37,15 @@ describe('renderReport', () => { it('throws on unsupported format', () => { expect(() => renderReport(sampleReport, 'xml' as never)).toThrow(); }); + + it('annotates the VEX analysis state on new CVEs in text and markdown', () => { + const report: ChangeReport = { + ...sampleReport, + newCVEs: [ + { id: 'CVE-2024-2000', affects: 'pkg:npm/foo@1.0.0', severity: 'critical', analysisState: 'not_affected' }, + ], + }; + expect(renderReport(report, 'text')).toContain('(VEX: not_affected)'); + expect(renderReport(report, 'markdown')).toContain('(VEX: not_affected)'); + }); }); diff --git a/src/cli.ts b/src/cli.ts index 4af54ba..b06d822 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -97,16 +97,37 @@ function assertFailOn(value: string | undefined): FailOn { ); } +/** + * CycloneDX VEX analysis states that explicitly declare the product is not + * impacted by a vulnerability. A CVE carrying one of these is a documented + * suppression, not an active finding, so it must never fail the CI gate — + * that is the entire purpose of VEX. See CycloneDX `vulnerabilities[].analysis.state`. + */ +const SUPPRESSED_ANALYSIS_STATES = new Set(['not_affected', 'false_positive']); + +/** + * True when a vulnerability carries a VEX analysis state that declares the + * product unaffected, so the gate should ignore it. + */ +export function isSuppressed(v: CVEEntry): boolean { + return v.analysisState !== undefined && SUPPRESSED_ANALYSIS_STATES.has(v.analysisState); +} + /** * 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. + * + * Vulnerabilities suppressed by a VEX `not_affected` / `false_positive` analysis + * state are excluded before the policy is applied, so an SBOM's own assessment + * that it is not impacted cannot produce a false gate failure. */ export function gateFailures(report: ChangeReport, failOn: FailOn): CVEEntry[] { if (failOn === 'none') return []; - if (failOn === 'any') return report.newCVEs; + const actionable = report.newCVEs.filter(v => !isSuppressed(v)); + if (failOn === 'any') return actionable; const threshold = SEVERITY_RANK[failOn]; - return report.newCVEs.filter( + return actionable.filter( v => v.severity !== undefined && SEVERITY_RANK[v.severity] >= threshold, ); } diff --git a/src/parser.ts b/src/parser.ts index 6232fc2..953b703 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -40,6 +40,7 @@ export function parseCycloneDX(obj: Record): SBOM { affects: extractCycloneDXAffects(v), severity: extractCycloneDXSeverity(v), description: typeof v.description === 'string' ? v.description : undefined, + analysisState: extractCycloneDXAnalysisState(v), })); return { @@ -157,6 +158,18 @@ function extractCycloneDXSeverity(v: Record): CVEEntry['severit return highest; } +/** + * Extract the VEX analysis state from a CycloneDX vulnerability's `analysis.state`. + * Returned lowercased so downstream comparisons are case-insensitive; undefined + * when no analysis block is present. + */ +function extractCycloneDXAnalysisState(v: Record): string | undefined { + const analysis = v.analysis; + if (!analysis || typeof analysis !== 'object') return undefined; + const state = (analysis as Record).state; + return typeof state === 'string' ? state.toLowerCase() : undefined; +} + function extractCycloneDXTimestamp(metadata: Record): string | undefined { return typeof metadata.timestamp === 'string' ? metadata.timestamp : undefined; } diff --git a/src/reporter.ts b/src/reporter.ts index 973b21d..43382c0 100644 --- a/src/reporter.ts +++ b/src/reporter.ts @@ -1,4 +1,13 @@ -import type { ChangeReport, ReportFormat } from './types.js'; +import type { ChangeReport, CVEEntry, ReportFormat } from './types.js'; + +/** + * A short parenthetical noting a vulnerability's VEX analysis state, so a + * suppressed CVE that still appears in the report explains why a CI gate did + * not fail on it. Empty when the entry carries no analysis state. + */ +function vexNote(v: CVEEntry): string { + return v.analysisState ? ` (VEX: ${v.analysisState})` : ''; +} /** * Render a ChangeReport to a human-readable string. @@ -46,7 +55,7 @@ function renderText(r: ChangeReport): string { if (r.newCVEs.length > 0) { lines.push('\u26a0 New CVEs:'); for (const v of r.newCVEs) { - lines.push(` ! ${v.id} [${v.severity ?? 'unknown'}] \u2014 ${v.affects}`); + lines.push(` ! ${v.id} [${v.severity ?? 'unknown'}]${vexNote(v)} \u2014 ${v.affects}`); } lines.push(''); } @@ -103,7 +112,7 @@ function renderMarkdown(r: ChangeReport): string { lines.push('## \ud83d\udea8 New CVEs', ''); lines.push('| CVE ID | Severity | Affects |'); lines.push('|--------|----------|---------|'); - for (const v of r.newCVEs) lines.push(`| ${v.id} | ${v.severity ?? '\u2014'} | ${v.affects} |`); + for (const v of r.newCVEs) lines.push(`| ${v.id} | ${(v.severity ?? '\u2014')}${vexNote(v)} | ${v.affects} |`); lines.push(''); } if (r.fixedCVEs.length > 0) { diff --git a/src/types.ts b/src/types.ts index ede0ac5..0e494b3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -37,6 +37,14 @@ export interface CVEEntry { cvssScore?: number; /** Short description */ description?: string; + /** + * VEX analysis state, lowercased, from CycloneDX `vulnerabilities[].analysis.state` + * (e.g. "not_affected", "false_positive", "exploitable", "in_triage", + * "resolved"). Absent when the SBOM carries no VEX analysis for the entry. + * States that declare the product is not impacted are treated as gate + * suppressions — see `isSuppressed` in cli.ts. + */ + analysisState?: string; } /** A parsed SBOM document */