echelongraph-mcp 1.0.3 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +167 -10
- package/dist/index.js +893 -80
- package/package.json +9 -6
- package/server.json +6 -2
package/dist/index.js
CHANGED
|
@@ -11,30 +11,48 @@
|
|
|
11
11
|
// - success: content[0] is the API's JSON verbatim; content[1] is a one-line note saying
|
|
12
12
|
// the call succeeded, where, and what it found — in words, including when it found
|
|
13
13
|
// nothing ("we looked and found nothing" is a measurement). One exception: exposure_radar
|
|
14
|
-
//
|
|
14
|
+
// relays each radar's answer cut to the fields it can label by what they count, and names
|
|
15
|
+
// what it left out (readKEVExposure, readExposedDatabases, readLeakedCredentials and
|
|
16
|
+
// readShadowAI; #2307, #2313).
|
|
15
17
|
// - failure (isError: true): the lookup did not complete — unreachable host, non-2xx,
|
|
16
18
|
// timeout, or a 2xx whose body is not a JSON object — named by tool, cause and base URL.
|
|
17
19
|
// Never a success with null fields: a model handed one of those tells its user "no
|
|
18
20
|
// exposure found", and an outage becomes an all-clear.
|
|
21
|
+
//
|
|
22
|
+
// Structured results (#2311, #2313). Every result, success or failure, also carries
|
|
23
|
+
// structuredContent: one envelope that says how the answer was measured — state (measured,
|
|
24
|
+
// not_assessed, failed, invalid_input), measured_at, method, coverage, freshness and notes —
|
|
25
|
+
// with, on a success, `data` equal to what content[0] carries, and on a failure an `error`.
|
|
26
|
+
// Each tool advertises the envelope as its outputSchema. The text blocks above are unchanged
|
|
27
|
+
// from 1.x, so a client that reads only `content` sees what it always saw.
|
|
28
|
+
//
|
|
29
|
+
// Protocol eras (#2311). serveStdio answers both: a 2026-07-28 client's server/discover and
|
|
30
|
+
// per-request `_meta` envelope, and a 2025-era client's `initialize` handshake. The first
|
|
31
|
+
// message of a connection picks the era; the same factory builds the server for either.
|
|
19
32
|
import { readFileSync } from "node:fs";
|
|
20
|
-
import { McpServer } from "@modelcontextprotocol/
|
|
21
|
-
import {
|
|
22
|
-
import
|
|
23
|
-
// The
|
|
24
|
-
// handshake and the User-Agent both carry
|
|
25
|
-
// version without a code change per release.
|
|
26
|
-
const
|
|
33
|
+
import { McpServer } from "@modelcontextprotocol/server";
|
|
34
|
+
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
35
|
+
import * as z from "zod";
|
|
36
|
+
// The package actually running, read from the package.json that ships beside dist/. The MCP
|
|
37
|
+
// handshake (serverInfo) and the User-Agent both carry its name and version, so the API's
|
|
38
|
+
// access log can count adoption per version without a code change per release.
|
|
39
|
+
const PKG = (() => {
|
|
27
40
|
try {
|
|
28
41
|
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
29
|
-
|
|
30
|
-
return typeof v === "string" && v ? v : "unknown";
|
|
42
|
+
return pkg !== null && typeof pkg === "object" && !Array.isArray(pkg) ? pkg : {};
|
|
31
43
|
}
|
|
32
44
|
catch {
|
|
33
|
-
return
|
|
45
|
+
return {};
|
|
34
46
|
}
|
|
35
47
|
})();
|
|
48
|
+
const pkgField = (k, fallback) => {
|
|
49
|
+
const v = PKG[k];
|
|
50
|
+
return typeof v === "string" && v ? v : fallback;
|
|
51
|
+
};
|
|
52
|
+
const NAME = pkgField("name", "echelongraph-mcp");
|
|
53
|
+
const VERSION = pkgField("version", "unknown");
|
|
36
54
|
const BASE = (process.env.ECHELONGRAPH_API_BASE || "https://app.echelongraph.io").replace(/\/+$/, "");
|
|
37
|
-
const UA =
|
|
55
|
+
const UA = `${NAME}/${VERSION} (+https://echelongraph.io/pulse/mcp)`;
|
|
38
56
|
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
39
57
|
const TIMEOUT_MS = (() => {
|
|
40
58
|
const n = Number(process.env.ECHELONGRAPH_API_TIMEOUT_MS);
|
|
@@ -146,6 +164,15 @@ async function api(path) {
|
|
|
146
164
|
}
|
|
147
165
|
}
|
|
148
166
|
const text = (t) => ({ type: "text", text: t });
|
|
167
|
+
// A note, one sentence per entry. Sentences end in ".", "!" or "?" before whitespace, so a URL,
|
|
168
|
+
// a version or a field path (observed.total, crt.sh) does not split one.
|
|
169
|
+
const sentences = (t) => t
|
|
170
|
+
.replace(/\s+/g, " ")
|
|
171
|
+
.trim()
|
|
172
|
+
.split(/(?<=[.!?])\s+/)
|
|
173
|
+
.filter(Boolean);
|
|
174
|
+
// A timestamp worth relaying as one: see realInstant below.
|
|
175
|
+
const instantOrNull = (s) => (realInstant(s) ? s : null);
|
|
149
176
|
// One sentence per failure kind: where we looked, for what, and what came back.
|
|
150
177
|
function describeFailure(f) {
|
|
151
178
|
const where = `from ${SHOWN_BASE} for GET ${f.path}`;
|
|
@@ -171,26 +198,61 @@ const NO_RECORD = "The API returned no record for this request; the quoted messa
|
|
|
171
198
|
// It is tagged invalid_input, the same state a request this server refuses locally carries.
|
|
172
199
|
const INVALID_INPUT = "(state: invalid_input)";
|
|
173
200
|
const REJECTED_INPUT = "The API rejected the request as invalid input, so nothing was looked up: this is not a finding. Do not report it as zero, none found, or unexposed; correct the input and retry.";
|
|
201
|
+
// A failure result: the #1874 text, and an envelope saying nothing was measured. The error
|
|
202
|
+
// quotes only what describeFailure already quotes (a redacted cause, the API's own message).
|
|
203
|
+
const failure = (state, message, error, coverage = null) => ({
|
|
204
|
+
content: [text(message)],
|
|
205
|
+
structuredContent: { state, measured_at: null, method: null, coverage, freshness: null, notes: sentences(message), error },
|
|
206
|
+
isError: true,
|
|
207
|
+
});
|
|
208
|
+
const errorOf = (f) => ({ kind: f.kind, path: f.path, status: f.status ?? null, message: f.detail });
|
|
174
209
|
const failed = (tool, f) => {
|
|
175
210
|
if (f.kind === "http" && f.status === 400) {
|
|
176
|
-
return
|
|
211
|
+
return failure("invalid_input", `${tool} FAILED ${INVALID_INPUT}: ${describeFailure(f)} ${REJECTED_INPUT}`, errorOf(f));
|
|
177
212
|
}
|
|
178
|
-
return {
|
|
179
|
-
content: [text(`${tool} FAILED: ${describeFailure(f)} ${f.kind === "http" && f.status === 404 ? NO_RECORD : NOT_A_FINDING}`)],
|
|
180
|
-
isError: true,
|
|
181
|
-
};
|
|
213
|
+
return failure("failed", `${tool} FAILED: ${describeFailure(f)} ${f.kind === "http" && f.status === 404 ? NO_RECORD : NOT_A_FINDING}`, errorOf(f));
|
|
182
214
|
};
|
|
183
|
-
const badInput = (tool, why) => ({
|
|
184
|
-
|
|
185
|
-
|
|
215
|
+
const badInput = (tool, why) => failure("invalid_input", `${tool} FAILED ${INVALID_INPUT}: ${why}. Nothing was looked up, so this is not a finding.`, {
|
|
216
|
+
kind: "invalid_input",
|
|
217
|
+
path: null,
|
|
218
|
+
status: null,
|
|
219
|
+
message: why,
|
|
186
220
|
});
|
|
187
221
|
// A bug in this server is still not a finding — it must never surface as an empty success.
|
|
188
|
-
const crashed = (tool, e) => ({
|
|
189
|
-
|
|
190
|
-
|
|
222
|
+
const crashed = (tool, e) => failure("failed", `${tool} FAILED inside the MCP server while querying ${SHOWN_BASE}: ${describeError(e)}. ${NOT_A_FINDING}`, {
|
|
223
|
+
kind: "internal",
|
|
224
|
+
path: null,
|
|
225
|
+
status: null,
|
|
226
|
+
message: describeError(e),
|
|
191
227
|
});
|
|
192
|
-
|
|
228
|
+
// A 2xx whose fields do not match the tool's outputSchema (a field of a type the schema does
|
|
229
|
+
// not allow) cannot be relayed as structuredContent: a client validates it against the schema
|
|
230
|
+
// and rejects it. So it is a failure, worded as one, rather than the SDK's bare validation error.
|
|
231
|
+
function unexpectedShape(tool, error) {
|
|
232
|
+
const issues = error.issues
|
|
233
|
+
.slice(0, 3)
|
|
234
|
+
.map((i) => `${i.path.map(String).join(".") || "(root)"}: ${i.message}`)
|
|
235
|
+
.join("; ");
|
|
236
|
+
const detail = `the answer did not match this tool's output schema (${issues})`;
|
|
237
|
+
return failure("failed", `${tool} FAILED: EchelonGraph at ${SHOWN_BASE} answered, but ${detail}, so it is not relayed. ${NOT_A_FINDING}`, {
|
|
238
|
+
kind: "unexpected_shape",
|
|
239
|
+
path: null,
|
|
240
|
+
status: null,
|
|
241
|
+
message: detail,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
// Every success is checked against the tool's own outputSchema before it is returned.
|
|
245
|
+
const checked = (tool, schema, r) => {
|
|
246
|
+
if (r.isError)
|
|
247
|
+
return r;
|
|
248
|
+
const p = schema.safeParse(r.structuredContent);
|
|
249
|
+
return p.success ? r : unexpectedShape(tool, p.error);
|
|
250
|
+
};
|
|
251
|
+
// A success: content[0] is the data, content[1] the note; the envelope carries both, the note
|
|
252
|
+
// as sentences after any the envelope adds about itself.
|
|
253
|
+
const succeeded = (data, note, env) => ({
|
|
193
254
|
content: [text(JSON.stringify(data, null, 2)), text(note)],
|
|
255
|
+
structuredContent: { ...env, notes: [...(env.notes ?? []), ...sentences(note)], data },
|
|
194
256
|
});
|
|
195
257
|
const okHead = (tool, status) => `${tool} OK: EchelonGraph answered${status === undefined ? "" : ` HTTP ${status}`} from ${SHOWN_BASE}.`;
|
|
196
258
|
// Tolerant readers for the note: a missing or renamed field degrades the sentence, never
|
|
@@ -216,36 +278,54 @@ const boolAt = (o, ...keys) => {
|
|
|
216
278
|
// (0001-01-01T00:00:00Z) where it had no instant to give; that is not a time, so it is never
|
|
217
279
|
// presented as one.
|
|
218
280
|
const realInstant = (s) => s !== undefined && Date.parse(s) > 0;
|
|
219
|
-
|
|
220
|
-
|
|
281
|
+
// ── The CVE feed tools: cve_summary, search_cves, get_cve ──
|
|
282
|
+
//
|
|
283
|
+
// Their numbers are CVE records and scores, not exposure counts, so a success is measured.
|
|
284
|
+
// measured_at is the time the answer itself gives for its records, where it gives one:
|
|
285
|
+
// summary.last_updated (MAX(modified) over the active records, core-backend cve/store.go
|
|
286
|
+
// Summary) for cve_summary, the record's updated_at (when EchelonGraph last wrote it) for
|
|
287
|
+
// get_cve, and none for a search page, whose rows each carry their own. None of them serves a
|
|
288
|
+
// fleet-wide time at which the feed's pollers last completed, so freshness is null: the
|
|
289
|
+
// summary's poller.last_poll_at is the answering instance's own NVD poller (cve/poller.go
|
|
290
|
+
// Stats, in memory), and is relayed in data but not presented as the feed's freshness.
|
|
291
|
+
const CVE_FEED_METHOD = "EchelonGraph's CVE Pulse feed: CVE records compiled from NVD, MITRE-CNA pre-NVD records, CISA-KEV, EPSS and GitHub GHSA, each polled from its source on a schedule.";
|
|
292
|
+
const NO_FEED_FRESHNESS = "freshness is null: the CVE feed's answer carries no time at which its pollers last completed a poll for the feed as a whole.";
|
|
293
|
+
async function cveSummary() {
|
|
221
294
|
const tool = "cve_summary";
|
|
222
295
|
try {
|
|
223
296
|
const r = await api("/api/v1/public/cves/summary");
|
|
224
297
|
if (!r.ok)
|
|
225
298
|
return failed(tool, r);
|
|
226
299
|
const head = okHead(tool, r.status);
|
|
300
|
+
const updated = strAt(r.data, "summary", "last_updated");
|
|
301
|
+
const env = {
|
|
302
|
+
state: "measured",
|
|
303
|
+
measured_at: instantOrNull(updated),
|
|
304
|
+
method: CVE_FEED_METHOD,
|
|
305
|
+
coverage: null,
|
|
306
|
+
freshness: null,
|
|
307
|
+
notes: [
|
|
308
|
+
realInstant(updated)
|
|
309
|
+
? "measured_at is summary.last_updated: the newest modification time among the active CVE records these counts include."
|
|
310
|
+
: "measured_at is null: the answer carries no real summary.last_updated.",
|
|
311
|
+
NO_FEED_FRESHNESS,
|
|
312
|
+
],
|
|
313
|
+
};
|
|
227
314
|
const total = numAt(r.data, "summary", "total");
|
|
228
315
|
if (total === undefined)
|
|
229
|
-
return succeeded(r.data, head);
|
|
316
|
+
return succeeded(r.data, head, env);
|
|
230
317
|
if (total === 0) {
|
|
231
|
-
return succeeded(r.data, `${head} The feed reports 0 active CVEs — a measured empty result (we looked and found nothing), not a lookup failure
|
|
318
|
+
return succeeded(r.data, `${head} The feed reports 0 active CVEs — a measured empty result (we looked and found nothing), not a lookup failure.`, env);
|
|
232
319
|
}
|
|
233
320
|
// A stamp that is not a real instant stays in the JSON and is not repeated as prose.
|
|
234
|
-
const updated = strAt(r.data, "summary", "last_updated");
|
|
235
321
|
const stamp = realInstant(updated) ? ` (last updated ${updated})` : "";
|
|
236
|
-
return succeeded(r.data, `${head} The feed holds ${total} active CVEs${stamp}
|
|
322
|
+
return succeeded(r.data, `${head} The feed holds ${total} active CVEs${stamp}.`, env);
|
|
237
323
|
}
|
|
238
324
|
catch (e) {
|
|
239
325
|
return crashed(tool, e);
|
|
240
326
|
}
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
search: z.string().optional().describe("free-text search (product, vendor, or keyword, e.g. 'tomcat')"),
|
|
244
|
-
severity: z.enum(["CRITICAL", "HIGH", "MEDIUM", "LOW"]).optional().describe("filter to one severity"),
|
|
245
|
-
min_cvss: z.number().min(0).max(10).optional().describe("minimum CVSS score"),
|
|
246
|
-
sort: z.enum(["published", "modified", "nvd", "echelongraph", "epss"]).optional().describe("sort order (default: published)"),
|
|
247
|
-
limit: z.number().int().min(1).max(50).optional().describe("page size (default 20, max 50)"),
|
|
248
|
-
}, async (a) => {
|
|
327
|
+
}
|
|
328
|
+
async function searchCVEs(a) {
|
|
249
329
|
const tool = "search_cves";
|
|
250
330
|
try {
|
|
251
331
|
const q = new URLSearchParams();
|
|
@@ -263,19 +343,53 @@ server.tool("search_cves", "Search/list CVEs from EchelonGraph's CVE feed (NVD +
|
|
|
263
343
|
return failed(tool, r);
|
|
264
344
|
const head = okHead(tool, r.status);
|
|
265
345
|
const shown = lenAt(r.data, "cves");
|
|
346
|
+
const counted = boolAt(r.data, "total_counted");
|
|
347
|
+
const floor = boolAt(r.data, "total_is_lower_bound");
|
|
348
|
+
const relaxed = boolAt(r.data, "search_relaxed");
|
|
349
|
+
const offset = numAt(r.data, "offset");
|
|
350
|
+
const env = {
|
|
351
|
+
state: "measured",
|
|
352
|
+
measured_at: null,
|
|
353
|
+
method: CVE_FEED_METHOD,
|
|
354
|
+
coverage: {
|
|
355
|
+
total: numAt(r.data, "total") ?? null,
|
|
356
|
+
total_counted: counted ?? null,
|
|
357
|
+
total_is_lower_bound: floor ?? null,
|
|
358
|
+
search_relaxed: relaxed ?? null,
|
|
359
|
+
returned: shown ?? null,
|
|
360
|
+
limit: numAt(r.data, "limit") ?? null,
|
|
361
|
+
offset: offset ?? null,
|
|
362
|
+
},
|
|
363
|
+
freshness: null,
|
|
364
|
+
notes: [
|
|
365
|
+
"measured_at is null: a page of search results has no single observation time; each CVE record in data.cves carries its own times where the API sends them.",
|
|
366
|
+
NO_FEED_FRESHNESS,
|
|
367
|
+
],
|
|
368
|
+
};
|
|
369
|
+
const relaxedNote = relaxed === true
|
|
370
|
+
? " The API relaxed the search phrase to all of its words (search_relaxed is true), so these rows are a superset of the rows that match the phrase itself."
|
|
371
|
+
: "";
|
|
372
|
+
// A total the API did not count is not a count: the page's own rows are all that is known.
|
|
373
|
+
if (counted === false) {
|
|
374
|
+
const empty = shown === 0 && (offset ?? 0) === 0;
|
|
375
|
+
return succeeded(r.data, empty
|
|
376
|
+
? `${head} The query returned 0 CVEs — a measured empty result: EchelonGraph was queried successfully and nothing matched these filters (we looked and found nothing). This is not a lookup failure.${relaxedNote}`
|
|
377
|
+
: `${head} The API did not count the matches (total_counted is false), so its total is not a count${shown === undefined ? "" : `; ${shown} returned in this page`}.${relaxedNote}`, env);
|
|
378
|
+
}
|
|
266
379
|
const total = numAt(r.data, "total") ?? shown;
|
|
267
380
|
if (total === undefined)
|
|
268
|
-
return succeeded(r.data, head);
|
|
269
|
-
if (total === 0) {
|
|
270
|
-
return succeeded(r.data, `${head} The query matched 0 CVEs — a measured empty result: EchelonGraph was queried successfully and nothing matched these filters (we looked and found nothing). This is not a lookup failure
|
|
381
|
+
return succeeded(r.data, `${head}${relaxedNote}`, env);
|
|
382
|
+
if (total === 0 && floor !== true) {
|
|
383
|
+
return succeeded(r.data, `${head} The query matched 0 CVEs — a measured empty result: EchelonGraph was queried successfully and nothing matched these filters (we looked and found nothing). This is not a lookup failure.${relaxedNote}`, env);
|
|
271
384
|
}
|
|
272
|
-
|
|
385
|
+
const matched = floor === true ? `at least ${total} CVEs (total_is_lower_bound is true: the count stopped at that floor)` : `${total} CVEs`;
|
|
386
|
+
return succeeded(r.data, `${head} The query matched ${matched}${shown === undefined ? "" : `; ${shown} returned in this page`}.${relaxedNote}`, env);
|
|
273
387
|
}
|
|
274
388
|
catch (e) {
|
|
275
389
|
return crashed(tool, e);
|
|
276
390
|
}
|
|
277
|
-
}
|
|
278
|
-
|
|
391
|
+
}
|
|
392
|
+
async function getCVE(cve_id) {
|
|
279
393
|
const tool = "get_cve";
|
|
280
394
|
const id = cve_id.trim();
|
|
281
395
|
if (!id)
|
|
@@ -284,12 +398,25 @@ server.tool("get_cve", "Full detail for one CVE: description, NVD CVSS v3/v4, th
|
|
|
284
398
|
const r = await api(`/api/v1/public/cves/${encodeURIComponent(id)}`);
|
|
285
399
|
if (!r.ok)
|
|
286
400
|
return failed(tool, r);
|
|
287
|
-
|
|
401
|
+
const written = strAt(r.data, "updated_at");
|
|
402
|
+
return succeeded(r.data, `${okHead(tool, r.status)} Returned the record for ${strAt(r.data, "cve_id") ?? id}.`, {
|
|
403
|
+
state: "measured",
|
|
404
|
+
measured_at: instantOrNull(written),
|
|
405
|
+
method: CVE_FEED_METHOD,
|
|
406
|
+
coverage: null,
|
|
407
|
+
freshness: null,
|
|
408
|
+
notes: [
|
|
409
|
+
realInstant(written)
|
|
410
|
+
? "measured_at is the record's updated_at: when EchelonGraph last wrote this record."
|
|
411
|
+
: "measured_at is null: the record carries no real updated_at.",
|
|
412
|
+
NO_FEED_FRESHNESS,
|
|
413
|
+
],
|
|
414
|
+
});
|
|
288
415
|
}
|
|
289
416
|
catch (e) {
|
|
290
417
|
return crashed(tool, e);
|
|
291
418
|
}
|
|
292
|
-
}
|
|
419
|
+
}
|
|
293
420
|
// cve_exposure. What the API's footprint means, and what it does not (#2306, #2307).
|
|
294
421
|
//
|
|
295
422
|
// The counts come from the KEV-exposure radar, which does not look at every CVE: every 12 h,
|
|
@@ -315,6 +442,7 @@ const SHODAN_ATTRIBUTION = "Exposure counts are derived from Shodan data.";
|
|
|
315
442
|
// and copyright in the applicable Shodan materials" (#2306). The attribution above does the
|
|
316
443
|
// first; this sentence, placed beside it wherever Shodan data is named, does the second.
|
|
317
444
|
const SHODAN_OWNERSHIP = "Shodan data is owned by Shodan, which holds its copyright (© Shodan).";
|
|
445
|
+
const CVE_EXPOSURE_DESCRIPTION = `Internet-exposure footprint for one CVE from EchelonGraph's KEV-exposure radar: how many internet-facing services (distinct ip:port, returned as exposed_hosts; a machine answering on two ports counts twice) the radar has on record running a version its CVE matcher maps to this CVE, with a country/product breakdown and a ransomware flag. Aggregate and host-redacted; free and keyless. Method: exposure counts are derived from Shodan data. ${SHODAN_OWNERSHIP} Every 12 h, when Shodan query credits allow, the radar runs one Shodan query per tracked product, reads up to 100 ip:port services per query, and keeps a service when its banner version matches a CISA-KEV or high-EPSS CVE; a service not seen on its port for 21 days is dropped. last_seen is when a service was last seen listening on its port, not when its vulnerable version was last confirmed: between searches a re-check that finds the port still listed refreshes it without re-reading the banner, so a patched service can stay counted while its port stays open. A count is therefore a banner-version inference over a sample, not an exploit test and not an internet-wide census. The radar only looks for its tracked set of CVEs: for a CVE outside that set the result says NOT ASSESSED, and its 0 is not a measurement.`;
|
|
318
446
|
function exposureNote(head, id, d) {
|
|
319
447
|
const cve = strAt(d, "cve_id") ?? id;
|
|
320
448
|
const hosts = numAt(d, "exposed_hosts");
|
|
@@ -357,7 +485,53 @@ function exposureNote(head, id, d) {
|
|
|
357
485
|
: "";
|
|
358
486
|
return `${head} (state: exposed) The radar has ${hosts} internet-facing services (distinct ip:port, the exposed_hosts field) on record whose banner version maps to ${cve}${countries === undefined ? "" : ` across ${countries} countries`}.${seen} ${kev} ${how}`;
|
|
359
487
|
}
|
|
360
|
-
|
|
488
|
+
function cveExposureState(d) {
|
|
489
|
+
const hosts = numAt(d, "exposed_hosts");
|
|
490
|
+
const tracked = boolAt(d, "tracked");
|
|
491
|
+
if (hosts === undefined)
|
|
492
|
+
return null;
|
|
493
|
+
if (tracked === false)
|
|
494
|
+
return "not_assessed";
|
|
495
|
+
if (hosts === 0)
|
|
496
|
+
return tracked === true ? "measured_zero" : "tracking_unknown";
|
|
497
|
+
return "exposed";
|
|
498
|
+
}
|
|
499
|
+
// The envelope of a cve_exposure answer. Only a count of services on record that the API dates
|
|
500
|
+
// (last_seen, when one of them was last seen listening on its port) is measured; every other
|
|
501
|
+
// answer is not_assessed, and its notes say why. A tracked zero is not_assessed too: it has no
|
|
502
|
+
// observation to date it, and the per-CVE answer does not carry the time of the radar's last
|
|
503
|
+
// completed check (exposure_radar relays that as kev_exposure.last_run_at).
|
|
504
|
+
function cveExposureEnvelope(cve, d) {
|
|
505
|
+
const exposureState = cveExposureState(d);
|
|
506
|
+
const last = strAt(d, "last_seen");
|
|
507
|
+
const measured = exposureState === "exposed" && realInstant(last);
|
|
508
|
+
const tracked = boolAt(d, "tracked");
|
|
509
|
+
const why = {
|
|
510
|
+
exposed: measured
|
|
511
|
+
? `state is measured: exposed_hosts counts the services the radar has on record for ${cve}, and measured_at is their latest last_seen, when one of them was last seen listening on its port (not when its vulnerable version was last confirmed).`
|
|
512
|
+
: `state is not_assessed: the answer gives no real last_seen for the services it counts for ${cve}, so that count is not presented as a dated measurement.`,
|
|
513
|
+
measured_zero: `state is not_assessed: the API marks ${cve} as tracked with 0 services on record, but a zero has no observation to date it and the answer does not say when the radar last looked, so it is not presented as a dated measurement.`,
|
|
514
|
+
not_assessed: `state is not_assessed: ${cve} is outside the radar's tracked set, so no number in data is a measurement of its exposure.`,
|
|
515
|
+
tracking_unknown: `state is not_assessed: the API does not say whether ${cve} is in the radar's tracked set, so its 0 is not a measurement.`,
|
|
516
|
+
none: `state is not_assessed: the answer carries no exposed_hosts count for ${cve}.`,
|
|
517
|
+
};
|
|
518
|
+
return {
|
|
519
|
+
state: measured ? "measured" : "not_assessed",
|
|
520
|
+
measured_at: measured ? last : null,
|
|
521
|
+
method: (strAt(d, "method") ?? EXPOSURE_METHOD).replace(/\s+$/, ""),
|
|
522
|
+
coverage: { in_scope: tracked ?? null },
|
|
523
|
+
freshness: null,
|
|
524
|
+
exposure_state: exposureState,
|
|
525
|
+
notes: [
|
|
526
|
+
why[exposureState ?? "none"],
|
|
527
|
+
tracked === undefined
|
|
528
|
+
? "coverage.in_scope is null: the answer does not say whether the CVE is in the radar's tracked set."
|
|
529
|
+
: `coverage.in_scope is the API's tracked verdict: ${tracked ? "the CVE is in the radar's tracked set" : "the CVE is outside the radar's tracked set"}.`,
|
|
530
|
+
"freshness is null: the per-CVE answer does not carry the time of the radar's last completed check.",
|
|
531
|
+
],
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
async function cveExposure(cve_id) {
|
|
361
535
|
const tool = "cve_exposure";
|
|
362
536
|
const id = cve_id.trim();
|
|
363
537
|
if (!id)
|
|
@@ -373,12 +547,12 @@ server.tool("cve_exposure", `Internet-exposure footprint for one CVE from Echelo
|
|
|
373
547
|
const r = await api(`/api/v1/public/kev-exposure/cve/${encodeURIComponent(cve)}`);
|
|
374
548
|
if (!r.ok)
|
|
375
549
|
return failed(tool, r);
|
|
376
|
-
return succeeded(r.data, exposureNote(okHead(tool, r.status), cve, r.data));
|
|
550
|
+
return succeeded(r.data, exposureNote(okHead(tool, r.status), cve, r.data), cveExposureEnvelope(strAt(r.data, "cve_id") ?? cve, r.data));
|
|
377
551
|
}
|
|
378
552
|
catch (e) {
|
|
379
553
|
return crashed(tool, e);
|
|
380
554
|
}
|
|
381
|
-
}
|
|
555
|
+
}
|
|
382
556
|
// Every quadrant that finds its services through Shodan, attributed. The exposed-database
|
|
383
557
|
// radar searches Shodan dorks and falls back to LeakIX when the Shodan query budget is low
|
|
384
558
|
// (core-backend/internal/exposeddb/poller.go).
|
|
@@ -455,20 +629,355 @@ function countMap(v) {
|
|
|
455
629
|
const entries = Object.entries(v);
|
|
456
630
|
return entries.every(([, n]) => isCount(n)) ? Object.fromEntries(entries) : undefined;
|
|
457
631
|
}
|
|
458
|
-
|
|
459
|
-
//
|
|
460
|
-
|
|
632
|
+
const isKind = (v, k) => (k === "number" ? isCount(v) : typeof v === k);
|
|
633
|
+
// A ranking, a series or a list, each row cut down to the fields its spec names, so a field the
|
|
634
|
+
// backend adds to a row later is left out rather than relayed unlabelled; extra lists those
|
|
635
|
+
// fields so the note can name them. All rows or none: a row that is not an object, lacks a
|
|
636
|
+
// required field, or carries a known field of the wrong type leaves the whole list out, since
|
|
637
|
+
// a partial list reads as a complete one. An optional field may be absent (Go's omitempty).
|
|
638
|
+
function readRows(v, spec) {
|
|
461
639
|
if (!Array.isArray(v))
|
|
462
|
-
return
|
|
640
|
+
return { extra: [] };
|
|
641
|
+
const optional = spec.optional ?? {};
|
|
463
642
|
const rows = [];
|
|
643
|
+
const extra = new Set();
|
|
464
644
|
for (const r of v) {
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
645
|
+
if (!isPlainObject(r))
|
|
646
|
+
return { extra: [] };
|
|
647
|
+
const row = {};
|
|
648
|
+
for (const [k, kind] of Object.entries(spec.required)) {
|
|
649
|
+
if (!isKind(r[k], kind))
|
|
650
|
+
return { extra: [] };
|
|
651
|
+
row[k] = r[k];
|
|
652
|
+
}
|
|
653
|
+
for (const [k, kind] of Object.entries(optional)) {
|
|
654
|
+
if (r[k] === undefined)
|
|
655
|
+
continue;
|
|
656
|
+
if (!isKind(r[k], kind))
|
|
657
|
+
return { extra: [] };
|
|
658
|
+
row[k] = r[k];
|
|
659
|
+
}
|
|
660
|
+
// An own-property test: `in` would also find toString and constructor on the prototype.
|
|
661
|
+
for (const k of Object.keys(r))
|
|
662
|
+
if (!Object.prototype.hasOwnProperty.call(spec.required, k) && !Object.prototype.hasOwnProperty.call(optional, k))
|
|
663
|
+
extra.add(k);
|
|
664
|
+
rows.push(row);
|
|
470
665
|
}
|
|
471
|
-
return rows;
|
|
666
|
+
return { rows, extra: [...extra] };
|
|
667
|
+
}
|
|
668
|
+
// A ranking or a daily series whose rows are a label and a count (the shadow-AI shape).
|
|
669
|
+
const countRows = (v, label) => readRows(v, { required: { [label]: "string", count: "number" } });
|
|
670
|
+
// The note's sentences about what a radar's answer carried that the tool left out.
|
|
671
|
+
function leftOut(radar, unknown, unusable) {
|
|
672
|
+
const out = [];
|
|
673
|
+
if (unknown.length)
|
|
674
|
+
out.push(`Left out of ${radar} because this version of the tool cannot label them: ${unknown.join(", ")}.`);
|
|
675
|
+
if (unusable.length)
|
|
676
|
+
out.push(`Left out of ${radar} because they were not in the expected shape: ${unusable.join(", ")}.`);
|
|
677
|
+
return out;
|
|
678
|
+
}
|
|
679
|
+
function readRadar(body, spec) {
|
|
680
|
+
const src = isPlainObject(body) ? body : {};
|
|
681
|
+
const data = {};
|
|
682
|
+
const unknown = [];
|
|
683
|
+
const unusable = [];
|
|
684
|
+
const present = (k) => src[k] !== undefined && src[k] !== null;
|
|
685
|
+
for (const k of spec.counts) {
|
|
686
|
+
if (isCount(src[k]))
|
|
687
|
+
data[k] = src[k];
|
|
688
|
+
else if (present(k))
|
|
689
|
+
unusable.push(k);
|
|
690
|
+
}
|
|
691
|
+
for (const [k, rowSpec] of Object.entries(spec.rows)) {
|
|
692
|
+
const r = readRows(src[k], rowSpec);
|
|
693
|
+
if (r.rows) {
|
|
694
|
+
data[k] = r.rows;
|
|
695
|
+
unknown.push(...r.extra.map((x) => `${k}[].${x}`));
|
|
696
|
+
}
|
|
697
|
+
else if (present(k))
|
|
698
|
+
unusable.push(k);
|
|
699
|
+
}
|
|
700
|
+
// A timestamp that is not a real instant (the Go zero time) is not relayed as one.
|
|
701
|
+
for (const k of spec.instants) {
|
|
702
|
+
const s = strAt(src, k);
|
|
703
|
+
if (realInstant(s))
|
|
704
|
+
data[k] = s;
|
|
705
|
+
else if (present(k) && typeof src[k] !== "string")
|
|
706
|
+
unusable.push(k);
|
|
707
|
+
}
|
|
708
|
+
const known = new Set([...spec.counts, ...Object.keys(spec.rows), ...spec.instants]);
|
|
709
|
+
unknown.unshift(...Object.keys(src).filter((k) => !known.has(k)));
|
|
710
|
+
return { data, unknown, unusable };
|
|
711
|
+
}
|
|
712
|
+
// ── last_run_at: when a radar last COMPLETED a check (#2335) ──
|
|
713
|
+
//
|
|
714
|
+
// Since #2335 the kev_exposure, exposed_databases and leaked_credentials stats answers carry
|
|
715
|
+
// last_run_at (core-backend pollerlock/published.go): the radar's completion row in
|
|
716
|
+
// poller_run_state, recorded for the radar as a whole rather than for the instance that
|
|
717
|
+
// answered, UTC, truncated to the second. Each poller writes that row at the END of a cycle,
|
|
718
|
+
// and only when the cycle's reads succeeded (poller.go checkCompleted / recordCheck), so a
|
|
719
|
+
// cycle that read nothing never moves it. What counts as a completed check differs per radar,
|
|
720
|
+
// and LAST_CHECK words each. The API omits the field when no completed check is on record,
|
|
721
|
+
// when its read failed, or when its last good read is more than two minutes old; it never
|
|
722
|
+
// sends the Go zero time.
|
|
723
|
+
//
|
|
724
|
+
// It is a timestamp, not a count, and not the time of the numbers beside it: those count
|
|
725
|
+
// everything still on record, not only what the last check found. Nor is it generated_at,
|
|
726
|
+
// which is only when the API's 60 s stats cache recomputed the totals. An absent stamp (or one
|
|
727
|
+
// that is not a real instant) means the API could not tell, so the note says nothing about it:
|
|
728
|
+
// no "never", no zero.
|
|
729
|
+
const LAST_CHECK = {
|
|
730
|
+
kev_exposure: "its Shodan search answered at least one query (others may have failed) and its list of services due for a re-check was read; a cycle that skipped the search for want of Shodan query credits, or whose reads failed, does not move it",
|
|
731
|
+
exposed_databases: "its Shodan search, or the LeakIX fallback, answered at least one query (others may have failed), its list of services due for a re-check was read, and the scan opt-out register could be consulted; a cycle that searched nothing, or whose reads failed, does not move it",
|
|
732
|
+
leaked_credentials: "it read the public GitHub event stream (fetches of some of the commits it lists may have failed); a cycle whose read of that stream failed does not move it",
|
|
733
|
+
};
|
|
734
|
+
// The note's sentences for one radar's relayed last_run_at, each naming the field so that any
|
|
735
|
+
// one of them quoted alone still says what it is; none when the stamp was not relayed.
|
|
736
|
+
function lastCheck(radar, data) {
|
|
737
|
+
const at = data.last_run_at;
|
|
738
|
+
if (typeof at !== "string")
|
|
739
|
+
return [];
|
|
740
|
+
return [
|
|
741
|
+
`${radar} last completed check: ${at} (${radar}.last_run_at, a timestamp, not a count).`,
|
|
742
|
+
`${radar}.last_run_at is when the radar last finished a cycle whose reads succeeded: ${LAST_CHECK[radar]}.`,
|
|
743
|
+
`${radar}.last_run_at is not the time of every record the ${radar} numbers count, which cover everything still on record, not only what that check found; nor is it ${radar}.generated_at, when the API computed those numbers.`,
|
|
744
|
+
];
|
|
745
|
+
}
|
|
746
|
+
// The first row of a relayed ranking, for the note: "(first: http_server, 9120 services)".
|
|
747
|
+
function firstOf(rows, label, count, unit) {
|
|
748
|
+
const top = Array.isArray(rows) ? rows[0] : undefined;
|
|
749
|
+
return top === undefined ? "" : ` (first: ${String(top[label])}, ${String(top[count])} ${unit})`;
|
|
750
|
+
}
|
|
751
|
+
// "a", "a and b", "a, b and c".
|
|
752
|
+
const listed = (xs) => (xs.length < 2 ? xs.join("") : `${xs.slice(0, -1).join(", ")} and ${xs[xs.length - 1]}`);
|
|
753
|
+
const numIn = (d, k) => (isCount(d[k]) ? d[k] : undefined);
|
|
754
|
+
const be = (n) => (n === 1 ? "is" : "are");
|
|
755
|
+
// ── kev_exposure: GET /api/v1/public/kev-exposure/stats (#2313 items 6 and 7) ──
|
|
756
|
+
//
|
|
757
|
+
// core-backend/internal/kevexposure store.go Stats. Every count is over the observation
|
|
758
|
+
// table, one row per (ip:port, CVE) (Upsert keys it obsID(host, cve), the host being
|
|
759
|
+
// "ip:port" from poller.go), filtered to rows whose kev_listed flag the poller copied in at
|
|
760
|
+
// match time:
|
|
761
|
+
// distinct_hosts COUNT(DISTINCT host): distinct ip:port services, not machines
|
|
762
|
+
// kev_cves_exposed COUNT(DISTINCT cve_id): CVEs with at least one such service
|
|
763
|
+
// correlations COUNT(*): service×CVE pairs, so a service with three CVEs counts 3 times
|
|
764
|
+
// ransomware_cves COUNT(DISTINCT cve_id) FILTER (WHERE ransomware)
|
|
765
|
+
// ransomware_hosts COUNT(DISTINCT host) FILTER (WHERE ransomware): services, not machines
|
|
766
|
+
// top_products / top_cves / top_countries COUNT(DISTINCT host) per group, LIMIT 12/12/10;
|
|
767
|
+
// a top_cves row's cvss_v3_score and epss_score are MAX() over its rows
|
|
768
|
+
// trend COUNT(*) per date_trunc('week', first_seen) over 12 weeks: pairs still on
|
|
769
|
+
// record, by the week first recorded (a pruned pair is gone, so earlier
|
|
770
|
+
// weeks read low)
|
|
771
|
+
// newest_kev the 15 cves rows with kev_listed and the latest kev_added_date, LEFT JOINed
|
|
772
|
+
// to COUNT(DISTINCT host) of their KEV-flagged rows, COALESCE(e.h, 0).
|
|
773
|
+
// last_run_at the radar's last completed check, KeyKEVExposureCheckCompleted (#2335; see
|
|
774
|
+
// LAST_CHECK). Its own row: KeyKEVExposure is the Shodan-credit watermark.
|
|
775
|
+
// So newest_kev[].exposed_hosts is 0 for every CVE the radar holds no row for, whether or not
|
|
776
|
+
// it could ever hold one: the join default, not a measurement. The per-CVE endpoint decides
|
|
777
|
+
// that with `tracked` (store.go trackedVerdict), which is true ONLY when rows exist
|
|
778
|
+
// (exposedHosts > 0) and otherwise false or absent: the backend never reports a measured zero.
|
|
779
|
+
// So a 0 here is never relayed: its row says exposure_state not_assessed and carries no
|
|
780
|
+
// count. If the stats answer ever sends a row's `tracked` (the contract proposed on #2313), it
|
|
781
|
+
// is read the way cve_exposure reads it: false is not assessed whatever the count, and true
|
|
782
|
+
// with 0 is a measured zero in the radar's sample.
|
|
783
|
+
const KEV_SPEC = {
|
|
784
|
+
counts: ["distinct_hosts", "ransomware_hosts", "kev_cves_exposed", "ransomware_cves", "correlations"],
|
|
785
|
+
rows: {
|
|
786
|
+
top_products: { required: { product: "string", hosts: "number" } },
|
|
787
|
+
top_countries: { required: { country: "string", hosts: "number" } },
|
|
788
|
+
top_cves: {
|
|
789
|
+
required: { cve_id: "string", hosts: "number" },
|
|
790
|
+
optional: { severity: "string", cvss_v3_score: "number", epss_score: "number", ransomware: "boolean" },
|
|
791
|
+
},
|
|
792
|
+
trend: { required: { week: "string", new_exposures: "number" } },
|
|
793
|
+
newest_kev: {
|
|
794
|
+
required: { cve_id: "string", exposed_hosts: "number" },
|
|
795
|
+
optional: {
|
|
796
|
+
added_date: "string",
|
|
797
|
+
vuln_name: "string",
|
|
798
|
+
severity: "string",
|
|
799
|
+
cvss_v3_score: "number",
|
|
800
|
+
epss_score: "number",
|
|
801
|
+
ransomware: "boolean",
|
|
802
|
+
tracked: "boolean",
|
|
803
|
+
},
|
|
804
|
+
},
|
|
805
|
+
},
|
|
806
|
+
instants: ["generated_at", "last_run_at"],
|
|
807
|
+
};
|
|
808
|
+
// One newest_kev row as relayed: the CVE record's fields, its exposure_state, and a count only
|
|
809
|
+
// when the count is a measurement. `tracked` is read, not relayed: exposure_state says it.
|
|
810
|
+
function newestKEVRow(r) {
|
|
811
|
+
const hosts = r.exposed_hosts;
|
|
812
|
+
const tracked = r.tracked;
|
|
813
|
+
const state = tracked === false ? "not_assessed" : hosts > 0 ? "exposed" : tracked === true ? "measured_zero" : "not_assessed";
|
|
814
|
+
const row = {};
|
|
815
|
+
for (const k of ["cve_id", "added_date", "vuln_name", "severity", "cvss_v3_score", "epss_score", "ransomware"]) {
|
|
816
|
+
if (r[k] !== undefined)
|
|
817
|
+
row[k] = r[k];
|
|
818
|
+
}
|
|
819
|
+
row.exposure_state = state;
|
|
820
|
+
if (state !== "not_assessed")
|
|
821
|
+
row.exposed_hosts = hosts;
|
|
822
|
+
return { row, state, outside: tracked === false };
|
|
823
|
+
}
|
|
824
|
+
function readKEVExposure(body) {
|
|
825
|
+
const { data, unknown, unusable } = readRadar(body, KEV_SPEC);
|
|
826
|
+
const sentences = [];
|
|
827
|
+
const n = (k) => numIn(data, k);
|
|
828
|
+
const totals = [
|
|
829
|
+
n("distinct_hosts") !== undefined &&
|
|
830
|
+
`kev_exposure.distinct_hosts (${n("distinct_hosts")}) counts distinct ip:port services, not machines (a machine answering on two ports counts twice), with at least one CISA-KEV-listed CVE on record`,
|
|
831
|
+
n("ransomware_hosts") !== undefined &&
|
|
832
|
+
`kev_exposure.ransomware_hosts (${n("ransomware_hosts")}) counts the services among them with at least one ransomware-linked KEV CVE (one whose KEV entry notes known use in ransomware campaigns)`,
|
|
833
|
+
n("kev_cves_exposed") !== undefined &&
|
|
834
|
+
`kev_exposure.kev_cves_exposed (${n("kev_cves_exposed")}) counts distinct CISA-KEV-listed CVEs with at least one service on record`,
|
|
835
|
+
n("ransomware_cves") !== undefined && `kev_exposure.ransomware_cves (${n("ransomware_cves")}) counts the ransomware-linked CVEs among them`,
|
|
836
|
+
n("correlations") !== undefined &&
|
|
837
|
+
`kev_exposure.correlations (${n("correlations")}) counts service×CVE pairs, not services: a service with three KEV CVEs counts three times`,
|
|
838
|
+
].filter((p) => typeof p === "string");
|
|
839
|
+
if (totals.length)
|
|
840
|
+
sentences.push(`KEV exposure: ${totals.join("; ")}.`);
|
|
841
|
+
const rankings = [
|
|
842
|
+
data.top_products !== undefined && `kev_exposure.top_products ranks up to 12 products${firstOf(data.top_products, "product", "hosts", "services")}`,
|
|
843
|
+
data.top_countries !== undefined && `kev_exposure.top_countries ranks up to 10 countries${firstOf(data.top_countries, "country", "hosts", "services")}`,
|
|
844
|
+
data.top_cves !== undefined && `kev_exposure.top_cves ranks up to 12 CVEs${firstOf(data.top_cves, "cve_id", "hosts", "services")}`,
|
|
845
|
+
].filter((p) => typeof p === "string");
|
|
846
|
+
if (rankings.length)
|
|
847
|
+
sentences.push(`${listed(rankings)}, each by distinct ip:port services with a CISA-KEV-listed CVE on record.`);
|
|
848
|
+
if (data.top_cves !== undefined) {
|
|
849
|
+
sentences.push("kev_exposure.top_cves[].cvss_v3_score and kev_exposure.top_cves[].epss_score are the highest CVSS v3 base score and EPSS probability (0 to 1) recorded on that CVE's observations: scores, not counts.");
|
|
850
|
+
}
|
|
851
|
+
if (data.trend !== undefined) {
|
|
852
|
+
sentences.push("kev_exposure.trend counts service×CVE pairs, not services, by the week (starting Monday) in which each pair was first recorded, over the last 12 weeks; only pairs still on record are counted, so earlier weeks read low.");
|
|
853
|
+
}
|
|
854
|
+
// newest_kev: a 0 is the join's default, never a measurement (see the block comment above).
|
|
855
|
+
if (Array.isArray(data.newest_kev)) {
|
|
856
|
+
const read = data.newest_kev.map(newestKEVRow);
|
|
857
|
+
data.newest_kev = read.map((x) => x.row);
|
|
858
|
+
const ids = (pred) => read.filter(pred).map((x) => String(x.row.cve_id));
|
|
859
|
+
const exposed = read.filter((x) => x.state === "exposed");
|
|
860
|
+
const zero = ids((x) => x.state === "measured_zero");
|
|
861
|
+
const outside = ids((x) => x.state === "not_assessed" && x.outside);
|
|
862
|
+
const unsaid = ids((x) => x.state === "not_assessed" && !x.outside);
|
|
863
|
+
sentences.push(read.length
|
|
864
|
+
? `kev_exposure.newest_kev lists the ${read.length} CVEs that EchelonGraph's CVE records most recently mark as CISA-KEV-listed (by added_date), each with an exposure_state.`
|
|
865
|
+
: "kev_exposure.newest_kev is empty: EchelonGraph's CVE records returned no CISA-KEV-listed CVE with a date added.");
|
|
866
|
+
if (exposed.length) {
|
|
867
|
+
sentences.push(`${exposed.length} of them ${be(exposed.length)} exposed (exposure_state exposed): kev_exposure.newest_kev[].exposed_hosts counts distinct ip:port services on record with that CVE (${exposed.map((x) => `${String(x.row.cve_id)}: ${String(x.row.exposed_hosts)}`).join(", ")}).`);
|
|
868
|
+
}
|
|
869
|
+
if (zero.length) {
|
|
870
|
+
sentences.push(`${zero.length} ${be(zero.length)} a measured zero in the radar's sample (exposure_state measured_zero, exposed_hosts 0): the API says the radar tracks them and holds no service for them, though it reads at most the first 100 Shodan results per tracked-product query, so this is not an internet-wide zero: ${zero.join(", ")}.`);
|
|
871
|
+
}
|
|
872
|
+
if (outside.length) {
|
|
873
|
+
sentences.push(`${outside.length} ${be(outside.length)} NOT ASSESSED (exposure_state not_assessed) because the API says they are outside the radar's tracked set, so no count is relayed for them: ${outside.join(", ")}.`);
|
|
874
|
+
}
|
|
875
|
+
if (unsaid.length) {
|
|
876
|
+
sentences.push(`${unsaid.length} ${be(unsaid.length)} NOT ASSESSED (exposure_state not_assessed): ${unsaid.join(", ")}. The API answers 0 for each, and that 0 is not a measurement: it is what the stats answer gives any CVE the radar holds no service for, whether or not the radar looks for that CVE at all, so no count is relayed for them. cve_exposure says per CVE whether it is in the radar's tracked set.`);
|
|
877
|
+
}
|
|
878
|
+
sentences.push("kev_exposure.newest_kev[].cvss_v3_score and kev_exposure.newest_kev[].epss_score are the CVE record's CVSS v3 base score and EPSS probability (0 to 1), absent when the record has none: scores, not counts.");
|
|
879
|
+
}
|
|
880
|
+
sentences.push(...lastCheck("kev_exposure", data));
|
|
881
|
+
sentences.push(...leftOut("kev_exposure", unknown, unusable));
|
|
882
|
+
return { data, sentences };
|
|
883
|
+
}
|
|
884
|
+
// ── exposed_databases: GET /api/v1/public/exposed-databases/stats (#2313 item 7) ──
|
|
885
|
+
//
|
|
886
|
+
// core-backend/internal/exposeddb store.go Stats, over one row per (ip:port, engine)
|
|
887
|
+
// (obsID(host, engine); the host is "ip:port", poller.go obsFrom). A row is stored only after
|
|
888
|
+
// EchelonGraph's own check confirmed the service answering without authentication.
|
|
889
|
+
// distinct_hosts COUNT(DISTINCT host): ip:port services, not machines
|
|
890
|
+
// engines COUNT(DISTINCT engine): engine types (mongodb, redis, grafana, ...)
|
|
891
|
+
// pii_likely / pci_likely COUNT(DISTINCT host) whose data_class names pii / pci AND whose
|
|
892
|
+
// schema terms pass the high-confidence gate (2+ distinct indicators, or one
|
|
893
|
+
// tier-1 term), mirroring classify.go dataConfidence. The classifier reads
|
|
894
|
+
// NAMES only (index / database / table / field names in the banner and the
|
|
895
|
+
// check's response, classify.go), never a record, and is precision-first:
|
|
896
|
+
// a service whose names miss the gate is not counted, whatever it holds.
|
|
897
|
+
// So these are a schema gate, not a census, and the rest are not "no PII".
|
|
898
|
+
// top_engines / top_countries COUNT(DISTINCT host) per group, LIMIT 15 / 10.
|
|
899
|
+
// last_run_at the radar's last completed check, KeyExposedDBCheckCompleted (#2335; see
|
|
900
|
+
// LAST_CHECK). Its own row: KeyExposedDB is claimed at the START of a cycle.
|
|
901
|
+
const EXPOSED_DB_SPEC = {
|
|
902
|
+
counts: ["distinct_hosts", "engines", "pii_likely", "pci_likely"],
|
|
903
|
+
rows: {
|
|
904
|
+
top_engines: { required: { engine: "string", hosts: "number" } },
|
|
905
|
+
top_countries: { required: { country: "string", hosts: "number" } },
|
|
906
|
+
},
|
|
907
|
+
instants: ["generated_at", "last_run_at"],
|
|
908
|
+
};
|
|
909
|
+
function readExposedDatabases(body) {
|
|
910
|
+
const { data, unknown, unusable } = readRadar(body, EXPOSED_DB_SPEC);
|
|
911
|
+
const sentences = [];
|
|
912
|
+
const n = (k) => numIn(data, k);
|
|
913
|
+
const totals = [
|
|
914
|
+
n("distinct_hosts") !== undefined &&
|
|
915
|
+
`exposed_databases.distinct_hosts (${n("distinct_hosts")}) counts distinct ip:port services, not machines (a machine answering on two ports counts twice), that EchelonGraph's check confirmed answering without authentication, data stores and observability UIs alike`,
|
|
916
|
+
n("engines") !== undefined && `exposed_databases.engines (${n("engines")}) counts the distinct engine types among them`,
|
|
917
|
+
].filter((p) => typeof p === "string");
|
|
918
|
+
if (totals.length)
|
|
919
|
+
sentences.push(`Exposed data stores: ${totals.join("; ")}.`);
|
|
920
|
+
const rankings = [
|
|
921
|
+
data.top_engines !== undefined && `exposed_databases.top_engines ranks up to 15 engines${firstOf(data.top_engines, "engine", "hosts", "services")}`,
|
|
922
|
+
data.top_countries !== undefined && `exposed_databases.top_countries ranks up to 10 countries${firstOf(data.top_countries, "country", "hosts", "services")}`,
|
|
923
|
+
].filter((p) => typeof p === "string");
|
|
924
|
+
if (rankings.length)
|
|
925
|
+
sentences.push(`${listed(rankings)}, each by those services.`);
|
|
926
|
+
const gated = [
|
|
927
|
+
n("pii_likely") !== undefined && `exposed_databases.pii_likely (${n("pii_likely")}) counts services whose schema names pass a high-confidence gate for personal data`,
|
|
928
|
+
n("pci_likely") !== undefined && `exposed_databases.pci_likely (${n("pci_likely")}) counts services whose schema names pass a high-confidence gate for payment-card data`,
|
|
929
|
+
].filter((p) => typeof p === "string");
|
|
930
|
+
if (gated.length) {
|
|
931
|
+
sentences.push(`${gated.join("; ")}. The names are index, database, table or field names in the Shodan or LeakIX banner or in the check's response, never record values, and the gate passes one unambiguous term (such as ssn or cardholder) or two distinct indicators. It is precision-first: a service whose names do not pass it is not counted, whatever it holds, so the other services are not shown to hold no personal or card data.`);
|
|
932
|
+
}
|
|
933
|
+
sentences.push(...lastCheck("exposed_databases", data));
|
|
934
|
+
sentences.push(...leftOut("exposed_databases", unknown, unusable));
|
|
935
|
+
return { data, sentences };
|
|
936
|
+
}
|
|
937
|
+
// ── leaked_credentials: GET /api/v1/public/leaked-credentials/stats (#2313 item 7) ──
|
|
938
|
+
//
|
|
939
|
+
// core-backend/internal/leakedcreds store.go Stats, over one row per (repository, secret):
|
|
940
|
+
// obsID(repo, fingerprint), "one row per leaked secret per repo".
|
|
941
|
+
// total COUNT(*): (repository, secret) pairs, so one secret in three
|
|
942
|
+
// repositories counts three times; not distinct secrets
|
|
943
|
+
// distinct_secrets COUNT(DISTINCT fingerprint); distinct_repos COUNT(DISTINCT repo)
|
|
944
|
+
// top_providers / top_types COUNT(*) per group, LIMIT 15: pairs again
|
|
945
|
+
// None is validated: detect.go says "verified" means STRUCTURALLY verified (a checksum or format
|
|
946
|
+
// decode), never "confirmed live", and the radar never uses a credential it finds.
|
|
947
|
+
// last_run_at the radar's last completed check, its own KeyLeakedCreds row, written at
|
|
948
|
+
// the END of a cycle that read the event stream (#2335; see LAST_CHECK).
|
|
949
|
+
const LEAKED_CREDS_SPEC = {
|
|
950
|
+
counts: ["total", "distinct_secrets", "distinct_repos"],
|
|
951
|
+
rows: {
|
|
952
|
+
top_providers: { required: { provider: "string", count: "number" } },
|
|
953
|
+
top_types: { required: { secret_type: "string", count: "number" } },
|
|
954
|
+
},
|
|
955
|
+
instants: ["generated_at", "last_run_at"],
|
|
956
|
+
};
|
|
957
|
+
function readLeakedCredentials(body) {
|
|
958
|
+
const { data, unknown, unusable } = readRadar(body, LEAKED_CREDS_SPEC);
|
|
959
|
+
const sentences = [];
|
|
960
|
+
const n = (k) => numIn(data, k);
|
|
961
|
+
const totals = [
|
|
962
|
+
n("total") !== undefined &&
|
|
963
|
+
`leaked_credentials.total (${n("total")}) counts (repository, secret) pairs, not distinct secrets: a secret committed to three repositories counts three times`,
|
|
964
|
+
n("distinct_secrets") !== undefined && `leaked_credentials.distinct_secrets (${n("distinct_secrets")}) counts each secret once`,
|
|
965
|
+
n("distinct_repos") !== undefined && `leaked_credentials.distinct_repos (${n("distinct_repos")}) counts public GitHub repositories`,
|
|
966
|
+
].filter((p) => typeof p === "string");
|
|
967
|
+
if (totals.length)
|
|
968
|
+
sentences.push(`Leaked credentials: ${totals.join("; ")}.`);
|
|
969
|
+
const rankings = [
|
|
970
|
+
data.top_providers !== undefined && `leaked_credentials.top_providers ranks up to 15 providers${firstOf(data.top_providers, "provider", "count", "pairs")}`,
|
|
971
|
+
data.top_types !== undefined && `leaked_credentials.top_types ranks up to 15 secret types${firstOf(data.top_types, "secret_type", "count", "pairs")}`,
|
|
972
|
+
].filter((p) => typeof p === "string");
|
|
973
|
+
if (rankings.length)
|
|
974
|
+
sentences.push(`${listed(rankings)}, each by the same (repository, secret) pairs.`);
|
|
975
|
+
if (totals.length || rankings.length) {
|
|
976
|
+
sentences.push("None of these is validated: each is a credential-shaped string in a public commit that passed EchelonGraph's filters, at most structurally checked (a checksum or format decode) and never tested against its provider, so none of them is a count of working credentials.");
|
|
977
|
+
}
|
|
978
|
+
sentences.push(...lastCheck("leaked_credentials", data));
|
|
979
|
+
sentences.push(...leftOut("leaked_credentials", unknown, unusable));
|
|
980
|
+
return { data, sentences };
|
|
472
981
|
}
|
|
473
982
|
function readShadowAI(sa) {
|
|
474
983
|
const sentences = [];
|
|
@@ -502,10 +1011,13 @@ function readShadowAI(sa) {
|
|
|
502
1011
|
if (last24 !== undefined)
|
|
503
1012
|
observed.last_24h = last24;
|
|
504
1013
|
const rows = {};
|
|
1014
|
+
const rowExtras = [];
|
|
505
1015
|
for (const [key, label] of SHADOW_AI_ROWS) {
|
|
506
|
-
const r =
|
|
507
|
-
if (r)
|
|
508
|
-
observed[key] = rows[key] = r;
|
|
1016
|
+
const r = countRows(field(stats, key), label);
|
|
1017
|
+
if (take(key, r.rows)) {
|
|
1018
|
+
observed[key] = rows[key] = r.rows;
|
|
1019
|
+
rowExtras.push(...r.extra.map((x) => `stats.${key}[].${x}`));
|
|
1020
|
+
}
|
|
509
1021
|
}
|
|
510
1022
|
// A timestamp, not a count; one that is not a real instant is dropped (see realInstant).
|
|
511
1023
|
const lastObservation = take("last_observation", strAt(stats, "last_observation"));
|
|
@@ -521,7 +1033,7 @@ function readShadowAI(sa) {
|
|
|
521
1033
|
authentication.not_determined = authUnknown;
|
|
522
1034
|
sentences.push(confirmed.total === undefined
|
|
523
1035
|
? `Shadow AI: the answer carries no usable stats.visible_by_category, so it does not say how many services are confirmed exposed${last24Visible === undefined ? "" : `; confirmed_exposed.last_24h (${last24Visible}) counts confirmed-exposed services first recorded in the last 24 h`}.`
|
|
524
|
-
: `Shadow AI confirmed exposed: ${confirmed.total} (confirmed_exposed.total, the sum of confirmed_exposed.by_category: services EchelonGraph's probes found answering without an authentication gate, with liveness active or, during a re-check, rechecking)${last24Visible === undefined ? "" : `, ${last24Visible} of them first recorded in the last 24 h (confirmed_exposed.last_24h)`}.
|
|
1036
|
+
: `Shadow AI confirmed exposed: ${confirmed.total} (confirmed_exposed.total, the sum of confirmed_exposed.by_category: services EchelonGraph's probes found answering without an authentication gate, with liveness active or, during a re-check, rechecking)${last24Visible === undefined ? "" : `, ${last24Visible} of them first recorded in the last 24 h (confirmed_exposed.last_24h)`}. Of the shadow_ai numbers, only confirmed_exposed counts exposed services.`);
|
|
525
1037
|
if (total !== undefined) {
|
|
526
1038
|
sentences.push(`Shadow AI observed: ${total} (observed.total) counts every Certificate Transparency or Shodan observation on record, whatever its verification state: that many observed, not that many exposed.`);
|
|
527
1039
|
}
|
|
@@ -557,18 +1069,14 @@ function readShadowAI(sa) {
|
|
|
557
1069
|
...Object.keys(isPlainObject(stats) ? stats : {})
|
|
558
1070
|
.filter((k) => !SHADOW_AI_STATS.has(k))
|
|
559
1071
|
.map((k) => `stats.${k}`),
|
|
1072
|
+
...rowExtras,
|
|
560
1073
|
];
|
|
561
1074
|
const unusable = Object.entries(isPlainObject(stats) ? stats : {})
|
|
562
1075
|
.filter(([k, v]) => SHADOW_AI_STATS.has(k) && !used.has(k) && v !== null)
|
|
563
1076
|
.map(([k]) => `stats.${k}`);
|
|
564
1077
|
if (!isPlainObject(stats))
|
|
565
1078
|
sentences.push("The answer carries no stats object, so it gives no shadow-AI counts.");
|
|
566
|
-
|
|
567
|
-
sentences.push(`Left out of shadow_ai because this version of the tool cannot label them: ${unknown.join(", ")}.`);
|
|
568
|
-
}
|
|
569
|
-
if (unusable.length) {
|
|
570
|
-
sentences.push(`Left out of shadow_ai because they were not in the expected shape: ${unusable.join(", ")}.`);
|
|
571
|
-
}
|
|
1079
|
+
sentences.push(...leftOut("shadow_ai", unknown, unusable));
|
|
572
1080
|
const data = {};
|
|
573
1081
|
if (Object.keys(confirmed).length)
|
|
574
1082
|
data.confirmed_exposed = confirmed;
|
|
@@ -596,7 +1104,22 @@ function readShadowAI(sa) {
|
|
|
596
1104
|
sentences.push(`Latest shadow-AI observation on record: ${lastObservation}.`);
|
|
597
1105
|
return { data, sentences };
|
|
598
1106
|
}
|
|
599
|
-
|
|
1107
|
+
const EXPOSURE_RADAR_DESCRIPTION = `Aggregate totals from EchelonGraph's internet-exposure radars, each refreshed on its own schedule: internet-facing services running actively-exploited (CISA-KEV) CVEs, plus the ransomware-linked subset, derived from Shodan data; unauthenticated data stores and observability UIs, found through Shodan (LeakIX when Shodan query credits run low) and then confirmed by EchelonGraph's own identified check (not a pure read: on Redis it names its client, and on ClickHouse its query is recorded in the server's query log); leaked credentials sampled from public GitHub push events; and shadow AI services found through Certificate Transparency logs and Shodan and then checked by EchelonGraph's identified probes. Every number in the result is labelled here and in the result's note by what it counts; a field this version cannot label is left out and named in the note. ` +
|
|
1108
|
+
`kev_exposure: kev_exposure.distinct_hosts counts distinct ip:port services, not machines (a machine answering on two ports counts twice), with at least one CISA-KEV-listed CVE on record, and kev_exposure.ransomware_hosts those with a ransomware-linked one; kev_exposure.kev_cves_exposed and kev_exposure.ransomware_cves count distinct CVEs with at least one such service; kev_exposure.correlations counts service×CVE pairs, not services, so a service with three KEV CVEs counts three times. kev_exposure.top_products, kev_exposure.top_countries and kev_exposure.top_cves rank up to 12 products, 10 countries and 12 CVEs by those services; kev_exposure.top_cves[].cvss_v3_score and kev_exposure.top_cves[].epss_score are the highest CVSS v3 base score and EPSS probability recorded on that CVE's observations. kev_exposure.trend counts service×CVE pairs still on record by the week in which each was first recorded, over 12 weeks, so earlier weeks read low. kev_exposure.newest_kev lists the 15 CVEs that EchelonGraph's CVE records most recently mark as CISA-KEV-listed, each with an exposure_state: exposed, where kev_exposure.newest_kev[].exposed_hosts counts distinct ip:port services on record with it; or not_assessed, where the API's answer holds no measurement for that CVE (its 0 is not one) and no count is relayed, and cve_exposure says per CVE whether the radar tracks it. kev_exposure.newest_kev[].cvss_v3_score and kev_exposure.newest_kev[].epss_score are the CVE record's CVSS v3 base score and EPSS probability. ` +
|
|
1109
|
+
`exposed_databases: exposed_databases.distinct_hosts counts distinct ip:port services the check confirmed answering without authentication, and exposed_databases.engines the distinct engine types among them; exposed_databases.top_engines and exposed_databases.top_countries rank up to 15 engines and 10 countries by those services. exposed_databases.pii_likely and exposed_databases.pci_likely count services whose schema names (never record values) pass a high-confidence, precision-first gate for personal or payment-card data, so a service outside them is not shown to hold no such data. ` +
|
|
1110
|
+
`leaked_credentials: leaked_credentials.total counts (repository, secret) pairs, not distinct secrets, so one secret in three repositories counts three times; leaked_credentials.distinct_secrets counts each secret once and leaked_credentials.distinct_repos counts repositories; leaked_credentials.top_providers and leaked_credentials.top_types rank up to 15 providers and secret types by those pairs. None is validated: each is a credential-shaped string that passed EchelonGraph's filters, at most structurally checked and never tested against its provider. Each of those three radars also carries generated_at, when the API computed its totals, and, when the API can tell, last_run_at. kev_exposure.last_run_at, exposed_databases.last_run_at and leaked_credentials.last_run_at are timestamps, not counts: each is when that radar last completed a check, a cycle whose reads succeeded, among them a Shodan search (for exposed_databases, or its LeakIX fallback) that answered at least one query, or for leaked_credentials a read of the public GitHub event stream. A cycle that read nothing does not move it, and it is not the time of every record a radar's numbers count, which cover everything still on record, not only what the last check found. A radar whose answer carries no last_run_at has none in the result, and the note says nothing about it. ` +
|
|
1111
|
+
`shadow_ai: the result is regrouped by what each number counts. shadow_ai.confirmed_exposed counts services EchelonGraph's probes found answering without an authentication gate (liveness active, or rechecking during a re-check): confirmed_exposed.total is the sum of confirmed_exposed.by_category, and confirmed_exposed.last_24h counts those first recorded in the last 24 h. Of the shadow_ai numbers, only confirmed_exposed counts exposed services. shadow_ai.observed counts every Certificate Transparency or Shodan observation on record, whatever its verification state: its numbers are observed, not exposed. They are observed.total; observed.by_category (the same observations by category); observed.last_24h (those first recorded in the last 24 h); observed.trend_30d (observations per UTC day over the last 30 days); and observed.top_products, observed.top_countries and observed.top_issuers (up to ten products, countries and issuers ranked by observations, where an issuer is the certificate's CA for a Certificate Transparency observation and the hosting operator Shodan reports for a Shodan one). shadow_ai.authentication counts observations by probe outcome: authentication.observed where a probe observed an authentication gate (a 401/403, a login page or an auth marker), and authentication.not_determined where the service answered but no probe could tell. Neither authentication count is part of confirmed_exposed, and observed.total minus confirmed_exposed.total is not a count of secured services. The shadow-AI poller block carries only running and last_run_at: last_run_at is when the radar's leader last completed a Certificate Transparency (crt.sh) cycle, and its running is true only when that was within ${SHADOW_AI_SILENT_AFTER} of the answer. ${SHODAN_OWNERSHIP}`;
|
|
1112
|
+
// exposure_radar's envelope. Every radar answered, but no stats answer says when the services
|
|
1113
|
+
// or records it counts were observed: each count covers everything that radar still holds on
|
|
1114
|
+
// record, and the answer carries only generated_at (when the API computed the totals) and
|
|
1115
|
+
// last_run_at (the radar's last completed check), neither of which is that time (#2335). So
|
|
1116
|
+
// the result is not_assessed with measured_at null, and says why; its numbers keep their labels,
|
|
1117
|
+
// and freshness carries each radar's last_run_at where the API serves one.
|
|
1118
|
+
const RADAR_METHOD = "Aggregate counts over what each radar holds on record: kev_exposure, Shodan banners whose version maps to a CISA-KEV-listed CVE; exposed_databases, services found through Shodan (LeakIX when Shodan query credits run low) and confirmed by EchelonGraph's own identified check; leaked_credentials, credential-shaped strings in public GitHub push events; shadow_ai, services found through Certificate Transparency logs and Shodan and checked by EchelonGraph's identified probes.";
|
|
1119
|
+
const RADAR_NAMES = RADARS.map(([name]) => name);
|
|
1120
|
+
const RADAR_STATE_NOTE = "state is not_assessed: every radar answered, but no stats answer says when the services or records it counts were observed, so no count here is presented as a dated measurement and measured_at is null. Each count is what that radar holds on record, labelled below by what it counts.";
|
|
1121
|
+
const RADAR_FRESHNESS_NOTE = "freshness gives each radar's last completed check where the API serves one (freshness.kev_exposure.last_run_at, freshness.exposed_databases.last_run_at, freshness.leaked_credentials.last_run_at, and freshness.shadow_ai.last_run_at with freshness.shadow_ai.running), and null where it does not.";
|
|
1122
|
+
async function exposureRadar() {
|
|
600
1123
|
const tool = "exposure_radar";
|
|
601
1124
|
try {
|
|
602
1125
|
const results = await Promise.all(RADARS.map(([, path]) => api(path)));
|
|
@@ -617,20 +1140,310 @@ server.tool("exposure_radar", `Aggregate totals from EchelonGraph's internet-exp
|
|
|
617
1140
|
const withheld = answered.length
|
|
618
1141
|
? ` The ${answered.length} radar(s) that did answer (${answered.join(", ")}) are withheld: a partial radar picture would be read as the whole one.`
|
|
619
1142
|
: "";
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
};
|
|
1143
|
+
const message = `${tool} FAILED: ${failures.length} of ${RADARS.length} radars could not be read from ${SHOWN_BASE}.\n${lines.join("\n")}\n${NOT_A_FINDING}${withheld}`;
|
|
1144
|
+
const radars = failures.map(([radar, f]) => ({ radar, kind: f.kind, path: f.path, status: f.status ?? null, message: f.detail }));
|
|
1145
|
+
return failure("failed", message, { kind: "radars", path: null, status: null, message: `${failures.length} of ${RADARS.length} radars could not be read`, radars }, { radars: RADAR_NAMES, answered, failed: failures.map(([name]) => name) });
|
|
624
1146
|
}
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
1147
|
+
// Each radar's answer is relayed cut to the fields its reader can label, in RADARS order.
|
|
1148
|
+
const read = {
|
|
1149
|
+
kev_exposure: readKEVExposure(data.kev_exposure),
|
|
1150
|
+
exposed_databases: readExposedDatabases(data.exposed_databases),
|
|
1151
|
+
leaked_credentials: readLeakedCredentials(data.leaked_credentials),
|
|
1152
|
+
shadow_ai: readShadowAI(data.shadow_ai),
|
|
1153
|
+
};
|
|
1154
|
+
const relayed = {};
|
|
1155
|
+
const said = [];
|
|
1156
|
+
for (const [name] of RADARS) {
|
|
1157
|
+
relayed[name] = read[name].data;
|
|
1158
|
+
said.push(...read[name].sentences);
|
|
1159
|
+
}
|
|
1160
|
+
// Only what the readers relayed: a last_run_at that is not a real instant never got there.
|
|
1161
|
+
const stamp = (radar, ...keys) => instantOrNull(strAt(relayed[radar], ...keys));
|
|
1162
|
+
const running = boolAt(relayed.shadow_ai, "poller", "running");
|
|
1163
|
+
return succeeded(relayed, `${okHead(tool)} All ${RADARS.length} radars answered: ${RADARS.map(([name]) => name).join(", ")}. Every number relayed is labelled below by what it counts, and a field this version cannot label is left out and named. ${said.join(" ")} ${RADAR_SOURCES} ${SHODAN_OWNERSHIP} Each radar refreshes on its own schedule.`, {
|
|
1164
|
+
state: "not_assessed",
|
|
1165
|
+
measured_at: null,
|
|
1166
|
+
method: RADAR_METHOD,
|
|
1167
|
+
coverage: { radars: RADAR_NAMES, answered: RADAR_NAMES, failed: [] },
|
|
1168
|
+
freshness: {
|
|
1169
|
+
kev_exposure: { last_run_at: stamp("kev_exposure", "last_run_at") },
|
|
1170
|
+
exposed_databases: { last_run_at: stamp("exposed_databases", "last_run_at") },
|
|
1171
|
+
leaked_credentials: { last_run_at: stamp("leaked_credentials", "last_run_at") },
|
|
1172
|
+
shadow_ai: { last_run_at: stamp("shadow_ai", "poller", "last_run_at"), running: running ?? null },
|
|
1173
|
+
},
|
|
1174
|
+
notes: [RADAR_STATE_NOTE, RADAR_FRESHNESS_NOTE],
|
|
1175
|
+
});
|
|
628
1176
|
}
|
|
629
1177
|
catch (e) {
|
|
630
1178
|
return crashed(tool, e);
|
|
631
1179
|
}
|
|
1180
|
+
}
|
|
1181
|
+
// ── outputSchema: the envelope, per tool (#2311, #2313) ──
|
|
1182
|
+
//
|
|
1183
|
+
// A discriminated union on state: a success (measured, not_assessed) carries data and a method;
|
|
1184
|
+
// a failure (failed, invalid_input) carries error, and measured_at, method and freshness null.
|
|
1185
|
+
// The CVE-feed and cve_exposure tools relay the API's JSON verbatim as data, so their data
|
|
1186
|
+
// schemas name the fields the tool descriptions mention and admit the rest (a field the API
|
|
1187
|
+
// adds later must not turn an answer into a failure). exposure_radar's data is the tool's own
|
|
1188
|
+
// cut, so its schema is closed: exactly the fields the readers relay, and nothing else.
|
|
1189
|
+
const Instant = z.string().describe("An RFC 3339 instant.");
|
|
1190
|
+
const ERROR_KINDS = ["network", "timeout", "http", "not_json", "not_object", "invalid_input", "internal", "unexpected_shape", "radars"];
|
|
1191
|
+
const ErrorSchema = z.strictObject({
|
|
1192
|
+
kind: z.enum(ERROR_KINDS).describe("What failed: the request (network, timeout), the answer (http, not_json, not_object, unexpected_shape), the input, or this server."),
|
|
1193
|
+
path: z.string().nullable().describe("The API path requested, when a request was made."),
|
|
1194
|
+
status: z.number().int().nullable().describe("The HTTP status, when the API answered one."),
|
|
1195
|
+
message: z.string().describe("The cause: the API's own message, or what went wrong."),
|
|
1196
|
+
});
|
|
1197
|
+
const NOTES = z.array(z.string()).describe("Caveats, one sentence each.");
|
|
1198
|
+
function envelopeSchema(o) {
|
|
1199
|
+
const coverage = o.coverage ? o.coverage.nullable() : z.null();
|
|
1200
|
+
return z.discriminatedUnion("state", [
|
|
1201
|
+
z.strictObject({
|
|
1202
|
+
state: z.enum(["measured", "not_assessed"]).describe("measured: a measurement of what was asked; an exposure count is measured only with measured_at and method. not_assessed: no dated measurement of what was asked, and no number in it is a finding."),
|
|
1203
|
+
measured_at: Instant.nullable().describe("When the underlying observation was made, as the API states it; null when the answer does not say or holds no observation."),
|
|
1204
|
+
method: z.string().describe("How the numbers were produced."),
|
|
1205
|
+
coverage: coverage.describe("What the answer covers; null where the answer says nothing about it."),
|
|
1206
|
+
freshness: (o.freshness ? o.freshness.nullable() : z.null()).describe("The producing radar's last completed check (last_run_at), where the API serves one."),
|
|
1207
|
+
notes: NOTES,
|
|
1208
|
+
...(o.extra ?? {}),
|
|
1209
|
+
data: o.data,
|
|
1210
|
+
}),
|
|
1211
|
+
z.strictObject({
|
|
1212
|
+
state: z.enum(["failed", "invalid_input"]).describe("failed: the lookup did not complete. invalid_input: the input was refused, so nothing was looked up. Neither is a finding."),
|
|
1213
|
+
measured_at: z.null(),
|
|
1214
|
+
method: z.null(),
|
|
1215
|
+
coverage,
|
|
1216
|
+
freshness: z.null(),
|
|
1217
|
+
notes: NOTES,
|
|
1218
|
+
error: o.error ?? ErrorSchema,
|
|
1219
|
+
}),
|
|
1220
|
+
]);
|
|
1221
|
+
}
|
|
1222
|
+
// A field of the API's JSON: optional, and null where the Go type can marshal null.
|
|
1223
|
+
const opt = (t) => t.nullable().optional();
|
|
1224
|
+
const CVERecord = z.looseObject({
|
|
1225
|
+
cve_id: opt(z.string()),
|
|
1226
|
+
description: opt(z.string()),
|
|
1227
|
+
severity: opt(z.string()),
|
|
1228
|
+
cvss_v3_score: opt(z.number()),
|
|
1229
|
+
cvss_v4_score: opt(z.number()),
|
|
1230
|
+
cvss_v4_severity: opt(z.string()),
|
|
1231
|
+
echelongraph_score: opt(z.number()),
|
|
1232
|
+
echelongraph_severity: opt(z.string()),
|
|
1233
|
+
score_confidence: opt(z.string()),
|
|
1234
|
+
epss_score: opt(z.number()),
|
|
1235
|
+
epss_percentile: opt(z.number()),
|
|
1236
|
+
kev_listed: opt(z.boolean()),
|
|
1237
|
+
kev_ransomware: opt(z.boolean()),
|
|
1238
|
+
kev_added_date: opt(z.string()),
|
|
1239
|
+
ghsa_id: opt(z.string()),
|
|
1240
|
+
references: z.unknown().optional(),
|
|
1241
|
+
published: opt(z.string()),
|
|
1242
|
+
modified: opt(z.string()),
|
|
1243
|
+
updated_at: opt(z.string()),
|
|
1244
|
+
});
|
|
1245
|
+
const CVE_SUMMARY_OUTPUT = envelopeSchema({
|
|
1246
|
+
data: z.looseObject({
|
|
1247
|
+
summary: z
|
|
1248
|
+
.looseObject({
|
|
1249
|
+
total: opt(z.number()),
|
|
1250
|
+
critical: opt(z.number()),
|
|
1251
|
+
high: opt(z.number()),
|
|
1252
|
+
medium: opt(z.number()),
|
|
1253
|
+
low: opt(z.number()),
|
|
1254
|
+
none: opt(z.number()),
|
|
1255
|
+
last_updated: opt(z.string()),
|
|
1256
|
+
})
|
|
1257
|
+
.optional(),
|
|
1258
|
+
}),
|
|
1259
|
+
coverage: null,
|
|
1260
|
+
freshness: null,
|
|
1261
|
+
});
|
|
1262
|
+
const SEARCH_CVES_OUTPUT = envelopeSchema({
|
|
1263
|
+
data: z.looseObject({
|
|
1264
|
+
cves: z.array(CVERecord).optional(),
|
|
1265
|
+
total: opt(z.number()),
|
|
1266
|
+
total_counted: opt(z.boolean()),
|
|
1267
|
+
total_is_lower_bound: opt(z.boolean()),
|
|
1268
|
+
search_relaxed: opt(z.boolean()),
|
|
1269
|
+
limit: opt(z.number()),
|
|
1270
|
+
offset: opt(z.number()),
|
|
1271
|
+
}),
|
|
1272
|
+
coverage: z.strictObject({
|
|
1273
|
+
total: z.number().nullable(),
|
|
1274
|
+
total_counted: z.boolean().nullable(),
|
|
1275
|
+
total_is_lower_bound: z.boolean().nullable(),
|
|
1276
|
+
search_relaxed: z.boolean().nullable(),
|
|
1277
|
+
returned: z.number().nullable(),
|
|
1278
|
+
limit: z.number().nullable(),
|
|
1279
|
+
offset: z.number().nullable(),
|
|
1280
|
+
}),
|
|
1281
|
+
freshness: null,
|
|
1282
|
+
});
|
|
1283
|
+
const GET_CVE_OUTPUT = envelopeSchema({ data: CVERecord, coverage: null, freshness: null });
|
|
1284
|
+
const CountryRows = z.array(z.looseObject({ country: opt(z.string()), hosts: opt(z.number()) }));
|
|
1285
|
+
const CVE_EXPOSURE_OUTPUT = envelopeSchema({
|
|
1286
|
+
data: z.looseObject({
|
|
1287
|
+
cve_id: opt(z.string()),
|
|
1288
|
+
exposed_hosts: opt(z.number()),
|
|
1289
|
+
countries: opt(z.number()),
|
|
1290
|
+
tracked: opt(z.boolean()),
|
|
1291
|
+
kev_listed: opt(z.boolean()),
|
|
1292
|
+
kev_catalog_listed: opt(z.boolean()),
|
|
1293
|
+
kev_seen_in_observations: opt(z.boolean()),
|
|
1294
|
+
ransomware: opt(z.boolean()),
|
|
1295
|
+
top_countries: opt(CountryRows),
|
|
1296
|
+
top_products: opt(z.array(z.looseObject({ product: opt(z.string()), hosts: opt(z.number()) }))),
|
|
1297
|
+
last_seen: opt(z.string()),
|
|
1298
|
+
method: opt(z.string()),
|
|
1299
|
+
generated_at: opt(z.string()),
|
|
1300
|
+
}),
|
|
1301
|
+
coverage: z.strictObject({
|
|
1302
|
+
in_scope: z.boolean().nullable().describe("The API's tracked verdict: whether the CVE is in the radar's tracked set; null when the answer does not say."),
|
|
1303
|
+
}),
|
|
1304
|
+
freshness: null,
|
|
1305
|
+
extra: {
|
|
1306
|
+
exposure_state: z
|
|
1307
|
+
.enum(["exposed", "measured_zero", "not_assessed", "tracking_unknown"])
|
|
1308
|
+
.nullable()
|
|
1309
|
+
.describe("What the answer's count is: exposed, a measured zero, not assessed, or tracking unknown; null when it carries no count."),
|
|
1310
|
+
},
|
|
632
1311
|
});
|
|
633
|
-
|
|
634
|
-
|
|
1312
|
+
// exposure_radar's data, from the readers' own specs, so the schema cannot drift from them.
|
|
1313
|
+
const zKind = (k) => (k === "number" ? z.number() : k === "string" ? z.string() : z.boolean());
|
|
1314
|
+
const zRow = (spec) => z.strictObject({
|
|
1315
|
+
...Object.fromEntries(Object.entries(spec.required).map(([k, kind]) => [k, zKind(kind)])),
|
|
1316
|
+
...Object.fromEntries(Object.entries(spec.optional ?? {}).map(([k, kind]) => [k, zKind(kind).optional()])),
|
|
1317
|
+
});
|
|
1318
|
+
const zRadar = (spec, rows = {}) => z.strictObject({
|
|
1319
|
+
...Object.fromEntries(spec.counts.map((k) => [k, z.number().optional()])),
|
|
1320
|
+
...Object.fromEntries(Object.entries(spec.rows).map(([k, rs]) => [k, (rows[k] ?? z.array(zRow(rs))).optional()])),
|
|
1321
|
+
...Object.fromEntries(spec.instants.map((k) => [k, Instant.optional()])),
|
|
1322
|
+
});
|
|
1323
|
+
// A newest_kev row as newestKEVRow relays it: `tracked` is read, not relayed, and a count only
|
|
1324
|
+
// when the row's exposure_state is not not_assessed.
|
|
1325
|
+
const { tracked: _read, ...newestKEVOptional } = KEV_SPEC.rows.newest_kev.optional ?? {};
|
|
1326
|
+
const NewestKEVRow = z.strictObject({
|
|
1327
|
+
cve_id: z.string(),
|
|
1328
|
+
...Object.fromEntries(Object.entries(newestKEVOptional).map(([k, kind]) => [k, zKind(kind).optional()])),
|
|
1329
|
+
exposure_state: z.enum(["exposed", "measured_zero", "not_assessed"]),
|
|
1330
|
+
exposed_hosts: z.number().optional(),
|
|
1331
|
+
});
|
|
1332
|
+
const CountMap = z.record(z.string(), z.number());
|
|
1333
|
+
const countRowsOf = (label) => z.array(z.strictObject({ [label]: z.string(), count: z.number() }));
|
|
1334
|
+
const ShadowAIData = z.strictObject({
|
|
1335
|
+
confirmed_exposed: z.strictObject({ total: z.number().optional(), by_category: CountMap.optional(), last_24h: z.number().optional() }).optional(),
|
|
1336
|
+
observed: z
|
|
1337
|
+
.strictObject({
|
|
1338
|
+
total: z.number().optional(),
|
|
1339
|
+
by_category: CountMap.optional(),
|
|
1340
|
+
last_24h: z.number().optional(),
|
|
1341
|
+
...Object.fromEntries(SHADOW_AI_ROWS.map(([key, label]) => [key, countRowsOf(label).optional()])),
|
|
1342
|
+
last_observation: Instant.optional(),
|
|
1343
|
+
})
|
|
1344
|
+
.optional(),
|
|
1345
|
+
authentication: z.strictObject({ observed: z.number().optional(), not_determined: z.number().optional() }).optional(),
|
|
1346
|
+
poller: z.strictObject({ running: z.boolean(), last_run_at: Instant }).optional(),
|
|
1347
|
+
});
|
|
1348
|
+
const LastRun = z.strictObject({ last_run_at: Instant.nullable() });
|
|
1349
|
+
const EXPOSURE_RADAR_OUTPUT = envelopeSchema({
|
|
1350
|
+
data: z.strictObject({
|
|
1351
|
+
kev_exposure: zRadar(KEV_SPEC, { newest_kev: z.array(NewestKEVRow) }),
|
|
1352
|
+
exposed_databases: zRadar(EXPOSED_DB_SPEC),
|
|
1353
|
+
leaked_credentials: zRadar(LEAKED_CREDS_SPEC),
|
|
1354
|
+
shadow_ai: ShadowAIData,
|
|
1355
|
+
}),
|
|
1356
|
+
coverage: z.strictObject({
|
|
1357
|
+
radars: z.array(z.string()).describe("The radars this tool reads."),
|
|
1358
|
+
answered: z.array(z.string()).describe("The radars that answered. On a failure their answers are withheld."),
|
|
1359
|
+
failed: z.array(z.string()).describe("The radars that could not be read."),
|
|
1360
|
+
}),
|
|
1361
|
+
freshness: z.strictObject({
|
|
1362
|
+
kev_exposure: LastRun,
|
|
1363
|
+
exposed_databases: LastRun,
|
|
1364
|
+
leaked_credentials: LastRun,
|
|
1365
|
+
shadow_ai: z.strictObject({ last_run_at: Instant.nullable(), running: z.boolean().nullable() }),
|
|
1366
|
+
}),
|
|
1367
|
+
error: ErrorSchema.extend({
|
|
1368
|
+
radars: z
|
|
1369
|
+
.array(z.strictObject({
|
|
1370
|
+
radar: z.string(),
|
|
1371
|
+
kind: z.enum(["network", "timeout", "http", "not_json", "not_object"]),
|
|
1372
|
+
path: z.string(),
|
|
1373
|
+
status: z.number().int().nullable(),
|
|
1374
|
+
message: z.string(),
|
|
1375
|
+
}))
|
|
1376
|
+
.optional()
|
|
1377
|
+
.describe("Each radar that could not be read, and why."),
|
|
1378
|
+
}),
|
|
1379
|
+
});
|
|
1380
|
+
// ── Server instructions (#2311) ──
|
|
1381
|
+
// No sentence here names a re-probe interval; any that does must say it applies to hosts we do
|
|
1382
|
+
// not own (the package's tests hold every shipped text to that).
|
|
1383
|
+
const INSTRUCTIONS = [
|
|
1384
|
+
"EchelonGraph's public CVE and internet-exposure data, read-only and keyless.",
|
|
1385
|
+
"Everything these tools return is public: EchelonGraph's CVE Pulse feed, and aggregate, host-redacted totals from its exposure radars.",
|
|
1386
|
+
"Every result's structuredContent says how it was measured: state, measured_at, method, coverage, freshness and notes.",
|
|
1387
|
+
"state is measured, not_assessed, failed or invalid_input.",
|
|
1388
|
+
"not_assessed means the answer holds no dated measurement of what was asked (for example a CVE outside the KEV-exposure radar's tracked set), so a zero in it is not a finding of no exposure.",
|
|
1389
|
+
"failed and invalid_input mean nothing was measured: never report them as zero, none found or unexposed.",
|
|
1390
|
+
"measured_at is when the underlying observation was made, and null when the answer does not say or holds none.",
|
|
1391
|
+
"freshness gives the producing radar's last_run_at: when it last completed a check whose reads succeeded.",
|
|
1392
|
+
"That is not the time of every record the radar's numbers count, which cover everything still on record, and it is null when the API does not say.",
|
|
1393
|
+
"Every figure is as fresh as the schedule that refreshes it.",
|
|
1394
|
+
"Exposure numbers are aggregate: counts of distinct ip:port services on EchelonGraph's record (a machine answering on two ports counts twice), not machines, and not an internet-wide census.",
|
|
1395
|
+
`${SHODAN_ATTRIBUTION} ${SHODAN_OWNERSHIP}`,
|
|
1396
|
+
].join(" ");
|
|
1397
|
+
const ANNOTATIONS = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
|
|
1398
|
+
const CVE_ID_ARG = z.string().describe("a CVE ID, e.g. CVE-2023-44487");
|
|
1399
|
+
// What each description says about its structured result, naming only fields its schema holds.
|
|
1400
|
+
const FEED_ENVELOPE = "Its structured result carries state (measured), measured_at, method, coverage, freshness (null: the feed serves no poll-completion time) and notes, with data equal to the API's JSON.";
|
|
1401
|
+
// One server, built per connection by serveStdio for whichever era the client opens with.
|
|
1402
|
+
// tools/list answers in registration order, which is the order below.
|
|
1403
|
+
function createServer() {
|
|
1404
|
+
const server = new McpServer({ name: NAME, version: VERSION }, { instructions: INSTRUCTIONS, capabilities: { tools: { listChanged: false } } });
|
|
1405
|
+
server.registerTool("cve_summary", {
|
|
1406
|
+
title: "CVE feed summary",
|
|
1407
|
+
description: `Summary of EchelonGraph's CVE Pulse feed: summary.total active CVEs and their counts by severity (summary.critical, summary.high, summary.medium, summary.low, summary.none), plus summary.last_updated, the newest modification time among those records. The feed is polled from its sources on a schedule, so this is the state as of that update. ${FEED_ENVELOPE}`,
|
|
1408
|
+
outputSchema: CVE_SUMMARY_OUTPUT,
|
|
1409
|
+
annotations: ANNOTATIONS,
|
|
1410
|
+
}, async () => checked("cve_summary", CVE_SUMMARY_OUTPUT, await cveSummary()));
|
|
1411
|
+
server.registerTool("search_cves", {
|
|
1412
|
+
title: "Search CVEs",
|
|
1413
|
+
description: `Search/list CVEs from EchelonGraph's CVE feed (NVD + MITRE-CNA pre-NVD + CISA-KEV + EPSS + GitHub GHSA, each polled on a schedule). Filter by severity, minimum CVSS, free text, and sort. Returns cves, each with cve_id, severity, cvss_v3_score, echelongraph_score, epss_score and kev_listed where the record has them, and the list's total, total_counted (false: the matches were not counted, so total is not a count), total_is_lower_bound (true: at least total), search_relaxed (true: a phrase was relaxed to all of its words), limit and offset. ${FEED_ENVELOPE} coverage repeats total, total_counted, total_is_lower_bound, search_relaxed, limit and offset, and gives returned, the rows in this page.`,
|
|
1414
|
+
inputSchema: z.object({
|
|
1415
|
+
search: z.string().optional().describe("free-text search (product, vendor, or keyword, e.g. 'tomcat')"),
|
|
1416
|
+
severity: z.enum(["CRITICAL", "HIGH", "MEDIUM", "LOW"]).optional().describe("filter to one severity"),
|
|
1417
|
+
min_cvss: z.number().min(0).max(10).optional().describe("minimum CVSS score"),
|
|
1418
|
+
sort: z.enum(["published", "modified", "nvd", "echelongraph", "epss"]).optional().describe("sort order (default: published)"),
|
|
1419
|
+
limit: z.number().int().min(1).max(50).optional().describe("page size (default 20, max 50)"),
|
|
1420
|
+
}),
|
|
1421
|
+
outputSchema: SEARCH_CVES_OUTPUT,
|
|
1422
|
+
annotations: ANNOTATIONS,
|
|
1423
|
+
}, async (a) => checked("search_cves", SEARCH_CVES_OUTPUT, await searchCVEs(a)));
|
|
1424
|
+
server.registerTool("get_cve", {
|
|
1425
|
+
title: "CVE detail",
|
|
1426
|
+
description: `Full record for one CVE: description, severity, cvss_v3_score, cvss_v4_score and cvss_v4_severity (when the record has a CVSS v4 score), echelongraph_score and echelongraph_severity with score_confidence (EchelonGraph's multi-source score), epss_score and epss_percentile, CISA-KEV status (kev_listed, kev_added_date, and kev_ransomware for known ransomware-campaign use), ghsa_id (GitHub GHSA), references, published, modified and updated_at; each field only where the record has it. Pass a CVE ID like CVE-2023-44487. ${FEED_ENVELOPE}`,
|
|
1427
|
+
inputSchema: z.object({ cve_id: CVE_ID_ARG }),
|
|
1428
|
+
outputSchema: GET_CVE_OUTPUT,
|
|
1429
|
+
annotations: ANNOTATIONS,
|
|
1430
|
+
}, async ({ cve_id }) => checked("get_cve", GET_CVE_OUTPUT, await getCVE(cve_id)));
|
|
1431
|
+
server.registerTool("cve_exposure", {
|
|
1432
|
+
title: "Internet exposure for one CVE",
|
|
1433
|
+
description: `${CVE_EXPOSURE_DESCRIPTION} Its structured result's state is measured only for a count of services on record that last_seen dates (measured_at); a tracked zero, a CVE outside the tracked set, or an answer that does not say, is not_assessed. exposure_state is exposed, measured_zero, not_assessed or tracking_unknown; coverage.in_scope is the API's tracked verdict; freshness is null, since the per-CVE answer carries no last completed check; data is the API's JSON.`,
|
|
1434
|
+
inputSchema: z.object({ cve_id: CVE_ID_ARG }),
|
|
1435
|
+
outputSchema: CVE_EXPOSURE_OUTPUT,
|
|
1436
|
+
annotations: ANNOTATIONS,
|
|
1437
|
+
}, async ({ cve_id }) => checked("cve_exposure", CVE_EXPOSURE_OUTPUT, await cveExposure(cve_id)));
|
|
1438
|
+
server.registerTool("exposure_radar", {
|
|
1439
|
+
title: "Exposure radar totals",
|
|
1440
|
+
description: `${EXPOSURE_RADAR_DESCRIPTION} Its structured result's state is not_assessed with measured_at null: every radar answered, but no stats answer says when the services or records it counts were observed, so no count is presented as a dated measurement. freshness gives each radar's last_run_at (and shadow_ai's running) where the API serves one; coverage names the radars that answered; data is the relayed result above.`,
|
|
1441
|
+
outputSchema: EXPOSURE_RADAR_OUTPUT,
|
|
1442
|
+
annotations: ANNOTATIONS,
|
|
1443
|
+
}, async () => checked("exposure_radar", EXPOSURE_RADAR_OUTPUT, await exposureRadar()));
|
|
1444
|
+
return server;
|
|
1445
|
+
}
|
|
1446
|
+
// Both eras on stdio: a 2026-07-28 client's server/discover, and a 2025-era client's initialize.
|
|
1447
|
+
serveStdio(createServer, { legacy: "serve" });
|
|
635
1448
|
// MCP uses stdout for the protocol — diagnostics go to stderr.
|
|
636
|
-
console.error(`EchelonGraph CVE MCP server running (API: ${SHOWN_BASE}, timeout ${TIMEOUT_MS} ms)`);
|
|
1449
|
+
console.error(`EchelonGraph CVE MCP server ${NAME}@${VERSION} running (API: ${SHOWN_BASE}, timeout ${TIMEOUT_MS} ms)`);
|