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/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
- // regroups the shadow-AI answer by what each number counts (readShadowAI, #2307).
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/sdk/server/mcp.js";
21
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
22
- import { z } from "zod";
23
- // The release actually running, read from the package.json that ships beside dist/. The MCP
24
- // handshake and the User-Agent both carry it, so the API's access log can count adoption per
25
- // version without a code change per release.
26
- const VERSION = (() => {
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
- const v = pkg?.version;
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 "unknown";
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 = `echelongraph-mcp/${VERSION} (+https://echelongraph.io/pulse/mcp)`;
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 { content: [text(`${tool} FAILED ${INVALID_INPUT}: ${describeFailure(f)} ${REJECTED_INPUT}`)], isError: true };
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
- content: [text(`${tool} FAILED ${INVALID_INPUT}: ${why}. Nothing was looked up, so this is not a finding.`)],
185
- isError: true,
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
- content: [text(`${tool} FAILED inside the MCP server while querying ${SHOWN_BASE}: ${describeError(e)}. ${NOT_A_FINDING}`)],
190
- isError: true,
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
- const succeeded = (data, note) => ({
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
- const server = new McpServer({ name: "echelongraph-cve", version: VERSION });
220
- server.tool("cve_summary", "Summary of EchelonGraph's CVE Pulse feed: total active CVEs and counts by severity (critical/high/medium/low/none), plus when the feed was last updated. The feed is polled from its sources on a schedule, so this is the state as of that update.", {}, async () => {
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
- server.tool("search_cves", "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 with the EchelonGraph multi-source score, severity, CVSS, EPSS, and KEV status.", {
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
- return succeeded(r.data, `${head} The query matched ${total} CVEs${shown === undefined ? "" : `; ${shown} returned in this page`}.`);
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
- server.tool("get_cve", "Full detail for one CVE: description, NVD CVSS v3/v4, the EchelonGraph multi-source score + confidence, EPSS, CISA-KEV status (including ransomware-campaign use), GitHub GHSA, CWE, and references. Pass a CVE ID like CVE-2023-44487.", { cve_id: z.string().describe("a CVE ID, e.g. CVE-2023-44487") }, async ({ cve_id }) => {
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
- return succeeded(r.data, `${okHead(tool, r.status)} Returned the record for ${strAt(r.data, "cve_id") ?? id}.`);
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
- server.tool("cve_exposure", `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.`, { cve_id: z.string().describe("a CVE ID, e.g. CVE-2023-44487") }, async ({ cve_id }) => {
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
- // A ranking or a daily series, each row cut down to its label and its count, so a field the
459
- // backend adds to a row later is left out rather than relayed unlabelled. All rows or none.
460
- function countRows(v, label) {
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 undefined;
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
- const name = field(r, label);
466
- const count = field(r, "count");
467
- if (typeof name !== "string" || !isCount(count))
468
- return undefined;
469
- rows.push({ [label]: name, count });
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 = take(key, countRows(field(stats, key), label));
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)`}. Only confirmed_exposed counts exposed services.`);
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
- if (unknown.length) {
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
- server.tool("exposure_radar", `Aggregate totals from EchelonGraph's internet-exposure radars, each refreshed on its own schedule: shadow AI services found through Certificate Transparency logs and Shodan and then checked by EchelonGraph's identified probes; 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); and leaked credentials sampled from public GitHub push events. The kev_exposure and exposed_databases distinct_hosts figures count distinct ip:port services, so a machine answering on two ports counts twice. The shadow_ai result is regrouped by what each number counts, and carries no number the tool cannot label. 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. 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}`, {}, async () => {
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
- return {
621
- content: [text(`${tool} FAILED: ${failures.length} of ${RADARS.length} radars could not be read from ${SHOWN_BASE}.\n${lines.join("\n")}\n${NOT_A_FINDING}${withheld}`)],
622
- isError: true,
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
- const shadow = readShadowAI(data.shadow_ai);
626
- data.shadow_ai = shadow.data;
627
- return succeeded(data, `${okHead(tool)} All ${RADARS.length} radars answered: ${RADARS.map(([name]) => name).join(", ")}. ${shadow.sentences.join(" ")} ${RADAR_SOURCES} ${SHODAN_OWNERSHIP} Each radar refreshes on its own schedule.`);
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
- const transport = new StdioServerTransport();
634
- await server.connect(transport);
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)`);