diff --git a/CHANGELOG.md b/CHANGELOG.md index fe2ec89..f94a22f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ backward compatible. ### Added +- **`vg scan --vulns` JSON includes EPSS and KEV when the advisory already has them.** Each advisory in `extended.vulnerabilities` now carries `epss` (0–1), `epssPercentile` (0–1), and `kev` when those signals are present on the OSV advisory body or in a `--package-manifest` bundle. A missing signal is `null`, and a real score of `0` is kept — absence is never written as `0`. The scan does not fetch EPSS, including in offline / `--package-manifest` mode. Text and SARIF findings are unchanged. + - **`vg sbom export` now reports the full resolved dependency tree, not just direct manifest deps.** SBOM export previously flattened only the packages a project's manifest scan sees — the names typed into `package.json` (or diff --git a/DOCS.md b/DOCS.md index 6ad775e..e805222 100644 --- a/DOCS.md +++ b/DOCS.md @@ -1135,6 +1135,16 @@ Expected results: `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. +When that advisory data already carries exploitability signals, each advisory object in `extended.vulnerabilities` (the `.vibgrate/scan_result.json` artifact and `--format json`) includes them: + +| Field | JSON | +| --- | --- | +| `epss` | FIRST [EPSS](https://vibgrate.com/glossary/epss) probability of exploitation within 30 days, from 0 to 1. `null` when the source did not carry a score. A real score of `0` is kept; a missing score is never written as `0`. | +| `epssPercentile` | EPSS percentile, from 0 to 1, or `null`. | +| `kev` | `true` or `false` when the source recorded a [CISA KEV](https://vibgrate.com/glossary/kev) listing. `null` when it did not say — that is not the same as "not listed". | + +The scan copies these from data it already has. On an OSV advisory that is the advisory body (`epss`, `epssPercentile` or `epss_percentile`, `kev` or `cisa_kev`, including a nested `exploitability` object). On a package-version manifest it is the same fields on each `vulns` entry. Offline and `--package-manifest` scans do not call out for EPSS. Text output and SARIF keep the existing severity, CVSS, and fix-version finding; they do not add these fields. Advisory order is unchanged: worst severity first, then id. + 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. The scan also reconstructs **closed** exposure windows from history — a vulnerable version that was later bumped out of the affected range or removed from the lockfile entirely — and reports real remediation time (MTTR) from them: measured, not estimated. Offline, a package-version manifest extends this to advisories that are fully fixed today, so a dependency that is clean now but was once vulnerable still counts toward your remediation record. diff --git a/README.md b/README.md index 3346e40..2c2bc70 100644 --- a/README.md +++ b/README.md @@ -419,7 +419,7 @@ One scan gives you: ## Find known vulnerabilities and who introduced them -`vg scan --vulns` checks your installed dependencies against the public [OSV](https://vibgrate.com/glossary/osv) database and reports each known vulnerability with its severity, CVSS score, and the version that fixes it — as text, JSON, or SARIF. Add `--package-manifest` to run it fully offline from a local advisory bundle. +`vg scan --vulns` checks your installed dependencies against the public [OSV](https://vibgrate.com/glossary/osv) database and reports each known vulnerability with its severity, CVSS score, and the version that fixes it — as text, JSON, or SARIF. When the advisory or an offline `--package-manifest` bundle already includes an [EPSS](https://vibgrate.com/glossary/epss) score, percentile, or [CISA KEV](https://vibgrate.com/glossary/kev) flag, the JSON artifact records `epss`, `epssPercentile`, and `kev` (`null` when absent, never coerced to `0`). The scan does not fetch EPSS. Add `--package-manifest` to run it fully offline from a local advisory bundle. ```bash vg scan --vulns # drift score + known vulnerabilities diff --git a/src/core-open/package-version-manifest.ts b/src/core-open/package-version-manifest.ts index f620246..d051a77 100644 --- a/src/core-open/package-version-manifest.ts +++ b/src/core-open/package-version-manifest.ts @@ -19,6 +19,28 @@ export interface ManifestAdvisory { severity?: 'low' | 'moderate' | 'high' | 'critical' | 'unknown'; cvss?: number; cvssVector?: string; + /** + * FIRST EPSS probability (0–1) when this bundle already carries one. + * Omit when unknown. A value of 0 is a real score — do not use it for "absent". + */ + epss?: number | null; + /** EPSS percentile (0–1), when the bundle carries one. Same absence rule as `epss`. */ + epssPercentile?: number | null; + /** + * CISA Known Exploited Vulnerabilities flag, when the bundle recorded it. + * `true` or `false` are explicit; omit when unknown (that is not the same as `false`). + */ + kev?: boolean | null; + /** + * Same signals grouped under one object (`epss`, `epssPercentile` or `percentile`, `kev`). + * Flat fields above win when both are set. + */ + exploitability?: { + epss?: number | null; + epssPercentile?: number | null; + percentile?: number | null; + kev?: boolean | null; + }; /** Affected semver ranges as [introduced, fixed) pairs (either bound optional). */ ranges?: Array<{ introduced?: string; fixed?: string }>; /** Explicit affected versions, as an alternative/complement to `ranges`. */ diff --git a/src/core-open/scanners/vulnerability-scanner.ts b/src/core-open/scanners/vulnerability-scanner.ts index 854f705..7ae60f0 100644 --- a/src/core-open/scanners/vulnerability-scanner.ts +++ b/src/core-open/scanners/vulnerability-scanner.ts @@ -169,6 +169,88 @@ export function isVersionAffected( return false; } +/** + * A 0–1 exploitability score from an advisory source. + * Numbers and decimal strings in range are kept, including 0. + * Anything else (missing, blank, non-numeric, out of range) is null — never 0. + */ +export function unitIntervalOrNull(value: unknown): number | null { + let n: number; + if (typeof value === 'number') { + n = value; + } else if (typeof value === 'string') { + const trimmed = value.trim(); + if (!/^(?:0|[1-9]\d*)(?:\.\d+)?$/.test(trimmed)) return null; + n = Number(trimmed); + } else { + return null; + } + if (!Number.isFinite(n) || n < 0 || n > 1) return null; + return n; +} + +function booleanOrNull(value: unknown): boolean | null { + return typeof value === 'boolean' ? value : null; +} + +/** Fields an advisory body may use for EPSS / KEV. Read-only; nothing here is fetched. */ +interface ExploitabilityCarrier { + epss?: unknown; + epssPercentile?: unknown; + epss_percentile?: unknown; + percentile?: unknown; + kev?: unknown; + cisaKev?: unknown; + cisa_kev?: unknown; + exploitability?: ExploitabilityCarrier; +} + +/** + * Copy EPSS / KEV off advisory data the scan already holds. + * The first explicit value wins (flat fields before a nested `exploitability` + * object, earlier sources before later ones). Missing and invalid values stay + * null so a later source can still supply a real score, including 0. + */ +function readExploitability(...values: unknown[]): { epss: number | null; epssPercentile: number | null; kev: boolean | null } { + const ranked: Array<{ source: ExploitabilityCarrier; percentileAlias: boolean }> = []; + const push = (value: unknown, percentileAlias: boolean): void => { + if (!value || typeof value !== 'object') return; + const source = value as ExploitabilityCarrier; + ranked.push({ source, percentileAlias }); + if (source.exploitability && typeof source.exploitability === 'object') { + ranked.push({ source: source.exploitability, percentileAlias: true }); + } + }; + for (const value of values) push(value, false); + + let epss: number | null = null; + let epssPercentile: number | null = null; + let kev: boolean | null = null; + for (const { source, percentileAlias } of ranked) { + if (epss == null) { + const score = unitIntervalOrNull(source.epss); + if (score != null) epss = score; + } + if (epssPercentile == null) { + const candidates = [source.epssPercentile, source.epss_percentile]; + if (percentileAlias) candidates.push(source.percentile); + for (const candidate of candidates) { + if (candidate === undefined) continue; + const score = unitIntervalOrNull(candidate); + if (score != null) { + epssPercentile = score; + break; + } + } + } + if (kev == null) { + const flag = booleanOrNull(source.kev ?? source.cisaKev ?? source.cisa_kev); + if (flag != null) kev = flag; + } + } + return { epss, epssPercentile, kev }; +} + // ── OSV advisory parsing (pure) ────────────────────────────────────────────── interface RawOsvSeverity { @@ -184,10 +266,10 @@ interface RawOsvAffected { package?: { ecosystem?: string; name?: string }; ranges?: Array<{ type?: string; events?: RawOsvRangeEvent[] }>; versions?: string[]; - ecosystem_specific?: { severity?: string }; - database_specific?: { severity?: string }; + ecosystem_specific?: { severity?: string } & ExploitabilityCarrier; + database_specific?: { severity?: string } & ExploitabilityCarrier; } -interface RawOsvVuln { +interface RawOsvVuln extends ExploitabilityCarrier { id?: string; aliases?: string[]; summary?: string; @@ -196,7 +278,7 @@ interface RawOsvVuln { severity?: RawOsvSeverity[]; affected?: RawOsvAffected[]; references?: Array<{ url?: string }>; - database_specific?: { severity?: string }; + database_specific?: { severity?: string } & ExploitabilityCarrier; } /** Parse a raw OSV advisory into our shape, scoped to a specific package name. */ @@ -212,6 +294,14 @@ export function parseOsvAdvisory(raw: RawOsvVuln, packageName: string): Vulnerab matchingAffected.map((a) => a.ecosystem_specific?.severity ?? a.database_specific?.severity).find(Boolean) ?? null; const severity: VulnSeverity = cvss != null ? severityFromCvss(cvss) : normalizeSeverityLabel(qualitative); + // EPSS/KEV live on the advisory body when a corpus already attached them. + // No separate EPSS request — offline scans never phone home for these. + const exploitability = readExploitability( + raw, + raw.database_specific, + ...matchingAffected.map((affected) => affected.database_specific), + ...matchingAffected.map((affected) => affected.ecosystem_specific), + ); const fixedVersions: string[] = []; const affectedRanges: AffectedRange[] = []; @@ -245,6 +335,9 @@ export function parseOsvAdvisory(raw: RawOsvVuln, packageName: string): Vulnerab severity, cvss, cvssVector, + epss: exploitability.epss, + epssPercentile: exploitability.epssPercentile, + kev: exploitability.kev, fixedVersions, published: raw.published ?? null, withdrawn: raw.withdrawn ?? null, @@ -261,6 +354,7 @@ export function manifestAdvisoryToAdvisory(m: ManifestAdvisory): VulnerabilityAd : cvss != null ? severityFromCvss(cvss) : 'unknown'; + const exploitability = readExploitability(m); return { id: m.id, aliases: m.aliases ?? [], @@ -268,6 +362,9 @@ export function manifestAdvisoryToAdvisory(m: ManifestAdvisory): VulnerabilityAd severity, cvss, cvssVector: m.cvssVector ?? null, + epss: exploitability.epss, + epssPercentile: exploitability.epssPercentile, + kev: exploitability.kev, fixedVersions: (m.ranges ?? []).map((r) => r.fixed).filter((f): f is string => Boolean(f)), published: m.published ?? null, withdrawn: m.withdrawn ?? null, @@ -408,6 +505,8 @@ export async function scanVulnerabilities( if (targets.length === 0) return emptyResult(); // Offline / air-gapped: advisories come only from the manifest. + // EPSS and KEV are copied from those entries when present; this path does + // not fetch them. if (opts.offline) { return scanFromManifest(targets, opts.manifest); } diff --git a/src/core-open/types.ts b/src/core-open/types.ts index 07f0335..c16ae55 100644 --- a/src/core-open/types.ts +++ b/src/core-open/types.ts @@ -492,6 +492,23 @@ export interface VulnerabilityAdvisory { cvss: number | null; /** Raw CVSS vector string, when the advisory carried one. */ cvssVector: string | null; + /** + * FIRST EPSS probability of exploitation in the wild within 30 days (0–1). + * `null` when the advisory source did not carry a score. Absence is never + * coerced to 0 — a stored 0 is a real score. Copied from data the scan + * already has (the advisory body or a `--package-manifest` bundle); the + * scan does not fetch EPSS. + */ + epss?: number | null; + /** + * EPSS percentile (0–1) when the source carried one. Same absence rule as `epss`. + */ + epssPercentile?: number | null; + /** + * CISA Known Exploited Vulnerabilities listing, when the source recorded it. + * `null` means the source did not say — not the same as `false`. + */ + kev?: boolean | null; /** First fixed version per affected range (empty when no fix is published). */ fixedVersions: string[]; /** ISO-8601 publish date, when known. */ diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 75333fc..76f7c67 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -16,7 +16,7 @@ import { loadOrDiscoverFederation } from '../runtime/federation.js'; import { highConfidenceBridges } from '../runtime/bridge-edges.js'; import { repositoryIdFromRoot } from '../runtime/paths.js'; import { parseGraph } from '../engine/serialize.js'; -import { loadVulnerabilities, filterBySeverity, resolvePackageTarget, openFixableAdvisories } from './vuln-data.js'; +import { loadVulnerabilities, filterBySeverity, resolvePackageTarget, openFixableAdvisories, advisoryExploitability } from './vuln-data.js'; import { attributedInventory } from './attribution.js'; import { computeUpgradeImpact, getChangelogSignals, type VulnSeverity } from '../core-open/index.js'; import { discoverModels } from '../engine/models.js'; @@ -792,6 +792,7 @@ export const TOOLS: VgTool[] = [ cve: a.aliases.find((x) => x.startsWith('CVE-')) ?? null, severity: a.severity, cvss: a.cvss, + ...advisoryExploitability(a), exposureDays: a.exposureDays ?? null, introduced: a.introduced ?? null, fixedVersions: a.fixedVersions, @@ -802,7 +803,7 @@ export const TOOLS: VgTool[] = [ }, { name: 'list_vulnerabilities', - description: 'Known vulnerabilities from the last `vg scan --vulns`: id/CVE, severity, CVSS, fixed version.', + description: 'Known vulnerabilities from the last `vg scan --vulns`: id/CVE, severity, CVSS, EPSS and KEV when the scan recorded them (null if absent, never 0), fixed version.', inputSchema: obj( { severity: { type: 'string', enum: ['low', 'moderate', 'high', 'critical'], description: 'minimum severity' } }, [], @@ -831,6 +832,7 @@ export const TOOLS: VgTool[] = [ cve: a.aliases.find((x) => x.startsWith('CVE-')) ?? null, severity: a.severity, cvss: a.cvss, + ...advisoryExploitability(a), fixedVersions: a.fixedVersions, summary: a.summary, })), diff --git a/src/mcp/vuln-data.ts b/src/mcp/vuln-data.ts index ddb87ad..1b39dcf 100644 --- a/src/mcp/vuln-data.ts +++ b/src/mcp/vuln-data.ts @@ -26,6 +26,22 @@ export function loadVulnerabilities(root: string): VulnerabilityScanResult | nul return readScanArtifact(root)?.extended?.vulnerabilities ?? null; } +/** + * EPSS / KEV for machine-readable vuln JSON. + * A missing key and an explicit null both mean "not supplied". A numeric 0 is kept. + */ +export function advisoryExploitability(advisory: { + epss?: number | null; + epssPercentile?: number | null; + kev?: boolean | null; +}): { epss: number | null; epssPercentile: number | null; kev: boolean | null } { + return { + epss: advisory.epss ?? null, + epssPercentile: advisory.epssPercentile ?? null, + kev: advisory.kev ?? null, + }; +} + /** The drift target for a package: ecosystem + installed/latest versions. */ export interface PackageTarget { ecosystem: VulnEcosystem | 'unknown'; diff --git a/test/scan-offline-network.test.ts b/test/scan-offline-network.test.ts index edb45ae..e889cdd 100644 --- a/test/scan-offline-network.test.ts +++ b/test/scan-offline-network.test.ts @@ -53,7 +53,17 @@ describe('offline scan network boundary', () => { { id: 'GHSA-offline-fixture', summary: 'Fixture advisory for the offline path', - severity: 'moderate', + severity: 'high', + epss: 0.42, + epssPercentile: 0.91, + kev: true, + ranges: [{ introduced: '0', fixed: '1.3.1' }], + }, + { + id: 'GHSA-offline-absent', + summary: 'Fixture advisory with no exploitability data', + severity: 'low', + epss: 0, ranges: [{ introduced: '0', fixed: '1.3.1' }], }, ], @@ -90,11 +100,26 @@ describe('offline scan network boundary', () => { expect(fetchTripwire).not.toHaveBeenCalled(); expect(fs.existsSync(reportPath)).toBe(true); const report = JSON.parse(fs.readFileSync(reportPath, 'utf8')) as { - findings: Array<{ ruleId?: string; message?: string }>; - extended?: { vulnerabilities?: { source?: string; totalAdvisories?: number } }; + findings: Array<{ ruleId?: string; message?: string; details?: Record }>; + extended?: { + vulnerabilities?: { + source?: string; + totalAdvisories?: number; + packages?: Array<{ advisories: Array<{ id: string; epss: number | null; epssPercentile: number | null; kev: boolean | null }> }>; + }; + }; }; - expect(report.extended?.vulnerabilities).toMatchObject({ source: 'manifest', totalAdvisories: 1 }); - expect(report.findings.some((finding) => finding.message?.includes('GHSA-offline-fixture'))).toBe(true); + expect(report.extended?.vulnerabilities).toMatchObject({ source: 'manifest', totalAdvisories: 2 }); + const advisories = report.extended?.vulnerabilities?.packages?.[0]?.advisories ?? []; + // Severity order, then id — EPSS does not reorder findings. + expect(advisories.map((advisory) => advisory.id)).toEqual(['GHSA-offline-fixture', 'GHSA-offline-absent']); + expect(advisories[0]).toMatchObject({ epss: 0.42, epssPercentile: 0.91, kev: true }); + // A real EPSS of 0 is kept. The percentile and KEV flag were not supplied, so they stay null. + expect(advisories[1]).toMatchObject({ epss: 0, epssPercentile: null, kev: null }); + const finding = report.findings.find((item) => item.message?.includes('GHSA-offline-fixture')); + expect(finding?.message).not.toMatch(/EPSS|epss/); + expect(finding?.details).not.toHaveProperty('epss'); + expect(finding?.details).not.toHaveProperty('kev'); expect(fs.existsSync(path.join(root, '.vibgrate', 'scan_result.json'))).toBe(true); }); }); diff --git a/test/vulnerabilities.test.ts b/test/vulnerabilities.test.ts index d3eff4e..f4d14cf 100644 --- a/test/vulnerabilities.test.ts +++ b/test/vulnerabilities.test.ts @@ -40,6 +40,7 @@ function writeArtifact(root: string, vulnerabilities?: VulnerabilityScanResult): } const listVulns = TOOLS.find((t) => t.name === 'list_vulnerabilities')!; +const vulnAttribution = TOOLS.find((t) => t.name === 'vuln_attribution')!; const stubGraph = {} as VgGraph; describe('vuln-data + list_vulnerabilities MCP tool', () => { @@ -73,6 +74,34 @@ describe('vuln-data + list_vulnerabilities MCP tool', () => { expect(res.packages.find((p) => p.package === 'lodash')?.advisories[0].cve).toBe('CVE-2021-1'); }); + it('returns EPSS and KEV from the scan, null when absent, and keeps a real 0', () => { + const data: VulnerabilityScanResult = structuredClone(VULNS); + data.packages[0].advisories[0].epss = 0; + data.packages[0].advisories[0].epssPercentile = 0.05; + data.packages[0].advisories[0].kev = false; + writeArtifact(dir, data); + + const listed = listVulns.handler(stubGraph, {}, { root: dir }) as { + packages: Array<{ package: string; advisories: Array<{ epss: number | null; epssPercentile: number | null; kev: boolean | null }> }>; + }; + expect(listed.packages.find((p) => p.package === 'lodash')?.advisories[0]).toMatchObject({ + epss: 0, + epssPercentile: 0.05, + kev: false, + }); + expect(listed.packages.find((p) => p.package === 'minimist')?.advisories[0]).toMatchObject({ + epss: null, + epssPercentile: null, + kev: null, + }); + + const attributed = vulnAttribution.handler(stubGraph, {}, { root: dir }) as { + packages: Array<{ package: string; advisories: Array<{ epss: number | null; kev: boolean | null }> }>; + }; + expect(attributed.packages.find((p) => p.package === 'lodash')?.advisories[0]).toMatchObject({ epss: 0, kev: false }); + expect(attributed.packages.find((p) => p.package === 'minimist')?.advisories[0].epss).toBeNull(); + }); + it('filters by minimum severity', () => { const onlyCritical = filterBySeverity(VULNS, 'high'); expect(onlyCritical.packages).toHaveLength(1); diff --git a/test/vulnerability-epss.test.ts b/test/vulnerability-epss.test.ts new file mode 100644 index 0000000..08cb46f --- /dev/null +++ b/test/vulnerability-epss.test.ts @@ -0,0 +1,237 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { + generateVulnerabilityFindings, + manifestAdvisoryToAdvisory, + parseOsvAdvisory, + scanVulnerabilities, + unitIntervalOrNull, + type ManifestAdvisory, +} from '../src/core-open/scanners/vulnerability-scanner.js'; +import { Semaphore } from '../src/core-open/utils/semaphore.js'; +import type { PackageVersionManifest } from '../src/core-open/package-version-manifest.js'; + +describe('unitIntervalOrNull', () => { + it('keeps a real 0 and rejects values that are not a 0–1 score', () => { + expect(unitIntervalOrNull(0)).toBe(0); + expect(unitIntervalOrNull(1)).toBe(1); + expect(unitIntervalOrNull('0')).toBe(0); + expect(unitIntervalOrNull(' 0.50 ')).toBe(0.5); + expect(unitIntervalOrNull(undefined)).toBeNull(); + expect(unitIntervalOrNull(null)).toBeNull(); + expect(unitIntervalOrNull('')).toBeNull(); + expect(unitIntervalOrNull('0.5abc')).toBeNull(); + expect(unitIntervalOrNull(1.01)).toBeNull(); + expect(unitIntervalOrNull(-0.01)).toBeNull(); + expect(unitIntervalOrNull(Number.NaN)).toBeNull(); + expect(unitIntervalOrNull(true)).toBeNull(); + }); +}); + +describe('parseOsvAdvisory exploitability', () => { + it('emits null — not 0 — when the advisory body has no EPSS or KEV', () => { + const advisory = parseOsvAdvisory({ id: 'GHSA-none' }, 'left-pad'); + expect(advisory.epss).toBeNull(); + expect(advisory.epssPercentile).toBeNull(); + expect(advisory.kev).toBeNull(); + expect(JSON.parse(JSON.stringify(advisory))).toMatchObject({ + epss: null, + epssPercentile: null, + kev: null, + }); + expect(Object.keys(advisory)).toEqual([ + 'id', + 'aliases', + 'summary', + 'severity', + 'cvss', + 'cvssVector', + 'epss', + 'epssPercentile', + 'kev', + 'fixedVersions', + 'published', + 'withdrawn', + 'references', + ]); + }); + + it('reads decimal strings and snake_case fields already on the advisory', () => { + const advisory = parseOsvAdvisory( + { + id: 'GHSA-str', + database_specific: { severity: 'HIGH', epss: '0.812', epss_percentile: '0.990', cisa_kev: true }, + }, + 'lodash', + ); + expect(advisory.severity).toBe('high'); + expect(advisory.epss).toBe(0.812); + expect(advisory.epssPercentile).toBe(0.99); + expect(advisory.kev).toBe(true); + }); + + it('keeps an explicit 0 and false, and does not let a later source overwrite them', () => { + const advisory = parseOsvAdvisory( + { + id: 'GHSA-zero', + epss: 0, + epssPercentile: 0, + kev: false, + database_specific: { epss: 0.9, epss_percentile: 0.9, kev: true }, + }, + 'lodash', + ); + expect(advisory.epss).toBe(0); + expect(advisory.epssPercentile).toBe(0); + expect(advisory.kev).toBe(false); + }); + + it('uses a nested exploitability object, with flat fields winning when both are set', () => { + const nested = parseOsvAdvisory( + { id: 'GHSA-nest', exploitability: { epss: '0.400', percentile: 0.8, kev: true } }, + 'pkg', + ); + expect(nested).toMatchObject({ epss: 0.4, epssPercentile: 0.8, kev: true }); + + const flatWins = parseOsvAdvisory( + { + id: 'GHSA-flat', + epss: 0.1, + epssPercentile: 0.7, + exploitability: { epss: 0.9, percentile: 0.2, kev: true }, + }, + 'pkg', + ); + expect(flatWins).toMatchObject({ epss: 0.1, epssPercentile: 0.7, kev: true }); + }); + + it('ignores out-of-range scores and falls through to a later valid one', () => { + const advisory = parseOsvAdvisory( + { + id: 'GHSA-range', + epss: 12, + epssPercentile: 'high', + kev: 'yes' as unknown as boolean, + database_specific: { epss: 0.2, epssPercentile: 0.3, kev: true }, + }, + 'pkg', + ); + expect(advisory).toMatchObject({ epss: 0.2, epssPercentile: 0.3, kev: true }); + }); + + it('reads EPSS from the matching affected entry only', () => { + const advisory = parseOsvAdvisory( + { + id: 'GHSA-aff', + affected: [ + { package: { name: 'other' }, database_specific: { epss: 0.3, kev: true } }, + { package: { name: 'Lodash' }, database_specific: { epss: 0, kev: false, epss_percentile: 0.05 } }, + ], + }, + 'lodash', + ); + expect(advisory).toMatchObject({ epss: 0, epssPercentile: 0.05, kev: false }); + + const unmatched = parseOsvAdvisory( + { + id: 'GHSA-other', + affected: [{ package: { name: 'other' }, database_specific: { epss: 0.3, kev: true } }], + }, + 'lodash', + ); + expect(unmatched.epss).toBeNull(); + expect(unmatched.kev).toBeNull(); + }); +}); + +describe('manifest advisory exploitability', () => { + it('copies manifest scores and leaves absent fields null', () => { + const present = manifestAdvisoryToAdvisory({ + id: 'GHSA-man', + severity: 'critical', + epss: 0.15, + exploitability: { percentile: 0.66, kev: true }, + ranges: [{ introduced: '0', fixed: '2.0.0' }], + }); + expect(present).toMatchObject({ epss: 0.15, epssPercentile: 0.66, kev: true, cvss: null }); + + const absent = manifestAdvisoryToAdvisory({ + id: 'GHSA-empty', + severity: 'low', + ranges: [{ introduced: '1.0.0' }], + }); + expect(absent.epss).toBeNull(); + expect(absent.epssPercentile).toBeNull(); + expect(absent.kev).toBeNull(); + + const zero = manifestAdvisoryToAdvisory({ + id: 'GHSA-zero', + epss: 0, + epssPercentile: 0, + kev: false, + }); + expect(zero).toMatchObject({ epss: 0, epssPercentile: 0, kev: false }); + }); + + it('accepts snake_case aliases on a manifest entry without coercing junk to 0', () => { + const advisory = manifestAdvisoryToAdvisory({ + id: 'GHSA-alias', + epss_percentile: '0.770', + cisa_kev: false, + epss: 'nope', + } as ManifestAdvisory); + expect(advisory.epss).toBeNull(); + expect(advisory.epssPercentile).toBe(0.77); + expect(advisory.kev).toBe(false); + }); +}); + +describe('scanVulnerabilities offline EPSS', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('orders by severity then id, keeps 0, and does not fetch', async () => { + const fetchTripwire = vi.fn(async () => { + throw new Error('unexpected network access'); + }); + vi.stubGlobal('fetch', fetchTripwire); + + const manifest: PackageVersionManifest = { + npm: { + zebra: { + vulns: [{ id: 'GHSA-z', severity: 'low', epss: 0.99, ranges: [{ introduced: '0' }] }], + }, + alpha: { + vulns: [ + { id: 'GHSA-aaa', severity: 'low', epss: 0.99, kev: true, ranges: [{ introduced: '0' }] }, + { id: 'GHSA-zzz', severity: 'high', epss: 0, ranges: [{ introduced: '0' }] }, + { id: 'GHSA-none', severity: 'moderate', ranges: [{ introduced: '0' }] }, + ], + }, + }, + }; + const targets = [ + { ecosystem: 'npm' as const, package: 'zebra', version: '1.0.0' }, + { ecosystem: 'npm' as const, package: 'alpha', version: '1.2.0' }, + ]; + const opts = { sem: new Semaphore(1), offline: true, manifest }; + const first = await scanVulnerabilities(targets, opts); + const second = await scanVulnerabilities([...targets].reverse(), opts); + + expect(fetchTripwire).not.toHaveBeenCalled(); + expect(second).toEqual(first); + expect(first.packages.map((pkg) => pkg.package)).toEqual(['alpha', 'zebra']); + expect(first.packages[0].advisories.map((advisory) => advisory.id)).toEqual(['GHSA-zzz', 'GHSA-none', 'GHSA-aaa']); + expect(first.packages[0].advisories.map((advisory) => advisory.epss)).toEqual([0, null, 0.99]); + expect(first.packages[0].advisories.map((advisory) => advisory.kev)).toEqual([null, null, true]); + + const findings = generateVulnerabilityFindings(first); + expect(findings.map((finding) => finding.details?.advisoryId)).toEqual(['GHSA-zzz', 'GHSA-none', 'GHSA-aaa', 'GHSA-z']); + for (const finding of findings) { + expect(finding.message).not.toMatch(/EPSS|epss/); + expect(finding.details).not.toHaveProperty('epss'); + expect(finding.details).not.toHaveProperty('epssPercentile'); + expect(finding.details).not.toHaveProperty('kev'); + } + }); +});