From bdba21ee4d94d7b5dfa5b5d24023669072148a7c Mon Sep 17 00:00:00 2001 From: Artur Zegarek Date: Sun, 9 Aug 2026 22:43:08 +0200 Subject: [PATCH 1/5] Enriches Shodan CVE results with CISA KEV and EPSS Shodan gives us bare CVE ids, which says nothing about whether a vulnerability is actually being exploited. This adds an enrichment step against two free, key-less feeds: - CISA KEV, for vulnerabilities confirmed exploited in the wild - FIRST EPSS, for the probability of exploitation in the next 30 days It also maps each CVE back to the exposed service that reported it, and derives a patch priority from the KEV-first ordering CISA recommends. Both feeds are best-effort: if either is unreachable the host result is returned unchanged, just with less context. Adds unit tests covering the parsing, merging and triage rules, run with `yarn test` via the built-in Node test runner (no new dependencies). --- .github/README.md | 2 + .github/workflows/ci.yml | 19 ++ api/_common/cve-intel.js | 283 +++++++++++++++++++++++++++ api/_common/cve-intel.test.js | 355 ++++++++++++++++++++++++++++++++++ api/shodan.js | 6 +- package.json | 1 + 6 files changed, 665 insertions(+), 1 deletion(-) create mode 100644 api/_common/cve-intel.js create mode 100644 api/_common/cve-intel.test.js diff --git a/.github/README.md b/.github/README.md index b190226ac..e4fcdafb1 100644 --- a/.github/README.md +++ b/.github/README.md @@ -982,6 +982,8 @@ Note that keys that are prefixed with `REACT_APP_` are used client-side, and as 3. Install dependencies: `yarn` 4. Start the dev server, with `yarn dev` +Unit tests for the API helpers run with `yarn test` (uses the built-in Node test runner, so there's nothing extra to install). + You'll need [Node.js](https://nodejs.org/en) (v22.12 or later) installed, plus [yarn](https://yarnpkg.com/getting-started/install) as well as [git](https://git-scm.com/). Some checks also require `chromium`, `traceroute` and `dns` to be installed within your environment. These jobs will just be skipped if those packages aren't present. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a2257b4f6..f213e3bed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,25 @@ jobs: - name: ๐Ÿ” Run ESLint run: yarn lint + test: + name: ๐Ÿงช Unit Tests + runs-on: ubuntu-latest + steps: + - name: ๐Ÿ›Ž๏ธ Checkout Code + uses: actions/checkout@v6 + + - name: ๐Ÿ”ง Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '22' + cache: 'yarn' + + - name: ๐Ÿ“ฆ Install Dependencies + run: yarn install --frozen-lockfile + + - name: ๐Ÿงช Run Tests + run: yarn test + typecheck: name: ๐Ÿงท Type Check runs-on: ubuntu-latest diff --git a/api/_common/cve-intel.js b/api/_common/cve-intel.js new file mode 100644 index 000000000..018d457aa --- /dev/null +++ b/api/_common/cve-intel.js @@ -0,0 +1,283 @@ +// Turns a bare list of CVE ids into something you can actually triage: +// CISA KEV (is it being exploited right now?), EPSS (how likely is it to be?) +// and the exposed service each CVE was seen on. Both feeds are free and +// key-less, so this enrichment runs for every Shodan result. + +import { httpGet } from './http.js'; + +const KEV_FEED = + 'https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json'; +const EPSS_API = 'https://api.first.org/data/v1/epss'; + +// EPSS is re-scored daily and the KEV catalog changes at most a few times a +// day, so a long-lived in-process cache keeps us well clear of both endpoints +const FEED_CACHE_TTL = 6 * 60 * 60 * 1000; +// api.first.org caps a single query at 100 CVEs +const EPSS_BATCH_SIZE = 100; +const FEED_TIMEOUT = 20000; + +const CVE_ID = /^CVE-\d{4}-\d{4,}$/i; + +// EPSS probability at or above which a CVE is worth an unscheduled patch +const EPSS_HIGH = 0.1; +// ...and above which it is worth putting in the next maintenance window +const EPSS_NOTABLE = 0.01; +const CVSS_HIGH = 9.0; + +const PRIORITY_RANK = { critical: 0, high: 1, medium: 2, low: 3 }; + +const toNumber = (value) => { + const num = typeof value === 'string' ? Number(value) : value; + return typeof num === 'number' && Number.isFinite(num) ? num : null; +}; + +export const chunk = (list, size) => { + const out = []; + for (let i = 0; i < list.length; i += size) out.push(list.slice(i, i + size)); + return out; +}; + +// Shodan reports vulns either as an array of ids or as an object keyed by id, +// both at the host level and again on each individual service banner +const readVulns = (vulns) => { + if (Array.isArray(vulns)) return vulns.map((id) => [id, {}]); + if (vulns && typeof vulns === 'object') return Object.entries(vulns); + return []; +}; + +const readService = (banner) => ({ + port: banner.port ?? null, + transport: banner.transport ?? null, + product: banner.product ?? null, + version: banner.version ?? null, + module: banner._shodan?.module ?? null, +}); + +// Merge whatever detail Shodan gave us for a CVE, without letting a later +// (emptier) mention wipe out an earlier one +const absorb = (entry, detail) => { + if (!detail || typeof detail !== 'object') return; + const cvss = toNumber(detail.cvss); + if (entry.cvss === null && cvss !== null) entry.cvss = cvss; + if (!entry.summary && detail.summary) entry.summary = detail.summary; + if (!entry.references.length && Array.isArray(detail.references)) { + entry.references = detail.references; + } + if (detail.verified) entry.verified = true; +}; + +// Pull every CVE out of a Shodan host response, keeping track of which exposed +// service each one came from +export const extractCveEntries = (shodanData) => { + if (!shodanData || typeof shodanData !== 'object') return []; + const entries = new Map(); + + const record = (rawId, detail, service) => { + const id = String(rawId || '').toUpperCase(); + if (!CVE_ID.test(id)) return; + if (!entries.has(id)) { + entries.set(id, { + id, + cvss: null, + summary: null, + references: [], + verified: false, + services: [], + detectedBy: ['Shodan'], + }); + } + const entry = entries.get(id); + absorb(entry, detail); + if (service) entry.services.push(service); + }; + + for (const [id, detail] of readVulns(shodanData.vulns)) record(id, detail, null); + + if (Array.isArray(shodanData.data)) { + for (const banner of shodanData.data) { + if (!banner || typeof banner !== 'object') continue; + const service = readService(banner); + for (const [id, detail] of readVulns(banner.vulns)) record(id, detail, service); + } + } + + return [...entries.values()]; +}; + +// --- CISA KEV ---------------------------------------------------------------- + +export const parseKevCatalog = (raw) => { + const catalog = { + version: raw?.catalogVersion ?? null, + released: raw?.dateReleased ?? null, + byCve: {}, + }; + if (!Array.isArray(raw?.vulnerabilities)) return catalog; + + for (const vuln of raw.vulnerabilities) { + const id = String(vuln?.cveID || '').toUpperCase(); + if (!CVE_ID.test(id)) continue; + catalog.byCve[id] = { + listed: true, + name: vuln.vulnerabilityName ?? null, + vendor: vuln.vendorProject ?? null, + product: vuln.product ?? null, + dateAdded: vuln.dateAdded ?? null, + dueDate: vuln.dueDate ?? null, + ransomware: vuln.knownRansomwareCampaignUse === 'Known', + requiredAction: vuln.requiredAction ?? null, + }; + } + return catalog; +}; + +let kevCache = null; + +export const fetchKevCatalog = async () => { + if (kevCache && Date.now() - kevCache.at < FEED_CACHE_TTL) return kevCache.catalog; + const res = await httpGet(KEV_FEED, { timeout: FEED_TIMEOUT }); + const catalog = parseKevCatalog(res.data); + kevCache = { at: Date.now(), catalog }; + return catalog; +}; + +// --- FIRST EPSS -------------------------------------------------------------- + +export const parseEpssScores = (raw) => { + const scores = {}; + if (!Array.isArray(raw?.data)) return scores; + + for (const row of raw.data) { + const id = String(row?.cve || '').toUpperCase(); + const score = toNumber(row?.epss); + if (!CVE_ID.test(id) || score === null) continue; + scores[id] = { + score, + percentile: toNumber(row?.percentile), + date: row?.date ?? null, + }; + } + return scores; +}; + +export const fetchEpssScores = async (cveIds) => { + if (!cveIds.length) return {}; + const batches = await Promise.all( + chunk(cveIds, EPSS_BATCH_SIZE).map((batch) => + httpGet(EPSS_API, { params: { cve: batch.join(',') }, timeout: FEED_TIMEOUT }), + ), + ); + return batches.reduce((all, res) => Object.assign(all, parseEpssScores(res.data)), {}); +}; + +// --- Triage ------------------------------------------------------------------ + +// CISA's own guidance is to work the KEV catalog first, then use EPSS to rank +// what is left โ€” a high CVSS on its own says how bad exploitation *would* be, +// not how likely it is, so it never outranks evidence of exploitation +export const computePriority = (entry) => { + const epss = entry?.epss?.score ?? null; + const cvss = entry?.cvss ?? null; + + if (entry?.kev?.listed) { + return { + level: 'critical', + label: 'Patch now', + reason: entry.kev.ransomware + ? 'In the CISA KEV catalog and linked to known ransomware campaigns' + : 'In the CISA KEV catalog โ€” exploitation in the wild is confirmed', + }; + } + + if (epss !== null && epss >= EPSS_HIGH) { + return { + level: 'high', + label: 'Patch soon', + reason: `EPSS puts exploitation in the next 30 days at ${(epss * 100).toFixed(1)}%`, + }; + } + + if ((epss !== null && epss >= EPSS_NOTABLE) || (cvss !== null && cvss >= CVSS_HIGH)) { + return { + level: 'medium', + label: 'Schedule', + reason: + cvss !== null && cvss >= CVSS_HIGH + ? 'Severe if exploited, but no evidence of active exploitation' + : 'Some measurable chance of exploitation in the next 30 days', + }; + } + + return { + level: 'low', + label: 'Monitor', + reason: 'Not in the KEV catalog and unlikely to be exploited in the next 30 days', + }; +}; + +const byRisk = (a, b) => { + const rank = PRIORITY_RANK[a.priority.level] - PRIORITY_RANK[b.priority.level]; + if (rank !== 0) return rank; + const epss = (b.epss?.score ?? -1) - (a.epss?.score ?? -1); + if (epss !== 0) return epss; + const cvss = (b.cvss ?? -1) - (a.cvss ?? -1); + if (cvss !== 0) return cvss; + return a.id.localeCompare(b.id); +}; + +export const enrichCveEntries = (entries, { kev, epss } = {}) => + entries + .map((entry) => { + const enriched = { + ...entry, + kev: kev?.byCve?.[entry.id] ?? { listed: false }, + epss: epss?.[entry.id] ?? null, + }; + return { ...enriched, priority: computePriority(enriched) }; + }) + .sort(byRisk); + +const maxOf = (values) => (values.length ? Math.max(...values) : null); + +export const summariseCves = (entries) => { + const levels = entries.map((e) => PRIORITY_RANK[e.priority.level]); + const worst = levels.length ? Math.min(...levels) : null; + return { + total: entries.length, + kevCount: entries.filter((e) => e.kev?.listed).length, + ransomwareCount: entries.filter((e) => e.kev?.ransomware).length, + maxCvss: maxOf(entries.map((e) => e.cvss).filter((v) => v !== null)), + maxEpss: maxOf(entries.map((e) => e.epss?.score).filter((v) => v != null)), + highestPriority: + worst === null ? null : Object.keys(PRIORITY_RANK).find((k) => PRIORITY_RANK[k] === worst), + }; +}; + +// --- Orchestration ----------------------------------------------------------- + +// Neither feed is essential: if one is down we still return the CVEs, just with +// less context, and say so rather than failing the whole check +export const buildCveIntel = async (shodanData) => { + const entries = extractCveEntries(shodanData); + if (!entries.length) { + return { vulns: [], summary: summariseCves([]), feeds: {} }; + } + + const [kevResult, epssResult] = await Promise.allSettled([ + fetchKevCatalog(), + fetchEpssScores(entries.map((e) => e.id)), + ]); + + const kev = kevResult.status === 'fulfilled' ? kevResult.value : null; + const epss = epssResult.status === 'fulfilled' ? epssResult.value : null; + const enriched = enrichCveEntries(entries, { kev, epss }); + + return { + vulns: enriched, + summary: summariseCves(enriched), + feeds: { + kev: kev ? { ok: true, version: kev.version, released: kev.released } : { ok: false }, + epss: epss ? { ok: true, date: Object.values(epss)[0]?.date ?? null } : { ok: false }, + }, + }; +}; diff --git a/api/_common/cve-intel.test.js b/api/_common/cve-intel.test.js new file mode 100644 index 000000000..f0ab6d2a8 --- /dev/null +++ b/api/_common/cve-intel.test.js @@ -0,0 +1,355 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + extractCveEntries, + parseKevCatalog, + parseEpssScores, + computePriority, + enrichCveEntries, + summariseCves, + chunk, +} from './cve-intel.js'; + +// --- extractCveEntries ------------------------------------------------------- + +test('extractCveEntries returns [] for empty or malformed input', () => { + assert.deepEqual(extractCveEntries(null), []); + assert.deepEqual(extractCveEntries(undefined), []); + assert.deepEqual(extractCveEntries({}), []); + assert.deepEqual(extractCveEntries({ vulns: [] }), []); + assert.deepEqual(extractCveEntries({ vulns: 'nope' }), []); +}); + +test('extractCveEntries handles the array form of shodan vulns', () => { + const entries = extractCveEntries({ vulns: ['CVE-2021-44228', 'CVE-2014-0160'] }); + assert.equal(entries.length, 2); + assert.deepEqual( + entries.map((e) => e.id), + ['CVE-2021-44228', 'CVE-2014-0160'], + ); + assert.equal(entries[0].cvss, null); + assert.deepEqual(entries[0].services, []); + assert.deepEqual(entries[0].detectedBy, ['Shodan']); +}); + +test('extractCveEntries keeps cvss, summary and references from the object form', () => { + const entries = extractCveEntries({ + vulns: { + 'CVE-2021-44228': { + cvss: 10, + summary: 'Log4Shell', + references: ['https://example.com/a'], + verified: true, + }, + }, + }); + assert.equal(entries.length, 1); + assert.equal(entries[0].cvss, 10); + assert.equal(entries[0].summary, 'Log4Shell'); + assert.deepEqual(entries[0].references, ['https://example.com/a']); + assert.equal(entries[0].verified, true); +}); + +test('extractCveEntries coerces string cvss scores to numbers', () => { + const [entry] = extractCveEntries({ vulns: { 'CVE-2021-44228': { cvss: '9.8' } } }); + assert.equal(entry.cvss, 9.8); +}); + +test('extractCveEntries ignores keys that are not CVE identifiers', () => { + const entries = extractCveEntries({ vulns: { 'CVE-2021-44228': {}, 'not-a-cve': {}, '': {} } }); + assert.deepEqual( + entries.map((e) => e.id), + ['CVE-2021-44228'], + ); +}); + +test('extractCveEntries normalises CVE ids to uppercase and de-duplicates', () => { + const entries = extractCveEntries({ + vulns: ['cve-2021-44228'], + data: [{ port: 443, vulns: { 'CVE-2021-44228': { cvss: 10 } } }], + }); + assert.equal(entries.length, 1); + assert.equal(entries[0].id, 'CVE-2021-44228'); + assert.equal(entries[0].cvss, 10, 'per-service detail should fill in a missing host-level cvss'); +}); + +test('extractCveEntries attaches the exposed service that reported each CVE', () => { + const entries = extractCveEntries({ + data: [ + { + port: 443, + transport: 'tcp', + product: 'nginx', + version: '1.18.0', + _shodan: { module: 'https' }, + vulns: { 'CVE-2021-23017': { cvss: 7.7 } }, + }, + { + port: 22, + transport: 'tcp', + product: 'OpenSSH', + version: '8.2p1', + _shodan: { module: 'ssh' }, + vulns: { 'CVE-2020-15778': { cvss: 6.8 } }, + }, + ], + }); + assert.equal(entries.length, 2); + const nginx = entries.find((e) => e.id === 'CVE-2021-23017'); + assert.deepEqual(nginx.services, [ + { port: 443, transport: 'tcp', product: 'nginx', version: '1.18.0', module: 'https' }, + ]); +}); + +test('extractCveEntries collects every service exposing the same CVE', () => { + const [entry] = extractCveEntries({ + data: [ + { port: 80, vulns: ['CVE-2021-23017'] }, + { port: 443, vulns: ['CVE-2021-23017'] }, + ], + }); + assert.deepEqual( + entry.services.map((s) => s.port), + [80, 443], + ); +}); + +test('extractCveEntries tolerates banners with no vulns', () => { + const entries = extractCveEntries({ vulns: ['CVE-2021-44228'], data: [{ port: 80 }, null] }); + assert.equal(entries.length, 1); + assert.deepEqual(entries[0].services, []); +}); + +// --- parseKevCatalog --------------------------------------------------------- + +const kevFixture = { + catalogVersion: '2026.08.07', + dateReleased: '08/07/2026 16:45:47', + count: 2, + vulnerabilities: [ + { + cveID: 'CVE-2021-44228', + vendorProject: 'Apache', + product: 'Log4j2', + vulnerabilityName: 'Apache Log4j2 Remote Code Execution Vulnerability', + dateAdded: '2021-12-10', + dueDate: '2021-12-24', + knownRansomwareCampaignUse: 'Known', + requiredAction: 'Apply updates per vendor instructions.', + }, + { + cveID: 'CVE-2020-15778', + vendorProject: 'OpenBSD', + product: 'OpenSSH', + vulnerabilityName: 'OpenSSH Command Injection Vulnerability', + dateAdded: '2024-01-02', + dueDate: '2024-01-23', + knownRansomwareCampaignUse: 'Unknown', + }, + ], +}; + +test('parseKevCatalog indexes the catalog by CVE id', () => { + const kev = parseKevCatalog(kevFixture); + assert.equal(kev.version, '2026.08.07'); + assert.equal(Object.keys(kev.byCve).length, 2); + assert.equal(kev.byCve['CVE-2021-44228'].vendor, 'Apache'); + assert.equal(kev.byCve['CVE-2021-44228'].product, 'Log4j2'); + assert.equal(kev.byCve['CVE-2021-44228'].dateAdded, '2021-12-10'); + assert.equal(kev.byCve['CVE-2021-44228'].dueDate, '2021-12-24'); +}); + +test('parseKevCatalog maps knownRansomwareCampaignUse to a boolean', () => { + const kev = parseKevCatalog(kevFixture); + assert.equal(kev.byCve['CVE-2021-44228'].ransomware, true); + assert.equal(kev.byCve['CVE-2020-15778'].ransomware, false); +}); + +test('parseKevCatalog returns an empty index for malformed input', () => { + assert.deepEqual(parseKevCatalog(null).byCve, {}); + assert.deepEqual(parseKevCatalog({}).byCve, {}); + assert.deepEqual(parseKevCatalog({ vulnerabilities: 'nope' }).byCve, {}); +}); + +// --- parseEpssScores --------------------------------------------------------- + +const epssFixture = { + status: 'OK', + data: [ + { cve: 'CVE-2021-44228', epss: '0.999990000', percentile: '1.000000000', date: '2026-08-09' }, + { cve: 'CVE-2020-15778', epss: '0.008720000', percentile: '0.821030000', date: '2026-08-09' }, + ], +}; + +test('parseEpssScores converts the string scores to numbers', () => { + const epss = parseEpssScores(epssFixture); + assert.equal(epss['CVE-2021-44228'].score, 0.99999); + assert.equal(epss['CVE-2021-44228'].percentile, 1); + assert.equal(epss['CVE-2020-15778'].score, 0.00872); + assert.equal(epss['CVE-2020-15778'].date, '2026-08-09'); +}); + +test('parseEpssScores omits CVEs that FIRST has no model output for', () => { + const epss = parseEpssScores(epssFixture); + assert.equal(epss['CVE-9999-99999'], undefined); +}); + +test('parseEpssScores returns {} for malformed input', () => { + assert.deepEqual(parseEpssScores(null), {}); + assert.deepEqual(parseEpssScores({}), {}); + assert.deepEqual(parseEpssScores({ data: 'nope' }), {}); + assert.deepEqual(parseEpssScores({ data: [{ cve: 'CVE-1-1', epss: 'abc' }] }), {}); +}); + +// --- computePriority --------------------------------------------------------- + +test('computePriority puts anything in CISA KEV at the top, whatever its scores say', () => { + const priority = computePriority({ + cvss: 4.3, + kev: { listed: true, ransomware: false }, + epss: { score: 0.0001, percentile: 0.01 }, + }); + assert.equal(priority.level, 'critical'); + assert.equal(priority.label, 'Patch now'); + assert.match(priority.reason, /KEV/); +}); + +test('computePriority calls out known ransomware campaign use', () => { + const priority = computePriority({ kev: { listed: true, ransomware: true } }); + assert.equal(priority.level, 'critical'); + assert.match(priority.reason, /ransomware/i); +}); + +test('computePriority escalates a high EPSS score without KEV listing', () => { + const priority = computePriority({ cvss: 5.3, epss: { score: 0.42, percentile: 0.98 } }); + assert.equal(priority.level, 'high'); + assert.equal(priority.label, 'Patch soon'); +}); + +test('computePriority de-escalates a high CVSS with negligible exploit probability', () => { + const priority = computePriority({ cvss: 9.8, epss: { score: 0.0004, percentile: 0.12 } }); + assert.equal(priority.level, 'medium'); + assert.equal(priority.label, 'Schedule'); +}); + +test('computePriority treats a modest EPSS score as worth scheduling', () => { + const priority = computePriority({ cvss: 5.0, epss: { score: 0.02, percentile: 0.9 } }); + assert.equal(priority.level, 'medium'); +}); + +test('computePriority falls back to monitoring when nothing stands out', () => { + const priority = computePriority({ cvss: 3.1, epss: { score: 0.0002, percentile: 0.05 } }); + assert.equal(priority.level, 'low'); + assert.equal(priority.label, 'Monitor'); +}); + +test('computePriority still ranks a high CVSS when EPSS data is missing', () => { + const priority = computePriority({ cvss: 9.8, epss: null, kev: { listed: false } }); + assert.equal(priority.level, 'medium'); +}); + +test('computePriority copes with an entry carrying no signals at all', () => { + const priority = computePriority({}); + assert.equal(priority.level, 'low'); + assert.equal(typeof priority.reason, 'string'); +}); + +// --- enrichCveEntries -------------------------------------------------------- + +test('enrichCveEntries merges KEV and EPSS onto each entry', () => { + const entries = extractCveEntries({ vulns: { 'CVE-2021-44228': { cvss: 10 } } }); + const [entry] = enrichCveEntries(entries, { + kev: parseKevCatalog(kevFixture), + epss: parseEpssScores(epssFixture), + }); + assert.equal(entry.kev.listed, true); + assert.equal(entry.kev.ransomware, true); + assert.equal(entry.epss.score, 0.99999); + assert.equal(entry.priority.level, 'critical'); +}); + +test('enrichCveEntries marks CVEs absent from the catalog as not listed', () => { + const entries = extractCveEntries({ vulns: ['CVE-2014-0160'] }); + const [entry] = enrichCveEntries(entries, { + kev: parseKevCatalog(kevFixture), + epss: parseEpssScores(epssFixture), + }); + assert.equal(entry.kev.listed, false); + assert.equal(entry.epss, null); +}); + +test('enrichCveEntries works when neither feed could be fetched', () => { + const entries = extractCveEntries({ vulns: ['CVE-2014-0160'] }); + const [entry] = enrichCveEntries(entries, {}); + assert.equal(entry.kev.listed, false); + assert.equal(entry.epss, null); + assert.equal(entry.priority.level, 'low'); +}); + +test('enrichCveEntries sorts KEV first, then by descending EPSS, then by CVSS', () => { + const entries = extractCveEntries({ + vulns: { + 'CVE-2014-0160': { cvss: 7.5 }, + 'CVE-2020-15778': { cvss: 6.8 }, + 'CVE-2021-44228': { cvss: 10 }, + 'CVE-2019-0001': { cvss: 9.9 }, + }, + }); + const enriched = enrichCveEntries(entries, { + kev: parseKevCatalog(kevFixture), + epss: parseEpssScores(epssFixture), + }); + assert.deepEqual( + enriched.map((e) => e.id), + ['CVE-2021-44228', 'CVE-2020-15778', 'CVE-2019-0001', 'CVE-2014-0160'], + ); +}); + +test('enrichCveEntries does not mutate the entries it was given', () => { + const entries = extractCveEntries({ vulns: ['CVE-2021-44228'] }); + enrichCveEntries(entries, { kev: parseKevCatalog(kevFixture) }); + assert.equal(entries[0].kev, undefined); + assert.equal(entries[0].priority, undefined); +}); + +// --- summariseCves ----------------------------------------------------------- + +test('summariseCves counts totals, KEV hits and the worst EPSS score', () => { + const entries = extractCveEntries({ + vulns: { 'CVE-2021-44228': { cvss: 10 }, 'CVE-2020-15778': { cvss: 6.8 } }, + }); + const summary = summariseCves( + enrichCveEntries(entries, { + kev: parseKevCatalog(kevFixture), + epss: parseEpssScores(epssFixture), + }), + ); + assert.equal(summary.total, 2); + assert.equal(summary.kevCount, 2); + assert.equal(summary.maxCvss, 10); + assert.equal(summary.maxEpss, 0.99999); + assert.equal(summary.highestPriority, 'critical'); +}); + +test('summariseCves handles a clean host', () => { + const summary = summariseCves([]); + assert.equal(summary.total, 0); + assert.equal(summary.kevCount, 0); + assert.equal(summary.maxEpss, null); + assert.equal(summary.highestPriority, null); +}); + +// --- chunk ------------------------------------------------------------------- + +test('chunk splits a list into batches for the EPSS query limit', () => { + assert.deepEqual(chunk([1, 2, 3, 4, 5], 2), [[1, 2], [3, 4], [5]]); + assert.deepEqual(chunk([], 100), []); + assert.equal( + chunk( + Array.from({ length: 250 }, (_, i) => i), + 100, + ).length, + 3, + ); +}); diff --git a/api/shodan.js b/api/shodan.js index 51a2eea7d..a35f7374b 100644 --- a/api/shodan.js +++ b/api/shodan.js @@ -2,6 +2,7 @@ import middleware from './_common/middleware.js'; import { httpGet } from './_common/http.js'; import { parseTarget } from './_common/parse-target.js'; import { requireEnv, upstreamError } from './_common/upstream.js'; +import { buildCveIntel } from './_common/cve-intel.js'; // Server-side Shodan lookup so the API key never touches the client const shodanHandler = async (url) => { @@ -10,7 +11,10 @@ const shodanHandler = async (url) => { const { hostname } = parseTarget(url); try { const res = await httpGet(`https://api.shodan.io/shodan/host/${hostname}?key=${auth.value}`); - return res.data; + // Shodan only gives us bare CVE ids, so rank them against the free CISA KEV + // and FIRST EPSS feeds. Best-effort: never lose the host result over this. + const cveIntel = await buildCveIntel(res.data).catch(() => null); + return cveIntel ? { ...res.data, cveIntel } : res.data; } catch (error) { return upstreamError(error, 'Shodan lookup'); } diff --git a/package.json b/package.json index e1a01bb4e..0e7d72268 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "dev:astro": "PUBLIC_API_ENDPOINT=http://localhost:3001/api astro dev", "dev": "concurrently -c magenta,cyan -n backend,frontend 'yarn dev:api' 'yarn dev:astro'", "typecheck": "astro check", + "test": "node --test", "lint": "eslint --config .config/eslint.config.js .", "format:check": "prettier --check --ignore-unknown '!yarn.lock' '!**/*.md' .", "format:fix": "prettier --write --ignore-unknown '!yarn.lock' '!**/*.md' .", From 70708f4c0376dd9d438e790a20e0d2f180716be3 Mon Sep 17 00:00:00 2001 From: Artur Zegarek Date: Sun, 9 Aug 2026 22:43:17 +0200 Subject: [PATCH 2/5] Rebuilds the vulnerabilities panel around exploitation risk The panel listed CVEs as flat links to NVD, leaving the reader to work out which ones matter. Each entry now shows its CVSS score, whether CISA lists it as actively exploited, its EPSS probability and percentile, the exposed service it was found on, and a resulting patch priority, sorted worst-first. A summary line reports how many CVEs are in the KEV catalog and the highest EPSS score on the host. Older API instances that return plain CVE ids still render, and the card says so when either feed was unavailable rather than showing a misleading "not listed". --- .../components/Results/Vulnerabilities.tsx | 186 +++++++++++++++--- src/client/utils/docs.ts | 6 +- src/client/utils/result-processor.ts | 113 ++++++++++- 3 files changed, 268 insertions(+), 37 deletions(-) diff --git a/src/client/components/Results/Vulnerabilities.tsx b/src/client/components/Results/Vulnerabilities.tsx index 22d80d7e2..1a4258b25 100644 --- a/src/client/components/Results/Vulnerabilities.tsx +++ b/src/client/components/Results/Vulnerabilities.tsx @@ -2,57 +2,185 @@ import styled from '@emotion/styled'; import colors from 'client/styles/colors'; import { Card } from 'client/components/Form/Card'; import Row from 'client/components/Form/Row'; +import type { + CveEntry, + CveIntel, + CvePriorityLevel, + CveService, +} from 'client/utils/result-processor'; +import { asCveIntel } from 'client/utils/result-processor'; const cardStyles = ` - ul { - list-style: none; - padding: 0; - margin: 0.5rem 0 0 0; - max-height: 22rem; - overflow: auto; - li { - padding: 0.25rem; - border-bottom: 1px solid ${colors.primaryTransparent}; - &:last-child { border-bottom: none } - } - a { - color: ${colors.textColor}; - &:hover { color: ${colors.primary} } - } - } + max-height: 60rem; `; +const priorityColors: Record = { + critical: colors.danger, + high: colors.error, + medium: colors.warning, + low: colors.info, +}; + const AllClear = styled.p` color: ${colors.success}; margin: 0.5rem 0; `; +const FeedNotice = styled.p` + color: ${colors.textColorSecondary}; + font-size: 0.8rem; + margin: 0.25rem 0 0 0; +`; + +const CveList = styled.ul` + list-style: none; + padding: 0; + margin: 0.5rem 0 0 0; +`; + +const CveItem = styled.li<{ level: CvePriorityLevel }>` + border-left: 3px solid ${(props) => priorityColors[props.level]}; + background: ${colors.primaryTransparent}; + border-radius: 0 4px 4px 0; + padding: 0.5rem; + margin-bottom: 0.5rem; +`; + +const CveHeader = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + a { + color: ${colors.textColor}; + font-weight: bold; + &:hover { + color: ${colors.primary}; + } + } +`; + +const PriorityBadge = styled.span<{ level: CvePriorityLevel }>` + background: ${(props) => priorityColors[props.level]}; + color: ${colors.backgroundDarker}; + border-radius: 4px; + padding: 0.1rem 0.4rem; + font-size: 0.75rem; + font-weight: bold; + text-transform: uppercase; + white-space: nowrap; +`; + +const Chips = styled.div` + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + justify-content: flex-end; + span { + background: ${colors.background}; + color: ${colors.textColorSecondary}; + border-radius: 4px; + padding: 0.05rem 0.35rem; + font-size: 0.75rem; + } +`; + +const Reason = styled.p` + color: ${colors.textColorSecondary}; + font-size: 0.8rem; + margin: 0.35rem 0 0 0; +`; + +const Summary = styled.p` + font-size: 0.8rem; + margin: 0.25rem 0 0 0; +`; + +const percent = (value: number | null | undefined): string => + value === null || value === undefined ? 'Unknown' : `${(value * 100).toFixed(1)}%`; + +// "HTTPS :443", falling back to the product banner when Shodan has no module +const serviceLabel = (service: CveService): string => { + const name = (service.module || service.product || 'Service').toUpperCase(); + const version = service.version ? ` ${service.version}` : ''; + return service.port ? `${name}${version} :${service.port}` : `${name}${version}`; +}; + +const CveRow = (props: { cve: CveEntry }): JSX.Element => { + const { cve } = props; + const { kev, epss, priority } = cve; + return ( + + + + {cve.id} + + {priority.label} + + + + {kev.listed && kev.dateAdded && } + {kev.listed && kev.dueDate && } + + + + Detected by + + {cve.detectedBy.map((source) => ( + {source} + ))} + + + {cve.services.length > 0 && ( + + Exposed service + + {cve.services.map((service, index) => ( + {serviceLabel(service)} + ))} + + + )} + {priority.reason} + {cve.summary && {cve.summary}} + + ); +}; + const VulnerabilitiesCard = (props: { data: any; title: string; actionButtons: any; }): JSX.Element => { - const vulns: string[] = props.data.vulns || []; + // parseShodanResults hands us an already-enriched CveIntel, but tolerate the + // legacy shape (a plain array of CVE ids) in case the API is an older build + const intel: CveIntel = asCveIntel(props.data?.vulns); + const { vulns, summary, feeds } = intel; + return ( {vulns.length === 0 ? ( โœ… No known active vulnerabilities ) : ( <> - -
    + + + + {feeds.kev?.ok === false && ( + โš ๏ธ CISA KEV feed unavailable โ€” exploitation status is unknown + )} + {feeds.epss?.ok === false && ( + โš ๏ธ EPSS feed unavailable โ€” exploit probability is unknown + )} + {vulns.map((cve) => ( -
  • - - {cve} - -
  • + ))} -
+ )}
diff --git a/src/client/utils/docs.ts b/src/client/utils/docs.ts index 388a0e758..30420dc10 100644 --- a/src/client/utils/docs.ts +++ b/src/client/utils/docs.ts @@ -290,12 +290,14 @@ const docs: Doc[] = [ id: 'vulnerabilities', title: 'Vulnerabilities', description: - 'This task lists the known CVEs (Common Vulnerabilities and Exposures) that Shodan associates with the services running on the target host, based on their detected product and version. Each entry links to its NVD record. If Shodan has scanned the host and found none, it reports that no known vulnerabilities are on file.', - use: "Known CVEs highlight where a host may be exploitable, and are a starting point for assessing its security posture. Bear in mind these are inferred from banner versions, so may include false positives (a patched service still reporting an old version) or miss issues Shodan hasn't catalogued.", + "This task lists the known CVEs (Common Vulnerabilities and Exposures) that Shodan associates with the services running on the target host, based on their detected product and version. Each CVE is then enriched with two free threat-intelligence feeds: the CISA Known Exploited Vulnerabilities (KEV) catalog, which records vulnerabilities confirmed to be exploited in the wild, and FIRST's EPSS, a daily-updated model estimating the probability that a CVE will be exploited in the next 30 days. Alongside each entry you also get the exposed service (port, product and version) that reported it, and a resulting patch priority. If Shodan has scanned the host and found none, it reports that no known vulnerabilities are on file.", + use: "Known CVEs highlight where a host may be exploitable, and are a starting point for assessing its security posture. A raw CVSS score only tells you how bad exploitation would be, not how likely it is โ€” which is why CISA recommends working the KEV catalog first and using EPSS to rank what is left. That ordering is what the priority column reflects: anything in KEV is confirmed to be under attack, a high EPSS score means attacks are probable, and a severe CVSS with a negligible EPSS can usually wait for the next maintenance window. Bear in mind the CVE list itself is inferred from banner versions, so may include false positives (a patched service still reporting an old version) or miss issues Shodan hasn't catalogued.", resources: [ 'https://nvd.nist.gov/vuln', 'https://cve.mitre.org/', 'https://www.shodan.io/', + 'https://www.cisa.gov/known-exploited-vulnerabilities-catalog', + 'https://www.first.org/epss/', 'https://en.wikipedia.org/wiki/Common_Vulnerabilities_and_Exposures', ], }, diff --git a/src/client/utils/result-processor.ts b/src/client/utils/result-processor.ts index c702d7c75..499d2ca69 100644 --- a/src/client/utils/result-processor.ts +++ b/src/client/utils/result-processor.ts @@ -101,21 +101,122 @@ export const getHostNames = (response: any): HostNames | null => { return results; }; +export type CvePriorityLevel = 'critical' | 'high' | 'medium' | 'low'; + +export interface CveService { + port: number | null; + transport: string | null; + product: string | null; + version: string | null; + module: string | null; +} + +export interface CveKev { + listed: boolean; + name?: string | null; + vendor?: string | null; + product?: string | null; + dateAdded?: string | null; + dueDate?: string | null; + ransomware?: boolean; + requiredAction?: string | null; +} + +export interface CveEpss { + score: number; + percentile: number | null; + date: string | null; +} + +export interface CvePriority { + level: CvePriorityLevel; + label: string; + reason: string; +} + +export interface CveEntry { + id: string; + cvss: number | null; + summary: string | null; + references: string[]; + verified: boolean; + services: CveService[]; + detectedBy: string[]; + kev: CveKev; + epss: CveEpss | null; + priority: CvePriority; +} + +export interface CveSummary { + total: number; + kevCount: number; + ransomwareCount: number; + maxCvss: number | null; + maxEpss: number | null; + highestPriority: CvePriorityLevel | null; +} + +export interface CveIntel { + vulns: CveEntry[]; + summary: CveSummary; + feeds: { + kev?: { ok: boolean; version?: string | null; released?: string | null }; + epss?: { ok: boolean; date?: string | null }; + }; +} + export interface ShodanResults { hostnames: HostNames | null; serverInfo: ServerInfo | null; - vulns: string[]; + vulns: CveIntel; } +const bareCveIds = (vulns: any): string[] => { + if (Array.isArray(vulns)) return vulns; + if (vulns && typeof vulns === 'object') return Object.keys(vulns); + return []; +}; + +// Older/self-hosted API instances return plain CVE ids with no enrichment, so +// build the same shape from what we have rather than breaking the card +const withoutIntel = (ids: string[]): CveIntel => ({ + vulns: ids.map((id) => ({ + id, + cvss: null, + summary: null, + references: [], + verified: false, + services: [], + detectedBy: ['Shodan'], + kev: { listed: false }, + epss: null, + priority: { level: 'low', label: 'Unranked', reason: 'No KEV or EPSS data available' }, + })), + summary: { + total: ids.length, + kevCount: 0, + ransomwareCount: 0, + maxCvss: null, + maxEpss: null, + highestPriority: null, + }, + feeds: {}, +}); + +// Accepts either an already-enriched CveIntel or a bare list/map of CVE ids +export const asCveIntel = (value: any): CveIntel => { + if (value && !Array.isArray(value) && Array.isArray(value.vulns)) return value as CveIntel; + return withoutIntel(bareCveIds(value)); +}; + +export const getCveIntel = (response: any): CveIntel => + asCveIntel(response?.cveIntel ?? response?.vulns); + export const parseShodanResults = (response: any): ShodanResults => { return { hostnames: getHostNames(response), serverInfo: getServerInfo(response), - vulns: Array.isArray(response?.vulns) - ? response.vulns - : response?.vulns && typeof response.vulns === 'object' - ? Object.keys(response.vulns) - : [], + vulns: getCveIntel(response), }; }; From 042819b4b3459fd28d96d302c9a05be00afa85e3 Mon Sep 17 00:00:00 2001 From: Artur Zegarek Date: Sun, 9 Aug 2026 22:59:01 +0200 Subject: [PATCH 3/5] Stops the exposed-service chip mixing protocol and product version The chip was built as module + version + port, which produced "HTTPS 2 :443" and "SSH 9.8 :22" on a real host: the version Shodan reports belongs to the product behind the service (Apache 2, OpenSSH 9.8), not to the protocol. The chip now reads "HTTPS :443", and the full banner plus transport moves to the hover title. --- .../components/Results/Vulnerabilities.tsx | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/client/components/Results/Vulnerabilities.tsx b/src/client/components/Results/Vulnerabilities.tsx index 1a4258b25..d06f9cc55 100644 --- a/src/client/components/Results/Vulnerabilities.tsx +++ b/src/client/components/Results/Vulnerabilities.tsx @@ -100,11 +100,19 @@ const Summary = styled.p` const percent = (value: number | null | undefined): string => value === null || value === undefined ? 'Unknown' : `${(value * 100).toFixed(1)}%`; -// "HTTPS :443", falling back to the product banner when Shodan has no module +// "HTTPS :443" โ€” the version Shodan reports belongs to the product behind the +// service (Apache 2, OpenSSH 9.8), not to the protocol, so it is never appended +// to a module name here; it goes in the tooltip instead const serviceLabel = (service: CveService): string => { const name = (service.module || service.product || 'Service').toUpperCase(); - const version = service.version ? ` ${service.version}` : ''; - return service.port ? `${name}${version} :${service.port}` : `${name}${version}`; + return service.port ? `${name} :${service.port}` : name; +}; + +// "Apache httpd 2 ยท tcp/443", for the hover title +const serviceDetail = (service: CveService): string => { + const banner = [service.product, service.version].filter(Boolean).join(' '); + const socket = service.transport && service.port ? `${service.transport}/${service.port}` : null; + return [banner, socket].filter(Boolean).join(' ยท ') || serviceLabel(service); }; const CveRow = (props: { cve: CveEntry }): JSX.Element => { @@ -140,7 +148,9 @@ const CveRow = (props: { cve: CveEntry }): JSX.Element => { Exposed service {cve.services.map((service, index) => ( - {serviceLabel(service)} + + {serviceLabel(service)} + ))} From 96517c6e19e1eb625f9ad871f345bb619b405009 Mon Sep 17 00:00:00 2001 From: Artur Zegarek Date: Sun, 9 Aug 2026 23:04:04 +0200 Subject: [PATCH 4/5] Restores the CVE advisory finding, ranked by exploitation evidence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changing the shape of parseShodanResults().vulns broke the server-info analyzer, which gated on Array.isArray(d.vulns). It silently returned nothing, so a host with CVEs โ€” including one in the KEV catalog โ€” showed no CVE entry in the Advisory section at all. The analyzer now reads the enrichment through asCveIntel and reports each CVE at the severity its evidence supports: KEV-listed as critical, high EPSS as an issue, and the long tail as warnings and info, instead of filing 25 mostly-dormant CVEs as equally critical. Nothing is dropped โ€” every CVE is still listed under one of the four headings. When there is no evidence to rank against, because the API is an older build or CISA and FIRST are both unreachable, it falls back to the original single critical finding rather than quietly downgrading everything to info. --- src/client/analysis/rules/server-info.ts | 77 ++++++++++++++++++++---- 1 file changed, 65 insertions(+), 12 deletions(-) diff --git a/src/client/analysis/rules/server-info.ts b/src/client/analysis/rules/server-info.ts index 829ce7759..428e47d20 100644 --- a/src/client/analysis/rules/server-info.ts +++ b/src/client/analysis/rules/server-info.ts @@ -1,19 +1,72 @@ -import type { Analyzer } from '../types'; +import type { Analyzer, Severity } from '../types'; +import type { CveEntry, CvePriorityLevel } from 'client/utils/result-processor'; +import { asCveIntel } from 'client/utils/result-processor'; const MAX_LISTED = 8; -// Surface CVEs Shodan attributes to this host +// Report each CVE at the severity its KEV/EPSS evidence supports, rather than +// treating everything Shodan attributes to the host as equally urgent +const SEVERITY: Record = { + critical: 'critical', + high: 'issue', + medium: 'warning', + low: 'info', +}; + +const HEADLINE: Record string> = { + critical: (n) => `${n} CVE(s) on this host are confirmed exploited in the wild`, + high: (n) => `${n} CVE(s) on this host are likely to be exploited soon`, + medium: (n) => `${n} CVE(s) on this host are worth scheduling a patch for`, + low: (n) => `${n} further CVE(s) reported by Shodan`, +}; + +const ADVICE: Record = { + critical: 'Listed in the CISA KEV catalog. Patch now, or block at the firewall', + high: 'EPSS puts exploitation within 30 days above 10%. Patch ahead of the next window', + medium: 'Patch in the next maintenance window', + low: 'No evidence of exploitation, but keep the affected services updated', +}; + +const listOf = (entries: CveEntry[]): string => { + const ids = entries + .slice(0, MAX_LISTED) + .map((entry) => entry.id) + .join(', '); + const more = entries.length > MAX_LISTED ? ` (+${entries.length - MAX_LISTED} more)` : ''; + return `${ids}${more}`; +}; + +const LEVELS = Object.keys(SEVERITY) as CvePriorityLevel[]; + +// Surface CVEs Shodan attributes to this host, ranked by CISA KEV and EPSS const serverInfo: Analyzer = (d) => { - if (!d || !Array.isArray(d.vulns) || !d.vulns.length) return []; - const cves = d.vulns.slice(0, MAX_LISTED).join(', '); - const more = d.vulns.length > MAX_LISTED ? ` (+${d.vulns.length - MAX_LISTED} more)` : ''; - return [ - { - severity: 'critical', - title: `Shodan reports ${d.vulns.length} CVE(s) on this host`, - detail: `${cves}${more}. Patch affected services or block at the firewall`, - }, - ]; + const intel = asCveIntel(d?.vulns); + if (!intel.vulns.length) return []; + + // With no feed to rank against โ€” an older API build, or CISA/FIRST being + // unreachable โ€” we cannot tell the urgent from the ignorable, so every CVE + // stays critical rather than being quietly downgraded + if (!intel.feeds.kev?.ok && !intel.feeds.epss?.ok) { + return [ + { + severity: 'critical', + title: `Shodan reports ${intel.vulns.length} CVE(s) on this host`, + detail: `${listOf(intel.vulns)}. Patch affected services or block at the firewall`, + }, + ]; + } + + return LEVELS.flatMap((level) => { + const entries = intel.vulns.filter((entry) => entry.priority.level === level); + if (!entries.length) return []; + return [ + { + severity: SEVERITY[level], + title: HEADLINE[level](entries.length), + detail: `${listOf(entries)}. ${ADVICE[level]}`, + }, + ]; + }); }; export default serverInfo; From d8d92e39af68feec208df6f7a79eddc789d8062e Mon Sep 17 00:00:00 2001 From: Artur Zegarek Date: Sun, 9 Aug 2026 23:04:04 +0200 Subject: [PATCH 5/5] Stops a slow CVE feed being able to time out the Shodan check The feed timeout was 20s, but the sample config suggests running the API with PUBLIC_API_TIMEOUT_LIMIT=25000. A hanging CISA or FIRST request could therefore exhaust the budget for the whole Shodan check and take the host name and server info cards down with it, neither of which has anything to do with CVE enrichment. The timeout drops to 6s, comfortably above the ~500ms a cold lookup actually costs. A failed catalog fetch is also cached now, for a shorter interval than a successful one, so an outage costs one timeout every five minutes rather than one on every single request. --- api/_common/cve-intel.js | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/api/_common/cve-intel.js b/api/_common/cve-intel.js index 018d457aa..32fe38830 100644 --- a/api/_common/cve-intel.js +++ b/api/_common/cve-intel.js @@ -12,9 +12,15 @@ const EPSS_API = 'https://api.first.org/data/v1/epss'; // EPSS is re-scored daily and the KEV catalog changes at most a few times a // day, so a long-lived in-process cache keeps us well clear of both endpoints const FEED_CACHE_TTL = 6 * 60 * 60 * 1000; +// ...but back off for much less time after a failure, so an outage recovers fast +const FEED_RETRY_TTL = 5 * 60 * 1000; // api.first.org caps a single query at 100 CVEs const EPSS_BATCH_SIZE = 100; -const FEED_TIMEOUT = 20000; +// Deliberately well under the tightest PUBLIC_API_TIMEOUT_LIMIT anyone runs +// (the sample config suggests 25s): enrichment is a bonus, and must never be +// able to time out the Shodan check that the host name and server info cards +// also depend on +const FEED_TIMEOUT = 6000; const CVE_ID = /^CVE-\d{4}-\d{4,}$/i; @@ -131,14 +137,24 @@ export const parseKevCatalog = (raw) => { return catalog; }; +// { at, catalog }, where a null catalog records a recent failure so that a CISA +// outage costs one timeout every FEED_RETRY_TTL rather than one per request let kevCache = null; export const fetchKevCatalog = async () => { - if (kevCache && Date.now() - kevCache.at < FEED_CACHE_TTL) return kevCache.catalog; - const res = await httpGet(KEV_FEED, { timeout: FEED_TIMEOUT }); - const catalog = parseKevCatalog(res.data); - kevCache = { at: Date.now(), catalog }; - return catalog; + if (kevCache && Date.now() - kevCache.at < (kevCache.catalog ? FEED_CACHE_TTL : FEED_RETRY_TTL)) { + if (!kevCache.catalog) throw new Error('CISA KEV feed was recently unavailable'); + return kevCache.catalog; + } + try { + const res = await httpGet(KEV_FEED, { timeout: FEED_TIMEOUT }); + const catalog = parseKevCatalog(res.data); + kevCache = { at: Date.now(), catalog }; + return catalog; + } catch (error) { + kevCache = { at: Date.now(), catalog: null }; + throw error; + } }; // --- FIRST EPSS --------------------------------------------------------------