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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 46 additions & 2 deletions src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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 => ({
Expand Down Expand Up @@ -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);
});
});
22 changes: 22 additions & 0 deletions src/__tests__/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
11 changes: 11 additions & 0 deletions src/__tests__/reporter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)');
});
});
25 changes: 23 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}
Expand Down
13 changes: 13 additions & 0 deletions src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export function parseCycloneDX(obj: Record<string, unknown>): SBOM {
affects: extractCycloneDXAffects(v),
severity: extractCycloneDXSeverity(v),
description: typeof v.description === 'string' ? v.description : undefined,
analysisState: extractCycloneDXAnalysisState(v),
}));

return {
Expand Down Expand Up @@ -157,6 +158,18 @@ function extractCycloneDXSeverity(v: Record<string, unknown>): 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, unknown>): string | undefined {
const analysis = v.analysis;
if (!analysis || typeof analysis !== 'object') return undefined;
const state = (analysis as Record<string, unknown>).state;
return typeof state === 'string' ? state.toLowerCase() : undefined;
}

function extractCycloneDXTimestamp(metadata: Record<string, unknown>): string | undefined {
return typeof metadata.timestamp === 'string' ? metadata.timestamp : undefined;
}
Expand Down
15 changes: 12 additions & 3 deletions src/reporter.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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('');
}
Expand Down Expand Up @@ -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) {
Expand Down
8 changes: 8 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
Loading