echelongraph-mcp 2.2.0 → 2.3.1
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 +96 -6
- package/dist/index.js +346 -25
- package/package.json +1 -1
- package/server.json +2 -2
package/README.md
CHANGED
|
@@ -18,14 +18,34 @@ API call a tool needs to answer.
|
|
|
18
18
|
| Tool | What it does |
|
|
19
19
|
|---|---|
|
|
20
20
|
| `cve_summary` | Counts of active CVEs by severity, and when the feed was last updated. |
|
|
21
|
-
| `search_cves` | Search/filter CVEs (severity, min CVSS, text, sort) with EchelonGraph scores. |
|
|
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. |
|
|
21
|
+
| `search_cves` | Search/filter CVEs (severity, min CVSS, text, sort) with EchelonGraph scores and `score_assessed`; the note names each row not yet scored. |
|
|
22
|
+
| `get_cve` | Full record for one CVE: CVSS v3 and (when scored) v4, the EchelonGraph score and its confidence, whether EchelonGraph has scored it (`score_assessed`), 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.
|
|
28
28
|
|
|
29
|
+
### How `get_cve` and `search_cves` read the EchelonGraph score
|
|
30
|
+
|
|
31
|
+
`echelongraph_score` (0 to 10), its band `echelongraph_severity` and the risk priority
|
|
32
|
+
`echelongraph_risk` (0 to 100) are EchelonGraph's score only when the record's
|
|
33
|
+
`score_assessed` is `true`. A CVE EchelonGraph has not scored carries `score_assessed: false`,
|
|
34
|
+
and it is **not yet scored**, not scored 0. The API leaves those three fields out on it, or (an
|
|
35
|
+
API before that change) sends `0`, `NONE` and `0` as placeholders, which are not a rating and do
|
|
36
|
+
not mean the CVE is harmless. Its `score_confidence` is `NONE`, and `score_unassessed_reason`
|
|
37
|
+
says why: no source has yet published severity data EchelonGraph can score, or the record was
|
|
38
|
+
rejected (withdrawn) by its numbering authority, and a rejected record is never scored.
|
|
39
|
+
|
|
40
|
+
Both tools relay the API's JSON as it was sent, and the note says what the score is, for
|
|
41
|
+
`get_cve` per CVE and for `search_cves` per row, naming each CVE it is about:
|
|
42
|
+
|
|
43
|
+
| API answer | What the note says |
|
|
44
|
+
|---|---|
|
|
45
|
+
| `score_assessed: true` | Nothing more: the score is a score. |
|
|
46
|
+
| `score_assessed: false` | **NOT YET SCORED**, tagged "(score_assessed: false)": report the CVE as not yet scored, never as a zero or low score. Each of `echelongraph_score`, `echelongraph_severity` and `echelongraph_risk` it carries is named as a placeholder. A rejected record is **NOT SCORED** instead. |
|
|
47
|
+
| no `score_assessed` field | The answer does not say whether the CVE was scored (an API older than the field), and a zero `echelongraph_score` in it is not a rating. |
|
|
48
|
+
|
|
29
49
|
### How `cve_exposure` counts
|
|
30
50
|
|
|
31
51
|
Every 12 hours, when Shodan query credits allow, the KEV-exposure radar runs one Shodan query
|
|
@@ -164,6 +184,74 @@ its name and its count, and a field added to one is named too. The `poller` bloc
|
|
|
164
184
|
checked the same way: a key this version does not know is left out and named, while the
|
|
165
185
|
instance fields it knows are left out as described below.
|
|
166
186
|
|
|
187
|
+
#### `mcp_servers`
|
|
188
|
+
|
|
189
|
+
The AI-exposure radar's MCP-server verdicts, from `GET /api/v1/public/ai-exposure/stats?service=mcp`.
|
|
190
|
+
Every hostname was named like an MCP server in EchelonGraph's own Certificate Transparency
|
|
191
|
+
feed, matched by hostname pattern; no Shodan data is used. Each is checked by EchelonGraph's
|
|
192
|
+
identified MCP probe: a `server/discover` request, and `initialize` only if that is refused. It
|
|
193
|
+
never sends `tools/call`. The answer is counts and timestamps only, with no hostname, version or
|
|
194
|
+
server name, and the tool repeats no string it carries but its timestamps. Each hostname is
|
|
195
|
+
counted once, by its latest verdict on record, and EchelonGraph's own control servers are left
|
|
196
|
+
out. The unit is a hostname, not an ip:port service.
|
|
197
|
+
|
|
198
|
+
| Field | What it counts |
|
|
199
|
+
|---|---|
|
|
200
|
+
| `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`. |
|
|
201
|
+
| `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. |
|
|
202
|
+
| `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`. |
|
|
203
|
+
| `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`. |
|
|
204
|
+
| `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). |
|
|
205
|
+
| `mcp_servers.own_controls_excluded` | EchelonGraph's own control servers, left out of every other `mcp_servers` number. |
|
|
206
|
+
| `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. |
|
|
207
|
+
| `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. |
|
|
208
|
+
| `mcp_servers.enabled` | Whether the API reports the radar running: true when a check completed within 45 minutes of its answer. |
|
|
209
|
+
| `mcp_servers.counted_at` | A timestamp, not a count: when the API read these counts. |
|
|
210
|
+
|
|
211
|
+
`mcp_servers.not_assessed_by_reason` divides `mcp_servers.not_assessed`:
|
|
212
|
+
|
|
213
|
+
| Bucket | What it holds |
|
|
214
|
+
|---|---|
|
|
215
|
+
| `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. |
|
|
216
|
+
| `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. |
|
|
217
|
+
| `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. |
|
|
218
|
+
| `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. |
|
|
219
|
+
| `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. |
|
|
220
|
+
| `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. |
|
|
221
|
+
| `mcp_servers.not_assessed_by_reason.no_authorization_servers` | An endpoint that asked for credentials, whose metadata document names no authorization server. |
|
|
222
|
+
| `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. |
|
|
223
|
+
| `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. |
|
|
224
|
+
| `mcp_servers.not_assessed_by_reason.challenge_unadjudicated` | An endpoint that asked for credentials before EchelonGraph read RFC 9728 metadata, not re-checked since. |
|
|
225
|
+
| `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. |
|
|
226
|
+
| `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. |
|
|
227
|
+
|
|
228
|
+
The eight credential-challenge buckets hold endpoints that asked for credentials: only their
|
|
229
|
+
metadata did not validate, so none of them is shown to lack protection.
|
|
230
|
+
|
|
231
|
+
`mcp_servers.era` and `mcp_servers.transport` each divide every counted hostname, so each adds
|
|
232
|
+
up to `mcp_servers.total`:
|
|
233
|
+
|
|
234
|
+
| Bucket | What it holds |
|
|
235
|
+
|---|---|
|
|
236
|
+
| `mcp_servers.era.legacy` | An initialize-era server: an InitializeResult, or the endpoint event of the deprecated HTTP+SSE transport. |
|
|
237
|
+
| `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. |
|
|
238
|
+
| `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. |
|
|
239
|
+
| `mcp_servers.era.unknown` | Nothing identified an era; every credential challenge is here. |
|
|
240
|
+
| `mcp_servers.era.not_measured` | A verdict recorded before EchelonGraph's probe began recording the era, not re-checked since. |
|
|
241
|
+
| `mcp_servers.transport.streamable_http` | Identified by a POST to `/mcp`. |
|
|
242
|
+
| `mcp_servers.transport.legacy_sse` | Identified by the endpoint event of `GET /sse`, the deprecated HTTP+SSE transport. |
|
|
243
|
+
| `mcp_servers.transport.unknown` | Nothing identified a transport. |
|
|
244
|
+
| `mcp_servers.transport.not_measured` | Recorded before the era probe, not re-checked since. |
|
|
245
|
+
|
|
246
|
+
The tool refuses the answer, as a failure of that radar and never as zeros, when it does not say
|
|
247
|
+
`service` `mcp` (an API older than `?service=mcp` ignores the parameter and answers its counts
|
|
248
|
+
over every AI service it checks), when `mcp_servers.total`, `mcp_servers.protected`,
|
|
249
|
+
`mcp_servers.pending_readjudication` or `mcp_servers.not_assessed` is missing or not a whole
|
|
250
|
+
number, or when they contradict each other. A partition whose buckets are not exactly those
|
|
251
|
+
above, each a whole number adding up to the count it divides, is left out whole and named. A
|
|
252
|
+
field this version does not know is left out, and named only when its name is shaped like a
|
|
253
|
+
field name.
|
|
254
|
+
|
|
167
255
|
## What a result means
|
|
168
256
|
|
|
169
257
|
Every tool answers in one of two shapes, so a model reading the result cannot mistake an
|
|
@@ -255,9 +343,11 @@ a note says so. Per tool:
|
|
|
255
343
|
`cve_exposure` counts", and `coverage.in_scope` is the API's `tracked`. It becomes `measured`
|
|
256
344
|
only when the API serves a time at which the counted services were observed.
|
|
257
345
|
- `exposure_radar` is `not_assessed` with `measured_at` `null`: every radar answered, but no
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
346
|
+
answer gives one time at which what it counts was observed, so no count is presented as a
|
|
347
|
+
dated measurement. `mcp_servers` dates its verdicts only by a window, `mcp_servers.window.from`
|
|
348
|
+
to `mcp_servers.window.to`, over checks made at different times, which is not one observation
|
|
349
|
+
time. Its numbers keep the labels above. `freshness` holds each radar's `last_run_at` where the
|
|
350
|
+
API serves one, for `shadow_ai` also `running`, and for `mcp_servers` also `enabled`.
|
|
261
351
|
|
|
262
352
|
The CVE feed tools' `freshness` is `null`: the feed's answers carry no time at which its
|
|
263
353
|
pollers last completed a poll for the feed as a whole.
|
package/dist/index.js
CHANGED
|
@@ -12,9 +12,9 @@
|
|
|
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,
|
|
17
|
-
// content[0] and the note already say.
|
|
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.
|
|
18
18
|
// - failure (isError: true): the lookup did not complete — unreachable host, non-2xx,
|
|
19
19
|
// timeout, or a 2xx whose body is not a JSON object — named by tool, cause and base URL,
|
|
20
20
|
// in content[0]; content[1] is the envelope below, as JSON, less the message's sentences.
|
|
@@ -199,6 +199,8 @@ function describeFailure(f) {
|
|
|
199
199
|
return `EchelonGraph answered HTTP ${f.status} ${where} but the body was not JSON (${f.detail}).`;
|
|
200
200
|
case "not_object":
|
|
201
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}.`;
|
|
202
204
|
}
|
|
203
205
|
}
|
|
204
206
|
// What a model must not conclude from a failure.
|
|
@@ -330,6 +332,60 @@ const realInstant = (s) => s !== undefined && Date.parse(s) > 0;
|
|
|
330
332
|
// Stats, in memory), and is relayed in data but not presented as the feed's freshness.
|
|
331
333
|
const CVE_FEED_METHOD = "EchelonGraph's CVE Pulse feed: CVE records compiled from NVD, MITRE-CNA pre-NVD records, CISA-KEV, EPSS and GitHub GHSA, each polled from its source on a schedule.";
|
|
332
334
|
const NO_FEED_FRESHNESS = "freshness is null: the CVE feed's answer carries no time at which its pollers last completed a poll for the feed as a whole.";
|
|
335
|
+
function scoreStateOf(rec) {
|
|
336
|
+
const v = boolAt(rec, "score_assessed");
|
|
337
|
+
return v === true ? "assessed" : v === false ? "not_assessed" : "unstated";
|
|
338
|
+
}
|
|
339
|
+
const scoreRejected = (rec) => strAt(rec, "score_unassessed_reason") === "rejected";
|
|
340
|
+
// The fields that hold a placeholder, not a score, on a CVE that is not scored.
|
|
341
|
+
const PLACEHOLDER_FIELDS = ["echelongraph_score", "echelongraph_severity", "echelongraph_risk"];
|
|
342
|
+
// No sentence below writes a digit: a note that says "0" beside "score" is the defect itself.
|
|
343
|
+
const UNSTATED_SCORE = "echelongraph_score is EchelonGraph's score only when score_assessed is true, so a zero echelongraph_score there is not a rating: it can be the placeholder a CVE not yet scored carries.";
|
|
344
|
+
// get_cve's sentences about the one CVE's score, each starting with a space; none when the
|
|
345
|
+
// answer says the CVE is scored.
|
|
346
|
+
function cveScoreNote(cve, rec) {
|
|
347
|
+
const state = scoreStateOf(rec);
|
|
348
|
+
if (state === "assessed")
|
|
349
|
+
return "";
|
|
350
|
+
if (state === "unstated") {
|
|
351
|
+
return ` The answer does not say whether EchelonGraph has scored ${cve}: it carries no score_assessed (an API older than that field sends none). ${UNSTATED_SCORE}`;
|
|
352
|
+
}
|
|
353
|
+
const rejected = scoreRejected(rec);
|
|
354
|
+
const reportAs = rejected ? "not scored" : "not yet scored";
|
|
355
|
+
const head = rejected
|
|
356
|
+
? ` (score_assessed: false) NOT SCORED: the record of ${cve} was rejected (withdrawn) by its numbering authority (score_unassessed_reason rejected), and EchelonGraph does not score a withdrawn record, so the record holds no EchelonGraph score for it.`
|
|
357
|
+
: ` (score_assessed: false) NOT YET SCORED: EchelonGraph has not yet scored ${cve}, so the record holds no EchelonGraph score for it, and the absence of one does not mean ${cve} is harmless.`;
|
|
358
|
+
const why = strAt(rec, "score_unassessed_reason") === "no_signal" ? " score_unassessed_reason is no_signal: no source has yet published severity data EchelonGraph can score." : "";
|
|
359
|
+
const carried = PLACEHOLDER_FIELDS.filter((k) => field(rec, k) !== undefined && field(rec, k) !== null);
|
|
360
|
+
const placeholders = carried.length
|
|
361
|
+
? ` ${listed(carried)} ${carried.length === 1 ? "is a placeholder" : "are placeholders"}, not a score: report ${cve} as ${reportAs}, and do not repeat ${carried.length === 1 ? "that value" : "those values"} as its score.`
|
|
362
|
+
: ` Report ${cve} as ${reportAs}, not as a zero or low score.`;
|
|
363
|
+
return `${head}${why}${placeholders}`;
|
|
364
|
+
}
|
|
365
|
+
// search_cves' sentences about its rows' scores, naming each row a sentence is about, each
|
|
366
|
+
// starting with a space; none when every row says it is scored.
|
|
367
|
+
function rowsScoreNote(d) {
|
|
368
|
+
const rows = field(d, "cves");
|
|
369
|
+
if (!Array.isArray(rows) || rows.length === 0)
|
|
370
|
+
return "";
|
|
371
|
+
const n = rows.length;
|
|
372
|
+
const ids = (pred) => rows.flatMap((r, i) => (pred(r) ? [strAt(r, "cve_id") ?? `cves[${i}]`] : []));
|
|
373
|
+
const notYet = ids((r) => scoreStateOf(r) === "not_assessed" && !scoreRejected(r));
|
|
374
|
+
const rejected = ids((r) => scoreStateOf(r) === "not_assessed" && scoreRejected(r));
|
|
375
|
+
const unstated = ids((r) => scoreStateOf(r) === "unstated");
|
|
376
|
+
const of = (k) => (k < n ? `${k} of the ${n} CVEs in this page` : n === 1 ? "the 1 CVE in this page" : `all ${n} CVEs in this page`);
|
|
377
|
+
const out = [];
|
|
378
|
+
if (notYet.length) {
|
|
379
|
+
out.push(`(score_assessed: false) NOT YET SCORED: ${of(notYet.length)} ${be(notYet.length)} not yet scored by EchelonGraph: ${listed(notYet)}. For each, echelongraph_score, echelongraph_severity and echelongraph_risk, where its row carries them, are placeholders, not a score, and the absence of a score does not mean the CVE is harmless: report each as not yet scored, not as a zero or low score.`);
|
|
380
|
+
}
|
|
381
|
+
if (rejected.length) {
|
|
382
|
+
out.push(`(score_assessed: false) NOT SCORED: ${of(rejected.length)} ${rejected.length === 1 ? "has a record" : "have records"} rejected (withdrawn) by the numbering authority (score_unassessed_reason rejected), which EchelonGraph does not score: ${listed(rejected)}. For each, echelongraph_score, echelongraph_severity and echelongraph_risk, where its row carries them, are placeholders, not a score: report each as not scored.`);
|
|
383
|
+
}
|
|
384
|
+
if (unstated.length) {
|
|
385
|
+
out.push(`The answer does not say whether EchelonGraph has scored ${of(unstated.length)}: ${listed(unstated)} (no score_assessed in ${unstated.length === 1 ? "its row" : "their rows"}; an API older than that field sends none). ${UNSTATED_SCORE}`);
|
|
386
|
+
}
|
|
387
|
+
return out.map((s) => ` ${s}`).join("");
|
|
388
|
+
}
|
|
333
389
|
async function cveSummary() {
|
|
334
390
|
const tool = "cve_summary";
|
|
335
391
|
try {
|
|
@@ -409,21 +465,24 @@ async function searchCVEs(a) {
|
|
|
409
465
|
const relaxedNote = relaxed === true
|
|
410
466
|
? " The API relaxed the search phrase to all of its words (search_relaxed is true), so these rows are a superset of the rows that match the phrase itself."
|
|
411
467
|
: "";
|
|
468
|
+
// Then what each row's score is (#2535): empty when every row says it is scored, or there
|
|
469
|
+
// are no rows.
|
|
470
|
+
const tail = `${relaxedNote}${rowsScoreNote(r.data)}`;
|
|
412
471
|
// A total the API did not count is not a count: the page's own rows are all that is known.
|
|
413
472
|
if (counted === false) {
|
|
414
473
|
const empty = shown === 0 && (offset ?? 0) === 0;
|
|
415
474
|
return succeeded(r.data, empty
|
|
416
|
-
? `${head} The query returned 0 CVEs — a measured empty result: EchelonGraph was queried successfully and nothing matched these filters (we looked and found nothing). This is not a lookup failure.${
|
|
417
|
-
: `${head} The API did not count the matches (total_counted is false), so its total is not a count${shown === undefined ? "" : `; ${shown} returned in this page`}.${
|
|
475
|
+
? `${head} The query returned 0 CVEs — a measured empty result: EchelonGraph was queried successfully and nothing matched these filters (we looked and found nothing). This is not a lookup failure.${tail}`
|
|
476
|
+
: `${head} The API did not count the matches (total_counted is false), so its total is not a count${shown === undefined ? "" : `; ${shown} returned in this page`}.${tail}`, env);
|
|
418
477
|
}
|
|
419
478
|
const total = numAt(r.data, "total") ?? shown;
|
|
420
479
|
if (total === undefined)
|
|
421
|
-
return succeeded(r.data, `${head}${
|
|
480
|
+
return succeeded(r.data, `${head}${tail}`, env);
|
|
422
481
|
if (total === 0 && floor !== true) {
|
|
423
|
-
return succeeded(r.data, `${head} The query matched 0 CVEs — a measured empty result: EchelonGraph was queried successfully and nothing matched these filters (we looked and found nothing). This is not a lookup failure.${
|
|
482
|
+
return succeeded(r.data, `${head} The query matched 0 CVEs — a measured empty result: EchelonGraph was queried successfully and nothing matched these filters (we looked and found nothing). This is not a lookup failure.${tail}`, env);
|
|
424
483
|
}
|
|
425
484
|
const matched = floor === true ? `at least ${total} CVEs (total_is_lower_bound is true: the count stopped at that floor)` : `${total} CVEs`;
|
|
426
|
-
return succeeded(r.data, `${head} The query matched ${matched}${shown === undefined ? "" : `; ${shown} returned in this page`}.${
|
|
485
|
+
return succeeded(r.data, `${head} The query matched ${matched}${shown === undefined ? "" : `; ${shown} returned in this page`}.${tail}`, env);
|
|
427
486
|
}
|
|
428
487
|
catch (e) {
|
|
429
488
|
return crashed(tool, e);
|
|
@@ -439,7 +498,8 @@ async function getCVE(cve_id) {
|
|
|
439
498
|
if (!r.ok)
|
|
440
499
|
return failed(tool, r);
|
|
441
500
|
const written = strAt(r.data, "updated_at");
|
|
442
|
-
|
|
501
|
+
const cve = strAt(r.data, "cve_id") ?? id;
|
|
502
|
+
return succeeded(r.data, `${okHead(tool, r.status)} Returned the record for ${cve}.${cveScoreNote(cve, r.data)}`, {
|
|
443
503
|
state: "measured",
|
|
444
504
|
measured_at: instantOrNull(written),
|
|
445
505
|
method: CVE_FEED_METHOD,
|
|
@@ -624,13 +684,16 @@ async function cveExposure(cve_id) {
|
|
|
624
684
|
}
|
|
625
685
|
// Every quadrant that finds its services through Shodan, attributed. The exposed-database
|
|
626
686
|
// radar searches Shodan dorks and falls back to LeakIX when the Shodan query budget is low
|
|
627
|
-
// (core-backend/internal/exposeddb/poller.go).
|
|
628
|
-
|
|
687
|
+
// (core-backend/internal/exposeddb/poller.go). MCP-server discovery is named too, as the one
|
|
688
|
+
// that uses none (#2315; see the block comment above readMCPServers), so the attribution is
|
|
689
|
+
// never read as covering it.
|
|
690
|
+
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.";
|
|
629
691
|
const RADARS = [
|
|
630
692
|
["kev_exposure", "/api/v1/public/kev-exposure/stats"],
|
|
631
693
|
["exposed_databases", "/api/v1/public/exposed-databases/stats"],
|
|
632
694
|
["leaked_credentials", "/api/v1/public/leaked-credentials/stats"],
|
|
633
695
|
["shadow_ai", "/api/v1/public/shadow-ai-radar/stats"],
|
|
696
|
+
["mcp_servers", "/api/v1/public/ai-exposure/stats?service=mcp"],
|
|
634
697
|
];
|
|
635
698
|
// The shadow-AI stats answer, regrouped by what each number counts (#2307).
|
|
636
699
|
//
|
|
@@ -1195,33 +1258,265 @@ function readShadowAI(sa) {
|
|
|
1195
1258
|
sentences.push(`Latest shadow-AI observation on record: ${lastObservation}.`);
|
|
1196
1259
|
return { data, sentences };
|
|
1197
1260
|
}
|
|
1198
|
-
|
|
1261
|
+
// ── mcp_servers: GET /api/v1/public/ai-exposure/stats?service=mcp (#2315) ──
|
|
1262
|
+
//
|
|
1263
|
+
// core-backend/internal/aiexposure (handler.go MCPPublicCounts, store.go): the AI-exposure
|
|
1264
|
+
// radar's MCP rows, one per hostname, each counted once by its latest verdict on record, with
|
|
1265
|
+
// EchelonGraph's own control servers left out and counted in own_controls_excluded. Counts,
|
|
1266
|
+
// timestamps and one constant (service "mcp"); no hostname, version or server name.
|
|
1267
|
+
// total protected + pending_readjudication + not_assessed
|
|
1268
|
+
// protected the /mcp endpoint answered 401/403 and its RFC 9728 protected-resource
|
|
1269
|
+
// metadata validated (prm.go); prm_via divides exactly these rows by
|
|
1270
|
+
// where that document was found
|
|
1271
|
+
// pending_readjudication a verdict of a rule since replaced, not yet re-checked (store.go
|
|
1272
|
+
// readjudicationPredicate); in neither of the other two
|
|
1273
|
+
// not_assessed the rest; not_assessed_by_reason divides exactly these rows, first
|
|
1274
|
+
// match wins: the eight prm.go reason codes (a challenge whose metadata
|
|
1275
|
+
// did not validate), identified_no_challenge, challenge_unadjudicated
|
|
1276
|
+
// (a challenge recorded before #2312), no_http_answer, not_identified_as_mcp
|
|
1277
|
+
// era, transport divide every counted row; a row written before #2314 recorded an era
|
|
1278
|
+
// is not_measured (era.go says what each value rests on)
|
|
1279
|
+
// window from/to: the oldest and newest last-check time over the counted rows
|
|
1280
|
+
// enabled, last_run_at exactly as the unparameterised answer computes them: the AI-exposure
|
|
1281
|
+
// radar's fleet-wide liveness and last completed check, over every AI
|
|
1282
|
+
// service it checks, not MCP servers alone. enabled is true when a check
|
|
1283
|
+
// completed within liveWindow (45 minutes) of the answer.
|
|
1284
|
+
// counted_at when the API read the counts
|
|
1285
|
+
//
|
|
1286
|
+
// Discovery uses no Shodan data, so this reader carries no Shodan attribution. poller.go
|
|
1287
|
+
// discover enqueues only hostnames that store.go CandidateHosts matches by pattern (catalog.go
|
|
1288
|
+
// HostPatternIndex) in ct_domains and shadow_ct_observations, and that query drops every domain
|
|
1289
|
+
// holding a ':'; a Shodan row's domain is its ip:port (shadowctlog poller.go shodanDomain), so
|
|
1290
|
+
// none reaches the queue.
|
|
1291
|
+
//
|
|
1292
|
+
// identified_no_challenge is where #2307's mistake would be made again: a server that
|
|
1293
|
+
// identified itself as an MCP server and asked for no credentials at the handshake. MCP
|
|
1294
|
+
// authorization is optional (MCP 2026-07-28, Authorization › Protocol Requirements), a server
|
|
1295
|
+
// can enforce it at tools/call, and the probe never sends tools/call (mcp.go), so nothing here
|
|
1296
|
+
// says whether such a server gives anything away. Every text says what the bucket is, and never
|
|
1297
|
+
// calls it open, exposed or unauthenticated.
|
|
1298
|
+
//
|
|
1299
|
+
// What is refused, and what is left out. An API older than ?service=mcp ignores the parameter
|
|
1300
|
+
// and answers its counts over EVERY AI service, `protected` among them (aiexposure PublicCounts);
|
|
1301
|
+
// relayed as MCP-server counts, that is #2307 again. And the API refuses to serve counts that
|
|
1302
|
+
// contradict its method (a 500, never a 200 that breaks a sum), so a 200 that breaks one is not
|
|
1303
|
+
// an answer of that contract. So the radar is refused, as a per-radar failure (mcpRefusal),
|
|
1304
|
+
// when the answer does not say service "mcp", when a headline count is missing or not a whole
|
|
1305
|
+
// number, or when total is not the sum of the other three. Below the headline, the file's rule
|
|
1306
|
+
// for a known field in an unexpected shape holds: a partition is relayed only with exactly its
|
|
1307
|
+
// buckets, each a whole number, adding up to the count it divides, or it is left out whole and
|
|
1308
|
+
// named, since a partition missing a bucket, or carrying one this version cannot label, is an
|
|
1309
|
+
// undercount that reads as complete. A field this version does not know is left out, and named
|
|
1310
|
+
// only when its name is shaped like a field name: the answer is counts only, a key could itself
|
|
1311
|
+
// be a hostname, and the tool repeats no string the answer carries but its timestamps.
|
|
1312
|
+
const MCP_HEADLINE = ["total", "protected", "pending_readjudication", "not_assessed"];
|
|
1313
|
+
const MCP_CHALLENGE_REASONS = [
|
|
1314
|
+
"resource_mismatch",
|
|
1315
|
+
"cross_origin_pointer",
|
|
1316
|
+
"bare_challenge_no_prm",
|
|
1317
|
+
"pointer_unreachable",
|
|
1318
|
+
"pointer_invalid",
|
|
1319
|
+
"no_authorization_servers",
|
|
1320
|
+
"wellknown_unreachable",
|
|
1321
|
+
"metadata_invalid",
|
|
1322
|
+
];
|
|
1323
|
+
const MCP_PARTITIONS = {
|
|
1324
|
+
not_assessed_by_reason: {
|
|
1325
|
+
of: "not_assessed",
|
|
1326
|
+
buckets: ["identified_no_challenge", ...MCP_CHALLENGE_REASONS, "challenge_unadjudicated", "no_http_answer", "not_identified_as_mcp"],
|
|
1327
|
+
},
|
|
1328
|
+
prm_via: { of: "protected", buckets: ["header", "wellknown_path", "wellknown_root"] },
|
|
1329
|
+
era: { of: "total", buckets: ["legacy", "dual", "modern", "unknown", "not_measured"] },
|
|
1330
|
+
transport: { of: "total", buckets: ["streamable_http", "legacy_sse", "unknown", "not_measured"] },
|
|
1331
|
+
};
|
|
1332
|
+
const MCP_PARTITION_NAMES = Object.keys(MCP_PARTITIONS);
|
|
1333
|
+
const MCP_INSTANTS = ["last_run_at", "counted_at"];
|
|
1334
|
+
const MCP_KNOWN = new Set(["service", ...MCP_HEADLINE, ...MCP_PARTITION_NAMES, "own_controls_excluded", "window", "enabled", ...MCP_INSTANTS]);
|
|
1335
|
+
// What each challenge reason code says (prm.go prmReasonCodes), for the note.
|
|
1336
|
+
const MCP_CHALLENGE_MEANS = {
|
|
1337
|
+
resource_mismatch: "the document's resource is absent or not identical to the server's identifier",
|
|
1338
|
+
cross_origin_pointer: "the challenge pointed to metadata on another origin, which EchelonGraph does not request",
|
|
1339
|
+
bare_challenge_no_prm: "no metadata URL was named and neither well-known URI answered 2xx",
|
|
1340
|
+
pointer_unreachable: "the same-origin metadata URL did not answer 2xx in full",
|
|
1341
|
+
pointer_invalid: "the metadata pointer is not a URL EchelonGraph will request",
|
|
1342
|
+
no_authorization_servers: "the document names no authorization server",
|
|
1343
|
+
wellknown_unreachable: "a well-known metadata request got no complete HTTP answer",
|
|
1344
|
+
metadata_invalid: "the answer is not a 200 JSON object within the size cap",
|
|
1345
|
+
};
|
|
1346
|
+
// A count as the MCP answer states one: a whole number, zero or more.
|
|
1347
|
+
const isWhole = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
|
|
1348
|
+
// The shape of a field name. An unknown key of this shape is named when left out; any other is
|
|
1349
|
+
// counted, not repeated, since it could be a hostname.
|
|
1350
|
+
const FIELD_NAME = /^[a-z][a-z0-9_]{0,63}$/;
|
|
1351
|
+
// core-backend aiexposure handler.go liveWindow: how recently a check must have completed for
|
|
1352
|
+
// the API to answer enabled true. A liveness window, not a re-probe interval.
|
|
1353
|
+
// MCP_CHALLENGE_REASONS as the tool description names them, spelled out as ONE constant string: the
|
|
1354
|
+
// description is published, and the site's tool-claims check (marketing-site lib/mcpToolClaims.test.ts)
|
|
1355
|
+
// reads every description statically, folding only references to constant strings, so a .map() here
|
|
1356
|
+
// made the whole description unreadable to it. tools.test.mjs pins this to MCP_CHALLENGE_REASONS.
|
|
1357
|
+
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";
|
|
1358
|
+
const MCP_ENABLED_WITHIN = "45 minutes";
|
|
1359
|
+
// Why an ?service=mcp answer is not relayed at all, or undefined when it can be read. The
|
|
1360
|
+
// sentence quotes no value the answer carries.
|
|
1361
|
+
function mcpRefusal(body) {
|
|
1362
|
+
if (!isPlainObject(body))
|
|
1363
|
+
return "the answer is not a JSON object carrying the MCP-server counts";
|
|
1364
|
+
if (body.service === undefined) {
|
|
1365
|
+
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";
|
|
1366
|
+
}
|
|
1367
|
+
if (body.service !== "mcp")
|
|
1368
|
+
return "the answer names a service other than mcp, so it is not the MCP-server counts";
|
|
1369
|
+
const bad = MCP_HEADLINE.filter((k) => !isWhole(body[k]));
|
|
1370
|
+
if (bad.length)
|
|
1371
|
+
return `its ${listed(bad)} ${be(bad.length)} missing or not a whole number of zero or more, so its counts are not relayed`;
|
|
1372
|
+
const [total, protectedN, pending, notAssessed] = MCP_HEADLINE.map((k) => body[k]);
|
|
1373
|
+
if (total !== protectedN + pending + notAssessed) {
|
|
1374
|
+
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";
|
|
1375
|
+
}
|
|
1376
|
+
return undefined;
|
|
1377
|
+
}
|
|
1378
|
+
function readMCPServers(src) {
|
|
1379
|
+
const data = {};
|
|
1380
|
+
const unusable = [];
|
|
1381
|
+
const unsummed = [];
|
|
1382
|
+
for (const k of MCP_HEADLINE)
|
|
1383
|
+
data[k] = src[k];
|
|
1384
|
+
for (const k of MCP_PARTITION_NAMES) {
|
|
1385
|
+
const { of, buckets } = MCP_PARTITIONS[k];
|
|
1386
|
+
const v = src[k];
|
|
1387
|
+
const exact = isPlainObject(v) && Object.keys(v).length === buckets.length && buckets.every((b) => Object.prototype.hasOwnProperty.call(v, b) && isWhole(v[b]));
|
|
1388
|
+
if (!exact) {
|
|
1389
|
+
unusable.push(k);
|
|
1390
|
+
continue;
|
|
1391
|
+
}
|
|
1392
|
+
if (buckets.reduce((sum, b) => sum + v[b], 0) !== src[of]) {
|
|
1393
|
+
unsummed.push(k);
|
|
1394
|
+
continue;
|
|
1395
|
+
}
|
|
1396
|
+
data[k] = Object.fromEntries(buckets.map((b) => [b, v[b]]));
|
|
1397
|
+
}
|
|
1398
|
+
if (isWhole(src.own_controls_excluded))
|
|
1399
|
+
data.own_controls_excluded = src.own_controls_excluded;
|
|
1400
|
+
else
|
|
1401
|
+
unusable.push("own_controls_excluded");
|
|
1402
|
+
// window: from and to, both real instants, from not after to. With no row counted the API has
|
|
1403
|
+
// no window to give (null, or from and to null), and none is relayed.
|
|
1404
|
+
const w = src.window;
|
|
1405
|
+
const from = strAt(w, "from");
|
|
1406
|
+
const to = strAt(w, "to");
|
|
1407
|
+
const noWindow = w === null || w === undefined || (isPlainObject(w) && w.from === null && w.to === null && Object.keys(w).length === 2);
|
|
1408
|
+
if (isPlainObject(w) && Object.keys(w).length === 2 && realInstant(from) && realInstant(to) && Date.parse(from) <= Date.parse(to))
|
|
1409
|
+
data.window = { from, to };
|
|
1410
|
+
else if (!(noWindow && src.total === 0))
|
|
1411
|
+
unusable.push("window");
|
|
1412
|
+
if (typeof src.enabled === "boolean")
|
|
1413
|
+
data.enabled = src.enabled;
|
|
1414
|
+
else
|
|
1415
|
+
unusable.push("enabled");
|
|
1416
|
+
// A timestamp that is not a real instant is not relayed as one (readRadar's rule).
|
|
1417
|
+
for (const k of MCP_INSTANTS) {
|
|
1418
|
+
const s = strAt(src, k);
|
|
1419
|
+
if (realInstant(s))
|
|
1420
|
+
data[k] = s;
|
|
1421
|
+
else if (src[k] !== undefined && src[k] !== null && typeof src[k] !== "string")
|
|
1422
|
+
unusable.push(k);
|
|
1423
|
+
}
|
|
1424
|
+
const extra = Object.keys(src).filter((k) => !MCP_KNOWN.has(k));
|
|
1425
|
+
const named = extra.filter((k) => FIELD_NAME.test(k));
|
|
1426
|
+
const unnamed = extra.length - named.length;
|
|
1427
|
+
const sentences = [];
|
|
1428
|
+
const n = (k) => data[k];
|
|
1429
|
+
const b = (p, bucket) => `mcp_servers.${p}.${bucket} (${data[p][bucket]})`;
|
|
1430
|
+
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.`);
|
|
1431
|
+
if (n("total") === 0)
|
|
1432
|
+
sentences.push("mcp_servers.total is 0: the radar holds no verdict on record for a hostname named like an MCP server.");
|
|
1433
|
+
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.`);
|
|
1434
|
+
if (data.prm_via) {
|
|
1435
|
+
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.`);
|
|
1436
|
+
}
|
|
1437
|
+
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" : ""}.`);
|
|
1438
|
+
if (data.not_assessed_by_reason) {
|
|
1439
|
+
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.`);
|
|
1440
|
+
}
|
|
1441
|
+
if (data.era) {
|
|
1442
|
+
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.`);
|
|
1443
|
+
}
|
|
1444
|
+
if (data.transport) {
|
|
1445
|
+
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.`);
|
|
1446
|
+
}
|
|
1447
|
+
if (data.own_controls_excluded !== undefined) {
|
|
1448
|
+
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.`);
|
|
1449
|
+
}
|
|
1450
|
+
if (data.window) {
|
|
1451
|
+
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.`);
|
|
1452
|
+
}
|
|
1453
|
+
const at = data.last_run_at;
|
|
1454
|
+
if (typeof at === "string") {
|
|
1455
|
+
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.");
|
|
1456
|
+
}
|
|
1457
|
+
if (data.enabled === true) {
|
|
1458
|
+
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.`);
|
|
1459
|
+
}
|
|
1460
|
+
else if (data.enabled === false) {
|
|
1461
|
+
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.`);
|
|
1462
|
+
}
|
|
1463
|
+
if (typeof data.counted_at === "string") {
|
|
1464
|
+
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.`);
|
|
1465
|
+
}
|
|
1466
|
+
sentences.push(...leftOut("mcp_servers", named, unusable));
|
|
1467
|
+
if (unsummed.length)
|
|
1468
|
+
sentences.push(`Left out of mcp_servers because their buckets do not add up to the count they divide: ${unsummed.join(", ")}.`);
|
|
1469
|
+
if (unnamed) {
|
|
1470
|
+
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.`);
|
|
1471
|
+
}
|
|
1472
|
+
return { data, sentences };
|
|
1473
|
+
}
|
|
1474
|
+
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. ` +
|
|
1199
1475
|
`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. ` +
|
|
1200
1476
|
`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. ` +
|
|
1201
1477
|
`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. ` +
|
|
1202
|
-
`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.
|
|
1478
|
+
`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. ` +
|
|
1479
|
+
`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}`;
|
|
1203
1480
|
// exposure_radar's envelope. Every radar answered, but no stats answer says when the services
|
|
1204
1481
|
// or records it counts were observed: each count covers everything that radar still holds on
|
|
1205
1482
|
// record, and the answer carries only generated_at (when the API computed the totals) and
|
|
1206
1483
|
// last_run_at (the radar's last completed check), neither of which is that time (#2335). So
|
|
1207
1484
|
// the result is not_assessed with measured_at null, and says why; its numbers keep their labels,
|
|
1208
1485
|
// and freshness carries each radar's last_run_at where the API serves one.
|
|
1209
|
-
|
|
1486
|
+
//
|
|
1487
|
+
// mcp_servers (#2315) does not change that, by the envelope's own rule (the `state` block above
|
|
1488
|
+
// the State type): an exposure count is measured only with measured_at, "when the underlying
|
|
1489
|
+
// observation was made, as the API states it", and otherwise it is not_assessed and relayed as
|
|
1490
|
+
// what the source holds on record. The MCP answer states no such time. Its counts are each
|
|
1491
|
+
// hostname's LATEST verdict, and window.from and window.to are the oldest and newest last-check
|
|
1492
|
+
// times over the counted rows, a span of checks made at different times, not one observation.
|
|
1493
|
+
// Taking window.to as measured_at would date every count by its newest verdict, the reading
|
|
1494
|
+
// #2335 and #2439 refuse for last_run_at and last_seen; window.from would date each one by its
|
|
1495
|
+
// oldest. And the envelope is one for five radars, four of which carry no time of observation at
|
|
1496
|
+
// all, so a measured state would date their numbers too (#2313 done-means 5). So the state stays
|
|
1497
|
+
// not_assessed with measured_at null, the window is relayed in data and labelled by the note,
|
|
1498
|
+
// and the envelope's own note names it.
|
|
1499
|
+
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.";
|
|
1210
1500
|
const RADAR_NAMES = RADARS.map(([name]) => name);
|
|
1211
|
-
const RADAR_STATE_NOTE = "state is not_assessed: every radar answered, but
|
|
1212
|
-
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,
|
|
1501
|
+
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.";
|
|
1502
|
+
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.";
|
|
1213
1503
|
async function exposureRadar() {
|
|
1214
1504
|
const tool = "exposure_radar";
|
|
1215
1505
|
try {
|
|
1216
1506
|
const results = await Promise.all(RADARS.map(([, path]) => api(path)));
|
|
1217
1507
|
const data = {};
|
|
1218
1508
|
const failures = [];
|
|
1219
|
-
RADARS.forEach(([name], i) => {
|
|
1509
|
+
RADARS.forEach(([name, path], i) => {
|
|
1220
1510
|
const r = results[i];
|
|
1221
|
-
if (r.ok)
|
|
1511
|
+
if (!r.ok)
|
|
1512
|
+
return void failures.push([name, r]);
|
|
1513
|
+
// mcp_servers is read only when the answer is the MCP-server counts at all (mcpRefusal): an
|
|
1514
|
+
// answer that is not is a failure of that radar, named like any other, never zeros.
|
|
1515
|
+
const refused = name === "mcp_servers" ? mcpRefusal(r.data) : undefined;
|
|
1516
|
+
if (refused === undefined)
|
|
1222
1517
|
data[name] = r.data;
|
|
1223
1518
|
else
|
|
1224
|
-
failures.push([name, r]);
|
|
1519
|
+
failures.push([name, { ok: false, kind: "unexpected_shape", path, status: r.status, detail: refused }]);
|
|
1225
1520
|
});
|
|
1226
1521
|
if (failures.length) {
|
|
1227
1522
|
// All or nothing: a radar picture with one quadrant missing reads as the whole one,
|
|
@@ -1241,6 +1536,7 @@ async function exposureRadar() {
|
|
|
1241
1536
|
exposed_databases: readExposedDatabases(data.exposed_databases),
|
|
1242
1537
|
leaked_credentials: readLeakedCredentials(data.leaked_credentials),
|
|
1243
1538
|
shadow_ai: readShadowAI(data.shadow_ai),
|
|
1539
|
+
mcp_servers: readMCPServers(data.mcp_servers),
|
|
1244
1540
|
};
|
|
1245
1541
|
const relayed = {};
|
|
1246
1542
|
const said = [];
|
|
@@ -1251,6 +1547,7 @@ async function exposureRadar() {
|
|
|
1251
1547
|
// Only what the readers relayed: a last_run_at that is not a real instant never got there.
|
|
1252
1548
|
const stamp = (radar, ...keys) => instantOrNull(strAt(relayed[radar], ...keys));
|
|
1253
1549
|
const running = boolAt(relayed.shadow_ai, "poller", "running");
|
|
1550
|
+
const enabled = boolAt(relayed.mcp_servers, "enabled");
|
|
1254
1551
|
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.`, {
|
|
1255
1552
|
state: "not_assessed",
|
|
1256
1553
|
measured_at: null,
|
|
@@ -1261,6 +1558,7 @@ async function exposureRadar() {
|
|
|
1261
1558
|
exposed_databases: { last_run_at: stamp("exposed_databases", "last_run_at") },
|
|
1262
1559
|
leaked_credentials: { last_run_at: stamp("leaked_credentials", "last_run_at") },
|
|
1263
1560
|
shadow_ai: { last_run_at: stamp("shadow_ai", "poller", "last_run_at"), running: running ?? null },
|
|
1561
|
+
mcp_servers: { last_run_at: stamp("mcp_servers", "last_run_at"), enabled: enabled ?? null },
|
|
1264
1562
|
},
|
|
1265
1563
|
notes: [RADAR_STATE_NOTE, RADAR_FRESHNESS_NOTE],
|
|
1266
1564
|
});
|
|
@@ -1327,9 +1625,13 @@ const CVERecord = z.looseObject({
|
|
|
1327
1625
|
cvss_v3_score: opt(z.number()),
|
|
1328
1626
|
cvss_v4_score: opt(z.number()),
|
|
1329
1627
|
cvss_v4_severity: opt(z.string()),
|
|
1330
|
-
|
|
1331
|
-
|
|
1628
|
+
// #2535: the score is a score only beside score_assessed true (see cveScoreNote).
|
|
1629
|
+
echelongraph_score: opt(z.number()).describe("EchelonGraph's 0-10 score for the CVE: a score only when score_assessed is true. With score_assessed false it is a placeholder, not a rating."),
|
|
1630
|
+
echelongraph_severity: opt(z.string()).describe("The severity band of echelongraph_score: a rating only when score_assessed is true. With score_assessed false it is a placeholder (NONE)."),
|
|
1631
|
+
echelongraph_risk: opt(z.number()).describe("EchelonGraph's 0-100 risk priority, fused from the score, exploitation and automatability: a rating only when score_assessed is true. With score_assessed false it is a placeholder."),
|
|
1332
1632
|
score_confidence: opt(z.string()),
|
|
1633
|
+
score_assessed: opt(z.boolean()).describe("Whether EchelonGraph has scored the CVE. true: echelongraph_score, echelongraph_severity and echelongraph_risk are its score. false: the CVE is NOT YET SCORED, not scored 0, and any of those three it carries is a placeholder, not a rating; the API may leave them out. Absent (an API older than the field): the answer does not say whether the CVE was scored."),
|
|
1634
|
+
score_unassessed_reason: opt(z.string()).describe("Why score_assessed is false: no_signal, no source has yet published severity data EchelonGraph can score; or rejected, the record was withdrawn by its numbering authority and is never scored. Absent when the CVE is scored."),
|
|
1333
1635
|
epss_score: opt(z.number()),
|
|
1334
1636
|
epss_percentile: opt(z.number()),
|
|
1335
1637
|
kev_listed: opt(z.boolean()),
|
|
@@ -1445,12 +1747,25 @@ const ShadowAIData = z.strictObject({
|
|
|
1445
1747
|
poller: z.strictObject({ running: z.boolean(), last_run_at: Instant }).optional(),
|
|
1446
1748
|
});
|
|
1447
1749
|
const LastRun = z.strictObject({ last_run_at: Instant.nullable() });
|
|
1750
|
+
// mcp_servers as readMCPServers relays it, from the same constants (#2315): the headline counts
|
|
1751
|
+
// always (an answer without them is refused), and each partition with exactly its buckets or not
|
|
1752
|
+
// at all.
|
|
1753
|
+
const Whole = z.number().int().min(0);
|
|
1754
|
+
const MCPServersData = z.strictObject({
|
|
1755
|
+
...Object.fromEntries(MCP_HEADLINE.map((k) => [k, Whole])),
|
|
1756
|
+
...Object.fromEntries(MCP_PARTITION_NAMES.map((k) => [k, z.strictObject(Object.fromEntries(MCP_PARTITIONS[k].buckets.map((b) => [b, Whole]))).optional()])),
|
|
1757
|
+
own_controls_excluded: Whole.optional(),
|
|
1758
|
+
window: z.strictObject({ from: Instant, to: Instant }).optional(),
|
|
1759
|
+
enabled: z.boolean().optional(),
|
|
1760
|
+
...Object.fromEntries(MCP_INSTANTS.map((k) => [k, Instant.optional()])),
|
|
1761
|
+
});
|
|
1448
1762
|
const EXPOSURE_RADAR_OUTPUT = envelopeSchema({
|
|
1449
1763
|
data: z.strictObject({
|
|
1450
1764
|
kev_exposure: zRadar(KEV_SPEC, { newest_kev: z.array(NewestKEVRow) }),
|
|
1451
1765
|
exposed_databases: zRadar(EXPOSED_DB_SPEC),
|
|
1452
1766
|
leaked_credentials: zRadar(LEAKED_CREDS_SPEC),
|
|
1453
1767
|
shadow_ai: ShadowAIData,
|
|
1768
|
+
mcp_servers: MCPServersData,
|
|
1454
1769
|
}),
|
|
1455
1770
|
coverage: z.strictObject({
|
|
1456
1771
|
radars: z.array(z.string()).describe("The radars this tool reads."),
|
|
@@ -1462,12 +1777,13 @@ const EXPOSURE_RADAR_OUTPUT = envelopeSchema({
|
|
|
1462
1777
|
exposed_databases: LastRun,
|
|
1463
1778
|
leaked_credentials: LastRun,
|
|
1464
1779
|
shadow_ai: z.strictObject({ last_run_at: Instant.nullable(), running: z.boolean().nullable() }),
|
|
1780
|
+
mcp_servers: z.strictObject({ last_run_at: Instant.nullable(), enabled: z.boolean().nullable() }),
|
|
1465
1781
|
}),
|
|
1466
1782
|
error: ErrorSchema.extend({
|
|
1467
1783
|
radars: z
|
|
1468
1784
|
.array(z.strictObject({
|
|
1469
1785
|
radar: z.string(),
|
|
1470
|
-
kind: z.enum(["network", "timeout", "http", "not_json", "not_object"]),
|
|
1786
|
+
kind: z.enum(["network", "timeout", "http", "not_json", "not_object", "unexpected_shape"]),
|
|
1471
1787
|
path: z.string(),
|
|
1472
1788
|
status: z.number().int().nullable(),
|
|
1473
1789
|
message: z.string(),
|
|
@@ -1494,12 +1810,17 @@ const INSTRUCTIONS = [
|
|
|
1494
1810
|
"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.",
|
|
1495
1811
|
"Every figure is as fresh as the schedule that refreshes it.",
|
|
1496
1812
|
"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.",
|
|
1813
|
+
"exposure_radar's mcp_servers counts hostnames instead, each by its latest verdict on record.",
|
|
1497
1814
|
`${SHODAN_ATTRIBUTION} ${SHODAN_OWNERSHIP}`,
|
|
1815
|
+
"exposure_radar's mcp_servers counts use no Shodan data: their hostnames come from EchelonGraph's own Certificate Transparency feed.",
|
|
1498
1816
|
].join(" ");
|
|
1499
1817
|
const ANNOTATIONS = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
|
|
1500
1818
|
const CVE_ID_ARG = z.string().describe("a CVE ID, e.g. CVE-2023-44487");
|
|
1501
1819
|
// What each description says about its structured result, naming only fields its schema holds.
|
|
1502
1820
|
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.";
|
|
1821
|
+
// #2535: what search_cves and get_cve say about score_assessed, one constant string, since the
|
|
1822
|
+
// site's tool-claims check (marketing-site lib/mcpToolClaims.test.ts) folds only constant strings.
|
|
1823
|
+
const SCORE_ASSESSED_DESCRIPTION = "echelongraph_score, echelongraph_severity and echelongraph_risk are EchelonGraph's score only when score_assessed is true. With score_assessed false the CVE is NOT YET SCORED, not scored 0: any of those three it carries (0, NONE, 0) is a placeholder, not a rating, and does not mean the CVE is harmless; the API may leave them out instead, score_confidence is NONE, and score_unassessed_reason says why (a rejected record, withdrawn by its numbering authority, is never scored, and the note says NOT SCORED). The note labels each such CVE NOT YET SCORED: report it that way, never as a score of 0. An answer with no score_assessed (an API older than that field) does not say whether the CVE was scored, the note says so, and a 0 there is not a rating either.";
|
|
1503
1824
|
// One server, built per connection by serveStdio for whichever era the client opens with.
|
|
1504
1825
|
// tools/list answers in registration order, which is the order below.
|
|
1505
1826
|
function createServer() {
|
|
@@ -1512,7 +1833,7 @@ function createServer() {
|
|
|
1512
1833
|
}, async () => checked("cve_summary", CVE_SUMMARY_OUTPUT, await cveSummary()));
|
|
1513
1834
|
server.registerTool("search_cves", {
|
|
1514
1835
|
title: "Search CVEs",
|
|
1515
|
-
description: `Search/list CVEs from EchelonGraph's CVE feed (NVD + MITRE-CNA pre-NVD + CISA-KEV + EPSS + GitHub GHSA, each polled on a schedule). Filter by severity, minimum CVSS, free text, and sort. Returns cves, each with cve_id, severity, cvss_v3_score, echelongraph_score, epss_score and kev_listed where the record has them, and the list's total, total_counted (false: the matches were not counted, so total is not a count), total_is_lower_bound (true: at least total), search_relaxed (true: a phrase was relaxed to all of its words), limit and offset. ${FEED_ENVELOPE} coverage repeats total, total_counted, total_is_lower_bound, search_relaxed, limit and offset, and gives returned, the rows in this page.`,
|
|
1836
|
+
description: `Search/list CVEs from EchelonGraph's CVE feed (NVD + MITRE-CNA pre-NVD + CISA-KEV + EPSS + GitHub GHSA, each polled on a schedule). Filter by severity, minimum CVSS, free text, and sort. Returns cves, each with cve_id, severity, cvss_v3_score, echelongraph_score and score_assessed (whether EchelonGraph has scored it), epss_score and kev_listed where the record has them, and the list's total, total_counted (false: the matches were not counted, so total is not a count), total_is_lower_bound (true: at least total), search_relaxed (true: a phrase was relaxed to all of its words), limit and offset. ${SCORE_ASSESSED_DESCRIPTION} ${FEED_ENVELOPE} coverage repeats total, total_counted, total_is_lower_bound, search_relaxed, limit and offset, and gives returned, the rows in this page.`,
|
|
1516
1837
|
inputSchema: z.object({
|
|
1517
1838
|
search: z.string().optional().describe("free-text search (product, vendor, or keyword, e.g. 'tomcat')"),
|
|
1518
1839
|
severity: z.enum(["CRITICAL", "HIGH", "MEDIUM", "LOW"]).optional().describe("filter to one severity"),
|
|
@@ -1525,7 +1846,7 @@ function createServer() {
|
|
|
1525
1846
|
}, async (a) => checked("search_cves", SEARCH_CVES_OUTPUT, await searchCVEs(a)));
|
|
1526
1847
|
server.registerTool("get_cve", {
|
|
1527
1848
|
title: "CVE detail",
|
|
1528
|
-
description: `Full record for one CVE: description, severity, cvss_v3_score, cvss_v4_score and cvss_v4_severity (when the record has a CVSS v4 score), echelongraph_score and echelongraph_severity with score_confidence (EchelonGraph's multi-source score), epss_score and epss_percentile, CISA-KEV status (kev_listed, kev_added_date, and kev_ransomware for known ransomware-campaign use), ghsa_id (GitHub GHSA), references, published, modified and updated_at; each field only where the record has it. Pass a CVE ID like CVE-2023-44487. ${FEED_ENVELOPE}`,
|
|
1849
|
+
description: `Full record for one CVE: description, severity, cvss_v3_score, cvss_v4_score and cvss_v4_severity (when the record has a CVSS v4 score), echelongraph_score and echelongraph_severity with score_confidence (EchelonGraph's multi-source score) and score_assessed (whether EchelonGraph has scored it), epss_score and epss_percentile, CISA-KEV status (kev_listed, kev_added_date, and kev_ransomware for known ransomware-campaign use), ghsa_id (GitHub GHSA), references, published, modified and updated_at; each field only where the record has it. Pass a CVE ID like CVE-2023-44487. ${SCORE_ASSESSED_DESCRIPTION} ${FEED_ENVELOPE}`,
|
|
1529
1850
|
inputSchema: z.object({ cve_id: CVE_ID_ARG }),
|
|
1530
1851
|
outputSchema: GET_CVE_OUTPUT,
|
|
1531
1852
|
annotations: ANNOTATIONS,
|
|
@@ -1539,7 +1860,7 @@ function createServer() {
|
|
|
1539
1860
|
}, async ({ cve_id }) => checked("cve_exposure", CVE_EXPOSURE_OUTPUT, await cveExposure(cve_id)));
|
|
1540
1861
|
server.registerTool("exposure_radar", {
|
|
1541
1862
|
title: "Exposure radar totals",
|
|
1542
|
-
description: `${EXPOSURE_RADAR_DESCRIPTION} Its structured result's state is not_assessed with measured_at null: every radar answered, but
|
|
1863
|
+
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.`,
|
|
1543
1864
|
outputSchema: EXPOSURE_RADAR_OUTPUT,
|
|
1544
1865
|
annotations: ANNOTATIONS,
|
|
1545
1866
|
}, 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.1",
|
|
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.1",
|
|
12
12
|
"packages": [
|
|
13
13
|
{
|
|
14
14
|
"registryType": "npm",
|
|
15
15
|
"identifier": "echelongraph-mcp",
|
|
16
|
-
"version": "2.
|
|
16
|
+
"version": "2.3.1",
|
|
17
17
|
"transport": {
|
|
18
18
|
"type": "stdio"
|
|
19
19
|
},
|