diff --git a/src/__tests__/parser.test.ts b/src/__tests__/parser.test.ts index e0378fb..39fd568 100644 --- a/src/__tests__/parser.test.ts +++ b/src/__tests__/parser.test.ts @@ -103,6 +103,61 @@ describe('parse (CycloneDX)', () => { expect(sbom.vulnerabilities![0].severity).toBe('medium'); }); + it('captures the numeric CVSS score (highest across ratings)', () => { + const sbom = parse({ + bomFormat: 'CycloneDX', + specVersion: '1.5', + components: [], + vulnerabilities: [ + { + id: 'CVE-2024-0003', + affects: [{ ref: 'pkg:npm/foo@1.0.0' }], + ratings: [ + { source: { name: 'vendor' }, severity: 'high', score: 7.5 }, + { source: { name: 'nvd' }, severity: 'critical', score: 9.8 }, + ], + }, + ], + }); + expect(sbom.vulnerabilities![0].cvssScore).toBe(9.8); + expect(sbom.vulnerabilities![0].severity).toBe('critical'); + }); + + it('derives severity from the CVSS score when no severity string is present', () => { + const sbom = parse({ + bomFormat: 'CycloneDX', + specVersion: '1.5', + components: [], + vulnerabilities: [ + { + id: 'CVE-2024-0004', + affects: [{ ref: 'pkg:npm/bar@1.0.0' }], + // A score-only rating, as emitted by some scanners. + ratings: [{ source: { name: 'nvd' }, score: 9.8 }], + }, + ], + }); + expect(sbom.vulnerabilities![0].cvssScore).toBe(9.8); + expect(sbom.vulnerabilities![0].severity).toBe('critical'); + }); + + it('leaves severity and cvssScore undefined when ratings carry neither', () => { + const sbom = parse({ + bomFormat: 'CycloneDX', + specVersion: '1.5', + components: [], + vulnerabilities: [ + { + id: 'CVE-2024-0005', + affects: [{ ref: 'pkg:npm/baz@1.0.0' }], + ratings: [{ source: { name: 'nvd' } }], + }, + ], + }); + expect(sbom.vulnerabilities![0].cvssScore).toBeUndefined(); + expect(sbom.vulnerabilities![0].severity).toBeUndefined(); + }); + it('parses metadata name and version', () => { const sbom = parse(cyclonedxFixture); expect(sbom.name).toBe('my-app'); diff --git a/src/parser.ts b/src/parser.ts index 6232fc2..e02e284 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -35,12 +35,16 @@ export function parseCycloneDX(obj: Record): SBOM { supplier: extractCycloneDXSupplier(c), })); - const vulnerabilities: CVEEntry[] = rawVulns.map((v: Record) => ({ - id: typeof v.id === 'string' ? v.id : 'UNKNOWN', - affects: extractCycloneDXAffects(v), - severity: extractCycloneDXSeverity(v), - description: typeof v.description === 'string' ? v.description : undefined, - })); + const vulnerabilities: CVEEntry[] = rawVulns.map((v: Record) => { + const { severity, cvssScore } = extractCycloneDXRating(v); + return { + id: typeof v.id === 'string' ? v.id : 'UNKNOWN', + affects: extractCycloneDXAffects(v), + severity, + cvssScore, + description: typeof v.description === 'string' ? v.description : undefined, + }; + }); return { format: 'cyclonedx', @@ -135,26 +139,65 @@ const SEVERITY_RANK: Record, number> = { critical: 4, }; -function extractCycloneDXSeverity(v: Record): CVEEntry['severity'] { +/** + * Map a numeric CVSS base score to its qualitative severity, per the CVSS v3 + * specification's qualitative rating scale. Used when a rating carries a `score` + * but no (or an unrecognized) `severity` string — common in output from + * scanners such as Grype and Trivy. + */ +function cvssScoreToSeverity(score: number): NonNullable { + if (score <= 0) return 'none'; + if (score < 4) return 'low'; + if (score < 7) return 'medium'; + if (score < 9) return 'high'; + return 'critical'; +} + +/** + * Extract the most severe rating from a CycloneDX vulnerability. + * + * A CycloneDX vulnerability may carry multiple ratings from different sources + * (e.g. a vendor advisory and NVD), and their order is not defined by severity, + * so we surface the highest severity rather than whichever happens to be listed + * first — under-reporting a critical CVE as low would defeat the tool's purpose. + * + * The numeric CVSS `score` is captured (highest across ratings) and, when a + * rating omits a usable `severity` string, its severity is derived from the + * score so a score-only rating still contributes to the threshold. + */ +function extractCycloneDXRating(v: Record): { + severity: CVEEntry['severity']; + cvssScore: number | undefined; +} { const ratings = v.ratings; - if (!Array.isArray(ratings) || ratings.length === 0) return undefined; - // A CycloneDX vulnerability may carry multiple ratings from different sources - // (e.g. a vendor advisory and NVD). Their order is not defined by severity, so - // surface the highest severity rather than whichever happens to be listed first — - // under-reporting a critical CVE as low would defeat the tool's purpose. - let highest: CVEEntry['severity']; + if (!Array.isArray(ratings) || ratings.length === 0) { + return { severity: undefined, cvssScore: undefined }; + } + let severity: CVEEntry['severity']; let highestRank = -1; + let cvssScore: number | undefined; for (const raw of ratings) { const rating = raw as Record; + + const score = typeof rating.score === 'number' ? rating.score : undefined; + if (score !== undefined && (cvssScore === undefined || score > cvssScore)) { + cvssScore = score; + } + const sev = typeof rating.severity === 'string' ? rating.severity.toLowerCase() : undefined; + let normalized: NonNullable | undefined; if (sev === 'critical' || sev === 'high' || sev === 'medium' || sev === 'low' || sev === 'none') { - if (SEVERITY_RANK[sev] > highestRank) { - highestRank = SEVERITY_RANK[sev]; - highest = sev; - } + normalized = sev; + } else if (score !== undefined) { + normalized = cvssScoreToSeverity(score); + } + + if (normalized && SEVERITY_RANK[normalized] > highestRank) { + highestRank = SEVERITY_RANK[normalized]; + severity = normalized; } } - return highest; + return { severity, cvssScore }; } function extractCycloneDXTimestamp(metadata: Record): string | undefined { diff --git a/src/reporter.ts b/src/reporter.ts index 973b21d..7a39b70 100644 --- a/src/reporter.ts +++ b/src/reporter.ts @@ -46,7 +46,8 @@ 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}`); + const score = v.cvssScore !== undefined ? `, CVSS ${v.cvssScore}` : ''; + lines.push(` ! ${v.id} [${v.severity ?? 'unknown'}${score}] \u2014 ${v.affects}`); } lines.push(''); } @@ -101,9 +102,12 @@ function renderMarkdown(r: ChangeReport): string { } if (r.newCVEs.length > 0) { 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} |`); + lines.push('| CVE ID | Severity | CVSS | Affects |'); + lines.push('|--------|----------|------|---------|'); + for (const v of r.newCVEs) { + const score = v.cvssScore !== undefined ? String(v.cvssScore) : '\u2014'; + lines.push(`| ${v.id} | ${v.severity ?? '\u2014'} | ${score} | ${v.affects} |`); + } lines.push(''); } if (r.fixedCVEs.length > 0) {