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..32fe38830 --- /dev/null +++ b/api/_common/cve-intel.js @@ -0,0 +1,299 @@ +// 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; +// ...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; +// 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; + +// 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; +}; + +// { 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 < (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 -------------------------------------------------------------- + +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' .", 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; diff --git a/src/client/components/Results/Vulnerabilities.tsx b/src/client/components/Results/Vulnerabilities.tsx index 22d80d7e2..d06f9cc55 100644 --- a/src/client/components/Results/Vulnerabilities.tsx +++ b/src/client/components/Results/Vulnerabilities.tsx @@ -2,57 +2,195 @@ 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" โ€” 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(); + 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 => { + 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), }; };