Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
19 changes: 19 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
299 changes: 299 additions & 0 deletions api/_common/cve-intel.js
Original file line number Diff line number Diff line change
@@ -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 },
},
};
};
Loading