echelongraph-mcp 1.0.1 → 1.0.3

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/README.md CHANGED
@@ -8,6 +8,7 @@ It exposes EchelonGraph's CVE Pulse (NVD + MITRE-CNA *pre-NVD* + CISA-KEV + EPSS
8
8
  GHSA, fused into one score) **plus a per-CVE internet-exposure footprint**: how many
9
9
  internet-facing services (distinct ip:port) EchelonGraph's KEV-exposure radar has on record
10
10
  running a version that maps to the CVE. Exposure counts are derived from Shodan data.
11
+ Shodan data is owned by Shodan, which holds its copyright (© Shodan).
11
12
 
12
13
  Free and keyless: no API key, no auth, read-only. The server makes no request other than the
13
14
  API call a tool needs to answer.
@@ -20,7 +21,7 @@ API call a tool needs to answer.
20
21
  | `search_cves` | Search/filter CVEs (severity, min CVSS, text, sort) with EchelonGraph scores. |
21
22
  | `get_cve` | Full detail for one CVE (CVSS v3/v4, EG score, EPSS, KEV+ransomware, GHSA, CWE, references). |
22
23
  | `cve_exposure` | Internet-exposure footprint for a CVE: exposed service count (distinct ip:port, the `exposed_hosts` field) + country/product breakdown, from the KEV-exposure radar. |
23
- | `exposure_radar` | Aggregate totals across the exposure radars: shadow AI; services running CISA-KEV CVEs; unauthenticated data stores and observability UIs, found through Shodan (LeakIX when Shodan query credits run low) and then confirmed by EchelonGraph's own read-only check; leaked credentials. |
24
+ | `exposure_radar` | Aggregate totals across the exposure radars: shadow AI; services running CISA-KEV CVEs; 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, which is not a pure read (on Redis it names its client; on ClickHouse its query lands in the server's query log); leaked credentials. |
24
25
 
25
26
  Every figure is as fresh as the schedule that refreshes it. The CVE feed is polled from its
26
27
  sources on a schedule; each radar refreshes on its own.
@@ -53,11 +54,31 @@ The radar only looks for its **tracked** set of CVEs, so a zero means different
53
54
 
54
55
  ### How `exposure_radar` counts shadow AI
55
56
 
56
- `shadow_ai.stats.total` counts **every** observation, whatever its verification state: that
57
- many observed, not that many exposed. The confirmed-exposed count is the sum of
58
- `stats.visible_by_category` (services EchelonGraph's probes found answering without an
59
- authentication gate), and `last_24h_visible_count` is its last-24-hour slice. The tool's note
60
- says which is which.
57
+ The shadow-AI radar's answer mixes numbers that count exposed services with numbers that count
58
+ every observation. So `exposure_radar` regroups it by what each number counts, and its note
59
+ labels each one. Only `shadow_ai.confirmed_exposed` counts exposed services.
60
+
61
+ | Field | What it counts |
62
+ |---|---|
63
+ | `confirmed_exposed.total` | Confirmed exposed: services EchelonGraph's probes found answering without an authentication gate (liveness `active`, or `rechecking` during a re-check). It is the sum of `confirmed_exposed.by_category`. |
64
+ | `confirmed_exposed.by_category` | The same services, by category. |
65
+ | `confirmed_exposed.last_24h` | Confirmed-exposed services first recorded in the last 24 hours. |
66
+ | `observed.total` | Every Certificate Transparency or Shodan observation on record, whatever its verification state: observed, not exposed. |
67
+ | `observed.by_category` | The same observations, by category. |
68
+ | `observed.last_24h` | Observations first recorded in the last 24 hours. |
69
+ | `observed.trend_30d` | Observations per UTC day over the last 30 days. |
70
+ | `observed.top_products`, `observed.top_countries`, `observed.top_issuers` | Up to ten products, countries and issuers, ranked by observations, not by exposed services. An issuer is the certificate's CA for a Certificate Transparency observation, and the hosting operator Shodan reports for a Shodan one. |
71
+ | `observed.last_observation` | When the latest observation was recorded. |
72
+ | `authentication.observed` | Observations where a probe observed an authentication gate: a 401/403, a login page or an auth marker. |
73
+ | `authentication.not_determined` | Observations whose service answered, but where no probe could tell whether it enforces authentication. |
74
+
75
+ Neither authentication count is part of `confirmed_exposed`, and `observed.total` minus
76
+ `confirmed_exposed.total` is not a count of secured services: it also holds observations not
77
+ yet verified, whose hostname no longer resolves, or whose service no longer answers openly.
78
+
79
+ The tool relays no number it cannot label. A stats field this version does not know is left
80
+ out, and the note names it; so is a count in an unexpected shape. A ranked row carries only
81
+ its name and its count.
61
82
 
62
83
  ## What a result means
63
84
 
@@ -67,11 +88,19 @@ outage for an all-clear:
67
88
  - **Success** — the first text block is the API's JSON verbatim; the second is a one-line
68
89
  note saying the call succeeded, which base URL answered, and what it found. When the
69
90
  feed genuinely holds nothing for the query the note says so in words ("we looked and
70
- found nothing … not a lookup failure"), because a measured zero is a measurement. One
71
- exception to verbatim: `exposure_radar` drops the shadow-AI `poller` block when it carries
72
- no real run time. That block describes whichever server instance answered, and a follower
73
- instance reports `running: false` and the zero time `0001-01-01T00:00:00Z`; the note then
74
- says the poller's freshness is unknown rather than presenting either as freshness.
91
+ found nothing … not a lookup failure"), because a measured zero is a measurement. The
92
+ exception to verbatim is `exposure_radar`'s `shadow_ai`: its counts are regrouped as above,
93
+ and its `poller` block carries only `running` and `last_run_at`, or is dropped when it
94
+ carries no real completion time. The block's other fields describe the server instance that
95
+ answered, not the radar. Its `last_run_at` is when the radar's leader last
96
+ completed a Certificate Transparency (crt.sh) cycle, and its `running` is true only when
97
+ that was within 30 minutes of the answer. When `running` is true, the note says the
98
+ radar's leader last completed a cycle at that time; when it is false, the note says no
99
+ cycle has completed since that time; when `last_run_at` is absent, the note says the
100
+ radar's freshness is unknown and the block is left out. An older API answered from
101
+ whichever server instance served the request, and a follower instance sent
102
+ `running: false` with the zero time `0001-01-01T00:00:00Z`; that too is unknown freshness,
103
+ never presented as a stopped radar.
75
104
  - **Failure** — an MCP error result (`isError: true`) whenever the lookup did not complete:
76
105
  the host could not be reached, it answered non-2xx, it took longer than the timeout, or
77
106
  it answered 2xx with a body that is not a JSON object. The text names the tool, the
@@ -136,4 +165,11 @@ counted as external MCP adoption. `npm test` never leaves the machine.
136
165
 
137
166
  ## License
138
167
 
139
- MIT · Data © EchelonGraph, served under the CVE Pulse free-access terms.
168
+ The code is MIT-licensed.
169
+
170
+ The CVE Pulse compilation (how EchelonGraph combines its CVE sources, and the EchelonGraph
171
+ score) is © EchelonGraph, served under the CVE Pulse free-access terms; the source records it
172
+ compiles stay under their publishers' terms.
173
+
174
+ Exposure counts are derived from Shodan data. Shodan data is owned by Shodan, which holds its
175
+ copyright (© Shodan). EchelonGraph claims no ownership of it or copyright in it.
package/dist/index.js CHANGED
@@ -10,7 +10,8 @@
10
10
  // Result contract (#1874). A tool answers in exactly one of two shapes, and they never blur:
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
- // nothing ("we looked and found nothing" is a measurement).
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
15
  // - failure (isError: true): the lookup did not complete — unreachable host, non-2xx,
15
16
  // timeout, or a 2xx whose body is not a JSON object — named by tool, cause and base URL.
16
17
  // Never a success with null fields: a model handed one of those tells its user "no
@@ -308,12 +309,18 @@ const CVE_ID = /^CVE-\d{4}-\d{4,}$/i;
308
309
  // Quoted when the API does not send its own `method` string.
309
310
  const EXPOSURE_METHOD = "Shodan banner match on the radar's tracked products; up to 100 ip:port services per product query; searched every 12 h when Shodan query credits allow";
310
311
  const SHODAN_ATTRIBUTION = "Exposure counts are derived from Shodan data.";
312
+ // Shodan's terms (https://static.shodan.io/legal/terms.html, read 2026-09-27) ask for two
313
+ // things: "You must attribute such usage to Shodan", and materials "referencing, including or
314
+ // otherwise based on Shodan information or materials, must clearly indicate Shodan's ownership
315
+ // and copyright in the applicable Shodan materials" (#2306). The attribution above does the
316
+ // first; this sentence, placed beside it wherever Shodan data is named, does the second.
317
+ const SHODAN_OWNERSHIP = "Shodan data is owned by Shodan, which holds its copyright (© Shodan).";
311
318
  function exposureNote(head, id, d) {
312
319
  const cve = strAt(d, "cve_id") ?? id;
313
320
  const hosts = numAt(d, "exposed_hosts");
314
321
  const tracked = boolAt(d, "tracked");
315
322
  // The API's own method sentence may end in a full stop; the note supplies one.
316
- const how = `Method: ${(strAt(d, "method") ?? EXPOSURE_METHOD).replace(/[.\s]+$/, "")}. ${SHODAN_ATTRIBUTION}`;
323
+ const how = `Method: ${(strAt(d, "method") ?? EXPOSURE_METHOD).replace(/[.\s]+$/, "")}. ${SHODAN_ATTRIBUTION} ${SHODAN_OWNERSHIP}`;
317
324
  // Before kev_catalog_listed existed, kev_listed was bool_or over the radar's observations:
318
325
  // false for every CVE the radar has no host for, whatever the CISA-KEV catalog says.
319
326
  // kev_catalog_listed is cves.kev_listed, the flag the KEV fetcher sets on EchelonGraph's CVE
@@ -350,7 +357,7 @@ function exposureNote(head, id, d) {
350
357
  : "";
351
358
  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}`;
352
359
  }
353
- 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. 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 }) => {
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 }) => {
354
361
  const tool = "cve_exposure";
355
362
  const id = cve_id.trim();
356
363
  if (!id)
@@ -382,53 +389,214 @@ const RADARS = [
382
389
  ["leaked_credentials", "/api/v1/public/leaked-credentials/stats"],
383
390
  ["shadow_ai", "/api/v1/public/shadow-ai-radar/stats"],
384
391
  ];
385
- // The shadow-AI stats answer, read honestly (#2307).
392
+ // The shadow-AI stats answer, regrouped by what each number counts (#2307).
386
393
  //
387
- // stats.total counts EVERY observation, whatever its verification state (35,473 on
388
- // 2026-09-26); the confirmed-exposed count is the sum of stats.visible_by_category (1,983
389
- // that day). Only the second may be called exposed.
394
+ // GET /api/v1/public/shadow-ai-radar/stats (core-backend shadowctlog store.go Stats) mixes
395
+ // three kinds of number, and only one of them counts exposed services:
396
+ // - confirmed exposed: rows whose liveness is active or rechecking, the published exposure
397
+ // set (visible_by_category, last_24h_visible_count);
398
+ // - observed: every Certificate Transparency or Shodan observation on record, whatever its
399
+ // verification state (total, by_category, last_24h_count, trend_30d, and the top_products,
400
+ // top_countries and top_issuers rankings);
401
+ // - authentication state: rows where a probe observed an authentication gate (auth_confirmed,
402
+ // liveness authenticated) or could not tell (auth_undetermined, liveness inconclusive).
403
+ // On 2026-09-27 total was 36,222 against 2,000 confirmed exposed, and top_products ranked
404
+ // LiteLLM at 6,813 observations while 1,395 LLM-PROXY services were confirmed exposed. Relayed
405
+ // under the API's names, every one of those reads as an exposure count. So the tool relays them
406
+ // grouped as confirmed_exposed, observed and authentication, with the note labelling each, and
407
+ // relays nothing it cannot label: a stats field this version does not know, a field other than
408
+ // a ranked row's label and count, and the poller block's instance fields are left out, the
409
+ // first named in the note.
390
410
  //
391
- // Its `poller` block describes whichever server instance answered, not the radar. A follower
392
- // instance answers running:false and last_run_at 0001-01-01T00:00:00Z, which reads as "the
393
- // radar is down and has never run". A block without a real run time is dropped from the
394
- // relayed data, and the note says freshness is unknown instead.
411
+ // The `poller` block's running and last_run_at describe the radar's fleet, not the instance
412
+ // that answered (core-backend shadowctlog Poller.Status): last_run_at is when the fleet's
413
+ // leader last COMPLETED a crt.sh (Certificate Transparency) cycle, and running is true only
414
+ // when that was within pollerSilentAfter, 30 minutes, of the answer. The API sends both or
415
+ // neither, and omits both when it cannot tell; it never sends the Go zero time. An API older
416
+ // than that answered from the serving instance's own memory, so a follower sent running:false
417
+ // and last_run_at 0001-01-01T00:00:00Z, which reads as "the radar is down and has never run".
418
+ // So the note reads three cases: running with a real completion time, stopped since a real
419
+ // completion time, and unknown. Only the first two are relayed, as those two fields alone; an
420
+ // unknown block (no real completion time and running verdict) is dropped, so no running:false
421
+ // or zero time reaches the model as freshness. The block's other fields (interval_seconds,
422
+ // shodan_enabled, and a polling instance's last_new_inserts, skipped_as_follower,
423
+ // consecutive_fails and shodan_last_new) describe the instance that answered, not the fleet,
424
+ // and none of them counts AI services.
425
+ const SHADOW_AI_SILENT_AFTER = "30 minutes";
426
+ // The stats fields readShadowAI knows how to label. Any other is left out and named.
427
+ const SHADOW_AI_STATS = new Set([
428
+ "total",
429
+ "by_category",
430
+ "visible_by_category",
431
+ "last_observation",
432
+ "last_24h_count",
433
+ "last_24h_visible_count",
434
+ "auth_confirmed",
435
+ "auth_undetermined",
436
+ "top_products",
437
+ "top_countries",
438
+ "top_issuers",
439
+ "trend_30d",
440
+ ]);
441
+ // The observed rankings and the daily series: API field, and the row field that names each row.
442
+ const SHADOW_AI_ROWS = [
443
+ ["trend_30d", "date"],
444
+ ["top_products", "product"],
445
+ ["top_countries", "country"],
446
+ ["top_issuers", "issuer"],
447
+ ];
448
+ const isCount = (v) => typeof v === "number" && Number.isFinite(v);
449
+ const isPlainObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
450
+ // A category → count map, or nothing unless every value is a count: a partial map is an
451
+ // undercount that reads as a measurement (store.go categoryCounts).
452
+ function countMap(v) {
453
+ if (!isPlainObject(v))
454
+ return undefined;
455
+ const entries = Object.entries(v);
456
+ return entries.every(([, n]) => isCount(n)) ? Object.fromEntries(entries) : undefined;
457
+ }
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) {
461
+ if (!Array.isArray(v))
462
+ return undefined;
463
+ const rows = [];
464
+ 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 });
470
+ }
471
+ return rows;
472
+ }
395
473
  function readShadowAI(sa) {
396
474
  const sentences = [];
397
- const byCategory = field(sa, "stats", "visible_by_category");
398
- let confirmed;
399
- if (byCategory !== null && typeof byCategory === "object" && !Array.isArray(byCategory)) {
400
- const counts = Object.values(byCategory);
401
- if (counts.every((n) => typeof n === "number" && Number.isFinite(n))) {
402
- confirmed = counts.reduce((a, b) => a + b, 0);
403
- }
475
+ const stats = field(sa, "stats");
476
+ const used = new Set();
477
+ const take = (key, v) => {
478
+ if (v !== undefined)
479
+ used.add(key);
480
+ return v;
481
+ };
482
+ // Confirmed exposed: liveness IN ('active','rechecking') (store.go Stats, visible_by_category
483
+ // and last_24h_visible_count).
484
+ const confirmed = {};
485
+ const visible = take("visible_by_category", countMap(field(stats, "visible_by_category")));
486
+ if (visible) {
487
+ confirmed.total = Object.values(visible).reduce((a, b) => a + b, 0);
488
+ confirmed.by_category = visible;
489
+ }
490
+ const last24Visible = take("last_24h_visible_count", numAt(stats, "last_24h_visible_count"));
491
+ if (last24Visible !== undefined)
492
+ confirmed.last_24h = last24Visible;
493
+ // Observed: every row of shadow_ct_observations, whatever its liveness.
494
+ const observed = {};
495
+ const total = take("total", numAt(stats, "total"));
496
+ if (total !== undefined)
497
+ observed.total = total;
498
+ const byCategory = take("by_category", countMap(field(stats, "by_category")));
499
+ if (byCategory)
500
+ observed.by_category = byCategory;
501
+ const last24 = take("last_24h_count", numAt(stats, "last_24h_count"));
502
+ if (last24 !== undefined)
503
+ observed.last_24h = last24;
504
+ const rows = {};
505
+ 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;
404
509
  }
405
- const last24 = numAt(sa, "stats", "last_24h_visible_count");
406
- sentences.push(confirmed === undefined
407
- ? "Shadow AI: the answer carries no usable stats.visible_by_category, so it does not say how many services are confirmed exposed."
408
- : `Shadow AI: ${confirmed} confirmed exposed (the sum of stats.visible_by_category: services EchelonGraph's probes found answering without an authentication gate)${last24 === undefined ? "" : `, ${last24} of them observed in the last 24 h (last_24h_visible_count)`}.`);
409
- const total = numAt(sa, "stats", "total");
510
+ // A timestamp, not a count; one that is not a real instant is dropped (see realInstant).
511
+ const lastObservation = take("last_observation", strAt(stats, "last_observation"));
512
+ if (realInstant(lastObservation))
513
+ observed.last_observation = lastObservation;
514
+ // Authentication state: liveness 'authenticated' and 'inconclusive'.
515
+ const authentication = {};
516
+ const authSeen = take("auth_confirmed", numAt(stats, "auth_confirmed"));
517
+ if (authSeen !== undefined)
518
+ authentication.observed = authSeen;
519
+ const authUnknown = take("auth_undetermined", numAt(stats, "auth_undetermined"));
520
+ if (authUnknown !== undefined)
521
+ authentication.not_determined = authUnknown;
522
+ sentences.push(confirmed.total === undefined
523
+ ? `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.`);
410
525
  if (total !== undefined) {
411
- sentences.push(`stats.total (${total}) counts every observation whatever its verification state: that many observed, not that many exposed.`);
526
+ 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.`);
412
527
  }
413
- let data = sa;
414
- if (field(sa, "poller") !== undefined) {
415
- const lastRun = strAt(sa, "poller", "last_run_at");
416
- if (boolAt(sa, "poller", "running") === true && realInstant(lastRun)) {
417
- sentences.push(`The server instance that answered last ran the shadow-AI poller at ${lastRun}.`);
418
- }
419
- else {
420
- const rest = { ...sa };
421
- delete rest.poller;
422
- data = rest;
423
- sentences.push("Shadow-AI poller freshness is unknown: the server instance that answered had no completed poller run of its own to report (a follower instance, or one that has not run yet), so its poller block was left out of the data above.");
424
- }
528
+ const first = (key, label) => {
529
+ const top = rows[key]?.[0];
530
+ return top === undefined ? "" : ` (first: ${top[label]}, ${top.count} observations)`;
531
+ };
532
+ const observedParts = [
533
+ byCategory && "observed.by_category counts them by category",
534
+ last24 !== undefined && `observed.last_24h (${last24}) counts those first recorded in the last 24 h`,
535
+ rows.trend_30d && "observed.trend_30d counts them per UTC day over the last 30 days",
536
+ rows.top_products && `observed.top_products ranks up to ten products by observations${first("top_products", "product")}`,
537
+ rows.top_countries && `observed.top_countries ranks up to ten countries by observations${first("top_countries", "country")}`,
538
+ rows.top_issuers &&
539
+ `observed.top_issuers ranks up to ten issuers by observations, an issuer being the certificate's CA for a Certificate Transparency observation and the hosting operator Shodan reports for a Shodan one${first("top_issuers", "issuer")}`,
540
+ ].filter((p) => typeof p === "string");
541
+ if (observedParts.length) {
542
+ sentences.push(`${total === undefined ? "Every number" : "Every other number"} under observed counts the same observations, not exposed services: ${observedParts.join("; ")}.`);
543
+ }
544
+ const authParts = [
545
+ authSeen !== undefined &&
546
+ `authentication.observed (${authSeen}) counts observations where a probe observed an authentication gate (a 401/403, a login page or an auth marker; liveness authenticated)`,
547
+ authUnknown !== undefined &&
548
+ `authentication.not_determined (${authUnknown}) counts observations whose service answered but where no probe could tell whether it enforces authentication (liveness inconclusive)`,
549
+ ].filter((p) => typeof p === "string");
550
+ if (authParts.length) {
551
+ sentences.push(`Shadow AI authentication, over every observation on record: ${authParts.join("; ")}.`);
552
+ sentences.push("Neither authentication count is part of confirmed_exposed, and observed.total minus confirmed_exposed.total is not a count of secured services: it also holds observations not yet verified (liveness unverified), whose hostname no longer resolves (unreachable), or whose service no longer answers openly (resolved).");
553
+ }
554
+ // What the answer carried that the tool cannot label: named, and left out.
555
+ const unknown = [
556
+ ...Object.keys(isPlainObject(sa) ? sa : {}).filter((k) => k !== "stats" && k !== "poller"),
557
+ ...Object.keys(isPlainObject(stats) ? stats : {})
558
+ .filter((k) => !SHADOW_AI_STATS.has(k))
559
+ .map((k) => `stats.${k}`),
560
+ ];
561
+ const unusable = Object.entries(isPlainObject(stats) ? stats : {})
562
+ .filter(([k, v]) => SHADOW_AI_STATS.has(k) && !used.has(k) && v !== null)
563
+ .map(([k]) => `stats.${k}`);
564
+ if (!isPlainObject(stats))
565
+ 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
+ }
572
+ const data = {};
573
+ if (Object.keys(confirmed).length)
574
+ data.confirmed_exposed = confirmed;
575
+ if (Object.keys(observed).length)
576
+ data.observed = observed;
577
+ if (Object.keys(authentication).length)
578
+ data.authentication = authentication;
579
+ const lastRun = strAt(sa, "poller", "last_run_at");
580
+ const running = boolAt(sa, "poller", "running");
581
+ if (realInstant(lastRun) && running === true) {
582
+ data.poller = { running, last_run_at: lastRun };
583
+ sentences.push(`Shadow-AI radar freshness: the radar's leader last completed a Certificate Transparency (crt.sh) discovery cycle at ${lastRun} (poller.running is true: that was within ${SHADOW_AI_SILENT_AFTER} of the API's answer).`);
584
+ }
585
+ else if (realInstant(lastRun) && running === false) {
586
+ data.poller = { running, last_run_at: lastRun };
587
+ sentences.push(`Shadow-AI radar freshness: no Certificate Transparency (crt.sh) discovery cycle has completed since ${lastRun}, when the radar's leader last completed one (poller.running is false: that was more than ${SHADOW_AI_SILENT_AFTER} before the API's answer), so the shadow-AI figures may be stale.`);
588
+ }
589
+ else if (field(sa, "poller") === undefined) {
590
+ sentences.push("Shadow-AI radar freshness is unknown: the answer carries no poller block.");
591
+ }
592
+ else {
593
+ sentences.push("Shadow-AI radar freshness is unknown: the answer's poller block does not give both a real time at which the radar's leader last completed a cycle (poller.last_run_at) and a running verdict (poller.running), so that block was left out of the data above.");
425
594
  }
426
- const lastObservation = strAt(sa, "stats", "last_observation");
427
595
  if (realInstant(lastObservation))
428
596
  sentences.push(`Latest shadow-AI observation on record: ${lastObservation}.`);
429
597
  return { data, sentences };
430
598
  }
431
- 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 read-only check; 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. For shadow AI, stats.total counts every observation whatever its verification state (observed, not exposed); the confirmed-exposed count is the sum of stats.visible_by_category, and last_24h_visible_count is its last-24-hour slice.", {}, async () => {
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 () => {
432
600
  const tool = "exposure_radar";
433
601
  try {
434
602
  const results = await Promise.all(RADARS.map(([, path]) => api(path)));
@@ -456,7 +624,7 @@ server.tool("exposure_radar", "Aggregate totals from EchelonGraph's internet-exp
456
624
  }
457
625
  const shadow = readShadowAI(data.shadow_ai);
458
626
  data.shadow_ai = shadow.data;
459
- return succeeded(data, `${okHead(tool)} All ${RADARS.length} radars answered: ${RADARS.map(([name]) => name).join(", ")}. ${shadow.sentences.join(" ")} ${RADAR_SOURCES} Each radar refreshes on its own schedule.`);
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.`);
460
628
  }
461
629
  catch (e) {
462
630
  return crashed(tool, e);
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "echelongraph-mcp",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "mcpName": "io.echelongraph/echelongraph-mcp",
5
- "description": "EchelonGraph CVE and internet-exposure data as an MCP server: CVEs with EchelonGraph multi-source scores, CISA-KEV and EPSS, plus a per-CVE internet-exposure footprint derived from Shodan data. Free and keyless, for Claude, Cursor and any MCP client.",
5
+ "description": "EchelonGraph CVE and internet-exposure data as an MCP server: CVEs with EchelonGraph multi-source scores, CISA-KEV and EPSS, plus a per-CVE internet-exposure footprint derived from Shodan data. Shodan data is owned by Shodan, which holds its copyright (© Shodan). Free and keyless, for Claude, Cursor and any MCP client.",
6
6
  "type": "module",
7
7
  "bin": {
8
8
  "echelongraph-mcp": "dist/index.js"
package/server.json CHANGED
@@ -2,14 +2,14 @@
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
3
  "name": "io.echelongraph/echelongraph-mcp",
4
4
  "title": "EchelonGraph CVE & Exposure",
5
- "description": "CVE data (NVD, CISA-KEV, EPSS, GHSA) and Shodan-derived internet exposure per CVE. Free, keyless.",
5
+ "description": "CVEs (NVD, CISA-KEV, EPSS, GHSA) and per-CVE exposure from Shodan data (© Shodan). Free, keyless.",
6
6
  "websiteUrl": "https://echelongraph.io/pulse/mcp",
7
- "version": "1.0.1",
7
+ "version": "1.0.3",
8
8
  "packages": [
9
9
  {
10
10
  "registryType": "npm",
11
11
  "identifier": "echelongraph-mcp",
12
- "version": "1.0.1",
12
+ "version": "1.0.3",
13
13
  "transport": {
14
14
  "type": "stdio"
15
15
  },