echelongraph-mcp 2.1.0 → 2.3.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/README.md +92 -15
- package/dist/index.js +331 -40
- package/package.json +1 -1
- package/server.json +2 -2
package/README.md
CHANGED
|
@@ -21,7 +21,7 @@ API call a tool needs to answer.
|
|
|
21
21
|
| `search_cves` | Search/filter CVEs (severity, min CVSS, text, sort) with EchelonGraph scores. |
|
|
22
22
|
| `get_cve` | Full record for one CVE: CVSS v3 and (when scored) v4, the EchelonGraph score and its confidence, EPSS, CISA-KEV status and known ransomware use, the GitHub GHSA id, references, and its published, modified and `updated_at` times. |
|
|
23
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. |
|
|
24
|
-
| `exposure_radar` | Aggregate totals across the exposure radars: 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; shadow AI. Every number is labelled by what it counts, and a field the tool cannot label is left out and named. |
|
|
24
|
+
| `exposure_radar` | Aggregate totals across the exposure radars: 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; shadow AI; and MCP servers found in EchelonGraph's own Certificate Transparency feed, by RFC 9728 verdict, protocol era and transport. Every number is labelled by what it counts, and a field the tool cannot label is left out and named. |
|
|
25
25
|
|
|
26
26
|
Every figure is as fresh as the schedule that refreshes it. The CVE feed is polled from its
|
|
27
27
|
sources on a schedule; each radar refreshes on its own.
|
|
@@ -164,6 +164,74 @@ its name and its count, and a field added to one is named too. The `poller` bloc
|
|
|
164
164
|
checked the same way: a key this version does not know is left out and named, while the
|
|
165
165
|
instance fields it knows are left out as described below.
|
|
166
166
|
|
|
167
|
+
#### `mcp_servers`
|
|
168
|
+
|
|
169
|
+
The AI-exposure radar's MCP-server verdicts, from `GET /api/v1/public/ai-exposure/stats?service=mcp`.
|
|
170
|
+
Every hostname was named like an MCP server in EchelonGraph's own Certificate Transparency
|
|
171
|
+
feed, matched by hostname pattern; no Shodan data is used. Each is checked by EchelonGraph's
|
|
172
|
+
identified MCP probe: a `server/discover` request, and `initialize` only if that is refused. It
|
|
173
|
+
never sends `tools/call`. The answer is counts and timestamps only, with no hostname, version or
|
|
174
|
+
server name, and the tool repeats no string it carries but its timestamps. Each hostname is
|
|
175
|
+
counted once, by its latest verdict on record, and EchelonGraph's own control servers are left
|
|
176
|
+
out. The unit is a hostname, not an ip:port service.
|
|
177
|
+
|
|
178
|
+
| Field | What it counts |
|
|
179
|
+
|---|---|
|
|
180
|
+
| `mcp_servers.total` | Hostnames the radar has checked for an MCP server, each once, by its latest verdict on record. Not every one is an MCP server: see `mcp_servers.not_assessed_by_reason`. It is `mcp_servers.protected` + `mcp_servers.pending_readjudication` + `mcp_servers.not_assessed`. |
|
|
181
|
+
| `mcp_servers.protected` | Hostnames whose `/mcp` endpoint asked for credentials (a 401 or 403) and whose OAuth protected-resource metadata validated under RFC 9728: a 200 JSON document whose resource is identical to the server's identifier and that names at least one authorization server. |
|
|
182
|
+
| `mcp_servers.prm_via.header`, `mcp_servers.prm_via.wellknown_path`, `mcp_servers.prm_via.wellknown_root` | The protected ones, by where that document was found: the same-origin URL the credential challenge named; `/.well-known/oauth-protected-resource` followed by the endpoint's path; or `/.well-known/oauth-protected-resource` itself. They add up to `mcp_servers.protected`. |
|
|
183
|
+
| `mcp_servers.pending_readjudication` | Verdicts decided by a rule EchelonGraph has since replaced and not yet re-checked under the current rules. They are in neither `mcp_servers.protected` nor `mcp_servers.not_assessed`. |
|
|
184
|
+
| `mcp_servers.not_assessed` | The rest, whose protection the radar could not assess. Not assessed does not mean unprotected. `mcp_servers.not_assessed_by_reason` puts each of them in exactly one bucket (below). |
|
|
185
|
+
| `mcp_servers.own_controls_excluded` | EchelonGraph's own control servers, left out of every other `mcp_servers` number. |
|
|
186
|
+
| `mcp_servers.window.from`, `mcp_servers.window.to` | Timestamps, not counts: when the oldest and the newest of the verdicts counted were last checked. The counts are each hostname's latest verdict, not one sweep at one time. With no verdict counted there is no window. |
|
|
187
|
+
| `mcp_servers.last_run_at` | A timestamp, not a count: when the AI-exposure radar, which checks other AI services as well as MCP servers, last completed a check (a cycle whose reads succeeded and whose scan opt-out register answered). It is not the time of every verdict counted. |
|
|
188
|
+
| `mcp_servers.enabled` | Whether the API reports the radar running: true when a check completed within 45 minutes of its answer. |
|
|
189
|
+
| `mcp_servers.counted_at` | A timestamp, not a count: when the API read these counts. |
|
|
190
|
+
|
|
191
|
+
`mcp_servers.not_assessed_by_reason` divides `mcp_servers.not_assessed`:
|
|
192
|
+
|
|
193
|
+
| Bucket | What it holds |
|
|
194
|
+
|---|---|
|
|
195
|
+
| `mcp_servers.not_assessed_by_reason.identified_no_challenge` | Servers that identified themselves as MCP servers (a DiscoverResult, an InitializeResult, or the endpoint event of the deprecated HTTP+SSE transport) and did not ask for credentials at the handshake. That is normal in MCP: authorization is optional in the spec, and a server can enforce it at `tools/call` instead, which EchelonGraph never sends. So this bucket is not a finding of exposure. |
|
|
196
|
+
| `mcp_servers.not_assessed_by_reason.resource_mismatch` | An endpoint that asked for credentials, whose metadata document's resource is absent or not identical to the server's identifier. |
|
|
197
|
+
| `mcp_servers.not_assessed_by_reason.cross_origin_pointer` | An endpoint that asked for credentials, whose challenge named a metadata URL on another origin, which EchelonGraph records and does not request. |
|
|
198
|
+
| `mcp_servers.not_assessed_by_reason.bare_challenge_no_prm` | An endpoint that asked for credentials, whose challenge named no metadata URL, and neither well-known URI answered 2xx. |
|
|
199
|
+
| `mcp_servers.not_assessed_by_reason.pointer_unreachable` | An endpoint that asked for credentials, whose same-origin metadata URL did not answer 2xx in full. |
|
|
200
|
+
| `mcp_servers.not_assessed_by_reason.pointer_invalid` | An endpoint that asked for credentials, whose challenge's metadata pointer is not a URL EchelonGraph will request. |
|
|
201
|
+
| `mcp_servers.not_assessed_by_reason.no_authorization_servers` | An endpoint that asked for credentials, whose metadata document names no authorization server. |
|
|
202
|
+
| `mcp_servers.not_assessed_by_reason.wellknown_unreachable` | An endpoint that asked for credentials, where a well-known metadata request got no complete HTTP answer. |
|
|
203
|
+
| `mcp_servers.not_assessed_by_reason.metadata_invalid` | An endpoint that asked for credentials, whose metadata answer is not a 200 with a JSON object within the size cap. |
|
|
204
|
+
| `mcp_servers.not_assessed_by_reason.challenge_unadjudicated` | An endpoint that asked for credentials before EchelonGraph read RFC 9728 metadata, not re-checked since. |
|
|
205
|
+
| `mcp_servers.not_assessed_by_reason.no_http_answer` | Hostnames that gave no HTTP answer: DNS, TCP or TLS failed, or the request timed out. Not a count of MCP servers. |
|
|
206
|
+
| `mcp_servers.not_assessed_by_reason.not_identified_as_mcp` | Hostnames that answered HTTP with nothing that identified an MCP server: a login or error page, a body that is not JSON-RPC, a 4xx or 5xx. Not a count of MCP servers. |
|
|
207
|
+
|
|
208
|
+
The eight credential-challenge buckets hold endpoints that asked for credentials: only their
|
|
209
|
+
metadata did not validate, so none of them is shown to lack protection.
|
|
210
|
+
|
|
211
|
+
`mcp_servers.era` and `mcp_servers.transport` each divide every counted hostname, so each adds
|
|
212
|
+
up to `mcp_servers.total`:
|
|
213
|
+
|
|
214
|
+
| Bucket | What it holds |
|
|
215
|
+
|---|---|
|
|
216
|
+
| `mcp_servers.era.legacy` | An initialize-era server: an InitializeResult, or the endpoint event of the deprecated HTTP+SSE transport. |
|
|
217
|
+
| `mcp_servers.era.dual` | A server that answered `server/discover` and itself named an initialize-era version too. A lower bound: a server built on the reference SDK names only modern versions there, and is counted modern. |
|
|
218
|
+
| `mcp_servers.era.modern` | A server that answered `server/discover` and named no initialize-era version. Not proven modern-only: EchelonGraph does not send it `initialize` to tell. |
|
|
219
|
+
| `mcp_servers.era.unknown` | Nothing identified an era; every credential challenge is here. |
|
|
220
|
+
| `mcp_servers.era.not_measured` | A verdict recorded before EchelonGraph's probe began recording the era, not re-checked since. |
|
|
221
|
+
| `mcp_servers.transport.streamable_http` | Identified by a POST to `/mcp`. |
|
|
222
|
+
| `mcp_servers.transport.legacy_sse` | Identified by the endpoint event of `GET /sse`, the deprecated HTTP+SSE transport. |
|
|
223
|
+
| `mcp_servers.transport.unknown` | Nothing identified a transport. |
|
|
224
|
+
| `mcp_servers.transport.not_measured` | Recorded before the era probe, not re-checked since. |
|
|
225
|
+
|
|
226
|
+
The tool refuses the answer, as a failure of that radar and never as zeros, when it does not say
|
|
227
|
+
`service` `mcp` (an API older than `?service=mcp` ignores the parameter and answers its counts
|
|
228
|
+
over every AI service it checks), when `mcp_servers.total`, `mcp_servers.protected`,
|
|
229
|
+
`mcp_servers.pending_readjudication` or `mcp_servers.not_assessed` is missing or not a whole
|
|
230
|
+
number, or when they contradict each other. A partition whose buckets are not exactly those
|
|
231
|
+
above, each a whole number adding up to the count it divides, is left out whole and named. A
|
|
232
|
+
field this version does not know is left out, and named only when its name is shaped like a
|
|
233
|
+
field name.
|
|
234
|
+
|
|
167
235
|
## What a result means
|
|
168
236
|
|
|
169
237
|
Every tool answers in one of two shapes, so a model reading the result cannot mistake an
|
|
@@ -171,7 +239,7 @@ outage for an all-clear:
|
|
|
171
239
|
|
|
172
240
|
- **Success** — the first text block is the API's JSON verbatim; the second is a one-line
|
|
173
241
|
note saying the call succeeded, which base URL answered, and what it found; the third is
|
|
174
|
-
the structured result
|
|
242
|
+
the structured result as JSON, less what the first two already say (below). When the
|
|
175
243
|
feed genuinely holds nothing for the query the note says so in words ("we looked and
|
|
176
244
|
found nothing … not a lookup failure"), because a measured zero is a measurement. The
|
|
177
245
|
exception to verbatim is `exposure_radar`: each radar is cut to the fields listed above, and
|
|
@@ -191,12 +259,13 @@ outage for an all-clear:
|
|
|
191
259
|
the host could not be reached, it answered non-2xx, it took longer than the timeout, or
|
|
192
260
|
it answered 2xx with a body that is not a JSON object. The text names the tool, the
|
|
193
261
|
cause (status code or error kind), the path, and the base URL, and says it is not a
|
|
194
|
-
finding; the second text block is the structured result
|
|
195
|
-
rendered as a success with null fields.
|
|
262
|
+
finding; the second text block is the structured result as JSON, less the message's
|
|
263
|
+
sentences (below). A failure is never rendered as a success with null fields.
|
|
196
264
|
|
|
197
|
-
Both shapes also carry a structured result, below, and repeat it in their last text block,
|
|
198
|
-
client that passes only `content` to the model still
|
|
199
|
-
first text block (the API's JSON, or the failure) and the
|
|
265
|
+
Both shapes also carry a structured result, below, and repeat it in their last text block, less
|
|
266
|
+
what an earlier block already says, so a client that passes only `content` to the model still
|
|
267
|
+
sees how the answer was measured. The first text block (the API's JSON, or the failure) and the
|
|
268
|
+
note after it are where 1.x put them.
|
|
200
269
|
|
|
201
270
|
## Structured results
|
|
202
271
|
|
|
@@ -216,15 +285,21 @@ declares its shape as an `outputSchema` in `tools/list`, with a title and the an
|
|
|
216
285
|
| `data` | On a success only: the same JSON as the first text block. |
|
|
217
286
|
| `error` | On a failure only: `kind`, `path`, `status` and `message`. |
|
|
218
287
|
|
|
219
|
-
The last text block of every result is this structured result serialized as JSON,
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
288
|
+
The last text block of every result is this structured result serialized as JSON, less what
|
|
289
|
+
an earlier text block already says verbatim, since a client may pass only `content` to the
|
|
290
|
+
model. It carries the same `state`, `measured_at`, `coverage` and `freshness` (and on a failure
|
|
291
|
+
`error`), key for key. It leaves out `data`, which is a success's first text block, and the
|
|
292
|
+
note's sentences, which are the text block just before it (on a failure, the message) and
|
|
293
|
+
with which `notes` ends: its `notes` holds only the sentences the envelope adds about itself,
|
|
294
|
+
and is left out when there are none. It leaves out `method` too where that note quotes it verbatim, as
|
|
295
|
+
`cve_exposure`'s does ("Method: …"). So the text blocks together carry the whole structured
|
|
296
|
+
result, and cannot disagree with it. Before 2.2.0 this block repeated the whole note, so every
|
|
297
|
+
note was sent twice.
|
|
223
298
|
|
|
224
299
|
| `state` | What it means |
|
|
225
300
|
|---|---|
|
|
226
301
|
| `measured` | A measurement of what was asked. An exposure count is `measured` only when the answer says when what it counts was observed (`measured_at`) and how it was produced (`method`). No exposure answer says when today, so neither exposure tool answers `measured`. |
|
|
227
|
-
| `not_assessed` | The answer holds no dated measurement of what was asked, so no
|
|
302
|
+
| `not_assessed` | The answer holds no dated measurement of what was asked, so no count in it is presented as one: the radar does not look for this CVE, the answer does not say whether it does, or the answer does not say when what it counts was observed. It can still relay a count, as what the source holds on record, undated: `cve_exposure`'s `exposed_hosts` when its `exposure_state` is `exposed`, and `exposure_radar`'s labelled totals. The notes, and `exposure_state` where the result carries it, say what each count is. |
|
|
228
303
|
| `failed` | The lookup did not complete. Not a finding. |
|
|
229
304
|
| `invalid_input` | The input was refused, by this server or by the API, so nothing was looked up. Not a finding. |
|
|
230
305
|
|
|
@@ -248,9 +323,11 @@ a note says so. Per tool:
|
|
|
248
323
|
`cve_exposure` counts", and `coverage.in_scope` is the API's `tracked`. It becomes `measured`
|
|
249
324
|
only when the API serves a time at which the counted services were observed.
|
|
250
325
|
- `exposure_radar` is `not_assessed` with `measured_at` `null`: every radar answered, but no
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
326
|
+
answer gives one time at which what it counts was observed, so no count is presented as a
|
|
327
|
+
dated measurement. `mcp_servers` dates its verdicts only by a window, `mcp_servers.window.from`
|
|
328
|
+
to `mcp_servers.window.to`, over checks made at different times, which is not one observation
|
|
329
|
+
time. Its numbers keep the labels above. `freshness` holds each radar's `last_run_at` where the
|
|
330
|
+
API serves one, for `shadow_ai` also `running`, and for `mcp_servers` also `enabled`.
|
|
254
331
|
|
|
255
332
|
The CVE feed tools' `freshness` is `null`: the feed's answers carry no time at which its
|
|
256
333
|
pollers last completed a poll for the feed as a whole.
|
package/dist/index.js
CHANGED
|
@@ -12,11 +12,12 @@
|
|
|
12
12
|
// the call succeeded, where, and what it found — in words, including when it found
|
|
13
13
|
// nothing ("we looked and found nothing" is a measurement). One exception: exposure_radar
|
|
14
14
|
// relays each radar's answer cut to the fields it can label by what they count, and names
|
|
15
|
-
// what it left out (readKEVExposure, readExposedDatabases, readLeakedCredentials
|
|
16
|
-
// readShadowAI; #2307, #2313). content[2] is the envelope below,
|
|
15
|
+
// what it left out (readKEVExposure, readExposedDatabases, readLeakedCredentials,
|
|
16
|
+
// readShadowAI and readMCPServers; #2307, #2313, #2315). content[2] is the envelope below,
|
|
17
|
+
// as JSON, less what content[0] and the note already say.
|
|
17
18
|
// - failure (isError: true): the lookup did not complete — unreachable host, non-2xx,
|
|
18
19
|
// timeout, or a 2xx whose body is not a JSON object — named by tool, cause and base URL,
|
|
19
|
-
// in content[0]; content[1] is the envelope below, as JSON.
|
|
20
|
+
// in content[0]; content[1] is the envelope below, as JSON, less the message's sentences.
|
|
20
21
|
// Never a success with null fields: a model handed one of those tells its user "no
|
|
21
22
|
// exposure found", and an outage becomes an all-clear.
|
|
22
23
|
//
|
|
@@ -26,13 +27,16 @@
|
|
|
26
27
|
// with, on a success, `data` equal to what content[0] carries, and on a failure an `error`.
|
|
27
28
|
// Each tool advertises the envelope as its outputSchema.
|
|
28
29
|
//
|
|
29
|
-
// The envelope in the text (#2440). Many clients pass only `content` to the model, so the
|
|
30
|
-
// text block of every result is structuredContent serialized as JSON,
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
30
|
+
// The envelope in the text (#2440, #2467). Many clients pass only `content` to the model, so the
|
|
31
|
+
// last text block of every result is structuredContent serialized as JSON, less what an earlier
|
|
32
|
+
// text block already carries verbatim: `data` (content[0] on a success), the note's sentences
|
|
33
|
+
// (the block just before, which structuredContent's notes end with), and method where that note
|
|
34
|
+
// quotes it. The rest — state, measured_at, coverage, freshness, the envelope's own notes and,
|
|
35
|
+
// on a failure, error — is copied key for key, so the text blocks together are the whole of
|
|
36
|
+
// structuredContent and cannot disagree with it. The MCP spec (2025-06-18, Tools, Structured
|
|
37
|
+
// Content) asks a tool that returns structured content to return it serialized in a text block
|
|
38
|
+
// too; the JSON block stays first and the note second, as in 1.x, so a client that parses
|
|
39
|
+
// content[0] is unaffected.
|
|
36
40
|
//
|
|
37
41
|
// Protocol eras (#2311). serveStdio answers both: a 2026-07-28 client's server/discover and
|
|
38
42
|
// per-request `_meta` envelope, and a 2025-era client's `initialize` handshake. The first
|
|
@@ -195,6 +199,8 @@ function describeFailure(f) {
|
|
|
195
199
|
return `EchelonGraph answered HTTP ${f.status} ${where} but the body was not JSON (${f.detail}).`;
|
|
196
200
|
case "not_object":
|
|
197
201
|
return `EchelonGraph answered HTTP ${f.status} ${where} but the body was not a JSON object (${f.detail}).`;
|
|
202
|
+
case "unexpected_shape":
|
|
203
|
+
return `EchelonGraph answered HTTP ${f.status} ${where}, but ${f.detail}.`;
|
|
198
204
|
}
|
|
199
205
|
}
|
|
200
206
|
// What a model must not conclude from a failure.
|
|
@@ -206,20 +212,39 @@ const NO_RECORD = "The API returned no record for this request; the quoted messa
|
|
|
206
212
|
// It is tagged invalid_input, the same state a request this server refuses locally carries.
|
|
207
213
|
const INVALID_INPUT = "(state: invalid_input)";
|
|
208
214
|
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.";
|
|
209
|
-
// The envelope as a text block (#2440)
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
// so
|
|
213
|
-
|
|
214
|
-
|
|
215
|
+
// The envelope as a text block (#2440), so a client that passes only `content` to the model
|
|
216
|
+
// still sees how the answer was measured: structuredContent serialized as JSON, less what an
|
|
217
|
+
// earlier text block already carries verbatim (#2467). 2.1.0 sent the whole envelope without
|
|
218
|
+
// `data`, so every note went out twice, once as prose and once as the notes array, and
|
|
219
|
+
// cve_exposure's method a third time.
|
|
220
|
+
// data left out: content[0] carries it verbatim.
|
|
221
|
+
// notes only the envelope's own sentences (`own`), which no text block says; the rest of
|
|
222
|
+
// structuredContent's notes are the sentences of `said`, the text block just before
|
|
223
|
+
// this one (the note, or a failure's message). Left out when there are none.
|
|
224
|
+
// method left out when `said` quotes it verbatim (cve_exposure's note, "Method: …").
|
|
225
|
+
// Every other key is copied as is, in structuredContent's order, so the text blocks together are
|
|
226
|
+
// the whole of structuredContent and cannot disagree with it.
|
|
227
|
+
const envelopeText = (structured, said, own) => {
|
|
228
|
+
const envelope = {};
|
|
229
|
+
for (const [k, v] of Object.entries(structured)) {
|
|
230
|
+
if (k === "data")
|
|
231
|
+
continue;
|
|
232
|
+
if (k === "notes") {
|
|
233
|
+
if (own.length)
|
|
234
|
+
envelope.notes = own;
|
|
235
|
+
}
|
|
236
|
+
else if (!(k === "method" && typeof v === "string" && said.includes(v)))
|
|
237
|
+
envelope[k] = v;
|
|
238
|
+
}
|
|
215
239
|
return text(JSON.stringify(envelope, null, 2));
|
|
216
240
|
};
|
|
217
241
|
// A failure result: the #1874 text, and an envelope saying nothing was measured, in
|
|
218
242
|
// structuredContent and again as the last text block. The error quotes only what
|
|
219
|
-
// describeFailure already quotes (a redacted cause, the API's own message).
|
|
243
|
+
// describeFailure already quotes (a redacted cause, the API's own message). Every note is a
|
|
244
|
+
// sentence of the message, so the text envelope carries none.
|
|
220
245
|
const failure = (state, message, error, coverage = null) => {
|
|
221
246
|
const structuredContent = { state, measured_at: null, method: null, coverage, freshness: null, notes: sentences(message), error };
|
|
222
|
-
return { content: [text(message), envelopeText(structuredContent)], structuredContent, isError: true };
|
|
247
|
+
return { content: [text(message), envelopeText(structuredContent, message, [])], structuredContent, isError: true };
|
|
223
248
|
};
|
|
224
249
|
const errorOf = (f) => ({ kind: f.kind, path: f.path, status: f.status ?? null, message: f.detail });
|
|
225
250
|
const failed = (tool, f) => {
|
|
@@ -264,11 +289,12 @@ const checked = (tool, schema, r) => {
|
|
|
264
289
|
const p = schema.safeParse(r.structuredContent);
|
|
265
290
|
return p.success ? r : unexpectedShape(tool, p.error);
|
|
266
291
|
};
|
|
267
|
-
// A success: content[0] is the data, content[1] the note, content[2] the envelope
|
|
292
|
+
// A success: content[0] is the data, content[1] the note, content[2] the envelope less both;
|
|
268
293
|
// the envelope carries both, the note as sentences after any the envelope adds about itself.
|
|
269
294
|
const succeeded = (data, note, env) => {
|
|
270
|
-
const
|
|
271
|
-
|
|
295
|
+
const own = env.notes ?? [];
|
|
296
|
+
const structuredContent = { ...env, notes: [...own, ...sentences(note)], data };
|
|
297
|
+
return { content: [text(JSON.stringify(data, null, 2)), text(note), envelopeText(structuredContent, note, own)], structuredContent };
|
|
272
298
|
};
|
|
273
299
|
const okHead = (tool, status) => `${tool} OK: EchelonGraph answered${status === undefined ? "" : ` HTTP ${status}`} from ${SHOWN_BASE}.`;
|
|
274
300
|
// Tolerant readers for the note: a missing or renamed field degrades the sentence, never
|
|
@@ -464,7 +490,7 @@ const SHODAN_ATTRIBUTION = "Exposure counts are derived from Shodan data.";
|
|
|
464
490
|
// and copyright in the applicable Shodan materials" (#2306). The attribution above does the
|
|
465
491
|
// first; this sentence, placed beside it wherever Shodan data is named, does the second.
|
|
466
492
|
const SHODAN_OWNERSHIP = "Shodan data is owned by Shodan, which holds its copyright (© Shodan).";
|
|
467
|
-
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 whose row has not been written or refreshed for 21 days is dropped. last_seen is when EchelonGraph last wrote or refreshed a service's row, not when the service was observed and not when its vulnerable version was last confirmed: it is set to the time of the write when a search matches the banner, and again when a re-check finds the port still listed by Shodan InternetDB, 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
|
|
493
|
+
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 whose row has not been written or refreshed for 21 days is dropped. last_seen is when EchelonGraph last wrote or refreshed a service's row, not when the service was observed and not when its vulnerable version was last confirmed: it is set to the time of the write when a search matches the banner, and again when a re-check finds the port still listed by Shodan InternetDB, 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 note says NOT ASSESSED (exposure_state not_assessed), and its 0 is not a measurement.`;
|
|
468
494
|
function exposureNote(head, id, d) {
|
|
469
495
|
const cve = strAt(d, "cve_id") ?? id;
|
|
470
496
|
const hosts = numAt(d, "exposed_hosts");
|
|
@@ -600,13 +626,16 @@ async function cveExposure(cve_id) {
|
|
|
600
626
|
}
|
|
601
627
|
// Every quadrant that finds its services through Shodan, attributed. The exposed-database
|
|
602
628
|
// radar searches Shodan dorks and falls back to LeakIX when the Shodan query budget is low
|
|
603
|
-
// (core-backend/internal/exposeddb/poller.go).
|
|
604
|
-
|
|
629
|
+
// (core-backend/internal/exposeddb/poller.go). MCP-server discovery is named too, as the one
|
|
630
|
+
// that uses none (#2315; see the block comment above readMCPServers), so the attribution is
|
|
631
|
+
// never read as covering it.
|
|
632
|
+
const RADAR_SOURCES = "KEV-exposure, exposed-database and shadow-AI discovery use Shodan data (shadow AI also uses Certificate Transparency logs; exposed databases fall back to LeakIX when Shodan query credits run low). MCP-server discovery uses no Shodan data: its hostnames come from EchelonGraph's own Certificate Transparency feed, matched by hostname pattern.";
|
|
605
633
|
const RADARS = [
|
|
606
634
|
["kev_exposure", "/api/v1/public/kev-exposure/stats"],
|
|
607
635
|
["exposed_databases", "/api/v1/public/exposed-databases/stats"],
|
|
608
636
|
["leaked_credentials", "/api/v1/public/leaked-credentials/stats"],
|
|
609
637
|
["shadow_ai", "/api/v1/public/shadow-ai-radar/stats"],
|
|
638
|
+
["mcp_servers", "/api/v1/public/ai-exposure/stats?service=mcp"],
|
|
610
639
|
];
|
|
611
640
|
// The shadow-AI stats answer, regrouped by what each number counts (#2307).
|
|
612
641
|
//
|
|
@@ -1171,33 +1200,265 @@ function readShadowAI(sa) {
|
|
|
1171
1200
|
sentences.push(`Latest shadow-AI observation on record: ${lastObservation}.`);
|
|
1172
1201
|
return { data, sentences };
|
|
1173
1202
|
}
|
|
1174
|
-
|
|
1203
|
+
// ── mcp_servers: GET /api/v1/public/ai-exposure/stats?service=mcp (#2315) ──
|
|
1204
|
+
//
|
|
1205
|
+
// core-backend/internal/aiexposure (handler.go MCPPublicCounts, store.go): the AI-exposure
|
|
1206
|
+
// radar's MCP rows, one per hostname, each counted once by its latest verdict on record, with
|
|
1207
|
+
// EchelonGraph's own control servers left out and counted in own_controls_excluded. Counts,
|
|
1208
|
+
// timestamps and one constant (service "mcp"); no hostname, version or server name.
|
|
1209
|
+
// total protected + pending_readjudication + not_assessed
|
|
1210
|
+
// protected the /mcp endpoint answered 401/403 and its RFC 9728 protected-resource
|
|
1211
|
+
// metadata validated (prm.go); prm_via divides exactly these rows by
|
|
1212
|
+
// where that document was found
|
|
1213
|
+
// pending_readjudication a verdict of a rule since replaced, not yet re-checked (store.go
|
|
1214
|
+
// readjudicationPredicate); in neither of the other two
|
|
1215
|
+
// not_assessed the rest; not_assessed_by_reason divides exactly these rows, first
|
|
1216
|
+
// match wins: the eight prm.go reason codes (a challenge whose metadata
|
|
1217
|
+
// did not validate), identified_no_challenge, challenge_unadjudicated
|
|
1218
|
+
// (a challenge recorded before #2312), no_http_answer, not_identified_as_mcp
|
|
1219
|
+
// era, transport divide every counted row; a row written before #2314 recorded an era
|
|
1220
|
+
// is not_measured (era.go says what each value rests on)
|
|
1221
|
+
// window from/to: the oldest and newest last-check time over the counted rows
|
|
1222
|
+
// enabled, last_run_at exactly as the unparameterised answer computes them: the AI-exposure
|
|
1223
|
+
// radar's fleet-wide liveness and last completed check, over every AI
|
|
1224
|
+
// service it checks, not MCP servers alone. enabled is true when a check
|
|
1225
|
+
// completed within liveWindow (45 minutes) of the answer.
|
|
1226
|
+
// counted_at when the API read the counts
|
|
1227
|
+
//
|
|
1228
|
+
// Discovery uses no Shodan data, so this reader carries no Shodan attribution. poller.go
|
|
1229
|
+
// discover enqueues only hostnames that store.go CandidateHosts matches by pattern (catalog.go
|
|
1230
|
+
// HostPatternIndex) in ct_domains and shadow_ct_observations, and that query drops every domain
|
|
1231
|
+
// holding a ':'; a Shodan row's domain is its ip:port (shadowctlog poller.go shodanDomain), so
|
|
1232
|
+
// none reaches the queue.
|
|
1233
|
+
//
|
|
1234
|
+
// identified_no_challenge is where #2307's mistake would be made again: a server that
|
|
1235
|
+
// identified itself as an MCP server and asked for no credentials at the handshake. MCP
|
|
1236
|
+
// authorization is optional (MCP 2026-07-28, Authorization › Protocol Requirements), a server
|
|
1237
|
+
// can enforce it at tools/call, and the probe never sends tools/call (mcp.go), so nothing here
|
|
1238
|
+
// says whether such a server gives anything away. Every text says what the bucket is, and never
|
|
1239
|
+
// calls it open, exposed or unauthenticated.
|
|
1240
|
+
//
|
|
1241
|
+
// What is refused, and what is left out. An API older than ?service=mcp ignores the parameter
|
|
1242
|
+
// and answers its counts over EVERY AI service, `protected` among them (aiexposure PublicCounts);
|
|
1243
|
+
// relayed as MCP-server counts, that is #2307 again. And the API refuses to serve counts that
|
|
1244
|
+
// contradict its method (a 500, never a 200 that breaks a sum), so a 200 that breaks one is not
|
|
1245
|
+
// an answer of that contract. So the radar is refused, as a per-radar failure (mcpRefusal),
|
|
1246
|
+
// when the answer does not say service "mcp", when a headline count is missing or not a whole
|
|
1247
|
+
// number, or when total is not the sum of the other three. Below the headline, the file's rule
|
|
1248
|
+
// for a known field in an unexpected shape holds: a partition is relayed only with exactly its
|
|
1249
|
+
// buckets, each a whole number, adding up to the count it divides, or it is left out whole and
|
|
1250
|
+
// named, since a partition missing a bucket, or carrying one this version cannot label, is an
|
|
1251
|
+
// undercount that reads as complete. A field this version does not know is left out, and named
|
|
1252
|
+
// only when its name is shaped like a field name: the answer is counts only, a key could itself
|
|
1253
|
+
// be a hostname, and the tool repeats no string the answer carries but its timestamps.
|
|
1254
|
+
const MCP_HEADLINE = ["total", "protected", "pending_readjudication", "not_assessed"];
|
|
1255
|
+
const MCP_CHALLENGE_REASONS = [
|
|
1256
|
+
"resource_mismatch",
|
|
1257
|
+
"cross_origin_pointer",
|
|
1258
|
+
"bare_challenge_no_prm",
|
|
1259
|
+
"pointer_unreachable",
|
|
1260
|
+
"pointer_invalid",
|
|
1261
|
+
"no_authorization_servers",
|
|
1262
|
+
"wellknown_unreachable",
|
|
1263
|
+
"metadata_invalid",
|
|
1264
|
+
];
|
|
1265
|
+
const MCP_PARTITIONS = {
|
|
1266
|
+
not_assessed_by_reason: {
|
|
1267
|
+
of: "not_assessed",
|
|
1268
|
+
buckets: ["identified_no_challenge", ...MCP_CHALLENGE_REASONS, "challenge_unadjudicated", "no_http_answer", "not_identified_as_mcp"],
|
|
1269
|
+
},
|
|
1270
|
+
prm_via: { of: "protected", buckets: ["header", "wellknown_path", "wellknown_root"] },
|
|
1271
|
+
era: { of: "total", buckets: ["legacy", "dual", "modern", "unknown", "not_measured"] },
|
|
1272
|
+
transport: { of: "total", buckets: ["streamable_http", "legacy_sse", "unknown", "not_measured"] },
|
|
1273
|
+
};
|
|
1274
|
+
const MCP_PARTITION_NAMES = Object.keys(MCP_PARTITIONS);
|
|
1275
|
+
const MCP_INSTANTS = ["last_run_at", "counted_at"];
|
|
1276
|
+
const MCP_KNOWN = new Set(["service", ...MCP_HEADLINE, ...MCP_PARTITION_NAMES, "own_controls_excluded", "window", "enabled", ...MCP_INSTANTS]);
|
|
1277
|
+
// What each challenge reason code says (prm.go prmReasonCodes), for the note.
|
|
1278
|
+
const MCP_CHALLENGE_MEANS = {
|
|
1279
|
+
resource_mismatch: "the document's resource is absent or not identical to the server's identifier",
|
|
1280
|
+
cross_origin_pointer: "the challenge pointed to metadata on another origin, which EchelonGraph does not request",
|
|
1281
|
+
bare_challenge_no_prm: "no metadata URL was named and neither well-known URI answered 2xx",
|
|
1282
|
+
pointer_unreachable: "the same-origin metadata URL did not answer 2xx in full",
|
|
1283
|
+
pointer_invalid: "the metadata pointer is not a URL EchelonGraph will request",
|
|
1284
|
+
no_authorization_servers: "the document names no authorization server",
|
|
1285
|
+
wellknown_unreachable: "a well-known metadata request got no complete HTTP answer",
|
|
1286
|
+
metadata_invalid: "the answer is not a 200 JSON object within the size cap",
|
|
1287
|
+
};
|
|
1288
|
+
// A count as the MCP answer states one: a whole number, zero or more.
|
|
1289
|
+
const isWhole = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
|
|
1290
|
+
// The shape of a field name. An unknown key of this shape is named when left out; any other is
|
|
1291
|
+
// counted, not repeated, since it could be a hostname.
|
|
1292
|
+
const FIELD_NAME = /^[a-z][a-z0-9_]{0,63}$/;
|
|
1293
|
+
// core-backend aiexposure handler.go liveWindow: how recently a check must have completed for
|
|
1294
|
+
// the API to answer enabled true. A liveness window, not a re-probe interval.
|
|
1295
|
+
// MCP_CHALLENGE_REASONS as the tool description names them, spelled out as ONE constant string: the
|
|
1296
|
+
// description is published, and the site's tool-claims check (marketing-site lib/mcpToolClaims.test.ts)
|
|
1297
|
+
// reads every description statically, folding only references to constant strings, so a .map() here
|
|
1298
|
+
// made the whole description unreadable to it. tools.test.mjs pins this to MCP_CHALLENGE_REASONS.
|
|
1299
|
+
const MCP_CHALLENGE_REASON_FIELDS = "mcp_servers.not_assessed_by_reason.resource_mismatch, mcp_servers.not_assessed_by_reason.cross_origin_pointer, mcp_servers.not_assessed_by_reason.bare_challenge_no_prm, mcp_servers.not_assessed_by_reason.pointer_unreachable, mcp_servers.not_assessed_by_reason.pointer_invalid, mcp_servers.not_assessed_by_reason.no_authorization_servers, mcp_servers.not_assessed_by_reason.wellknown_unreachable and mcp_servers.not_assessed_by_reason.metadata_invalid";
|
|
1300
|
+
const MCP_ENABLED_WITHIN = "45 minutes";
|
|
1301
|
+
// Why an ?service=mcp answer is not relayed at all, or undefined when it can be read. The
|
|
1302
|
+
// sentence quotes no value the answer carries.
|
|
1303
|
+
function mcpRefusal(body) {
|
|
1304
|
+
if (!isPlainObject(body))
|
|
1305
|
+
return "the answer is not a JSON object carrying the MCP-server counts";
|
|
1306
|
+
if (body.service === undefined) {
|
|
1307
|
+
return "the answer carries no service field, so it is not the MCP-server counts: an API older than service=mcp ignores that parameter and answers the AI-exposure radar's counts over every AI service it checks, which this tool does not relay as MCP-server counts";
|
|
1308
|
+
}
|
|
1309
|
+
if (body.service !== "mcp")
|
|
1310
|
+
return "the answer names a service other than mcp, so it is not the MCP-server counts";
|
|
1311
|
+
const bad = MCP_HEADLINE.filter((k) => !isWhole(body[k]));
|
|
1312
|
+
if (bad.length)
|
|
1313
|
+
return `its ${listed(bad)} ${be(bad.length)} missing or not a whole number of zero or more, so its counts are not relayed`;
|
|
1314
|
+
const [total, protectedN, pending, notAssessed] = MCP_HEADLINE.map((k) => body[k]);
|
|
1315
|
+
if (total !== protectedN + pending + notAssessed) {
|
|
1316
|
+
return "its counts contradict each other (total is not protected + pending_readjudication + not_assessed, an answer the API's own integrity check does not serve), so they are not relayed";
|
|
1317
|
+
}
|
|
1318
|
+
return undefined;
|
|
1319
|
+
}
|
|
1320
|
+
function readMCPServers(src) {
|
|
1321
|
+
const data = {};
|
|
1322
|
+
const unusable = [];
|
|
1323
|
+
const unsummed = [];
|
|
1324
|
+
for (const k of MCP_HEADLINE)
|
|
1325
|
+
data[k] = src[k];
|
|
1326
|
+
for (const k of MCP_PARTITION_NAMES) {
|
|
1327
|
+
const { of, buckets } = MCP_PARTITIONS[k];
|
|
1328
|
+
const v = src[k];
|
|
1329
|
+
const exact = isPlainObject(v) && Object.keys(v).length === buckets.length && buckets.every((b) => Object.prototype.hasOwnProperty.call(v, b) && isWhole(v[b]));
|
|
1330
|
+
if (!exact) {
|
|
1331
|
+
unusable.push(k);
|
|
1332
|
+
continue;
|
|
1333
|
+
}
|
|
1334
|
+
if (buckets.reduce((sum, b) => sum + v[b], 0) !== src[of]) {
|
|
1335
|
+
unsummed.push(k);
|
|
1336
|
+
continue;
|
|
1337
|
+
}
|
|
1338
|
+
data[k] = Object.fromEntries(buckets.map((b) => [b, v[b]]));
|
|
1339
|
+
}
|
|
1340
|
+
if (isWhole(src.own_controls_excluded))
|
|
1341
|
+
data.own_controls_excluded = src.own_controls_excluded;
|
|
1342
|
+
else
|
|
1343
|
+
unusable.push("own_controls_excluded");
|
|
1344
|
+
// window: from and to, both real instants, from not after to. With no row counted the API has
|
|
1345
|
+
// no window to give (null, or from and to null), and none is relayed.
|
|
1346
|
+
const w = src.window;
|
|
1347
|
+
const from = strAt(w, "from");
|
|
1348
|
+
const to = strAt(w, "to");
|
|
1349
|
+
const noWindow = w === null || w === undefined || (isPlainObject(w) && w.from === null && w.to === null && Object.keys(w).length === 2);
|
|
1350
|
+
if (isPlainObject(w) && Object.keys(w).length === 2 && realInstant(from) && realInstant(to) && Date.parse(from) <= Date.parse(to))
|
|
1351
|
+
data.window = { from, to };
|
|
1352
|
+
else if (!(noWindow && src.total === 0))
|
|
1353
|
+
unusable.push("window");
|
|
1354
|
+
if (typeof src.enabled === "boolean")
|
|
1355
|
+
data.enabled = src.enabled;
|
|
1356
|
+
else
|
|
1357
|
+
unusable.push("enabled");
|
|
1358
|
+
// A timestamp that is not a real instant is not relayed as one (readRadar's rule).
|
|
1359
|
+
for (const k of MCP_INSTANTS) {
|
|
1360
|
+
const s = strAt(src, k);
|
|
1361
|
+
if (realInstant(s))
|
|
1362
|
+
data[k] = s;
|
|
1363
|
+
else if (src[k] !== undefined && src[k] !== null && typeof src[k] !== "string")
|
|
1364
|
+
unusable.push(k);
|
|
1365
|
+
}
|
|
1366
|
+
const extra = Object.keys(src).filter((k) => !MCP_KNOWN.has(k));
|
|
1367
|
+
const named = extra.filter((k) => FIELD_NAME.test(k));
|
|
1368
|
+
const unnamed = extra.length - named.length;
|
|
1369
|
+
const sentences = [];
|
|
1370
|
+
const n = (k) => data[k];
|
|
1371
|
+
const b = (p, bucket) => `mcp_servers.${p}.${bucket} (${data[p][bucket]})`;
|
|
1372
|
+
sentences.push(`MCP servers: mcp_servers.total (${n("total")}) counts hostnames the AI-exposure radar has checked for an MCP server, each once, by its latest verdict on record; each was named like an MCP server in EchelonGraph's own Certificate Transparency feed, and not every one is an MCP server.`);
|
|
1373
|
+
if (n("total") === 0)
|
|
1374
|
+
sentences.push("mcp_servers.total is 0: the radar holds no verdict on record for a hostname named like an MCP server.");
|
|
1375
|
+
sentences.push(`mcp_servers.protected (${n("protected")}) counts hostnames whose /mcp endpoint asked for credentials (a 401 or 403) and whose OAuth protected-resource metadata validated under RFC 9728: a 200 JSON document whose resource is identical to the server's identifier and that names at least one authorization server.`);
|
|
1376
|
+
if (data.prm_via) {
|
|
1377
|
+
sentences.push(`mcp_servers.prm_via divides them by where that document was found: ${b("prm_via", "header")}, the same-origin URL the challenge named; ${b("prm_via", "wellknown_path")}, /.well-known/oauth-protected-resource followed by the endpoint's path; and ${b("prm_via", "wellknown_root")}, that URI at the root.`);
|
|
1378
|
+
}
|
|
1379
|
+
sentences.push(`mcp_servers.pending_readjudication (${n("pending_readjudication")}) counts verdicts decided by a rule EchelonGraph has since replaced and not yet re-checked under the current rules; they are in neither mcp_servers.protected nor mcp_servers.not_assessed.`, `mcp_servers.not_assessed (${n("not_assessed")}) counts the rest, whose protection the radar could not assess; not assessed does not mean unprotected${data.not_assessed_by_reason ? ", and mcp_servers.not_assessed_by_reason puts each in exactly one bucket" : ""}.`);
|
|
1380
|
+
if (data.not_assessed_by_reason) {
|
|
1381
|
+
sentences.push(`${b("not_assessed_by_reason", "identified_no_challenge")} counts servers that identified themselves as MCP servers (a DiscoverResult, an InitializeResult, or the endpoint event of the deprecated HTTP+SSE transport) and did not ask for credentials at the handshake.`, "That is normal in MCP: authorization is optional in the spec, and a server can enforce it at tools/call instead, which EchelonGraph never sends, so this bucket is not a finding of exposure.", `Endpoints that asked for credentials (a 401 or 403) but whose RFC 9728 metadata did not validate are counted by why: ${MCP_CHALLENGE_REASONS.map((r, i) => `${i === MCP_CHALLENGE_REASONS.length - 1 ? "and " : ""}${b("not_assessed_by_reason", r)}, ${MCP_CHALLENGE_MEANS[r]}`).join("; ")}.`, "Each of those endpoints asked for credentials, so none of them is shown to lack protection: only its metadata did not validate.", `${b("not_assessed_by_reason", "challenge_unadjudicated")} counts endpoints that asked for credentials before EchelonGraph read RFC 9728 metadata, not re-checked since.`, `${b("not_assessed_by_reason", "no_http_answer")} counts hostnames that gave no HTTP answer (DNS, TCP or TLS failed, or the request timed out), and ${b("not_assessed_by_reason", "not_identified_as_mcp")} hostnames that answered HTTP with nothing that identified an MCP server (a login or error page, a body that is not JSON-RPC, a 4xx or 5xx): neither is a count of MCP servers.`);
|
|
1382
|
+
}
|
|
1383
|
+
if (data.era) {
|
|
1384
|
+
sentences.push(`mcp_servers.era divides every counted hostname by the protocol era its answer identified: ${b("era", "legacy")}, an initialize-era server (an InitializeResult, or the legacy SSE endpoint event); ${b("era", "dual")}, answered server/discover and named an initialize-era version too, a lower bound, since a server built on the reference SDK names only modern versions there and is counted modern; ${b("era", "modern")}, answered server/discover and named no initialize-era version, not proven modern-only, since EchelonGraph does not send initialize to tell; ${b("era", "unknown")}, nothing identified an era, every credential challenge included; and ${b("era", "not_measured")}, a verdict recorded before EchelonGraph's probe began recording the era and not re-checked since.`);
|
|
1385
|
+
}
|
|
1386
|
+
if (data.transport) {
|
|
1387
|
+
sentences.push(`mcp_servers.transport divides them by the transport that identified the server: ${b("transport", "streamable_http")}, a POST to /mcp; ${b("transport", "legacy_sse")}, the endpoint event of GET /sse (the deprecated HTTP+SSE transport); ${b("transport", "unknown")}, nothing identified one; and ${b("transport", "not_measured")}, recorded before the era probe and not re-checked since.`);
|
|
1388
|
+
}
|
|
1389
|
+
if (data.own_controls_excluded !== undefined) {
|
|
1390
|
+
sentences.push(`mcp_servers.own_controls_excluded (${n("own_controls_excluded")}) counts EchelonGraph's own control servers, which are left out of every other mcp_servers number.`);
|
|
1391
|
+
}
|
|
1392
|
+
if (data.window) {
|
|
1393
|
+
sentences.push(`mcp_servers.window says when the verdicts counted were last checked: the oldest at ${from} (mcp_servers.window.from) and the newest at ${to} (mcp_servers.window.to), so the counts are each hostname's latest verdict, not one sweep at one time.`);
|
|
1394
|
+
}
|
|
1395
|
+
const at = data.last_run_at;
|
|
1396
|
+
if (typeof at === "string") {
|
|
1397
|
+
sentences.push(`mcp_servers last completed check: ${at} (mcp_servers.last_run_at, a timestamp, not a count).`, "mcp_servers.last_run_at is when the AI-exposure radar, which checks other AI services as well as MCP servers, last finished a cycle whose reads succeeded and whose scan opt-out register answered.", "mcp_servers.last_run_at is not the time of every verdict the mcp_servers numbers count (mcp_servers.window says when those were checked); nor is it mcp_servers.counted_at, when the API read those numbers.");
|
|
1398
|
+
}
|
|
1399
|
+
if (data.enabled === true) {
|
|
1400
|
+
sentences.push(`mcp_servers.enabled is true: the API reports the AI-exposure radar running, a check having completed within ${MCP_ENABLED_WITHIN} of its answer.`);
|
|
1401
|
+
}
|
|
1402
|
+
else if (data.enabled === false) {
|
|
1403
|
+
sentences.push(`mcp_servers.enabled is false: the API does not report the AI-exposure radar running (no check completed within ${MCP_ENABLED_WITHIN} of its answer), so these numbers may be stale.`);
|
|
1404
|
+
}
|
|
1405
|
+
if (typeof data.counted_at === "string") {
|
|
1406
|
+
sentences.push(`mcp_servers.counted_at (${data.counted_at}) is when the API read these counts: a timestamp, not a count, and not when any verdict was checked.`);
|
|
1407
|
+
}
|
|
1408
|
+
sentences.push(...leftOut("mcp_servers", named, unusable));
|
|
1409
|
+
if (unsummed.length)
|
|
1410
|
+
sentences.push(`Left out of mcp_servers because their buckets do not add up to the count they divide: ${unsummed.join(", ")}.`);
|
|
1411
|
+
if (unnamed) {
|
|
1412
|
+
sentences.push(`Also left out of mcp_servers: ${unnamed} field${unnamed === 1 ? "" : "s"} whose name is not shaped like a field name, not repeated here, since a name could itself identify a server.`);
|
|
1413
|
+
}
|
|
1414
|
+
return { data, sentences };
|
|
1415
|
+
}
|
|
1416
|
+
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. It also gives MCP-server counts: hostnames named like an MCP server in EchelonGraph's own Certificate Transparency feed (no Shodan data), each counted once by its latest verdict from EchelonGraph's identified MCP probe, which never sends tools/call. 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. ` +
|
|
1175
1417
|
`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. ` +
|
|
1176
1418
|
`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. ` +
|
|
1177
1419
|
`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. ` +
|
|
1178
|
-
`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.
|
|
1420
|
+
`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. ` +
|
|
1421
|
+
`mcp_servers: counts and timestamps only, no hostname. mcp_servers.total counts hostnames the AI-exposure radar has checked for an MCP server, each once by its latest verdict on record, and not every one is an MCP server; EchelonGraph's own control servers are left out, and mcp_servers.own_controls_excluded counts them. mcp_servers.total is mcp_servers.protected plus mcp_servers.pending_readjudication plus mcp_servers.not_assessed. mcp_servers.protected counts hostnames whose /mcp endpoint asked for credentials and whose OAuth protected-resource metadata validated under RFC 9728; mcp_servers.prm_via divides them by where that document was found: mcp_servers.prm_via.header, mcp_servers.prm_via.wellknown_path and mcp_servers.prm_via.wellknown_root. mcp_servers.pending_readjudication counts verdicts of a rule since replaced, not yet re-checked. mcp_servers.not_assessed counts the rest, whose protection the radar could not assess (not assessed does not mean unprotected), and mcp_servers.not_assessed_by_reason puts each in one bucket. mcp_servers.not_assessed_by_reason.identified_no_challenge holds servers that identified themselves as MCP servers and did not ask for credentials at the handshake. That is normal in MCP: authorization is optional in the spec, and a server can enforce it at tools/call instead, which EchelonGraph never sends, so this bucket is not a finding of exposure. ${MCP_CHALLENGE_REASON_FIELDS} hold endpoints that asked for credentials but whose RFC 9728 metadata did not validate, by why, so none of them is shown to lack protection; mcp_servers.not_assessed_by_reason.challenge_unadjudicated holds endpoints that asked for credentials before that check existed, not yet re-checked. mcp_servers.not_assessed_by_reason.no_http_answer holds hostnames that gave no HTTP answer, and mcp_servers.not_assessed_by_reason.not_identified_as_mcp hostnames whose HTTP answer identified no MCP server. mcp_servers.era divides every counted hostname by protocol era: mcp_servers.era.legacy; mcp_servers.era.dual, a lower bound; mcp_servers.era.modern, not proven modern-only; mcp_servers.era.unknown; and mcp_servers.era.not_measured, recorded before the era probe and not re-checked since. mcp_servers.transport divides them by transport: mcp_servers.transport.streamable_http, mcp_servers.transport.legacy_sse, mcp_servers.transport.unknown and mcp_servers.transport.not_measured. mcp_servers.window.from and mcp_servers.window.to are when the oldest and the newest of the verdicts counted were last checked. mcp_servers.last_run_at is a timestamp, not a count: when the AI-exposure radar, which checks other AI services too, last completed a check; mcp_servers.enabled is whether one completed within ${MCP_ENABLED_WITHIN} of the answer, and mcp_servers.counted_at is when the API read the counts. A partition whose buckets do not add up to the count it divides is left out and named; an answer that does not say it is the MCP-server counts, or whose mcp_servers.total, mcp_servers.protected, mcp_servers.pending_readjudication and mcp_servers.not_assessed are missing or contradict each other, is a failure. ${SHODAN_OWNERSHIP}`;
|
|
1179
1422
|
// exposure_radar's envelope. Every radar answered, but no stats answer says when the services
|
|
1180
1423
|
// or records it counts were observed: each count covers everything that radar still holds on
|
|
1181
1424
|
// record, and the answer carries only generated_at (when the API computed the totals) and
|
|
1182
1425
|
// last_run_at (the radar's last completed check), neither of which is that time (#2335). So
|
|
1183
1426
|
// the result is not_assessed with measured_at null, and says why; its numbers keep their labels,
|
|
1184
1427
|
// and freshness carries each radar's last_run_at where the API serves one.
|
|
1185
|
-
|
|
1428
|
+
//
|
|
1429
|
+
// mcp_servers (#2315) does not change that, by the envelope's own rule (the `state` block above
|
|
1430
|
+
// the State type): an exposure count is measured only with measured_at, "when the underlying
|
|
1431
|
+
// observation was made, as the API states it", and otherwise it is not_assessed and relayed as
|
|
1432
|
+
// what the source holds on record. The MCP answer states no such time. Its counts are each
|
|
1433
|
+
// hostname's LATEST verdict, and window.from and window.to are the oldest and newest last-check
|
|
1434
|
+
// times over the counted rows, a span of checks made at different times, not one observation.
|
|
1435
|
+
// Taking window.to as measured_at would date every count by its newest verdict, the reading
|
|
1436
|
+
// #2335 and #2439 refuse for last_run_at and last_seen; window.from would date each one by its
|
|
1437
|
+
// oldest. And the envelope is one for five radars, four of which carry no time of observation at
|
|
1438
|
+
// all, so a measured state would date their numbers too (#2313 done-means 5). So the state stays
|
|
1439
|
+
// not_assessed with measured_at null, the window is relayed in data and labelled by the note,
|
|
1440
|
+
// and the envelope's own note names it.
|
|
1441
|
+
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; mcp_servers, the latest verdict on record per hostname named like an MCP server in EchelonGraph's own Certificate Transparency feed, from EchelonGraph's identified MCP probe (server/discover, and initialize only if that is refused; never tools/call), where protected means the endpoint's RFC 9728 protected-resource metadata validated.";
|
|
1186
1442
|
const RADAR_NAMES = RADARS.map(([name]) => name);
|
|
1187
|
-
const RADAR_STATE_NOTE = "state is not_assessed: every radar answered, but
|
|
1188
|
-
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,
|
|
1443
|
+
const RADAR_STATE_NOTE = "state is not_assessed: every radar answered, but none gives one time at which what it counts was observed: kev_exposure, exposed_databases, leaked_credentials and shadow_ai give none, and mcp_servers dates its verdicts at most by a window (mcp_servers.window), from the oldest check among them to the newest, so no count here is presented as a dated measurement and measured_at is null. Each count is what that radar holds on record, and the note labels each one by what it counts.";
|
|
1444
|
+
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, freshness.shadow_ai.last_run_at with freshness.shadow_ai.running, and freshness.mcp_servers.last_run_at with freshness.mcp_servers.enabled), and null where it does not.";
|
|
1189
1445
|
async function exposureRadar() {
|
|
1190
1446
|
const tool = "exposure_radar";
|
|
1191
1447
|
try {
|
|
1192
1448
|
const results = await Promise.all(RADARS.map(([, path]) => api(path)));
|
|
1193
1449
|
const data = {};
|
|
1194
1450
|
const failures = [];
|
|
1195
|
-
RADARS.forEach(([name], i) => {
|
|
1451
|
+
RADARS.forEach(([name, path], i) => {
|
|
1196
1452
|
const r = results[i];
|
|
1197
|
-
if (r.ok)
|
|
1453
|
+
if (!r.ok)
|
|
1454
|
+
return void failures.push([name, r]);
|
|
1455
|
+
// mcp_servers is read only when the answer is the MCP-server counts at all (mcpRefusal): an
|
|
1456
|
+
// answer that is not is a failure of that radar, named like any other, never zeros.
|
|
1457
|
+
const refused = name === "mcp_servers" ? mcpRefusal(r.data) : undefined;
|
|
1458
|
+
if (refused === undefined)
|
|
1198
1459
|
data[name] = r.data;
|
|
1199
1460
|
else
|
|
1200
|
-
failures.push([name, r]);
|
|
1461
|
+
failures.push([name, { ok: false, kind: "unexpected_shape", path, status: r.status, detail: refused }]);
|
|
1201
1462
|
});
|
|
1202
1463
|
if (failures.length) {
|
|
1203
1464
|
// All or nothing: a radar picture with one quadrant missing reads as the whole one,
|
|
@@ -1217,6 +1478,7 @@ async function exposureRadar() {
|
|
|
1217
1478
|
exposed_databases: readExposedDatabases(data.exposed_databases),
|
|
1218
1479
|
leaked_credentials: readLeakedCredentials(data.leaked_credentials),
|
|
1219
1480
|
shadow_ai: readShadowAI(data.shadow_ai),
|
|
1481
|
+
mcp_servers: readMCPServers(data.mcp_servers),
|
|
1220
1482
|
};
|
|
1221
1483
|
const relayed = {};
|
|
1222
1484
|
const said = [];
|
|
@@ -1227,6 +1489,7 @@ async function exposureRadar() {
|
|
|
1227
1489
|
// Only what the readers relayed: a last_run_at that is not a real instant never got there.
|
|
1228
1490
|
const stamp = (radar, ...keys) => instantOrNull(strAt(relayed[radar], ...keys));
|
|
1229
1491
|
const running = boolAt(relayed.shadow_ai, "poller", "running");
|
|
1492
|
+
const enabled = boolAt(relayed.mcp_servers, "enabled");
|
|
1230
1493
|
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.`, {
|
|
1231
1494
|
state: "not_assessed",
|
|
1232
1495
|
measured_at: null,
|
|
@@ -1237,6 +1500,7 @@ async function exposureRadar() {
|
|
|
1237
1500
|
exposed_databases: { last_run_at: stamp("exposed_databases", "last_run_at") },
|
|
1238
1501
|
leaked_credentials: { last_run_at: stamp("leaked_credentials", "last_run_at") },
|
|
1239
1502
|
shadow_ai: { last_run_at: stamp("shadow_ai", "poller", "last_run_at"), running: running ?? null },
|
|
1503
|
+
mcp_servers: { last_run_at: stamp("mcp_servers", "last_run_at"), enabled: enabled ?? null },
|
|
1240
1504
|
},
|
|
1241
1505
|
notes: [RADAR_STATE_NOTE, RADAR_FRESHNESS_NOTE],
|
|
1242
1506
|
});
|
|
@@ -1262,11 +1526,19 @@ const ErrorSchema = z.strictObject({
|
|
|
1262
1526
|
message: z.string().describe("The cause: the API's own message, or what went wrong."),
|
|
1263
1527
|
});
|
|
1264
1528
|
const NOTES = z.array(z.string()).describe("Caveats, one sentence each.");
|
|
1529
|
+
// The success states (#2465). 2.1.0 described not_assessed as no dated measurement whose numbers
|
|
1530
|
+
// were all denied the status of findings, on the same schema as a cve_exposure answer relaying
|
|
1531
|
+
// services on record for a CISA-KEV CVE, which is not_assessed because its count is undated
|
|
1532
|
+
// (#2439). A client that respects the schema was told that count is not a finding. The count is
|
|
1533
|
+
// what the radar holds on record: not a dated measurement, and not nothing. So the description
|
|
1534
|
+
// says both, and leaves what each count is to the notes and exposure_state, which say it per
|
|
1535
|
+
// answer; it makes no blanket claim about the numbers, in either direction.
|
|
1536
|
+
const SUCCESS_STATE = "measured: a measurement of what was asked; an exposure count is measured only with measured_at and method. not_assessed: the answer holds no dated measurement of what was asked, so no count in it is presented as one; it can still relay a count, as what the source holds on record, undated, and its notes (and exposure_state, where the result carries it) say what each count is.";
|
|
1265
1537
|
function envelopeSchema(o) {
|
|
1266
1538
|
const coverage = o.coverage ? o.coverage.nullable() : z.null();
|
|
1267
1539
|
return z.discriminatedUnion("state", [
|
|
1268
1540
|
z.strictObject({
|
|
1269
|
-
state: z.enum(["measured", "not_assessed"]).describe(
|
|
1541
|
+
state: z.enum(["measured", "not_assessed"]).describe(SUCCESS_STATE),
|
|
1270
1542
|
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."),
|
|
1271
1543
|
method: z.string().describe("How the numbers were produced."),
|
|
1272
1544
|
coverage: coverage.describe("What the answer covers; null where the answer says nothing about it."),
|
|
@@ -1373,7 +1645,7 @@ const CVE_EXPOSURE_OUTPUT = envelopeSchema({
|
|
|
1373
1645
|
exposure_state: z
|
|
1374
1646
|
.enum(["exposed", "measured_zero", "not_assessed", "tracking_unknown"])
|
|
1375
1647
|
.nullable()
|
|
1376
|
-
.describe("What the answer's count is: exposed, a
|
|
1648
|
+
.describe("What the answer's count is, as the note's (exposure_state: …) tag says; not state, which says whether the answer is a dated measurement. exposed: data.exposed_hosts counts the services the radar holds on record for the CVE, above 0. measured_zero: the API marks the CVE as tracked with 0 services, a zero in the radar's sample. not_assessed: the CVE is outside the radar's tracked set, so its count, if any, is not a current measurement. tracking_unknown: the API does not say whether the radar tracks the CVE, so its 0 is not evidence either way. null: the answer carries no count."),
|
|
1377
1649
|
},
|
|
1378
1650
|
});
|
|
1379
1651
|
// exposure_radar's data, from the readers' own specs, so the schema cannot drift from them.
|
|
@@ -1413,12 +1685,25 @@ const ShadowAIData = z.strictObject({
|
|
|
1413
1685
|
poller: z.strictObject({ running: z.boolean(), last_run_at: Instant }).optional(),
|
|
1414
1686
|
});
|
|
1415
1687
|
const LastRun = z.strictObject({ last_run_at: Instant.nullable() });
|
|
1688
|
+
// mcp_servers as readMCPServers relays it, from the same constants (#2315): the headline counts
|
|
1689
|
+
// always (an answer without them is refused), and each partition with exactly its buckets or not
|
|
1690
|
+
// at all.
|
|
1691
|
+
const Whole = z.number().int().min(0);
|
|
1692
|
+
const MCPServersData = z.strictObject({
|
|
1693
|
+
...Object.fromEntries(MCP_HEADLINE.map((k) => [k, Whole])),
|
|
1694
|
+
...Object.fromEntries(MCP_PARTITION_NAMES.map((k) => [k, z.strictObject(Object.fromEntries(MCP_PARTITIONS[k].buckets.map((b) => [b, Whole]))).optional()])),
|
|
1695
|
+
own_controls_excluded: Whole.optional(),
|
|
1696
|
+
window: z.strictObject({ from: Instant, to: Instant }).optional(),
|
|
1697
|
+
enabled: z.boolean().optional(),
|
|
1698
|
+
...Object.fromEntries(MCP_INSTANTS.map((k) => [k, Instant.optional()])),
|
|
1699
|
+
});
|
|
1416
1700
|
const EXPOSURE_RADAR_OUTPUT = envelopeSchema({
|
|
1417
1701
|
data: z.strictObject({
|
|
1418
1702
|
kev_exposure: zRadar(KEV_SPEC, { newest_kev: z.array(NewestKEVRow) }),
|
|
1419
1703
|
exposed_databases: zRadar(EXPOSED_DB_SPEC),
|
|
1420
1704
|
leaked_credentials: zRadar(LEAKED_CREDS_SPEC),
|
|
1421
1705
|
shadow_ai: ShadowAIData,
|
|
1706
|
+
mcp_servers: MCPServersData,
|
|
1422
1707
|
}),
|
|
1423
1708
|
coverage: z.strictObject({
|
|
1424
1709
|
radars: z.array(z.string()).describe("The radars this tool reads."),
|
|
@@ -1430,12 +1715,13 @@ const EXPOSURE_RADAR_OUTPUT = envelopeSchema({
|
|
|
1430
1715
|
exposed_databases: LastRun,
|
|
1431
1716
|
leaked_credentials: LastRun,
|
|
1432
1717
|
shadow_ai: z.strictObject({ last_run_at: Instant.nullable(), running: z.boolean().nullable() }),
|
|
1718
|
+
mcp_servers: z.strictObject({ last_run_at: Instant.nullable(), enabled: z.boolean().nullable() }),
|
|
1433
1719
|
}),
|
|
1434
1720
|
error: ErrorSchema.extend({
|
|
1435
1721
|
radars: z
|
|
1436
1722
|
.array(z.strictObject({
|
|
1437
1723
|
radar: z.string(),
|
|
1438
|
-
kind: z.enum(["network", "timeout", "http", "not_json", "not_object"]),
|
|
1724
|
+
kind: z.enum(["network", "timeout", "http", "not_json", "not_object", "unexpected_shape"]),
|
|
1439
1725
|
path: z.string(),
|
|
1440
1726
|
status: z.number().int().nullable(),
|
|
1441
1727
|
message: z.string(),
|
|
@@ -1450,21 +1736,26 @@ const EXPOSURE_RADAR_OUTPUT = envelopeSchema({
|
|
|
1450
1736
|
const INSTRUCTIONS = [
|
|
1451
1737
|
"EchelonGraph's public CVE and internet-exposure data, read-only and keyless.",
|
|
1452
1738
|
"Everything these tools return is public: EchelonGraph's CVE Pulse feed, and aggregate, host-redacted totals from its exposure radars.",
|
|
1453
|
-
"Every result says how it was measured: state, measured_at, method, coverage, freshness and notes, in its structuredContent and again in its
|
|
1739
|
+
"Every result says how it was measured: state, measured_at, method, coverage, freshness and notes, in its structuredContent and again in its text.",
|
|
1740
|
+
"Its last text block is that structuredContent as JSON, less what an earlier text block already gives verbatim: data, which is a success's first text block; the sentences of the text block just before it (the note, or a failure's message), with which structuredContent's notes end; and method, where that block quotes it.",
|
|
1454
1741
|
"state is measured, not_assessed, failed or invalid_input.",
|
|
1455
|
-
"not_assessed means the answer holds no dated measurement of what was asked
|
|
1742
|
+
"not_assessed means the answer holds no dated measurement of what was asked, so no count in it is presented as one.",
|
|
1743
|
+
"It can still relay a count, as what the source holds on record, undated, and its notes say what each count is: cve_exposure's exposed_hosts when its exposure_state is exposed, and exposure_radar's labelled totals, are such counts.",
|
|
1744
|
+
"A zero is not a finding of no exposure where the answer holds no measurement for that CVE, for example a CVE outside the KEV-exposure radar's tracked set (exposure_state not_assessed).",
|
|
1456
1745
|
"failed and invalid_input mean nothing was measured: never report them as zero, none found or unexposed.",
|
|
1457
1746
|
"measured_at is when the underlying observation was made, and null when the answer does not say or holds none.",
|
|
1458
1747
|
"freshness gives the producing radar's last_run_at: when it last completed a check whose reads succeeded.",
|
|
1459
1748
|
"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.",
|
|
1460
1749
|
"Every figure is as fresh as the schedule that refreshes it.",
|
|
1461
1750
|
"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.",
|
|
1751
|
+
"exposure_radar's mcp_servers counts hostnames instead, each by its latest verdict on record.",
|
|
1462
1752
|
`${SHODAN_ATTRIBUTION} ${SHODAN_OWNERSHIP}`,
|
|
1753
|
+
"exposure_radar's mcp_servers counts use no Shodan data: their hostnames come from EchelonGraph's own Certificate Transparency feed.",
|
|
1463
1754
|
].join(" ");
|
|
1464
1755
|
const ANNOTATIONS = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
|
|
1465
1756
|
const CVE_ID_ARG = z.string().describe("a CVE ID, e.g. CVE-2023-44487");
|
|
1466
1757
|
// What each description says about its structured result, naming only fields its schema holds.
|
|
1467
|
-
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; the result's last text block repeats it without data.";
|
|
1758
|
+
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; the result's last text block repeats it without data (the first text block) and without the note's sentences (the text block before it), with which notes ends.";
|
|
1468
1759
|
// One server, built per connection by serveStdio for whichever era the client opens with.
|
|
1469
1760
|
// tools/list answers in registration order, which is the order below.
|
|
1470
1761
|
function createServer() {
|
|
@@ -1497,14 +1788,14 @@ function createServer() {
|
|
|
1497
1788
|
}, async ({ cve_id }) => checked("get_cve", GET_CVE_OUTPUT, await getCVE(cve_id)));
|
|
1498
1789
|
server.registerTool("cve_exposure", {
|
|
1499
1790
|
title: "Internet exposure for one CVE",
|
|
1500
|
-
description: `${CVE_EXPOSURE_DESCRIPTION} Its structured result's state is not_assessed with measured_at null: the per-CVE answer says when EchelonGraph last wrote a row (last_seen), not when any counted service was observed, so no count is presented as a dated measurement; the count is still relayed, labelled. exposure_state says what the count 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. The result's last text block repeats the structured result without data.`,
|
|
1791
|
+
description: `${CVE_EXPOSURE_DESCRIPTION} Its structured result's state is not_assessed with measured_at null: the per-CVE answer says when EchelonGraph last wrote a row (last_seen), not when any counted service was observed, so no count is presented as a dated measurement; the count is still relayed, labelled, as what the radar holds on record. exposure_state says what the count 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. The result's last text block repeats the structured result without data (the first text block), without the note's sentences (the text block before it), with which notes ends, and without method where the note quotes it verbatim ("Method: …").`,
|
|
1501
1792
|
inputSchema: z.object({ cve_id: CVE_ID_ARG }),
|
|
1502
1793
|
outputSchema: CVE_EXPOSURE_OUTPUT,
|
|
1503
1794
|
annotations: ANNOTATIONS,
|
|
1504
1795
|
}, async ({ cve_id }) => checked("cve_exposure", CVE_EXPOSURE_OUTPUT, await cveExposure(cve_id)));
|
|
1505
1796
|
server.registerTool("exposure_radar", {
|
|
1506
1797
|
title: "Exposure radar totals",
|
|
1507
|
-
description: `${EXPOSURE_RADAR_DESCRIPTION} Its structured result's state is not_assessed with measured_at null: every radar answered, but
|
|
1798
|
+
description: `${EXPOSURE_RADAR_DESCRIPTION} Its structured result's state is not_assessed with measured_at null: every radar answered, but none gives one time at which what it counts was observed (mcp_servers dates its verdicts at most by a window, mcp_servers.window), so no count is presented as a dated measurement; each count is still relayed, labelled, as what that radar holds on record. freshness gives each radar's last_run_at (and running for shadow_ai, enabled for mcp_servers) where the API serves one; coverage names the radars that answered; data is the relayed result above. The result's last text block repeats the structured result without data (the first text block) and without the note's sentences (the text block before it), with which notes ends.`,
|
|
1508
1799
|
outputSchema: EXPOSURE_RADAR_OUTPUT,
|
|
1509
1800
|
annotations: ANNOTATIONS,
|
|
1510
1801
|
}, async () => checked("exposure_radar", EXPOSURE_RADAR_OUTPUT, await exposureRadar()));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "echelongraph-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"mcpName": "io.echelongraph/echelongraph-mcp",
|
|
5
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",
|
package/server.json
CHANGED
|
@@ -8,12 +8,12 @@
|
|
|
8
8
|
"url": "https://github.com/echelongraph/echelongraph-mcp",
|
|
9
9
|
"source": "github"
|
|
10
10
|
},
|
|
11
|
-
"version": "2.
|
|
11
|
+
"version": "2.3.0",
|
|
12
12
|
"packages": [
|
|
13
13
|
{
|
|
14
14
|
"registryType": "npm",
|
|
15
15
|
"identifier": "echelongraph-mcp",
|
|
16
|
-
"version": "2.
|
|
16
|
+
"version": "2.3.0",
|
|
17
17
|
"transport": {
|
|
18
18
|
"type": "stdio"
|
|
19
19
|
},
|