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
55 changes: 55 additions & 0 deletions src/__tests__/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
79 changes: 61 additions & 18 deletions src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,16 @@ export function parseCycloneDX(obj: Record<string, unknown>): SBOM {
supplier: extractCycloneDXSupplier(c),
}));

const vulnerabilities: CVEEntry[] = rawVulns.map((v: Record<string, unknown>) => ({
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<string, unknown>) => {
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',
Expand Down Expand Up @@ -135,26 +139,65 @@ const SEVERITY_RANK: Record<NonNullable<CVEEntry['severity']>, number> = {
critical: 4,
};

function extractCycloneDXSeverity(v: Record<string, unknown>): 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<CVEEntry['severity']> {
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<string, unknown>): {
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<string, unknown>;

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<CVEEntry['severity']> | 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, unknown>): string | undefined {
Expand Down
12 changes: 8 additions & 4 deletions src/reporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('');
}
Expand Down Expand Up @@ -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) {
Expand Down
Loading