From 1734fa842d35dd598d21bb90d4595cbf3f860fd0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 3 Oct 2026 15:15:58 +0000 Subject: [PATCH] fix: report unparseable CVSS vectors instead of a missing score A severity vector that fails to parse now leaves the numeric score unset and emits a stable warning naming the failure and what to supply instead. An absent vector stays absent, and a valid CVSS v3 base vector still scores as before. Fixes #240 Signed-off-by: Cursor Agent Co-authored-by: vibgrate-team --- CHANGELOG.md | 2 + DOCS.md | 2 +- src/commands/why.ts | 1 + src/core-open/formatters/sarif.ts | 5 + src/core-open/index.ts | 4 +- .../scanners/vulnerability-scanner.ts | 141 +++++++++---- src/core-open/scoring/cvss.test.ts | 186 ++++++++++++++++++ src/core-open/scoring/cvss.ts | 95 ++++++++- src/core-open/types.ts | 15 +- src/mcp/tools.ts | 2 + 10 files changed, 410 insertions(+), 43 deletions(-) create mode 100644 src/core-open/scoring/cvss.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fe2ec89..a2eca97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -131,6 +131,8 @@ backward compatible. ### Fixed +- **An unparseable CVSS or severity vector is no longer silent.** `vg scan --vulns` (and the offline package manifest) used to drop a vector it could not parse and leave the advisory with no numeric score, the same shape as an advisory that never had one. A failed vector now keeps the score unset and adds a warning (`vibgrate/cvss-vector`) naming the parse failure and what to supply instead. A missing vector stays missing. A valid CVSS v3.0/v3.1 base vector still scores as before, including a real zero when every impact metric is None. The warning does not echo the raw vector. + - **`vg show arch` clipped the map to a fixed viewport.** Columns that ran off the bottom of the window could not be scrolled or zoomed; the canvas is now a pannable, zoomable map (scroll or drag, pinch / Ctrl-scroll, + / −). diff --git a/DOCS.md b/DOCS.md index 6ad775e..0193719 100644 --- a/DOCS.md +++ b/DOCS.md @@ -1133,7 +1133,7 @@ Expected results: ### Vulnerabilities and exposure attribution -`vg scan --vulns` matches your installed dependencies against the public OSV database and records each known vulnerability — advisory id and CVE, severity, CVSS, and the fixing version — in the scan artifact, as findings, and in SARIF. Supply advisories in a `--package-manifest` bundle to run it offline. +`vg scan --vulns` matches your installed dependencies against the public OSV database and records each known vulnerability — advisory id and CVE, severity, CVSS, and the fixing version — in the scan artifact, as findings, and in SARIF. Supply advisories in a `--package-manifest` bundle to run it offline. A CVSS or severity vector that cannot be parsed is reported as a warning (`vibgrate/cvss-vector`) and left without a numeric score: it is not shown as a missing score and it is not shown as zero. An advisory that carries no vector still has no score. In a git repository the scan also attributes each finding: the commit, author, and date that introduced the vulnerable version, and how long you have been exposed. These exposure windows aggregate into remediation metrics framed around the [EU Cyber Resilience Act (CRA)](https://vibgrate.com/compliance/cra): open counts by severity, mean and maximum time exposed, and per-severity SLA breaches (defaults: critical 7 days, high 30, moderate 90, low 180). The metrics are descriptive — they show whether remediation keeps pace; they are not a compliance certification. diff --git a/src/commands/why.ts b/src/commands/why.ts index a66a91b..598f357 100644 --- a/src/commands/why.ts +++ b/src/commands/why.ts @@ -78,6 +78,7 @@ export function registerWhy(program: Command): void { const cvss = adv.cvss != null ? ` cvss ${adv.cvss}` : ''; const fixed = adv.fixedVersions.length ? ` — fixed in ${adv.fixedVersions.join(', ')}` : ' — no fix available'; info(` ${severityTag(adv.severity)} ${idLabel}${c.dim(cvss)}${c.dim(fixed)}`); + if (adv.cvssDiagnostic) info(c.yellow(` ${adv.cvssDiagnostic}`)); if (adv.introduced) { const exposure = adv.exposureDays != null ? `, ${adv.exposureDays}d exposed` : ''; info( diff --git a/src/core-open/formatters/sarif.ts b/src/core-open/formatters/sarif.ts index 9825a8d..0500e28 100644 --- a/src/core-open/formatters/sarif.ts +++ b/src/core-open/formatters/sarif.ts @@ -170,6 +170,11 @@ function buildRules(findings: Finding[]) { shortDescription: { text: 'Known vulnerability in an installed dependency' }, helpUri: 'https://vibgrate.com/rules/vulnerability', }, + 'vibgrate/cvss-vector': { + id: 'vibgrate/cvss-vector', + shortDescription: { text: 'CVSS or severity vector could not be parsed' }, + helpUri: 'https://vibgrate.com/rules/cvss-vector', + }, }; return descriptions[id] ?? { id, diff --git a/src/core-open/index.ts b/src/core-open/index.ts index 2142847..48dca8a 100644 --- a/src/core-open/index.ts +++ b/src/core-open/index.ts @@ -142,9 +142,11 @@ export { parseOsvAdvisory, projectTypeToVulnEcosystem, VULN_RULE_ID, + CVSS_VECTOR_RULE_ID, type VulnTarget, } from './scanners/vulnerability-scanner.js'; -export { cvssV3BaseScore, severityFromCvss, severityRank, normalizeSeverityLabel } from './scoring/cvss.js'; +export { cvssV3BaseScore, parseCvssV3, severityFromCvss, severityRank, normalizeSeverityLabel } from './scoring/cvss.js'; +export type { CvssParseResult } from './scoring/cvss.js'; export { computeUpgradeImpact, analyzeUsage, computeVersionJump } from './scanners/upgrade-impact.js'; export { getChangelogSignals, diff --git a/src/core-open/scanners/vulnerability-scanner.ts b/src/core-open/scanners/vulnerability-scanner.ts index 854f705..16b2be9 100644 --- a/src/core-open/scanners/vulnerability-scanner.ts +++ b/src/core-open/scanners/vulnerability-scanner.ts @@ -9,7 +9,7 @@ import { type ManifestEcosystem, type PackageVersionManifest, } from '../package-version-manifest.js'; -import { cvssV3BaseScore, normalizeSeverityLabel, severityFromCvss, severityRank } from '../scoring/cvss.js'; +import { parseCvssV3, normalizeSeverityLabel, severityFromCvss, severityRank } from '../scoring/cvss.js'; import type { AffectedRange, Finding, @@ -173,7 +173,7 @@ export function isVersionAffected( interface RawOsvSeverity { type?: string; - score?: string; + score?: unknown; } interface RawOsvRangeEvent { introduced?: string; @@ -199,10 +199,59 @@ interface RawOsvVuln { database_specific?: { severity?: string }; } +/** A severity score that is present (blank strings count as absent). */ +function severityScorePresent(score: unknown): boolean { + if (score == null) return false; + if (typeof score === 'string') return score.trim().length > 0; + return true; +} + +/** + * The CVSS / severity vector to score. + * + * A CVSS v3 entry wins, matching the previous selection. When the advisory + * carries only some other CVSS vector (v2, v4, …), that string is still + * returned so a failed parse is visible instead of looking like no score. + * No severity vector at all returns null. + */ +function advisorySeverityVector(raw: RawOsvVuln): unknown { + const entries = raw.severity ?? []; + const v3 = entries.find( + (s) => (s.type ?? '').toUpperCase().startsWith('CVSS_V3') && severityScorePresent(s.score), + ); + if (v3) return v3.score; + const other = entries.find( + (s) => (s.type ?? '').toUpperCase().startsWith('CVSS') && severityScorePresent(s.score), + ); + return other ? other.score : null; +} + +/** + * Keep a short, single-line vector on the advisory. A value with line breaks + * or an unreasonable length is omitted so a vector field that actually holds + * unrelated text is not copied into the report. + */ +function reportVector(value: unknown): string | null { + if (typeof value !== 'string') return null; + if (/[\r\n]/.test(value)) return null; + const trimmed = value.trim(); + if (!trimmed || trimmed.length > 180) return null; + return value; +} + +function withDiagnostic( + advisory: T, + diagnostic: string | undefined, +): T & { cvssDiagnostic?: string } { + return diagnostic ? { ...advisory, cvssDiagnostic: diagnostic } : advisory; +} + /** Parse a raw OSV advisory into our shape, scoped to a specific package name. */ export function parseOsvAdvisory(raw: RawOsvVuln, packageName: string): VulnerabilityAdvisory { - const cvssVector = raw.severity?.find((s) => (s.type ?? '').toUpperCase().startsWith('CVSS_V3'))?.score ?? null; - const cvss = cvssV3BaseScore(cvssVector); + const selected = advisorySeverityVector(raw); + const parsed = parseCvssV3(selected); + const cvss = parsed.score; + const cvssVector = reportVector(selected); // Qualitative fallback severity (GHSA): top-level, then the matching affected entry. const lowerName = packageName.toLowerCase(); @@ -238,43 +287,50 @@ export function parseOsvAdvisory(raw: RawOsvVuln, packageName: string): Vulnerab for (const v of affected.versions ?? []) if (!affectedVersions.includes(v)) affectedVersions.push(v); } - return { - id: raw.id ?? 'UNKNOWN', - aliases: Array.isArray(raw.aliases) ? raw.aliases : [], - summary: raw.summary ?? null, - severity, - cvss, - cvssVector, - fixedVersions, - published: raw.published ?? null, - withdrawn: raw.withdrawn ?? null, - references: (raw.references ?? []).map((r) => r.url).filter((u): u is string => Boolean(u)), - ...(affectedRanges.length ? { affectedRanges } : {}), - ...(affectedVersions.length ? { affectedVersions } : {}), - }; + return withDiagnostic( + { + id: raw.id ?? 'UNKNOWN', + aliases: Array.isArray(raw.aliases) ? raw.aliases : [], + summary: raw.summary ?? null, + severity, + cvss, + cvssVector, + fixedVersions, + published: raw.published ?? null, + withdrawn: raw.withdrawn ?? null, + references: (raw.references ?? []).map((r) => r.url).filter((u): u is string => Boolean(u)), + ...(affectedRanges.length ? { affectedRanges } : {}), + ...(affectedVersions.length ? { affectedVersions } : {}), + }, + parsed.diagnostic, + ); } export function manifestAdvisoryToAdvisory(m: ManifestAdvisory): VulnerabilityAdvisory { - const cvss = typeof m.cvss === 'number' ? m.cvss : cvssV3BaseScore(m.cvssVector ?? null); + const parsed = parseCvssV3(m.cvssVector ?? null); + const cvss = typeof m.cvss === 'number' ? m.cvss : parsed.score; const severity: VulnSeverity = m.severity ? normalizeSeverityLabel(m.severity) : cvss != null ? severityFromCvss(cvss) : 'unknown'; - return { - id: m.id, - aliases: m.aliases ?? [], - summary: m.summary ?? null, - severity, - cvss, - cvssVector: m.cvssVector ?? null, - fixedVersions: (m.ranges ?? []).map((r) => r.fixed).filter((f): f is string => Boolean(f)), - published: m.published ?? null, - withdrawn: m.withdrawn ?? null, - references: m.references ?? [], - ...(m.ranges?.length ? { affectedRanges: m.ranges } : {}), - ...(m.versions?.length ? { affectedVersions: m.versions } : {}), - }; + return withDiagnostic( + { + id: m.id, + aliases: m.aliases ?? [], + summary: m.summary ?? null, + severity, + cvss, + cvssVector: reportVector(m.cvssVector ?? null), + fixedVersions: (m.ranges ?? []).map((r) => r.fixed).filter((f): f is string => Boolean(f)), + published: m.published ?? null, + withdrawn: m.withdrawn ?? null, + references: m.references ?? [], + ...(m.ranges?.length ? { affectedRanges: m.ranges } : {}), + ...(m.versions?.length ? { affectedVersions: m.versions } : {}), + }, + parsed.diagnostic, + ); } // ── OSV HTTP (thin, best-effort) ───────────────────────────────────────────── @@ -478,6 +534,9 @@ function scanFromManifest(targets: VulnTarget[], manifest?: PackageVersionManife /** SARIF/text rule id for vulnerability findings. */ export const VULN_RULE_ID = 'vibgrate/vulnerability'; +/** SARIF/text rule id when a CVSS or severity vector could not be parsed. */ +export const CVSS_VECTOR_RULE_ID = 'vibgrate/cvss-vector'; + function levelForSeverity(severity: VulnSeverity): Finding['level'] { switch (severity) { case 'critical': @@ -517,6 +576,7 @@ export function generateVulnerabilityFindings(result: VulnerabilityScanResult): severity: adv.severity, cvss: adv.cvss, fixedVersions: adv.fixedVersions, + ...(adv.cvssDiagnostic ? { cvssDiagnostic: adv.cvssDiagnostic } : {}), ...(adv.introduced ? { introducedBy: adv.introduced.authorName, @@ -527,6 +587,21 @@ export function generateVulnerabilityFindings(result: VulnerabilityScanResult): : {}), }, }); + if (adv.cvssDiagnostic) { + findings.push({ + ruleId: CVSS_VECTOR_RULE_ID, + level: 'warning', + message: `${pkg.package}@${pkg.version}: ${idLabel}: ${adv.cvssDiagnostic}`, + location: pkg.package, + details: { + ecosystem: pkg.ecosystem, + package: pkg.package, + installedVersion: pkg.version, + advisoryId: adv.id, + cvssDiagnostic: adv.cvssDiagnostic, + }, + }); + } } } return findings; diff --git a/src/core-open/scoring/cvss.test.ts b/src/core-open/scoring/cvss.test.ts new file mode 100644 index 0000000..61c3149 --- /dev/null +++ b/src/core-open/scoring/cvss.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it } from 'vitest'; +import { + CVSS_VECTOR_RULE_ID, + generateVulnerabilityFindings, + parseOsvAdvisory, + type VulnerabilityScanResult, +} from '../index.js'; +import { manifestAdvisoryToAdvisory } from '../scanners/vulnerability-scanner.js'; +import { cvssV3BaseScore, parseCvssV3 } from './cvss.js'; + +const VALID = 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H'; +const NONE_IMPACT = 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:N'; +const BAD = 'CVSS:3.1/AV:NOPE'; +const FILE_MARKER = 'UNRELATED_FILE_MARKER'; +const FILE_SECRET = 'ghp_shouldNotAppear'; +const FILE_BLOB = ['# advisory dump', FILE_MARKER, `token=${FILE_SECRET}`, 'more unrelated text'].join('\n'); + +function scanOf(advisory: ReturnType): VulnerabilityScanResult { + return { + source: 'osv', + packages: [{ ecosystem: 'npm', package: 'left-pad', version: '1.0.0', advisories: [advisory] }], + totalAdvisories: 1, + severityCounts: { low: 0, moderate: 0, high: 0, critical: 0, unknown: 0 }, + }; +} + +describe('parseCvssV3', () => { + it('reports an unparseable vector as a diagnostic, not a missing or zero score', () => { + const parsed = parseCvssV3(BAD); + expect(parsed.score).toBeNull(); + expect(parsed.score).not.toBe(0); + expect(parsed.diagnostic).toBe(parseCvssV3(BAD).diagnostic); + expect(parsed.diagnostic).toMatch(/could not be parsed/); + expect(parsed.diagnostic).toMatch(/No base score was derived/); + expect(parsed.diagnostic).toMatch(/Replace the vector/); + expect(parsed.diagnostic).toMatch(/qualitative severity/); + expect(parsed.diagnostic).not.toContain('NOPE'); + expect(parsed.diagnostic).not.toContain(FILE_MARKER); + + const fromFile = parseCvssV3(FILE_BLOB); + expect(fromFile.score).toBeNull(); + expect(fromFile.score).not.toBe(0); + expect(fromFile.diagnostic).toMatch(/could not be parsed/); + expect(fromFile.diagnostic).not.toContain(FILE_MARKER); + expect(fromFile.diagnostic).not.toContain(FILE_SECRET); + expect(fromFile.diagnostic).not.toContain('advisory dump'); + }); + + it('leaves a genuinely absent score absent', () => { + for (const absent of [null, undefined, '', ' ']) { + expect(parseCvssV3(absent)).toEqual({ score: null }); + expect(cvssV3BaseScore(absent)).toBeNull(); + } + }); + + it('parses a valid vector to the same base score as before', () => { + expect(parseCvssV3(VALID)).toEqual({ score: 9.8 }); + expect(cvssV3BaseScore(VALID)).toBe(9.8); + expect(parseCvssV3(` ${VALID} `)).toEqual({ score: 9.8 }); + expect(parseCvssV3(VALID.toLowerCase())).toEqual({ score: 9.8 }); + expect(parseCvssV3(`${VALID}/E:P`)).toEqual({ score: 9.8 }); + expect(parseCvssV3('CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N')).toEqual({ score: 7.5 }); + // A real zero (every impact metric None) is a score, not a parse failure. + expect(parseCvssV3(NONE_IMPACT)).toEqual({ score: 0 }); + expect(cvssV3BaseScore(NONE_IMPACT)).toBe(0); + }); +}); + +describe('advisory CVSS vectors', () => { + it('surfaces a bad vector on the advisory and as a warning finding', () => { + const advisory = parseOsvAdvisory( + { + id: 'GHSA-bad', + summary: 'broken vector', + severity: [{ type: 'CVSS_V3', score: BAD }], + affected: [{ package: { ecosystem: 'npm', name: 'left-pad' } }], + }, + 'left-pad', + ); + expect(advisory.cvss).toBeNull(); + expect(advisory.cvss).not.toBe(0); + expect(advisory.cvssVector).toBe(BAD); + expect(advisory.cvssDiagnostic).toMatch(/could not be parsed/); + expect(advisory.cvssDiagnostic).not.toContain('NOPE'); + + const findings = generateVulnerabilityFindings(scanOf(advisory)); + const warning = findings.find((f) => f.ruleId === CVSS_VECTOR_RULE_ID); + expect(warning?.level).toBe('warning'); + expect(warning?.message).toMatch(/GHSA-bad/); + expect(warning?.message).toMatch(/could not be parsed/); + expect(warning?.message).toMatch(/Replace the vector/); + expect(warning?.message).not.toContain('NOPE'); + expect(warning?.details?.cvss).toBeUndefined(); + expect(findings.find((f) => f.ruleId !== CVSS_VECTOR_RULE_ID)?.details?.cvss).toBeNull(); + + const dumped = parseOsvAdvisory( + { + id: 'GHSA-dump', + severity: [{ type: 'CVSS_V3', score: FILE_BLOB }], + affected: [{ package: { name: 'left-pad' } }], + }, + 'left-pad', + ); + expect(dumped.cvss).toBeNull(); + expect(dumped.cvssVector).toBeNull(); + expect(dumped.cvssDiagnostic).not.toContain(FILE_MARKER); + expect(dumped.cvssDiagnostic).not.toContain(FILE_SECRET); + const dumpFindings = generateVulnerabilityFindings(scanOf(dumped)); + expect(dumpFindings.map((f) => f.message).join('\n')).not.toContain(FILE_MARKER); + expect(dumpFindings.map((f) => f.message).join('\n')).not.toContain(FILE_SECRET); + expect(JSON.stringify(dumpFindings)).not.toContain(FILE_MARKER); + }); + + it('keeps an advisory with no vector unscored and unwarned', () => { + const advisory = parseOsvAdvisory( + { + id: 'GHSA-none', + affected: [{ package: { name: 'left-pad' }, database_specific: { severity: 'HIGH' } }], + }, + 'left-pad', + ); + expect(advisory.cvss).toBeNull(); + expect(advisory.cvssDiagnostic).toBeUndefined(); + expect(advisory.cvssVector).toBeNull(); + expect(advisory.severity).toBe('high'); + const findings = generateVulnerabilityFindings(scanOf(advisory)); + expect(findings.map((f) => f.ruleId)).toEqual(['vibgrate/vulnerability']); + expect(findings[0]?.message).not.toMatch(/could not be parsed/); + expect(findings[0]?.details?.cvss).toBeNull(); + }); + + it('keeps a valid vector on the advisory and does not warn', () => { + const advisory = parseOsvAdvisory( + { + id: 'GHSA-ok', + severity: [{ type: 'CVSS_V3', score: VALID }], + database_specific: { severity: 'LOW' }, + affected: [{ package: { name: 'left-pad' } }], + }, + 'left-pad', + ); + expect(advisory.cvss).toBe(9.8); + expect(advisory.cvssVector).toBe(VALID); + expect(advisory.cvssDiagnostic).toBeUndefined(); + expect(advisory.severity).toBe('critical'); + const findings = generateVulnerabilityFindings(scanOf(advisory)); + expect(findings).toHaveLength(1); + expect(findings[0]?.message).toContain('critical 9.8'); + expect(findings[0]?.details?.cvss).toBe(9.8); + }); + + it('warns for a non-v3 severity vector instead of dropping it', () => { + const advisory = parseOsvAdvisory( + { + id: 'GHSA-v4', + severity: [{ type: 'CVSS_V4', score: 'CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N' }], + affected: [{ package: { name: 'left-pad' } }], + }, + 'left-pad', + ); + expect(advisory.cvss).toBeNull(); + expect(advisory.cvss).not.toBe(0); + expect(advisory.cvssDiagnostic).toMatch(/does not start with CVSS:3.0 or CVSS:3.1/); + expect(advisory.cvssDiagnostic).not.toContain('CVSS:4.0'); + }); + + it('reads the same three cases from a package manifest', () => { + const bad = manifestAdvisoryToAdvisory({ id: 'M-bad', cvssVector: BAD }); + expect(bad.cvss).toBeNull(); + expect(bad.cvssDiagnostic).toMatch(/could not be parsed/); + expect(bad.severity).toBe('unknown'); + + const missing = manifestAdvisoryToAdvisory({ id: 'M-none' }); + expect(missing.cvss).toBeNull(); + expect(missing.cvssDiagnostic).toBeUndefined(); + + const valid = manifestAdvisoryToAdvisory({ id: 'M-ok', cvssVector: VALID }); + expect(valid.cvss).toBe(9.8); + expect(valid.cvssDiagnostic).toBeUndefined(); + expect(valid.severity).toBe('critical'); + + const zero = manifestAdvisoryToAdvisory({ id: 'M-zero', cvssVector: NONE_IMPACT }); + expect(zero.cvss).toBe(0); + expect(zero.cvssDiagnostic).toBeUndefined(); + }); +}); diff --git a/src/core-open/scoring/cvss.ts b/src/core-open/scoring/cvss.ts index f45d5a0..f0e04c2 100644 --- a/src/core-open/scoring/cvss.ts +++ b/src/core-open/scoring/cvss.ts @@ -9,8 +9,9 @@ import type { VulnSeverity } from '../types.js'; * Advisories (OSV/GHSA) carry severity as a CVSS *vector string*, not a number. * To order findings and apply CRA severity thresholds we need the numeric base * score, so this implements the official base-score formula. It is deterministic - * and offline. Temporal/environmental metrics are ignored (base score only); - * non-v3 vectors (e.g. CVSS v2) return null rather than guessing. + * and offline. Temporal/environmental metrics are ignored (base score only). + * A vector that is present but not a v3 base vector does not yield a score — + * {@link parseCvssV3} reports that separately from a vector that was never supplied. */ const AV: Record = { N: 0.85, A: 0.62, L: 0.55, P: 0.2 }; @@ -29,11 +30,61 @@ function roundup(x: number): number { } /** - * Compute the CVSS v3 base score (0–10) from a vector string, or null when the - * vector is not a parseable v3 base vector. + * Outcome of reading a CVSS / severity vector. + * + * `score` is the v3 base score when the vector parsed, including a real zero + * when impact is none. It is null both when no vector was supplied and when a + * vector was supplied but could not be parsed — `diagnostic` is set only in + * the second case, so a parse failure is never reported as an absent score + * or as zero. */ -export function cvssV3BaseScore(vector: string | null | undefined): number | null { - if (!vector || typeof vector !== 'string') return null; +export interface CvssParseResult { + score: number | null; + diagnostic?: string; +} + +const PARSE_NEXT_STEP = + 'Replace the vector with a CVSS v3.0 or v3.1 base vector such as CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N, or remove the vector and set a qualitative severity (low, moderate, high, or critical).'; + +/** User-facing parse failure. Never includes the supplied value. */ +function cvssDiagnostic(reason: string): string { + return `CVSS vector could not be parsed (${reason}). No base score was derived from it. ${PARSE_NEXT_STEP}`; +} + +/** + * Why a non-blank string did not score. Structural only: metric names and the + * version we accept. The raw value is never copied in, so a vector field that + * actually holds unrelated text cannot leak into the diagnostic. + */ +function cvssFailureReason(vector: string): string { + const trimmed = vector.trim(); + if (/[\r\n]/.test(vector) || trimmed.length > 180) { + return 'it is not a single CVSS v3.0 or v3.1 base vector'; + } + const parts = trimmed.split('/'); + if (!/^CVSS:3\.[01]$/i.test(parts[0] ?? '')) { + return 'it does not start with CVSS:3.0 or CVSS:3.1'; + } + const m: Record = {}; + for (const part of parts.slice(1)) { + const [k, v] = part.split(':'); + if (k && v) m[k.toUpperCase()] = v.toUpperCase(); + } + const scopeChanged = m.S === 'C'; + const bad: string[] = []; + if (AV[m.AV] === undefined) bad.push('AV'); + if (AC[m.AC] === undefined) bad.push('AC'); + if (UI[m.UI] === undefined) bad.push('UI'); + if ((scopeChanged ? PR_CHANGED : PR_UNCHANGED)[m.PR] === undefined) bad.push('PR'); + if (CIA[m.C] === undefined) bad.push('C'); + if (CIA[m.I] === undefined) bad.push('I'); + if (CIA[m.A] === undefined) bad.push('A'); + if (bad.length) return `required base metric ${bad.join(', ')} is missing or not a CVSS v3 value`; + return 'it is not a CVSS v3.0 or v3.1 base vector'; +} + +/** Score a string that has already passed the present-and-string check. Same formula as before. */ +function computeCvssV3BaseScore(vector: string): number | null { const parts = vector.trim().split('/'); if (!parts.length) return null; if (!/^CVSS:3\.[01]$/i.test(parts[0])) return null; @@ -67,6 +118,38 @@ export function cvssV3BaseScore(vector: string | null | undefined): number | nul return roundup(raw); } +/** + * Parse a CVSS v3.0/v3.1 base vector. + * + * - No vector (`null`, `undefined`, `''`, whitespace) → `{ score: null }` and + * no diagnostic. An absent score stays absent. + * - A vector that parses → `{ score }` with the same base score as before, + * including `0` when every impact metric is None. + * - Anything else that was supplied → `{ score: null, diagnostic }`. The + * diagnostic names the parse failure and the next step. It does not quote + * the supplied value. + */ +export function parseCvssV3(vector: unknown): CvssParseResult { + if (vector == null) return { score: null }; + if (typeof vector !== 'string') { + return { score: null, diagnostic: cvssDiagnostic('it was not a CVSS vector string') }; + } + if (!vector.trim()) return { score: null }; + const score = computeCvssV3BaseScore(vector); + if (score != null) return { score }; + return { score: null, diagnostic: cvssDiagnostic(cvssFailureReason(vector)) }; +} + +/** + * Compute the CVSS v3 base score (0–10) from a vector string, or null when the + * vector is absent or not a parseable v3 base vector. Null does not distinguish + * those two cases — use {@link parseCvssV3} when a parse failure must not be + * reported as a missing score. + */ +export function cvssV3BaseScore(vector: string | null | undefined): number | null { + return parseCvssV3(vector).score; +} + /** Map a CVSS v3 base score to its qualitative severity band. */ export function severityFromCvss(score: number): VulnSeverity { if (score >= 9.0) return 'critical'; diff --git a/src/core-open/types.ts b/src/core-open/types.ts index 07f0335..99322b6 100644 --- a/src/core-open/types.ts +++ b/src/core-open/types.ts @@ -488,9 +488,20 @@ export interface VulnerabilityAdvisory { summary: string | null; /** Qualitative severity. */ severity: VulnSeverity; - /** CVSS v3 base score (0–10), or null when not derivable from the advisory. */ + /** + * CVSS v3 base score (0–10), or null when the advisory did not yield one. + * Null with {@link cvssDiagnostic} set means the vector failed to parse — + * not an absent score and not zero. Null without that field means no vector + * was supplied. + */ cvss: number | null; - /** Raw CVSS vector string, when the advisory carried one. */ + /** + * Set when a CVSS or severity vector was present but could not be parsed. + * Omitted when the vector parsed (including a real zero) or when no vector + * was supplied. + */ + cvssDiagnostic?: string; + /** Raw CVSS vector string, when the advisory carried one short enough to keep. */ cvssVector: string | null; /** First fixed version per affected range (empty when no fix is published). */ fixedVersions: string[]; diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 75333fc..d288bef 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -792,6 +792,7 @@ export const TOOLS: VgTool[] = [ cve: a.aliases.find((x) => x.startsWith('CVE-')) ?? null, severity: a.severity, cvss: a.cvss, + ...(a.cvssDiagnostic ? { cvssDiagnostic: a.cvssDiagnostic } : {}), exposureDays: a.exposureDays ?? null, introduced: a.introduced ?? null, fixedVersions: a.fixedVersions, @@ -831,6 +832,7 @@ export const TOOLS: VgTool[] = [ cve: a.aliases.find((x) => x.startsWith('CVE-')) ?? null, severity: a.severity, cvss: a.cvss, + ...(a.cvssDiagnostic ? { cvssDiagnostic: a.cvssDiagnostic } : {}), fixedVersions: a.fixedVersions, summary: a.summary, })),