/** * Analysis for research report EFF-RR-2026-002: does severity predict exploitation? * * node research/epss-2026/analyse.mjs * * must contain (see README.md for how to retrieve them): * nvd-2023.json.gz ... nvd-2026.json.gz NVD CVE 2.0 yearly feeds * kev.json CISA KEV catalogue (JSON feed) * epss-.csv.gz FIRST EPSS scores published on each snapshot date below * * Outputs: results.json (every number in the report) and outcomes.csv (every vulnerability that was * confirmed exploited in a test window, with the scores it had at the start of that window). */ import { createReadStream, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { createGunzip, gunzipSync } from 'node:zlib'; import { join } from 'node:path'; const [dir, out] = process.argv.slice(2); if (!out) { console.error('Usage: node analyse.mjs '); process.exit(1); } const YEARS = [2023, 2024, 2025, 2026]; // Quarterly test dates. At each one we take the scores published that day, then see which vulnerabilities // CISA adds to KEV before the next date. The last window ends on the KEV catalogue date. const SNAPSHOTS = ['2024-02-10', '2024-05-10', '2024-08-10', '2024-11-10', '2025-02-10', '2025-05-10', '2025-08-10', '2025-11-10', '2026-02-10', '2026-05-10', '2026-08-10']; const END = '2026-11-10'; const windowEnd = (i) => SNAPSHOTS[i + 1] || END; // A single one-year window, used only to show how much exploitation involves vulnerabilities not yet published. const YEAR_START = '2025-11-10'; /* ---------- Reading the NVD feeds without loading 500 MB files whole ---------- */ async function* vulnerabilities(file) { const stream = createReadStream(file).pipe(createGunzip()); stream.setEncoding('utf8'); let buf = '', started = false, depth = 0, inStr = false, esc = false, start = 0, pos = 0; for await (const chunk of stream) { buf += chunk; if (!started) { const i = buf.indexOf('"vulnerabilities"'); const j = i < 0 ? -1 : buf.indexOf('[', i); if (j < 0) continue; buf = buf.slice(j + 1); started = true; pos = 0; } for (; pos < buf.length; pos++) { const c = buf[pos]; if (inStr) { if (esc) esc = false; else if (c === '\\') esc = true; else if (c === '"') inStr = false; continue; } if (c === '"') inStr = true; else if (c === '{') { if (depth++ === 0) start = pos; } else if (c === '}' && --depth === 0) { yield JSON.parse(buf.slice(start, pos + 1)).cve; buf = buf.slice(pos + 1); pos = -1; } else if (c === ']' && depth === 0) return; } if (depth === 0) { buf = ''; pos = 0; } } } /** * CVSS v3.x base scores. The CNA's own score (Secondary) is normally set when the CVE is published, so we * use it as the score a defender would have seen at the time. NVD's score (Primary) is often added later, * and NVD analyses exploited vulnerabilities first, so using it would favour CVSS with hindsight. */ function cvss3(metrics = {}) { const all = [...(metrics.cvssMetricV31 || []), ...(metrics.cvssMetricV30 || [])]; const nvd = all.find((x) => x.type === 'Primary')?.cvssData.baseScore ?? null; const cna = all.find((x) => x.type === 'Secondary')?.cvssData.baseScore ?? null; return { score: cna ?? nvd, source: cna !== null ? 'cna' : nvd !== null ? 'nvd' : 'none', nvd, hasNvd: nvd !== null }; } const band = (s) => (s === null ? 'none' : s >= 9 ? 'critical' : s >= 7 ? 'high' : s >= 4 ? 'medium' : s > 0 ? 'low' : 'none'); const BANDS = ['critical', 'high', 'medium', 'low', 'none']; const BAND_LABEL = { critical: 'Critical (9.0–10)', high: 'High (7.0–8.9)', medium: 'Medium (4.0–6.9)', low: 'Low (0.1–3.9)', none: 'No CVSS v3 score' }; /* ---------- Load ---------- */ const kevDoc = JSON.parse(readFileSync(join(dir, 'kev.json'), 'utf8')); const kev = new Map(kevDoc.vulnerabilities.map((v) => [v.cveID, v])); const epssModels = {}; function loadEpss(date) { const text = gunzipSync(readFileSync(join(dir, `epss-${date}.csv.gz`))).toString('utf8'); epssModels[date] = text.slice(0, text.indexOf('\n')); const m = new Map(); for (const line of text.split('\n').slice(2)) { const [id, p] = line.split(','); if (id) m.set(id, Number(p)); } return m; } const cves = []; let rejected = 0; for (const y of YEARS) { for await (const c of vulnerabilities(join(dir, `nvd-${y}.json.gz`))) { if (c.vulnStatus === 'Rejected') { rejected++; continue; } const s = cvss3(c.metrics); const k = kev.get(c.id); cves.push({ id: c.id, idYear: y, published: c.published.slice(0, 10), score: s.score, scoreSource: s.source, band: band(s.score), nvdBand: band(s.nvd), hasNvd: s.hasNvd, kevAdded: k ? k.dateAdded : null, vendor: k ? k.vendorProject.trim() : '', product: k ? k.product.trim() : '', }); } console.log(`Read ${y}: ${cves.length} CVEs so far`); } /* ---------- Helpers ---------- */ const pct = (n, d) => (d ? Math.round((n / d) * 1000) / 10 : 0); const tally = (rows, f) => rows.reduce((m, r) => ((m[f(r)] = (m[f(r)] || 0) + 1), m), {}); /* ---------- 1. Whole population, 2023–2026 identifiers ---------- */ const exploited = cves.filter((c) => c.kevAdded); const population = { cves: cves.length, rejectedExcluded: rejected, byIdYear: tally(cves, (c) => c.idYear), exploited: exploited.length, exploitedPct: Math.round((exploited.length / cves.length) * 10000) / 100, scoreSource: Object.fromEntries(Object.entries(tally(cves, (c) => c.scoreSource)).map(([k, n]) => [k, { n, pct: pct(n, cves.length) }])), // Share of CVEs, by identifier year, that NVD has scored itself / that have any CVSS v3 score at all. nvdScoredByYear: Object.fromEntries(YEARS.map((y) => { const s = cves.filter((c) => c.idYear === y); return [y, { nvd: pct(s.filter((c) => c.hasNvd).length, s.length), any: pct(s.filter((c) => c.score !== null).length, s.length) }]; })), nvdScoredAll: pct(cves.filter((c) => c.hasNvd).length, cves.length), nvdBandsOfExploited: Object.fromEntries(BANDS.map((b) => [b, exploited.filter((c) => c.nvdBand === b).length])), bands: Object.fromEntries(BANDS.map((b) => { const all = cves.filter((c) => c.band === b).length; const ex = exploited.filter((c) => c.band === b).length; return [b, { label: BAND_LABEL[b], cves: all, cvesPct: pct(all, cves.length), exploited: ex, exploitedShare: pct(ex, exploited.length), exploitRate: Math.round((ex / (all || 1)) * 10000) / 100 }]; })), }; /* ---------- 2. Prediction test: scores known at each snapshot, outcomes before the next ---------- */ const epssBucket = (p) => (p >= 0.1 ? '10% or more' : p >= 0.01 ? '1% to 10%' : p >= 0.001 ? '0.1% to 1%' : 'Under 0.1%'); const EPSS_BUCKETS = ['10% or more', '1% to 10%', '0.1% to 1%', 'Under 0.1%']; const STRATEGIES = [ ['cvssCritical', 'CVSS Critical'], ['cvssCriticalHigh', 'CVSS Critical or High'], ['epssMatchCritical', 'EPSS highest, same number as CVSS Critical'], ['epssMatchCriticalHigh', 'EPSS highest, same number as CVSS Critical or High'], ['epss10', 'EPSS 10% or more'], ['epss1', 'EPSS 1% or more'], ['criticalOrEpss10', 'CVSS Critical, or EPSS 10% or more'], ['nvdCriticalHigh', 'Sensitivity: CVSS Critical or High using NVD scores'], ]; const total = Object.fromEntries(STRATEGIES.map(([id]) => [id, { flagged: 0, caught: 0 }])); const totals = { pool: 0, positives: 0, newExploited: 0, missingEpss: 0, positivesByBand: {}, poolByBand: {}, positivesByEpss: {}, poolByEpss: {}, missedByBoth: 0 }; const add = (o, k, n = 1) => { o[k] = (o[k] || 0) + n; }; const perSnapshot = []; const outcomes = []; SNAPSHOTS.forEach((S, i) => { const E = windowEnd(i); const epss = loadEpss(S); const eligible = cves.filter((c) => c.published <= S && !(c.kevAdded && c.kevAdded <= S)); const pool = eligible.filter((c) => epss.has(c.id)).map((c) => ({ ...c, epss: epss.get(c.id) })); const hit = (c) => Boolean(c.kevAdded && c.kevAdded > S && c.kevAdded <= E); const positives = pool.filter(hit); const ranked = [...pool].sort((a, b) => b.epss - a.epss); const crit = pool.filter((c) => c.band === 'critical'); const critHigh = pool.filter((c) => c.band === 'critical' || c.band === 'high'); const flags = { cvssCritical: crit, cvssCriticalHigh: critHigh, epssMatchCritical: ranked.slice(0, crit.length), epssMatchCriticalHigh: ranked.slice(0, critHigh.length), epss10: pool.filter((c) => c.epss >= 0.1), epss1: pool.filter((c) => c.epss >= 0.01), criticalOrEpss10: pool.filter((c) => c.band === 'critical' || c.epss >= 0.1), nvdCriticalHigh: pool.filter((c) => c.nvdBand === 'critical' || c.nvdBand === 'high'), }; const row = { snapshot: S, windowEnd: E, model: epssModels[S].match(/model_version:([^,]+)/)[1], pool: pool.length, positives: positives.length }; for (const [id] of STRATEGIES) { const f = flags[id]; const caught = f.filter(hit).length; total[id].flagged += f.length; total[id].caught += caught; row[id] = caught; } const newExploited = cves.filter((c) => c.published > S && c.kevAdded && c.kevAdded > S && c.kevAdded <= E).length; row.newExploited = newExploited; totals.pool += pool.length; totals.positives += positives.length; totals.newExploited += newExploited; totals.missingEpss += eligible.length - pool.length; for (const c of pool) { add(totals.poolByBand, c.band); add(totals.poolByEpss, epssBucket(c.epss)); } for (const c of positives) { add(totals.positivesByBand, c.band); add(totals.positivesByEpss, epssBucket(c.epss)); if (c.band !== 'critical' && c.band !== 'high' && c.epss < 0.01) totals.missedByBoth++; outcomes.push({ ...c, snapshot: S }); } perSnapshot.push(row); }); const prediction = { design: 'At each quarterly snapshot: all CVEs with 2023-2026 identifiers published by that date, not yet in KEV, with an EPSS score that day. Outcome: added to KEV after the snapshot and on or before the next snapshot.', epssModels, snapshots: perSnapshot, poolTotal: totals.pool, positives: totals.positives, missingEpss: totals.missingEpss, baseRatePct: Math.round((totals.positives / totals.pool) * 100000) / 1000, strategies: STRATEGIES.map(([id, name]) => ({ id, name, flagged: total[id].flagged, effortPct: pct(total[id].flagged, totals.pool), caught: total[id].caught, coveragePct: pct(total[id].caught, totals.positives), perThousandFlagged: Math.round((total[id].caught / (total[id].flagged || 1)) * 10000) / 10, })), positivesByBand: Object.fromEntries(BANDS.map((b) => [b, { n: totals.positivesByBand[b] || 0, pct: pct(totals.positivesByBand[b] || 0, totals.positives) }])), poolByBand: Object.fromEntries(BANDS.map((b) => [b, { n: totals.poolByBand[b] || 0, pct: pct(totals.poolByBand[b] || 0, totals.pool) }])), positivesByEpss: Object.fromEntries(EPSS_BUCKETS.map((k) => [k, { n: totals.positivesByEpss[k] || 0, pct: pct(totals.positivesByEpss[k] || 0, totals.positives) }])), poolByEpss: Object.fromEntries(EPSS_BUCKETS.map((k) => [k, { n: totals.poolByEpss[k] || 0, pct: pct(totals.poolByEpss[k] || 0, totals.pool) }])), missedByBoth: totals.missedByBoth, newExploitedInWindows: totals.newExploited, newShareOfWindowAdditions: pct(totals.newExploited, totals.newExploited + totals.positives), }; /* ---------- 3. One year: how much exploitation involves vulnerabilities not yet published ---------- */ const inYear = cves.filter((c) => c.kevAdded && c.kevAdded > YEAR_START && c.kevAdded <= END); const yearNew = inYear.filter((c) => c.published > YEAR_START); const oneYear = { from: YEAR_START, through: END, exploited: inYear.length, publishedDuringYear: yearNew.length, sharePct: pct(yearNew.length, inYear.length), newByBand: Object.fromEntries(BANDS.map((b) => [b, yearNew.filter((c) => c.band === b).length])), }; const results = { sources: { kevCatalogVersion: kevDoc.catalogVersion, kevDateReleased: kevDoc.dateReleased, nvdYears: YEARS, epssModels }, population, prediction, oneYear, }; mkdirSync(out, { recursive: true }); writeFileSync(join(out, 'results.json'), JSON.stringify(results, null, 2) + '\n'); const cols = ['snapshot', 'id', 'published', 'kevAdded', 'vendor', 'product', 'score', 'scoreSource', 'band', 'epss']; const cell = (v) => { const s = v === null ? '' : String(v); return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; }; writeFileSync(join(out, 'outcomes.csv'), [cols.join(','), ...outcomes.map((c) => cols.map((k) => cell(c[k])).join(','))].join('\n') + '\n'); console.log(`Done: ${cves.length} CVEs; ${prediction.positives} exploited in ${SNAPSHOTS.length} test windows.`);