echelongraph-mcp 1.0.4 → 2.1.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 +124 -18
- package/dist/index.js +606 -77
- package/package.json +5 -5
- package/server.json +2 -2
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ API call a tool needs to answer.
|
|
|
19
19
|
|---|---|
|
|
20
20
|
| `cve_summary` | Counts of active CVEs by severity, and when the feed was last updated. |
|
|
21
21
|
| `search_cves` | Search/filter CVEs (severity, min CVSS, text, sort) with EchelonGraph scores. |
|
|
22
|
-
| `get_cve` | Full
|
|
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
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. |
|
|
25
25
|
|
|
@@ -30,29 +30,38 @@ sources on a schedule; each radar refreshes on its own.
|
|
|
30
30
|
|
|
31
31
|
Every 12 hours, when Shodan query credits allow, the KEV-exposure radar runs one Shodan query
|
|
32
32
|
per tracked product, reads up to 100 ip:port services per query, and keeps a service when its
|
|
33
|
-
banner version matches a CISA-KEV or high-EPSS (>= 0.5) CVE. A service
|
|
34
|
-
for 21 days is dropped. A count is therefore a banner-version inference
|
|
35
|
-
exploit test, and not an internet-wide census.
|
|
36
|
-
|
|
37
|
-
`last_seen` is when
|
|
38
|
-
version was last confirmed.
|
|
39
|
-
|
|
40
|
-
|
|
33
|
+
banner version matches a CISA-KEV or high-EPSS (>= 0.5) CVE. A service whose row has not been
|
|
34
|
+
written or refreshed for 21 days is dropped. A count is therefore a banner-version inference
|
|
35
|
+
over a sample: not an exploit test, and not an internet-wide census.
|
|
36
|
+
|
|
37
|
+
`last_seen` is when EchelonGraph last wrote or refreshed a service's row, not when the service
|
|
38
|
+
was observed, and not when its vulnerable version was last confirmed. It is set to the time of
|
|
39
|
+
the write when a search matches the banner, and again when a re-check finds the port still
|
|
40
|
+
listed by Shodan InternetDB, without re-reading the banner, so a patched service can stay
|
|
41
|
+
counted while its port stays open. Shodan's own time for the banner is not stored. So
|
|
42
|
+
`last_seen` dates the write, never the sighting, and it is never `measured_at` (see
|
|
43
|
+
"Structured results").
|
|
41
44
|
|
|
42
45
|
The unit is a service, not a machine. Shodan returns one banner per port and the radar keys
|
|
43
46
|
each observation on ip:port, so a machine answering on two ports counts twice. The API's field
|
|
44
47
|
names are kept (`exposed_hosts` here; `distinct_hosts`, `ransomware_hosts` and each ranked
|
|
45
48
|
row's count in `exposure_radar`), but every one of them counts ip:port services.
|
|
46
49
|
|
|
47
|
-
The radar only looks for its **tracked** set of CVEs, so a zero means different things
|
|
50
|
+
The radar only looks for its **tracked** set of CVEs, so a zero means different things. The
|
|
51
|
+
note tags each case with its `exposure_state`, as "(exposure_state: …)"; that tag says what the
|
|
52
|
+
count is, and is not the result's `state`:
|
|
48
53
|
|
|
49
54
|
| API answer | What the tool says |
|
|
50
55
|
|---|---|
|
|
51
|
-
| `tracked:
|
|
52
|
-
| `tracked:
|
|
53
|
-
|
|
|
56
|
+
| `tracked: true`, `exposed_hosts` above 0 | `exposure_state` `exposed`: the count of services the radar has on record for the CVE. |
|
|
57
|
+
| `tracked: false` | `exposure_state` `not_assessed`, **NOT ASSESSED**: outside the radar's tracked set; 0 is not a measurement. |
|
|
58
|
+
| `tracked: true`, `exposed_hosts: 0` | `exposure_state` `measured_zero`, a zero in the radar's sample: none of the up to 100 services it read per tracked-product query matched. Not an internet-wide zero, and undated: the answer does not say when the radar looked. The backend does not send this answer today. |
|
|
59
|
+
| no `tracked` field | The API did not say whether the CVE is in the tracked set: an older API, or the radar cannot decide (for example a CISA-KEV or high-EPSS CVE in a tracked product with 0 services on record). 0 exposed services on record, with no claim either way (`exposure_state` `tracking_unknown`). |
|
|
54
60
|
| HTTP 400, or an id that is not `CVE-YYYY-NNNN…` | An error result tagged `invalid_input`; nothing was looked up. |
|
|
55
61
|
|
|
62
|
+
Whatever the `exposure_state`, the result's `state` is `not_assessed` with `measured_at` `null`:
|
|
63
|
+
the answer does not say when any service it counts was observed (see "Structured results").
|
|
64
|
+
|
|
56
65
|
### How `exposure_radar` counts
|
|
57
66
|
|
|
58
67
|
`exposure_radar` relays each radar's answer cut to the fields it can label, and its note labels
|
|
@@ -151,7 +160,9 @@ yet verified, whose hostname no longer resolves, or whose service no longer answ
|
|
|
151
160
|
|
|
152
161
|
The tool relays no number it cannot label. A stats field this version does not know is left
|
|
153
162
|
out, and the note names it; so is a count in an unexpected shape. A ranked row carries only
|
|
154
|
-
its name and its count, and a field added to one is named too.
|
|
163
|
+
its name and its count, and a field added to one is named too. The `poller` block's keys are
|
|
164
|
+
checked the same way: a key this version does not know is left out and named, while the
|
|
165
|
+
instance fields it knows are left out as described below.
|
|
155
166
|
|
|
156
167
|
## What a result means
|
|
157
168
|
|
|
@@ -159,7 +170,8 @@ Every tool answers in one of two shapes, so a model reading the result cannot mi
|
|
|
159
170
|
outage for an all-clear:
|
|
160
171
|
|
|
161
172
|
- **Success** — the first text block is the API's JSON verbatim; the second is a one-line
|
|
162
|
-
note saying the call succeeded, which base URL answered, and what it found
|
|
173
|
+
note saying the call succeeded, which base URL answered, and what it found; the third is
|
|
174
|
+
the structured result without `data`, as JSON (below). When the
|
|
163
175
|
feed genuinely holds nothing for the query the note says so in words ("we looked and
|
|
164
176
|
found nothing … not a lookup failure"), because a measured zero is a measurement. The
|
|
165
177
|
exception to verbatim is `exposure_radar`: each radar is cut to the fields listed above, and
|
|
@@ -179,11 +191,100 @@ outage for an all-clear:
|
|
|
179
191
|
the host could not be reached, it answered non-2xx, it took longer than the timeout, or
|
|
180
192
|
it answered 2xx with a body that is not a JSON object. The text names the tool, the
|
|
181
193
|
cause (status code or error kind), the path, and the base URL, and says it is not a
|
|
182
|
-
finding
|
|
194
|
+
finding; the second text block is the structured result, as JSON (below). A failure is never
|
|
195
|
+
rendered as a success with null fields.
|
|
196
|
+
|
|
197
|
+
Both shapes also carry a structured result, below, and repeat it in their last text block, so a
|
|
198
|
+
client that passes only `content` to the model still sees how the answer was measured. The
|
|
199
|
+
first text block (the API's JSON, or the failure) and the note after it are where 1.x put them.
|
|
200
|
+
|
|
201
|
+
## Structured results
|
|
202
|
+
|
|
203
|
+
Since 2.0.0 every result, success or failure, carries `structuredContent`, and every tool
|
|
204
|
+
declares its shape as an `outputSchema` in `tools/list`, with a title and the annotations
|
|
205
|
+
`readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true` and
|
|
206
|
+
`openWorldHint: true` (hints, which a client treats as untrusted).
|
|
207
|
+
|
|
208
|
+
| Field | What it holds |
|
|
209
|
+
|---|---|
|
|
210
|
+
| `state` | `measured`, `not_assessed`, `failed` or `invalid_input` (below). |
|
|
211
|
+
| `measured_at` | When the underlying observation was made, as the API states it. `null` when the answer does not say or holds no observation; never the Go zero time. |
|
|
212
|
+
| `method` | How the numbers were produced. `null` on a failure. |
|
|
213
|
+
| `coverage` | What the answer covers, where the tool can say: `in_scope` for `cve_exposure`, the list's own account of its count for `search_cves`, and the radars that answered for `exposure_radar`. |
|
|
214
|
+
| `freshness` | The producing radar's last completed check (`last_run_at`), where the API serves one; `null` where it serves none. |
|
|
215
|
+
| `notes` | The caveats, one sentence each: what the envelope itself needs saying, then the note from the text block. |
|
|
216
|
+
| `data` | On a success only: the same JSON as the first text block. |
|
|
217
|
+
| `error` | On a failure only: `kind`, `path`, `status` and `message`. |
|
|
218
|
+
|
|
219
|
+
The last text block of every result is this structured result serialized as JSON, without
|
|
220
|
+
`data`: the same `state`, `measured_at`, `method`, `coverage`, `freshness` and `notes` (and on
|
|
221
|
+
a failure `error`), key for key, since a client may pass only `content` to the model. With
|
|
222
|
+
the first text block as `data`, the text carries the whole structured result.
|
|
223
|
+
|
|
224
|
+
| `state` | What it means |
|
|
225
|
+
|---|---|
|
|
226
|
+
| `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 number in it is a finding: 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. |
|
|
228
|
+
| `failed` | The lookup did not complete. Not a finding. |
|
|
229
|
+
| `invalid_input` | The input was refused, by this server or by the API, so nothing was looked up. Not a finding. |
|
|
230
|
+
|
|
231
|
+
Every field comes from what the API sends; where the API does not say, the field is `null` and
|
|
232
|
+
a note says so. Per tool:
|
|
233
|
+
|
|
234
|
+
- `cve_summary` is `measured`, and its `measured_at` is `summary.last_updated`, the newest
|
|
235
|
+
modification time among the active CVE records it counts.
|
|
236
|
+
- `search_cves` is `measured`, with `measured_at` `null`: each record carries its own times. Its
|
|
237
|
+
`coverage` repeats the list's `total`, `total_counted`, `total_is_lower_bound`,
|
|
238
|
+
`search_relaxed`, `limit` and `offset`, and adds `returned`, the rows in the page. When
|
|
239
|
+
`total_counted` is false the total is not a count, and the note does not call it one.
|
|
240
|
+
- `get_cve` is `measured`, and its `measured_at` is the record's `updated_at`, when EchelonGraph
|
|
241
|
+
last wrote it.
|
|
242
|
+
- `cve_exposure` is `not_assessed` with `measured_at` `null`, every answer: the per-CVE answer
|
|
243
|
+
carries no time at which the services it counts were observed. Its `last_seen` is when
|
|
244
|
+
EchelonGraph last wrote or refreshed one of their rows, not when any of them was observed,
|
|
245
|
+
so it is never `measured_at`, and a note says so. A tracked zero is `not_assessed` too, since
|
|
246
|
+
a zero has no observation to date it. The count is still relayed, labelled: `exposure_state`
|
|
247
|
+
is `exposed`, `measured_zero`, `not_assessed` or `tracking_unknown`, the cases of "How
|
|
248
|
+
`cve_exposure` counts", and `coverage.in_scope` is the API's `tracked`. It becomes `measured`
|
|
249
|
+
only when the API serves a time at which the counted services were observed.
|
|
250
|
+
- `exposure_radar` is `not_assessed` with `measured_at` `null`: every radar answered, but no
|
|
251
|
+
stats answer says when the services or records it counts were observed, so no count is
|
|
252
|
+
presented as a dated measurement. Its numbers keep the labels above. `freshness` holds each
|
|
253
|
+
radar's `last_run_at` where the API serves one, and for `shadow_ai` also `running`.
|
|
254
|
+
|
|
255
|
+
The CVE feed tools' `freshness` is `null`: the feed's answers carry no time at which its
|
|
256
|
+
pollers last completed a poll for the feed as a whole.
|
|
257
|
+
|
|
258
|
+
A success whose fields do not fit the tool's `outputSchema` (a field of a type the schema does
|
|
259
|
+
not allow) is returned as a failure with `error.kind` `unexpected_shape`, never relayed. A
|
|
260
|
+
field the API adds later is still relayed by the four tools that relay the API's JSON, and
|
|
261
|
+
`exposure_radar` leaves it out and names it, as above.
|
|
262
|
+
|
|
263
|
+
## Protocol versions
|
|
264
|
+
|
|
265
|
+
The server answers both eras of the Model Context Protocol on stdio:
|
|
266
|
+
|
|
267
|
+
- **2026-07-28**: a client that opens with `server/discover` receives a DiscoverResult listing
|
|
268
|
+
`2026-07-28`, the tools capability and the server instructions, and then sends each request
|
|
269
|
+
with the per-request `_meta` envelope.
|
|
270
|
+
- **2025 and earlier**: a client that opens with `initialize`, as every 1.x SDK client does,
|
|
271
|
+
negotiates `2025-11-25`, `2025-06-18`, `2025-03-26` or `2024-11-05`; a version the server does
|
|
272
|
+
not know is answered with `2025-11-25`.
|
|
273
|
+
|
|
274
|
+
The first message of a connection picks its era. The DiscoverResult lists only `2026-07-28`,
|
|
275
|
+
as the SDK builds it: the 2025-era versions are reached through `initialize`. Both handshakes
|
|
276
|
+
carry the package's name and version (`serverInfo`), and every API request carries them in its
|
|
277
|
+
User-Agent, `echelongraph-mcp/<version>`.
|
|
278
|
+
|
|
279
|
+
Both handshakes also carry the server instructions: the data is public; what `state`,
|
|
280
|
+
`measured_at` and `freshness` mean, and that the last text block repeats them; that exposure
|
|
281
|
+
numbers are aggregate counts of ip:port services, not an internet-wide census; and that Shodan
|
|
282
|
+
data is Shodan's.
|
|
183
283
|
|
|
184
284
|
## Install
|
|
185
285
|
|
|
186
|
-
Add it to your MCP client's config. It runs via `npx` — no global
|
|
286
|
+
Requires Node.js 20 or later. Add it to your MCP client's config. It runs via `npx` — no global
|
|
287
|
+
install needed.
|
|
187
288
|
|
|
188
289
|
### Claude Desktop
|
|
189
290
|
|
|
@@ -230,13 +331,18 @@ services does EchelonGraph's radar have on record for it?"*
|
|
|
230
331
|
```bash
|
|
231
332
|
npm install
|
|
232
333
|
npm run build # tsc → dist/
|
|
233
|
-
npm test # build, then behavioural
|
|
334
|
+
npm test # build, then the behavioural suite over both protocol eras, against a stub API (no network needed)
|
|
234
335
|
npm run smoke # spawn the server + call cve_exposure against the production API
|
|
235
336
|
```
|
|
236
337
|
|
|
237
338
|
`npm run smoke` calls the production API with this package's User-Agent, so its requests are
|
|
238
339
|
counted as external MCP adoption. `npm test` never leaves the machine.
|
|
239
340
|
|
|
341
|
+
`npm test` runs the suite once over a 2026-07-28 `server/discover` and once over a 2025-06-18
|
|
342
|
+
`initialize`, plus the era tests. To run it against an installed package rather than `dist/`,
|
|
343
|
+
set `ECHELONGRAPH_MCP_BIN` to that package's `echelongraph-mcp` bin: the tests then run the bin
|
|
344
|
+
directly, as `npx` does, and read that package's own files.
|
|
345
|
+
|
|
240
346
|
## License
|
|
241
347
|
|
|
242
348
|
The code is MIT-licensed.
|
package/dist/index.js
CHANGED
|
@@ -13,30 +13,54 @@
|
|
|
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
15
|
// what it left out (readKEVExposure, readExposedDatabases, readLeakedCredentials and
|
|
16
|
-
// readShadowAI; #2307, #2313).
|
|
16
|
+
// readShadowAI; #2307, #2313). content[2] is the envelope below, as JSON.
|
|
17
17
|
// - failure (isError: true): the lookup did not complete — unreachable host, non-2xx,
|
|
18
|
-
// timeout, or a 2xx whose body is not a JSON object — named by tool, cause and base URL
|
|
18
|
+
// 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.
|
|
19
20
|
// Never a success with null fields: a model handed one of those tells its user "no
|
|
20
21
|
// exposure found", and an outage becomes an all-clear.
|
|
22
|
+
//
|
|
23
|
+
// Structured results (#2311, #2313). Every result, success or failure, also carries
|
|
24
|
+
// structuredContent: one envelope that says how the answer was measured — state (measured,
|
|
25
|
+
// not_assessed, failed, invalid_input), measured_at, method, coverage, freshness and notes —
|
|
26
|
+
// with, on a success, `data` equal to what content[0] carries, and on a failure an `error`.
|
|
27
|
+
// Each tool advertises the envelope as its outputSchema.
|
|
28
|
+
//
|
|
29
|
+
// The envelope in the text (#2440). Many clients pass only `content` to the model, so the last
|
|
30
|
+
// text block of every result is structuredContent serialized as JSON, without `data` (which
|
|
31
|
+
// content[0] already carries verbatim): the same state, measured_at, method, coverage,
|
|
32
|
+
// freshness, notes and, on a failure, error, key for key. content[0] + that block is the whole
|
|
33
|
+
// of structuredContent. The MCP spec (2025-06-18, Tools, Structured Content) asks a tool that
|
|
34
|
+
// returns structured content to return it serialized in a text block too; the JSON block stays
|
|
35
|
+
// first and the note second, as in 1.x, so a client that parses content[0] is unaffected.
|
|
36
|
+
//
|
|
37
|
+
// Protocol eras (#2311). serveStdio answers both: a 2026-07-28 client's server/discover and
|
|
38
|
+
// per-request `_meta` envelope, and a 2025-era client's `initialize` handshake. The first
|
|
39
|
+
// message of a connection picks the era; the same factory builds the server for either.
|
|
21
40
|
import { readFileSync } from "node:fs";
|
|
22
|
-
import { McpServer } from "@modelcontextprotocol/
|
|
23
|
-
import {
|
|
24
|
-
import
|
|
25
|
-
// The
|
|
26
|
-
// handshake and the User-Agent both carry
|
|
27
|
-
// version without a code change per release.
|
|
28
|
-
const
|
|
41
|
+
import { McpServer } from "@modelcontextprotocol/server";
|
|
42
|
+
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
43
|
+
import * as z from "zod";
|
|
44
|
+
// The package actually running, read from the package.json that ships beside dist/. The MCP
|
|
45
|
+
// handshake (serverInfo) and the User-Agent both carry its name and version, so the API's
|
|
46
|
+
// access log can count adoption per version without a code change per release.
|
|
47
|
+
const PKG = (() => {
|
|
29
48
|
try {
|
|
30
49
|
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
31
|
-
|
|
32
|
-
return typeof v === "string" && v ? v : "unknown";
|
|
50
|
+
return pkg !== null && typeof pkg === "object" && !Array.isArray(pkg) ? pkg : {};
|
|
33
51
|
}
|
|
34
52
|
catch {
|
|
35
|
-
return
|
|
53
|
+
return {};
|
|
36
54
|
}
|
|
37
55
|
})();
|
|
56
|
+
const pkgField = (k, fallback) => {
|
|
57
|
+
const v = PKG[k];
|
|
58
|
+
return typeof v === "string" && v ? v : fallback;
|
|
59
|
+
};
|
|
60
|
+
const NAME = pkgField("name", "echelongraph-mcp");
|
|
61
|
+
const VERSION = pkgField("version", "unknown");
|
|
38
62
|
const BASE = (process.env.ECHELONGRAPH_API_BASE || "https://app.echelongraph.io").replace(/\/+$/, "");
|
|
39
|
-
const UA =
|
|
63
|
+
const UA = `${NAME}/${VERSION} (+https://echelongraph.io/pulse/mcp)`;
|
|
40
64
|
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
41
65
|
const TIMEOUT_MS = (() => {
|
|
42
66
|
const n = Number(process.env.ECHELONGRAPH_API_TIMEOUT_MS);
|
|
@@ -148,6 +172,15 @@ async function api(path) {
|
|
|
148
172
|
}
|
|
149
173
|
}
|
|
150
174
|
const text = (t) => ({ type: "text", text: t });
|
|
175
|
+
// A note, one sentence per entry. Sentences end in ".", "!" or "?" before whitespace, so a URL,
|
|
176
|
+
// a version or a field path (observed.total, crt.sh) does not split one.
|
|
177
|
+
const sentences = (t) => t
|
|
178
|
+
.replace(/\s+/g, " ")
|
|
179
|
+
.trim()
|
|
180
|
+
.split(/(?<=[.!?])\s+/)
|
|
181
|
+
.filter(Boolean);
|
|
182
|
+
// A timestamp worth relaying as one: see realInstant below.
|
|
183
|
+
const instantOrNull = (s) => (realInstant(s) ? s : null);
|
|
151
184
|
// One sentence per failure kind: where we looked, for what, and what came back.
|
|
152
185
|
function describeFailure(f) {
|
|
153
186
|
const where = `from ${SHOWN_BASE} for GET ${f.path}`;
|
|
@@ -173,27 +206,70 @@ const NO_RECORD = "The API returned no record for this request; the quoted messa
|
|
|
173
206
|
// It is tagged invalid_input, the same state a request this server refuses locally carries.
|
|
174
207
|
const INVALID_INPUT = "(state: invalid_input)";
|
|
175
208
|
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): structuredContent without `data`, serialized as JSON,
|
|
210
|
+
// so a client that passes only `content` to the model still sees how the answer was measured.
|
|
211
|
+
// `data` is left out because content[0] carries it verbatim; every other key is copied as is,
|
|
212
|
+
// so the text and the structured result cannot disagree.
|
|
213
|
+
const envelopeText = (structured) => {
|
|
214
|
+
const { data: _data, ...envelope } = structured;
|
|
215
|
+
return text(JSON.stringify(envelope, null, 2));
|
|
216
|
+
};
|
|
217
|
+
// A failure result: the #1874 text, and an envelope saying nothing was measured, in
|
|
218
|
+
// 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).
|
|
220
|
+
const failure = (state, message, error, coverage = null) => {
|
|
221
|
+
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 };
|
|
223
|
+
};
|
|
224
|
+
const errorOf = (f) => ({ kind: f.kind, path: f.path, status: f.status ?? null, message: f.detail });
|
|
176
225
|
const failed = (tool, f) => {
|
|
177
226
|
if (f.kind === "http" && f.status === 400) {
|
|
178
|
-
return
|
|
227
|
+
return failure("invalid_input", `${tool} FAILED ${INVALID_INPUT}: ${describeFailure(f)} ${REJECTED_INPUT}`, errorOf(f));
|
|
179
228
|
}
|
|
180
|
-
return {
|
|
181
|
-
content: [text(`${tool} FAILED: ${describeFailure(f)} ${f.kind === "http" && f.status === 404 ? NO_RECORD : NOT_A_FINDING}`)],
|
|
182
|
-
isError: true,
|
|
183
|
-
};
|
|
229
|
+
return failure("failed", `${tool} FAILED: ${describeFailure(f)} ${f.kind === "http" && f.status === 404 ? NO_RECORD : NOT_A_FINDING}`, errorOf(f));
|
|
184
230
|
};
|
|
185
|
-
const badInput = (tool, why) => ({
|
|
186
|
-
|
|
187
|
-
|
|
231
|
+
const badInput = (tool, why) => failure("invalid_input", `${tool} FAILED ${INVALID_INPUT}: ${why}. Nothing was looked up, so this is not a finding.`, {
|
|
232
|
+
kind: "invalid_input",
|
|
233
|
+
path: null,
|
|
234
|
+
status: null,
|
|
235
|
+
message: why,
|
|
188
236
|
});
|
|
189
237
|
// A bug in this server is still not a finding — it must never surface as an empty success.
|
|
190
|
-
const crashed = (tool, e) => ({
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
content: [text(JSON.stringify(data, null, 2)), text(note)],
|
|
238
|
+
const crashed = (tool, e) => failure("failed", `${tool} FAILED inside the MCP server while querying ${SHOWN_BASE}: ${describeError(e)}. ${NOT_A_FINDING}`, {
|
|
239
|
+
kind: "internal",
|
|
240
|
+
path: null,
|
|
241
|
+
status: null,
|
|
242
|
+
message: describeError(e),
|
|
196
243
|
});
|
|
244
|
+
// A 2xx whose fields do not match the tool's outputSchema (a field of a type the schema does
|
|
245
|
+
// not allow) cannot be relayed as structuredContent: a client validates it against the schema
|
|
246
|
+
// and rejects it. So it is a failure, worded as one, rather than the SDK's bare validation error.
|
|
247
|
+
function unexpectedShape(tool, error) {
|
|
248
|
+
const issues = error.issues
|
|
249
|
+
.slice(0, 3)
|
|
250
|
+
.map((i) => `${i.path.map(String).join(".") || "(root)"}: ${i.message}`)
|
|
251
|
+
.join("; ");
|
|
252
|
+
const detail = `the answer did not match this tool's output schema (${issues})`;
|
|
253
|
+
return failure("failed", `${tool} FAILED: EchelonGraph at ${SHOWN_BASE} answered, but ${detail}, so it is not relayed. ${NOT_A_FINDING}`, {
|
|
254
|
+
kind: "unexpected_shape",
|
|
255
|
+
path: null,
|
|
256
|
+
status: null,
|
|
257
|
+
message: detail,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
// Every success is checked against the tool's own outputSchema before it is returned.
|
|
261
|
+
const checked = (tool, schema, r) => {
|
|
262
|
+
if (r.isError)
|
|
263
|
+
return r;
|
|
264
|
+
const p = schema.safeParse(r.structuredContent);
|
|
265
|
+
return p.success ? r : unexpectedShape(tool, p.error);
|
|
266
|
+
};
|
|
267
|
+
// A success: content[0] is the data, content[1] the note, content[2] the envelope without data;
|
|
268
|
+
// the envelope carries both, the note as sentences after any the envelope adds about itself.
|
|
269
|
+
const succeeded = (data, note, env) => {
|
|
270
|
+
const structuredContent = { ...env, notes: [...(env.notes ?? []), ...sentences(note)], data };
|
|
271
|
+
return { content: [text(JSON.stringify(data, null, 2)), text(note), envelopeText(structuredContent)], structuredContent };
|
|
272
|
+
};
|
|
197
273
|
const okHead = (tool, status) => `${tool} OK: EchelonGraph answered${status === undefined ? "" : ` HTTP ${status}`} from ${SHOWN_BASE}.`;
|
|
198
274
|
// Tolerant readers for the note: a missing or renamed field degrades the sentence, never
|
|
199
275
|
// the result — content[0] still carries whatever the API sent.
|
|
@@ -218,36 +294,54 @@ const boolAt = (o, ...keys) => {
|
|
|
218
294
|
// (0001-01-01T00:00:00Z) where it had no instant to give; that is not a time, so it is never
|
|
219
295
|
// presented as one.
|
|
220
296
|
const realInstant = (s) => s !== undefined && Date.parse(s) > 0;
|
|
221
|
-
|
|
222
|
-
|
|
297
|
+
// ── The CVE feed tools: cve_summary, search_cves, get_cve ──
|
|
298
|
+
//
|
|
299
|
+
// Their numbers are CVE records and scores, not exposure counts, so a success is measured.
|
|
300
|
+
// measured_at is the time the answer itself gives for its records, where it gives one:
|
|
301
|
+
// summary.last_updated (MAX(modified) over the active records, core-backend cve/store.go
|
|
302
|
+
// Summary) for cve_summary, the record's updated_at (when EchelonGraph last wrote it) for
|
|
303
|
+
// get_cve, and none for a search page, whose rows each carry their own. None of them serves a
|
|
304
|
+
// fleet-wide time at which the feed's pollers last completed, so freshness is null: the
|
|
305
|
+
// summary's poller.last_poll_at is the answering instance's own NVD poller (cve/poller.go
|
|
306
|
+
// Stats, in memory), and is relayed in data but not presented as the feed's freshness.
|
|
307
|
+
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.";
|
|
308
|
+
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.";
|
|
309
|
+
async function cveSummary() {
|
|
223
310
|
const tool = "cve_summary";
|
|
224
311
|
try {
|
|
225
312
|
const r = await api("/api/v1/public/cves/summary");
|
|
226
313
|
if (!r.ok)
|
|
227
314
|
return failed(tool, r);
|
|
228
315
|
const head = okHead(tool, r.status);
|
|
316
|
+
const updated = strAt(r.data, "summary", "last_updated");
|
|
317
|
+
const env = {
|
|
318
|
+
state: "measured",
|
|
319
|
+
measured_at: instantOrNull(updated),
|
|
320
|
+
method: CVE_FEED_METHOD,
|
|
321
|
+
coverage: null,
|
|
322
|
+
freshness: null,
|
|
323
|
+
notes: [
|
|
324
|
+
realInstant(updated)
|
|
325
|
+
? "measured_at is summary.last_updated: the newest modification time among the active CVE records these counts include."
|
|
326
|
+
: "measured_at is null: the answer carries no real summary.last_updated.",
|
|
327
|
+
NO_FEED_FRESHNESS,
|
|
328
|
+
],
|
|
329
|
+
};
|
|
229
330
|
const total = numAt(r.data, "summary", "total");
|
|
230
331
|
if (total === undefined)
|
|
231
|
-
return succeeded(r.data, head);
|
|
332
|
+
return succeeded(r.data, head, env);
|
|
232
333
|
if (total === 0) {
|
|
233
|
-
return succeeded(r.data, `${head} The feed reports 0 active CVEs — a measured empty result (we looked and found nothing), not a lookup failure
|
|
334
|
+
return succeeded(r.data, `${head} The feed reports 0 active CVEs — a measured empty result (we looked and found nothing), not a lookup failure.`, env);
|
|
234
335
|
}
|
|
235
336
|
// A stamp that is not a real instant stays in the JSON and is not repeated as prose.
|
|
236
|
-
const updated = strAt(r.data, "summary", "last_updated");
|
|
237
337
|
const stamp = realInstant(updated) ? ` (last updated ${updated})` : "";
|
|
238
|
-
return succeeded(r.data, `${head} The feed holds ${total} active CVEs${stamp}
|
|
338
|
+
return succeeded(r.data, `${head} The feed holds ${total} active CVEs${stamp}.`, env);
|
|
239
339
|
}
|
|
240
340
|
catch (e) {
|
|
241
341
|
return crashed(tool, e);
|
|
242
342
|
}
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
search: z.string().optional().describe("free-text search (product, vendor, or keyword, e.g. 'tomcat')"),
|
|
246
|
-
severity: z.enum(["CRITICAL", "HIGH", "MEDIUM", "LOW"]).optional().describe("filter to one severity"),
|
|
247
|
-
min_cvss: z.number().min(0).max(10).optional().describe("minimum CVSS score"),
|
|
248
|
-
sort: z.enum(["published", "modified", "nvd", "echelongraph", "epss"]).optional().describe("sort order (default: published)"),
|
|
249
|
-
limit: z.number().int().min(1).max(50).optional().describe("page size (default 20, max 50)"),
|
|
250
|
-
}, async (a) => {
|
|
343
|
+
}
|
|
344
|
+
async function searchCVEs(a) {
|
|
251
345
|
const tool = "search_cves";
|
|
252
346
|
try {
|
|
253
347
|
const q = new URLSearchParams();
|
|
@@ -265,19 +359,53 @@ server.tool("search_cves", "Search/list CVEs from EchelonGraph's CVE feed (NVD +
|
|
|
265
359
|
return failed(tool, r);
|
|
266
360
|
const head = okHead(tool, r.status);
|
|
267
361
|
const shown = lenAt(r.data, "cves");
|
|
362
|
+
const counted = boolAt(r.data, "total_counted");
|
|
363
|
+
const floor = boolAt(r.data, "total_is_lower_bound");
|
|
364
|
+
const relaxed = boolAt(r.data, "search_relaxed");
|
|
365
|
+
const offset = numAt(r.data, "offset");
|
|
366
|
+
const env = {
|
|
367
|
+
state: "measured",
|
|
368
|
+
measured_at: null,
|
|
369
|
+
method: CVE_FEED_METHOD,
|
|
370
|
+
coverage: {
|
|
371
|
+
total: numAt(r.data, "total") ?? null,
|
|
372
|
+
total_counted: counted ?? null,
|
|
373
|
+
total_is_lower_bound: floor ?? null,
|
|
374
|
+
search_relaxed: relaxed ?? null,
|
|
375
|
+
returned: shown ?? null,
|
|
376
|
+
limit: numAt(r.data, "limit") ?? null,
|
|
377
|
+
offset: offset ?? null,
|
|
378
|
+
},
|
|
379
|
+
freshness: null,
|
|
380
|
+
notes: [
|
|
381
|
+
"measured_at is null: a page of search results has no single observation time; each CVE record in data.cves carries its own times where the API sends them.",
|
|
382
|
+
NO_FEED_FRESHNESS,
|
|
383
|
+
],
|
|
384
|
+
};
|
|
385
|
+
const relaxedNote = relaxed === true
|
|
386
|
+
? " 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."
|
|
387
|
+
: "";
|
|
388
|
+
// A total the API did not count is not a count: the page's own rows are all that is known.
|
|
389
|
+
if (counted === false) {
|
|
390
|
+
const empty = shown === 0 && (offset ?? 0) === 0;
|
|
391
|
+
return succeeded(r.data, empty
|
|
392
|
+
? `${head} The query returned 0 CVEs — a measured empty result: EchelonGraph was queried successfully and nothing matched these filters (we looked and found nothing). This is not a lookup failure.${relaxedNote}`
|
|
393
|
+
: `${head} The API did not count the matches (total_counted is false), so its total is not a count${shown === undefined ? "" : `; ${shown} returned in this page`}.${relaxedNote}`, env);
|
|
394
|
+
}
|
|
268
395
|
const total = numAt(r.data, "total") ?? shown;
|
|
269
396
|
if (total === undefined)
|
|
270
|
-
return succeeded(r.data, head);
|
|
271
|
-
if (total === 0) {
|
|
272
|
-
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
|
|
397
|
+
return succeeded(r.data, `${head}${relaxedNote}`, env);
|
|
398
|
+
if (total === 0 && floor !== true) {
|
|
399
|
+
return succeeded(r.data, `${head} The query matched 0 CVEs — a measured empty result: EchelonGraph was queried successfully and nothing matched these filters (we looked and found nothing). This is not a lookup failure.${relaxedNote}`, env);
|
|
273
400
|
}
|
|
274
|
-
|
|
401
|
+
const matched = floor === true ? `at least ${total} CVEs (total_is_lower_bound is true: the count stopped at that floor)` : `${total} CVEs`;
|
|
402
|
+
return succeeded(r.data, `${head} The query matched ${matched}${shown === undefined ? "" : `; ${shown} returned in this page`}.${relaxedNote}`, env);
|
|
275
403
|
}
|
|
276
404
|
catch (e) {
|
|
277
405
|
return crashed(tool, e);
|
|
278
406
|
}
|
|
279
|
-
}
|
|
280
|
-
|
|
407
|
+
}
|
|
408
|
+
async function getCVE(cve_id) {
|
|
281
409
|
const tool = "get_cve";
|
|
282
410
|
const id = cve_id.trim();
|
|
283
411
|
if (!id)
|
|
@@ -286,12 +414,25 @@ server.tool("get_cve", "Full detail for one CVE: description, NVD CVSS v3/v4, th
|
|
|
286
414
|
const r = await api(`/api/v1/public/cves/${encodeURIComponent(id)}`);
|
|
287
415
|
if (!r.ok)
|
|
288
416
|
return failed(tool, r);
|
|
289
|
-
|
|
417
|
+
const written = strAt(r.data, "updated_at");
|
|
418
|
+
return succeeded(r.data, `${okHead(tool, r.status)} Returned the record for ${strAt(r.data, "cve_id") ?? id}.`, {
|
|
419
|
+
state: "measured",
|
|
420
|
+
measured_at: instantOrNull(written),
|
|
421
|
+
method: CVE_FEED_METHOD,
|
|
422
|
+
coverage: null,
|
|
423
|
+
freshness: null,
|
|
424
|
+
notes: [
|
|
425
|
+
realInstant(written)
|
|
426
|
+
? "measured_at is the record's updated_at: when EchelonGraph last wrote this record."
|
|
427
|
+
: "measured_at is null: the record carries no real updated_at.",
|
|
428
|
+
NO_FEED_FRESHNESS,
|
|
429
|
+
],
|
|
430
|
+
});
|
|
290
431
|
}
|
|
291
432
|
catch (e) {
|
|
292
433
|
return crashed(tool, e);
|
|
293
434
|
}
|
|
294
|
-
}
|
|
435
|
+
}
|
|
295
436
|
// cve_exposure. What the API's footprint means, and what it does not (#2306, #2307).
|
|
296
437
|
//
|
|
297
438
|
// The counts come from the KEV-exposure radar, which does not look at every CVE: every 12 h,
|
|
@@ -303,10 +444,16 @@ server.tool("get_cve", "Full detail for one CVE: description, NVD CVSS v3/v4, th
|
|
|
303
444
|
// The API says which with `tracked`; the note reads it and, when the field is absent (an older
|
|
304
445
|
// API, or a CVE the radar cannot decide on), makes no claim either way about the zero.
|
|
305
446
|
//
|
|
306
|
-
// last_seen is not
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
//
|
|
447
|
+
// last_seen is not an observation time (#2439). It is when EchelonGraph last wrote or refreshed
|
|
448
|
+
// a service's row: core-backend kevexposure store.go Upsert sets it to now() when a search
|
|
449
|
+
// matches the banner, and Touch sets it to now() when the InternetDB re-check (poller.go
|
|
450
|
+
// reconcileStale) finds the service's PORT still listed, without re-reading the version. So a
|
|
451
|
+
// patched service can stay counted while its port stays open, and last_seen is our write time,
|
|
452
|
+
// not the time Shodan saw the service: on 2026-09-27 production's 50 newest rows carried
|
|
453
|
+
// last_seen within 0.78 s of one another, a batch write. Shodan's own banner timestamp is not
|
|
454
|
+
// stored.
|
|
455
|
+
// Every text calls last_seen a write or refresh time, never a sighting ("last seen listening",
|
|
456
|
+
// "re-seen", "most recently seen"), and it is never measured_at (see observedAt).
|
|
310
457
|
const CVE_ID = /^CVE-\d{4}-\d{4,}$/i;
|
|
311
458
|
// Quoted when the API does not send its own `method` string.
|
|
312
459
|
const EXPOSURE_METHOD = "Shodan banner match on the radar's tracked products; up to 100 ip:port services per product query; searched every 12 h when Shodan query credits allow";
|
|
@@ -317,6 +464,7 @@ const SHODAN_ATTRIBUTION = "Exposure counts are derived from Shodan data.";
|
|
|
317
464
|
// and copyright in the applicable Shodan materials" (#2306). The attribution above does the
|
|
318
465
|
// first; this sentence, placed beside it wherever Shodan data is named, does the second.
|
|
319
466
|
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 result says NOT ASSESSED, and its 0 is not a measurement.`;
|
|
320
468
|
function exposureNote(head, id, d) {
|
|
321
469
|
const cve = strAt(d, "cve_id") ?? id;
|
|
322
470
|
const hosts = numAt(d, "exposed_hosts");
|
|
@@ -342,24 +490,93 @@ function exposureNote(head, id, d) {
|
|
|
342
490
|
const why = hosts === 0
|
|
343
491
|
? "outside the radar's tracked set; 0 is not a measurement, and it says nothing about whether any host is exposed to it."
|
|
344
492
|
: `outside the radar's tracked set, so the radar is not looking for it; the ${hosts} service(s) (distinct ip:port) on record are left from earlier scans and are not a current measurement.`;
|
|
345
|
-
return `${head} (
|
|
493
|
+
return `${head} (exposure_state: not_assessed) NOT ASSESSED: ${cve} is ${why} ${kev} ${how}`;
|
|
346
494
|
}
|
|
495
|
+
// The tag in parentheses is exposure_state, named as such (#2440): it says what the count is,
|
|
496
|
+
// and is not the envelope's state, which the last text block gives.
|
|
347
497
|
if (hosts === 0 && tracked === true) {
|
|
348
498
|
// The backend answers tracked:true only when it holds rows (exposed_hosts > 0), so this
|
|
349
499
|
// branch does not fire today; it is worded for the day it does.
|
|
350
|
-
return `${head} (
|
|
500
|
+
return `${head} (exposure_state: measured_zero) The radar has 0 internet-facing services (distinct ip:port) on record whose banner version maps to ${cve}: a measured zero in the radar's sample. ${cve} is in the tracked set, and the radar looked and found nothing. This is not a lookup failure. It is a zero in a sample, not an internet-wide zero: the radar reads at most the first 100 Shodan results per tracked-product query, so a vulnerable service outside those results is not counted. ${kev} ${how}`;
|
|
351
501
|
}
|
|
352
502
|
if (hosts === 0) {
|
|
353
|
-
return `${head} (
|
|
503
|
+
return `${head} (exposure_state: tracking_unknown) EchelonGraph reports 0 exposed services (distinct ip:port) on record for ${cve}. The API does not say whether ${cve} is in the radar's tracked set: it is an API older than that field, or the radar cannot decide for this CVE (for example a CISA-KEV or high-EPSS CVE in a tracked product that it has no service on record for). The radar only looks for a tracked set of CISA-KEV and high-EPSS CVEs in specific products, so this 0 is not evidence either way: it neither shows that nothing is exposed nor that ${cve} went unassessed. This is not a lookup failure. ${kev} ${how}`;
|
|
354
504
|
}
|
|
355
505
|
const countries = numAt(d, "countries");
|
|
356
506
|
const last = strAt(d, "last_seen");
|
|
357
507
|
const seen = realInstant(last)
|
|
358
|
-
? ` Their latest last_seen is ${last}: when one of
|
|
508
|
+
? ` Their latest last_seen is ${last}: when EchelonGraph last wrote or refreshed one of their rows, not when any of them was observed and not when a vulnerable version was last confirmed.`
|
|
359
509
|
: "";
|
|
360
|
-
return `${head} (
|
|
510
|
+
return `${head} (exposure_state: exposed) The radar has ${hosts} internet-facing services (distinct ip:port, the exposed_hosts field) on record whose banner version maps to ${cve}${countries === undefined ? "" : ` across ${countries} countries`}.${seen} ${kev} ${how}`;
|
|
511
|
+
}
|
|
512
|
+
function cveExposureState(d) {
|
|
513
|
+
const hosts = numAt(d, "exposed_hosts");
|
|
514
|
+
const tracked = boolAt(d, "tracked");
|
|
515
|
+
if (hosts === undefined)
|
|
516
|
+
return null;
|
|
517
|
+
if (tracked === false)
|
|
518
|
+
return "not_assessed";
|
|
519
|
+
if (hosts === 0)
|
|
520
|
+
return tracked === true ? "measured_zero" : "tracking_unknown";
|
|
521
|
+
return "exposed";
|
|
522
|
+
}
|
|
523
|
+
// The seam for an observation time (#2439). A cve_exposure count is measured only when the
|
|
524
|
+
// answer says when the services it counts were observed, and this is the one place that reads
|
|
525
|
+
// such a time. The per-CVE answer serves none today: its fields are cve_id, exposed_hosts,
|
|
526
|
+
// countries, tracked, kev_listed, kev_catalog_listed, kev_seen_in_observations, ransomware,
|
|
527
|
+
// top_countries, top_products, method, last_seen and generated_at (production, 2026-09-27), and
|
|
528
|
+
// last_seen is EchelonGraph's write or refresh time (see the comment above CVE_ID), while
|
|
529
|
+
// generated_at is when the API built the answer. So this returns null and every cve_exposure
|
|
530
|
+
// answer is not_assessed. When the API serves an observation time (#2438), read that field here
|
|
531
|
+
// and nowhere else: measured and measured_at follow from this function alone, never from
|
|
532
|
+
// last_seen.
|
|
533
|
+
function observedAt(_d) {
|
|
534
|
+
return null;
|
|
535
|
+
}
|
|
536
|
+
// The envelope of a cve_exposure answer. Only a count of services on record that the answer
|
|
537
|
+
// dates by when they were observed (observedAt) is measured; every other answer is
|
|
538
|
+
// not_assessed, and its notes say why. Today that is every answer: the count is relayed,
|
|
539
|
+
// labelled by exposure_state and the note, as what the radar holds on record. A tracked zero
|
|
540
|
+
// is not_assessed too: it has no observation to date it, and the per-CVE answer does not carry
|
|
541
|
+
// the time of the radar's last completed check (exposure_radar relays that as
|
|
542
|
+
// kev_exposure.last_run_at).
|
|
543
|
+
function cveExposureEnvelope(cve, d) {
|
|
544
|
+
const exposureState = cveExposureState(d);
|
|
545
|
+
const observed = observedAt(d);
|
|
546
|
+
const measured = exposureState === "exposed" && realInstant(observed ?? undefined);
|
|
547
|
+
const last = strAt(d, "last_seen");
|
|
548
|
+
const tracked = boolAt(d, "tracked");
|
|
549
|
+
const why = {
|
|
550
|
+
exposed: measured
|
|
551
|
+
? `state is measured: exposed_hosts counts the services the radar has on record for ${cve}, and measured_at is the time the API gives for when they were observed.`
|
|
552
|
+
: `state is not_assessed: exposed_hosts counts the services the radar has on record for ${cve}, but the answer does not say when any of them was observed, so that count is relayed as what the radar holds on record, not as a dated measurement, and measured_at is null.`,
|
|
553
|
+
measured_zero: `state is not_assessed: the API marks ${cve} as tracked with 0 services on record, but a zero has no observation to date it and the answer does not say when the radar last looked, so it is not presented as a dated measurement.`,
|
|
554
|
+
not_assessed: `state is not_assessed: ${cve} is outside the radar's tracked set, so no number in data is a measurement of its exposure.`,
|
|
555
|
+
tracking_unknown: `state is not_assessed: the API does not say whether ${cve} is in the radar's tracked set, so its 0 is not a measurement.`,
|
|
556
|
+
none: `state is not_assessed: the answer carries no exposed_hosts count for ${cve}.`,
|
|
557
|
+
};
|
|
558
|
+
return {
|
|
559
|
+
state: measured ? "measured" : "not_assessed",
|
|
560
|
+
measured_at: measured ? observed : null,
|
|
561
|
+
method: (strAt(d, "method") ?? EXPOSURE_METHOD).replace(/\s+$/, ""),
|
|
562
|
+
coverage: { in_scope: tracked ?? null },
|
|
563
|
+
freshness: null,
|
|
564
|
+
exposure_state: exposureState,
|
|
565
|
+
notes: [
|
|
566
|
+
why[exposureState ?? "none"],
|
|
567
|
+
...(realInstant(last)
|
|
568
|
+
? [
|
|
569
|
+
`last_seen (${last}) is not measured_at: it is when EchelonGraph last wrote or refreshed one of these rows (when a Shodan search matched the banner, or a re-check found the port still listed), not when any service was observed, and Shodan's own banner time is not stored.`,
|
|
570
|
+
]
|
|
571
|
+
: []),
|
|
572
|
+
tracked === undefined
|
|
573
|
+
? "coverage.in_scope is null: the answer does not say whether the CVE is in the radar's tracked set."
|
|
574
|
+
: `coverage.in_scope is the API's tracked verdict: ${tracked ? "the CVE is in the radar's tracked set" : "the CVE is outside the radar's tracked set"}.`,
|
|
575
|
+
"freshness is null: the per-CVE answer does not carry the time of the radar's last completed check.",
|
|
576
|
+
],
|
|
577
|
+
};
|
|
361
578
|
}
|
|
362
|
-
|
|
579
|
+
async function cveExposure(cve_id) {
|
|
363
580
|
const tool = "cve_exposure";
|
|
364
581
|
const id = cve_id.trim();
|
|
365
582
|
if (!id)
|
|
@@ -375,12 +592,12 @@ server.tool("cve_exposure", `Internet-exposure footprint for one CVE from Echelo
|
|
|
375
592
|
const r = await api(`/api/v1/public/kev-exposure/cve/${encodeURIComponent(cve)}`);
|
|
376
593
|
if (!r.ok)
|
|
377
594
|
return failed(tool, r);
|
|
378
|
-
return succeeded(r.data, exposureNote(okHead(tool, r.status), cve, r.data));
|
|
595
|
+
return succeeded(r.data, exposureNote(okHead(tool, r.status), cve, r.data), cveExposureEnvelope(strAt(r.data, "cve_id") ?? cve, r.data));
|
|
379
596
|
}
|
|
380
597
|
catch (e) {
|
|
381
598
|
return crashed(tool, e);
|
|
382
599
|
}
|
|
383
|
-
}
|
|
600
|
+
}
|
|
384
601
|
// Every quadrant that finds its services through Shodan, attributed. The exposed-database
|
|
385
602
|
// radar searches Shodan dorks and falls back to LeakIX when the Shodan query budget is low
|
|
386
603
|
// (core-backend/internal/exposeddb/poller.go).
|
|
@@ -407,8 +624,8 @@ const RADARS = [
|
|
|
407
624
|
// under the API's names, every one of those reads as an exposure count. So the tool relays them
|
|
408
625
|
// grouped as confirmed_exposed, observed and authentication, with the note labelling each, and
|
|
409
626
|
// relays nothing it cannot label: a stats field this version does not know, a field other than
|
|
410
|
-
// a ranked row's label and count, and the poller block's instance fields are left out,
|
|
411
|
-
//
|
|
627
|
+
// a ranked row's label and count, and the poller block's instance fields are left out, and a
|
|
628
|
+
// field of any of them this version does not know is named in the note.
|
|
412
629
|
//
|
|
413
630
|
// The `poller` block's running and last_run_at describe the radar's fleet, not the instance
|
|
414
631
|
// that answered (core-backend shadowctlog Poller.Status): last_run_at is when the fleet's
|
|
@@ -440,6 +657,22 @@ const SHADOW_AI_STATS = new Set([
|
|
|
440
657
|
"top_issuers",
|
|
441
658
|
"trend_30d",
|
|
442
659
|
]);
|
|
660
|
+
// The poller block's fields this version knows (core-backend shadowctlog poller.go Status):
|
|
661
|
+
// running and last_run_at, relayed when both are real; the rest describe the instance that
|
|
662
|
+
// answered and are left out by design (see above). Any other key is left out and named, as a
|
|
663
|
+
// stats field is (#2313 item 7, #2440): an injected poller.exposed_now must not vanish unnamed.
|
|
664
|
+
const SHADOW_AI_POLLER = new Set([
|
|
665
|
+
"running",
|
|
666
|
+
"last_run_at",
|
|
667
|
+
"interval_seconds",
|
|
668
|
+
"shodan_enabled",
|
|
669
|
+
"last_new_inserts",
|
|
670
|
+
"last_error",
|
|
671
|
+
"skipped_as_follower",
|
|
672
|
+
"source_health",
|
|
673
|
+
"consecutive_fails",
|
|
674
|
+
"shodan_last_new",
|
|
675
|
+
]);
|
|
443
676
|
// The observed rankings and the daily series: API field, and the row field that names each row.
|
|
444
677
|
const SHADOW_AI_ROWS = [
|
|
445
678
|
["trend_30d", "date"],
|
|
@@ -891,13 +1124,19 @@ function readShadowAI(sa) {
|
|
|
891
1124
|
sentences.push(`Shadow AI authentication, over every observation on record: ${authParts.join("; ")}.`);
|
|
892
1125
|
sentences.push("Neither authentication count is part of confirmed_exposed, and observed.total minus confirmed_exposed.total is not a count of secured services: it also holds observations not yet verified (liveness unverified), whose hostname no longer resolves (unreachable), or whose service no longer answers openly (resolved).");
|
|
893
1126
|
}
|
|
894
|
-
// What the answer carried that the tool cannot label: named, and left out.
|
|
1127
|
+
// What the answer carried that the tool cannot label: named, and left out. The poller block's
|
|
1128
|
+
// keys are checked like the stats keys: its known instance fields are left out by design, and
|
|
1129
|
+
// any other key is named.
|
|
1130
|
+
const poller = field(sa, "poller");
|
|
895
1131
|
const unknown = [
|
|
896
1132
|
...Object.keys(isPlainObject(sa) ? sa : {}).filter((k) => k !== "stats" && k !== "poller"),
|
|
897
1133
|
...Object.keys(isPlainObject(stats) ? stats : {})
|
|
898
1134
|
.filter((k) => !SHADOW_AI_STATS.has(k))
|
|
899
1135
|
.map((k) => `stats.${k}`),
|
|
900
1136
|
...rowExtras,
|
|
1137
|
+
...Object.keys(isPlainObject(poller) ? poller : {})
|
|
1138
|
+
.filter((k) => !SHADOW_AI_POLLER.has(k))
|
|
1139
|
+
.map((k) => `poller.${k}`),
|
|
901
1140
|
];
|
|
902
1141
|
const unusable = Object.entries(isPlainObject(stats) ? stats : {})
|
|
903
1142
|
.filter(([k, v]) => SHADOW_AI_STATS.has(k) && !used.has(k) && v !== null)
|
|
@@ -922,7 +1161,7 @@ function readShadowAI(sa) {
|
|
|
922
1161
|
data.poller = { running, last_run_at: lastRun };
|
|
923
1162
|
sentences.push(`Shadow-AI radar freshness: no Certificate Transparency (crt.sh) discovery cycle has completed since ${lastRun}, when the radar's leader last completed one (poller.running is false: that was more than ${SHADOW_AI_SILENT_AFTER} before the API's answer), so the shadow-AI figures may be stale.`);
|
|
924
1163
|
}
|
|
925
|
-
else if (
|
|
1164
|
+
else if (poller === undefined) {
|
|
926
1165
|
sentences.push("Shadow-AI radar freshness is unknown: the answer carries no poller block.");
|
|
927
1166
|
}
|
|
928
1167
|
else {
|
|
@@ -932,11 +1171,22 @@ function readShadowAI(sa) {
|
|
|
932
1171
|
sentences.push(`Latest shadow-AI observation on record: ${lastObservation}.`);
|
|
933
1172
|
return { data, sentences };
|
|
934
1173
|
}
|
|
935
|
-
|
|
1174
|
+
const EXPOSURE_RADAR_DESCRIPTION = `Aggregate totals from EchelonGraph's internet-exposure radars, each refreshed on its own schedule: internet-facing services running actively-exploited (CISA-KEV) CVEs, plus the ransomware-linked subset, derived from Shodan data; unauthenticated data stores and observability UIs, found through Shodan (LeakIX when Shodan query credits run low) and then confirmed by EchelonGraph's own identified check (not a pure read: on Redis it names its client, and on ClickHouse its query is recorded in the server's query log); leaked credentials sampled from public GitHub push events; and shadow AI services found through Certificate Transparency logs and Shodan and then checked by EchelonGraph's identified probes. Every number in the result is labelled here and in the result's note by what it counts; a field this version cannot label is left out and named in the note. ` +
|
|
936
1175
|
`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. ` +
|
|
937
1176
|
`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. ` +
|
|
938
1177
|
`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. ` +
|
|
939
|
-
`shadow_ai: the result is regrouped by what each number counts. shadow_ai.confirmed_exposed counts services EchelonGraph's probes found answering without an authentication gate (liveness active, or rechecking during a re-check): confirmed_exposed.total is the sum of confirmed_exposed.by_category, and confirmed_exposed.last_24h counts those first recorded in the last 24 h. Of the shadow_ai numbers, only confirmed_exposed counts exposed services. shadow_ai.observed counts every Certificate Transparency or Shodan observation on record, whatever its verification state: its numbers are observed, not exposed. They are observed.total; observed.by_category (the same observations by category); observed.last_24h (those first recorded in the last 24 h); observed.trend_30d (observations per UTC day over the last 30 days); and observed.top_products, observed.top_countries and observed.top_issuers (up to ten products, countries and issuers ranked by observations, where an issuer is the certificate's CA for a Certificate Transparency observation and the hosting operator Shodan reports for a Shodan one). shadow_ai.authentication counts observations by probe outcome: authentication.observed where a probe observed an authentication gate (a 401/403, a login page or an auth marker), and authentication.not_determined where the service answered but no probe could tell. Neither authentication count is part of confirmed_exposed, and observed.total minus confirmed_exposed.total is not a count of secured services. The shadow-AI poller block carries only running and last_run_at: last_run_at is when the radar's leader last completed a Certificate Transparency (crt.sh) cycle, and its running is true only when that was within ${SHADOW_AI_SILENT_AFTER} of the answer. ${SHODAN_OWNERSHIP}
|
|
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. ${SHODAN_OWNERSHIP}`;
|
|
1179
|
+
// exposure_radar's envelope. Every radar answered, but no stats answer says when the services
|
|
1180
|
+
// or records it counts were observed: each count covers everything that radar still holds on
|
|
1181
|
+
// record, and the answer carries only generated_at (when the API computed the totals) and
|
|
1182
|
+
// last_run_at (the radar's last completed check), neither of which is that time (#2335). So
|
|
1183
|
+
// the result is not_assessed with measured_at null, and says why; its numbers keep their labels,
|
|
1184
|
+
// and freshness carries each radar's last_run_at where the API serves one.
|
|
1185
|
+
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.";
|
|
1186
|
+
const RADAR_NAMES = RADARS.map(([name]) => name);
|
|
1187
|
+
const RADAR_STATE_NOTE = "state is not_assessed: every radar answered, but no stats answer says when the services or records it counts were observed, so no count here is presented as a dated measurement and measured_at is null. Each count is what that radar holds on record, labelled below by what it counts.";
|
|
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, and freshness.shadow_ai.last_run_at with freshness.shadow_ai.running), and null where it does not.";
|
|
1189
|
+
async function exposureRadar() {
|
|
940
1190
|
const tool = "exposure_radar";
|
|
941
1191
|
try {
|
|
942
1192
|
const results = await Promise.all(RADARS.map(([, path]) => api(path)));
|
|
@@ -957,10 +1207,9 @@ server.tool("exposure_radar", `Aggregate totals from EchelonGraph's internet-exp
|
|
|
957
1207
|
const withheld = answered.length
|
|
958
1208
|
? ` The ${answered.length} radar(s) that did answer (${answered.join(", ")}) are withheld: a partial radar picture would be read as the whole one.`
|
|
959
1209
|
: "";
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
};
|
|
1210
|
+
const message = `${tool} FAILED: ${failures.length} of ${RADARS.length} radars could not be read from ${SHOWN_BASE}.\n${lines.join("\n")}\n${NOT_A_FINDING}${withheld}`;
|
|
1211
|
+
const radars = failures.map(([radar, f]) => ({ radar, kind: f.kind, path: f.path, status: f.status ?? null, message: f.detail }));
|
|
1212
|
+
return failure("failed", message, { kind: "radars", path: null, status: null, message: `${failures.length} of ${RADARS.length} radars could not be read`, radars }, { radars: RADAR_NAMES, answered, failed: failures.map(([name]) => name) });
|
|
964
1213
|
}
|
|
965
1214
|
// Each radar's answer is relayed cut to the fields its reader can label, in RADARS order.
|
|
966
1215
|
const read = {
|
|
@@ -970,18 +1219,298 @@ server.tool("exposure_radar", `Aggregate totals from EchelonGraph's internet-exp
|
|
|
970
1219
|
shadow_ai: readShadowAI(data.shadow_ai),
|
|
971
1220
|
};
|
|
972
1221
|
const relayed = {};
|
|
973
|
-
const
|
|
1222
|
+
const said = [];
|
|
974
1223
|
for (const [name] of RADARS) {
|
|
975
1224
|
relayed[name] = read[name].data;
|
|
976
|
-
|
|
1225
|
+
said.push(...read[name].sentences);
|
|
977
1226
|
}
|
|
978
|
-
|
|
1227
|
+
// Only what the readers relayed: a last_run_at that is not a real instant never got there.
|
|
1228
|
+
const stamp = (radar, ...keys) => instantOrNull(strAt(relayed[radar], ...keys));
|
|
1229
|
+
const running = boolAt(relayed.shadow_ai, "poller", "running");
|
|
1230
|
+
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
|
+
state: "not_assessed",
|
|
1232
|
+
measured_at: null,
|
|
1233
|
+
method: RADAR_METHOD,
|
|
1234
|
+
coverage: { radars: RADAR_NAMES, answered: RADAR_NAMES, failed: [] },
|
|
1235
|
+
freshness: {
|
|
1236
|
+
kev_exposure: { last_run_at: stamp("kev_exposure", "last_run_at") },
|
|
1237
|
+
exposed_databases: { last_run_at: stamp("exposed_databases", "last_run_at") },
|
|
1238
|
+
leaked_credentials: { last_run_at: stamp("leaked_credentials", "last_run_at") },
|
|
1239
|
+
shadow_ai: { last_run_at: stamp("shadow_ai", "poller", "last_run_at"), running: running ?? null },
|
|
1240
|
+
},
|
|
1241
|
+
notes: [RADAR_STATE_NOTE, RADAR_FRESHNESS_NOTE],
|
|
1242
|
+
});
|
|
979
1243
|
}
|
|
980
1244
|
catch (e) {
|
|
981
1245
|
return crashed(tool, e);
|
|
982
1246
|
}
|
|
1247
|
+
}
|
|
1248
|
+
// ── outputSchema: the envelope, per tool (#2311, #2313) ──
|
|
1249
|
+
//
|
|
1250
|
+
// A discriminated union on state: a success (measured, not_assessed) carries data and a method;
|
|
1251
|
+
// a failure (failed, invalid_input) carries error, and measured_at, method and freshness null.
|
|
1252
|
+
// The CVE-feed and cve_exposure tools relay the API's JSON verbatim as data, so their data
|
|
1253
|
+
// schemas name the fields the tool descriptions mention and admit the rest (a field the API
|
|
1254
|
+
// adds later must not turn an answer into a failure). exposure_radar's data is the tool's own
|
|
1255
|
+
// cut, so its schema is closed: exactly the fields the readers relay, and nothing else.
|
|
1256
|
+
const Instant = z.string().describe("An RFC 3339 instant.");
|
|
1257
|
+
const ERROR_KINDS = ["network", "timeout", "http", "not_json", "not_object", "invalid_input", "internal", "unexpected_shape", "radars"];
|
|
1258
|
+
const ErrorSchema = z.strictObject({
|
|
1259
|
+
kind: z.enum(ERROR_KINDS).describe("What failed: the request (network, timeout), the answer (http, not_json, not_object, unexpected_shape), the input, or this server."),
|
|
1260
|
+
path: z.string().nullable().describe("The API path requested, when a request was made."),
|
|
1261
|
+
status: z.number().int().nullable().describe("The HTTP status, when the API answered one."),
|
|
1262
|
+
message: z.string().describe("The cause: the API's own message, or what went wrong."),
|
|
1263
|
+
});
|
|
1264
|
+
const NOTES = z.array(z.string()).describe("Caveats, one sentence each.");
|
|
1265
|
+
function envelopeSchema(o) {
|
|
1266
|
+
const coverage = o.coverage ? o.coverage.nullable() : z.null();
|
|
1267
|
+
return z.discriminatedUnion("state", [
|
|
1268
|
+
z.strictObject({
|
|
1269
|
+
state: z.enum(["measured", "not_assessed"]).describe("measured: a measurement of what was asked; an exposure count is measured only with measured_at and method. not_assessed: no dated measurement of what was asked, and no number in it is a finding."),
|
|
1270
|
+
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
|
+
method: z.string().describe("How the numbers were produced."),
|
|
1272
|
+
coverage: coverage.describe("What the answer covers; null where the answer says nothing about it."),
|
|
1273
|
+
freshness: (o.freshness ? o.freshness.nullable() : z.null()).describe("The producing radar's last completed check (last_run_at), where the API serves one."),
|
|
1274
|
+
notes: NOTES,
|
|
1275
|
+
...(o.extra ?? {}),
|
|
1276
|
+
data: o.data,
|
|
1277
|
+
}),
|
|
1278
|
+
z.strictObject({
|
|
1279
|
+
state: z.enum(["failed", "invalid_input"]).describe("failed: the lookup did not complete. invalid_input: the input was refused, so nothing was looked up. Neither is a finding."),
|
|
1280
|
+
measured_at: z.null(),
|
|
1281
|
+
method: z.null(),
|
|
1282
|
+
coverage,
|
|
1283
|
+
freshness: z.null(),
|
|
1284
|
+
notes: NOTES,
|
|
1285
|
+
error: o.error ?? ErrorSchema,
|
|
1286
|
+
}),
|
|
1287
|
+
]);
|
|
1288
|
+
}
|
|
1289
|
+
// A field of the API's JSON: optional, and null where the Go type can marshal null.
|
|
1290
|
+
const opt = (t) => t.nullable().optional();
|
|
1291
|
+
const CVERecord = z.looseObject({
|
|
1292
|
+
cve_id: opt(z.string()),
|
|
1293
|
+
description: opt(z.string()),
|
|
1294
|
+
severity: opt(z.string()),
|
|
1295
|
+
cvss_v3_score: opt(z.number()),
|
|
1296
|
+
cvss_v4_score: opt(z.number()),
|
|
1297
|
+
cvss_v4_severity: opt(z.string()),
|
|
1298
|
+
echelongraph_score: opt(z.number()),
|
|
1299
|
+
echelongraph_severity: opt(z.string()),
|
|
1300
|
+
score_confidence: opt(z.string()),
|
|
1301
|
+
epss_score: opt(z.number()),
|
|
1302
|
+
epss_percentile: opt(z.number()),
|
|
1303
|
+
kev_listed: opt(z.boolean()),
|
|
1304
|
+
kev_ransomware: opt(z.boolean()),
|
|
1305
|
+
kev_added_date: opt(z.string()),
|
|
1306
|
+
ghsa_id: opt(z.string()),
|
|
1307
|
+
references: z.unknown().optional(),
|
|
1308
|
+
published: opt(z.string()),
|
|
1309
|
+
modified: opt(z.string()),
|
|
1310
|
+
updated_at: opt(z.string()),
|
|
1311
|
+
});
|
|
1312
|
+
const CVE_SUMMARY_OUTPUT = envelopeSchema({
|
|
1313
|
+
data: z.looseObject({
|
|
1314
|
+
summary: z
|
|
1315
|
+
.looseObject({
|
|
1316
|
+
total: opt(z.number()),
|
|
1317
|
+
critical: opt(z.number()),
|
|
1318
|
+
high: opt(z.number()),
|
|
1319
|
+
medium: opt(z.number()),
|
|
1320
|
+
low: opt(z.number()),
|
|
1321
|
+
none: opt(z.number()),
|
|
1322
|
+
last_updated: opt(z.string()),
|
|
1323
|
+
})
|
|
1324
|
+
.optional(),
|
|
1325
|
+
}),
|
|
1326
|
+
coverage: null,
|
|
1327
|
+
freshness: null,
|
|
1328
|
+
});
|
|
1329
|
+
const SEARCH_CVES_OUTPUT = envelopeSchema({
|
|
1330
|
+
data: z.looseObject({
|
|
1331
|
+
cves: z.array(CVERecord).optional(),
|
|
1332
|
+
total: opt(z.number()),
|
|
1333
|
+
total_counted: opt(z.boolean()),
|
|
1334
|
+
total_is_lower_bound: opt(z.boolean()),
|
|
1335
|
+
search_relaxed: opt(z.boolean()),
|
|
1336
|
+
limit: opt(z.number()),
|
|
1337
|
+
offset: opt(z.number()),
|
|
1338
|
+
}),
|
|
1339
|
+
coverage: z.strictObject({
|
|
1340
|
+
total: z.number().nullable(),
|
|
1341
|
+
total_counted: z.boolean().nullable(),
|
|
1342
|
+
total_is_lower_bound: z.boolean().nullable(),
|
|
1343
|
+
search_relaxed: z.boolean().nullable(),
|
|
1344
|
+
returned: z.number().nullable(),
|
|
1345
|
+
limit: z.number().nullable(),
|
|
1346
|
+
offset: z.number().nullable(),
|
|
1347
|
+
}),
|
|
1348
|
+
freshness: null,
|
|
1349
|
+
});
|
|
1350
|
+
const GET_CVE_OUTPUT = envelopeSchema({ data: CVERecord, coverage: null, freshness: null });
|
|
1351
|
+
const CountryRows = z.array(z.looseObject({ country: opt(z.string()), hosts: opt(z.number()) }));
|
|
1352
|
+
const CVE_EXPOSURE_OUTPUT = envelopeSchema({
|
|
1353
|
+
data: z.looseObject({
|
|
1354
|
+
cve_id: opt(z.string()),
|
|
1355
|
+
exposed_hosts: opt(z.number()),
|
|
1356
|
+
countries: opt(z.number()),
|
|
1357
|
+
tracked: opt(z.boolean()),
|
|
1358
|
+
kev_listed: opt(z.boolean()),
|
|
1359
|
+
kev_catalog_listed: opt(z.boolean()),
|
|
1360
|
+
kev_seen_in_observations: opt(z.boolean()),
|
|
1361
|
+
ransomware: opt(z.boolean()),
|
|
1362
|
+
top_countries: opt(CountryRows),
|
|
1363
|
+
top_products: opt(z.array(z.looseObject({ product: opt(z.string()), hosts: opt(z.number()) }))),
|
|
1364
|
+
last_seen: opt(z.string()),
|
|
1365
|
+
method: opt(z.string()),
|
|
1366
|
+
generated_at: opt(z.string()),
|
|
1367
|
+
}),
|
|
1368
|
+
coverage: z.strictObject({
|
|
1369
|
+
in_scope: z.boolean().nullable().describe("The API's tracked verdict: whether the CVE is in the radar's tracked set; null when the answer does not say."),
|
|
1370
|
+
}),
|
|
1371
|
+
freshness: null,
|
|
1372
|
+
extra: {
|
|
1373
|
+
exposure_state: z
|
|
1374
|
+
.enum(["exposed", "measured_zero", "not_assessed", "tracking_unknown"])
|
|
1375
|
+
.nullable()
|
|
1376
|
+
.describe("What the answer's count is: exposed, a measured zero, not assessed, or tracking unknown; null when it carries no count."),
|
|
1377
|
+
},
|
|
1378
|
+
});
|
|
1379
|
+
// exposure_radar's data, from the readers' own specs, so the schema cannot drift from them.
|
|
1380
|
+
const zKind = (k) => (k === "number" ? z.number() : k === "string" ? z.string() : z.boolean());
|
|
1381
|
+
const zRow = (spec) => z.strictObject({
|
|
1382
|
+
...Object.fromEntries(Object.entries(spec.required).map(([k, kind]) => [k, zKind(kind)])),
|
|
1383
|
+
...Object.fromEntries(Object.entries(spec.optional ?? {}).map(([k, kind]) => [k, zKind(kind).optional()])),
|
|
1384
|
+
});
|
|
1385
|
+
const zRadar = (spec, rows = {}) => z.strictObject({
|
|
1386
|
+
...Object.fromEntries(spec.counts.map((k) => [k, z.number().optional()])),
|
|
1387
|
+
...Object.fromEntries(Object.entries(spec.rows).map(([k, rs]) => [k, (rows[k] ?? z.array(zRow(rs))).optional()])),
|
|
1388
|
+
...Object.fromEntries(spec.instants.map((k) => [k, Instant.optional()])),
|
|
1389
|
+
});
|
|
1390
|
+
// A newest_kev row as newestKEVRow relays it: `tracked` is read, not relayed, and a count only
|
|
1391
|
+
// when the row's exposure_state is not not_assessed.
|
|
1392
|
+
const { tracked: _read, ...newestKEVOptional } = KEV_SPEC.rows.newest_kev.optional ?? {};
|
|
1393
|
+
const NewestKEVRow = z.strictObject({
|
|
1394
|
+
cve_id: z.string(),
|
|
1395
|
+
...Object.fromEntries(Object.entries(newestKEVOptional).map(([k, kind]) => [k, zKind(kind).optional()])),
|
|
1396
|
+
exposure_state: z.enum(["exposed", "measured_zero", "not_assessed"]),
|
|
1397
|
+
exposed_hosts: z.number().optional(),
|
|
983
1398
|
});
|
|
984
|
-
const
|
|
985
|
-
|
|
1399
|
+
const CountMap = z.record(z.string(), z.number());
|
|
1400
|
+
const countRowsOf = (label) => z.array(z.strictObject({ [label]: z.string(), count: z.number() }));
|
|
1401
|
+
const ShadowAIData = z.strictObject({
|
|
1402
|
+
confirmed_exposed: z.strictObject({ total: z.number().optional(), by_category: CountMap.optional(), last_24h: z.number().optional() }).optional(),
|
|
1403
|
+
observed: z
|
|
1404
|
+
.strictObject({
|
|
1405
|
+
total: z.number().optional(),
|
|
1406
|
+
by_category: CountMap.optional(),
|
|
1407
|
+
last_24h: z.number().optional(),
|
|
1408
|
+
...Object.fromEntries(SHADOW_AI_ROWS.map(([key, label]) => [key, countRowsOf(label).optional()])),
|
|
1409
|
+
last_observation: Instant.optional(),
|
|
1410
|
+
})
|
|
1411
|
+
.optional(),
|
|
1412
|
+
authentication: z.strictObject({ observed: z.number().optional(), not_determined: z.number().optional() }).optional(),
|
|
1413
|
+
poller: z.strictObject({ running: z.boolean(), last_run_at: Instant }).optional(),
|
|
1414
|
+
});
|
|
1415
|
+
const LastRun = z.strictObject({ last_run_at: Instant.nullable() });
|
|
1416
|
+
const EXPOSURE_RADAR_OUTPUT = envelopeSchema({
|
|
1417
|
+
data: z.strictObject({
|
|
1418
|
+
kev_exposure: zRadar(KEV_SPEC, { newest_kev: z.array(NewestKEVRow) }),
|
|
1419
|
+
exposed_databases: zRadar(EXPOSED_DB_SPEC),
|
|
1420
|
+
leaked_credentials: zRadar(LEAKED_CREDS_SPEC),
|
|
1421
|
+
shadow_ai: ShadowAIData,
|
|
1422
|
+
}),
|
|
1423
|
+
coverage: z.strictObject({
|
|
1424
|
+
radars: z.array(z.string()).describe("The radars this tool reads."),
|
|
1425
|
+
answered: z.array(z.string()).describe("The radars that answered. On a failure their answers are withheld."),
|
|
1426
|
+
failed: z.array(z.string()).describe("The radars that could not be read."),
|
|
1427
|
+
}),
|
|
1428
|
+
freshness: z.strictObject({
|
|
1429
|
+
kev_exposure: LastRun,
|
|
1430
|
+
exposed_databases: LastRun,
|
|
1431
|
+
leaked_credentials: LastRun,
|
|
1432
|
+
shadow_ai: z.strictObject({ last_run_at: Instant.nullable(), running: z.boolean().nullable() }),
|
|
1433
|
+
}),
|
|
1434
|
+
error: ErrorSchema.extend({
|
|
1435
|
+
radars: z
|
|
1436
|
+
.array(z.strictObject({
|
|
1437
|
+
radar: z.string(),
|
|
1438
|
+
kind: z.enum(["network", "timeout", "http", "not_json", "not_object"]),
|
|
1439
|
+
path: z.string(),
|
|
1440
|
+
status: z.number().int().nullable(),
|
|
1441
|
+
message: z.string(),
|
|
1442
|
+
}))
|
|
1443
|
+
.optional()
|
|
1444
|
+
.describe("Each radar that could not be read, and why."),
|
|
1445
|
+
}),
|
|
1446
|
+
});
|
|
1447
|
+
// ── Server instructions (#2311) ──
|
|
1448
|
+
// No sentence here names a re-probe interval; any that does must say it applies to hosts we do
|
|
1449
|
+
// not own (the package's tests hold every shipped text to that).
|
|
1450
|
+
const INSTRUCTIONS = [
|
|
1451
|
+
"EchelonGraph's public CVE and internet-exposure data, read-only and keyless.",
|
|
1452
|
+
"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 last text block, which is that structuredContent as JSON without data (the first text block is the data).",
|
|
1454
|
+
"state is measured, not_assessed, failed or invalid_input.",
|
|
1455
|
+
"not_assessed means the answer holds no dated measurement of what was asked (for example a CVE outside the KEV-exposure radar's tracked set), so a zero in it is not a finding of no exposure.",
|
|
1456
|
+
"failed and invalid_input mean nothing was measured: never report them as zero, none found or unexposed.",
|
|
1457
|
+
"measured_at is when the underlying observation was made, and null when the answer does not say or holds none.",
|
|
1458
|
+
"freshness gives the producing radar's last_run_at: when it last completed a check whose reads succeeded.",
|
|
1459
|
+
"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
|
+
"Every figure is as fresh as the schedule that refreshes it.",
|
|
1461
|
+
"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.",
|
|
1462
|
+
`${SHODAN_ATTRIBUTION} ${SHODAN_OWNERSHIP}`,
|
|
1463
|
+
].join(" ");
|
|
1464
|
+
const ANNOTATIONS = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
|
|
1465
|
+
const CVE_ID_ARG = z.string().describe("a CVE ID, e.g. CVE-2023-44487");
|
|
1466
|
+
// 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.";
|
|
1468
|
+
// One server, built per connection by serveStdio for whichever era the client opens with.
|
|
1469
|
+
// tools/list answers in registration order, which is the order below.
|
|
1470
|
+
function createServer() {
|
|
1471
|
+
const server = new McpServer({ name: NAME, version: VERSION }, { instructions: INSTRUCTIONS, capabilities: { tools: { listChanged: false } } });
|
|
1472
|
+
server.registerTool("cve_summary", {
|
|
1473
|
+
title: "CVE feed summary",
|
|
1474
|
+
description: `Summary of EchelonGraph's CVE Pulse feed: summary.total active CVEs and their counts by severity (summary.critical, summary.high, summary.medium, summary.low, summary.none), plus summary.last_updated, the newest modification time among those records. The feed is polled from its sources on a schedule, so this is the state as of that update. ${FEED_ENVELOPE}`,
|
|
1475
|
+
outputSchema: CVE_SUMMARY_OUTPUT,
|
|
1476
|
+
annotations: ANNOTATIONS,
|
|
1477
|
+
}, async () => checked("cve_summary", CVE_SUMMARY_OUTPUT, await cveSummary()));
|
|
1478
|
+
server.registerTool("search_cves", {
|
|
1479
|
+
title: "Search CVEs",
|
|
1480
|
+
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.`,
|
|
1481
|
+
inputSchema: z.object({
|
|
1482
|
+
search: z.string().optional().describe("free-text search (product, vendor, or keyword, e.g. 'tomcat')"),
|
|
1483
|
+
severity: z.enum(["CRITICAL", "HIGH", "MEDIUM", "LOW"]).optional().describe("filter to one severity"),
|
|
1484
|
+
min_cvss: z.number().min(0).max(10).optional().describe("minimum CVSS score"),
|
|
1485
|
+
sort: z.enum(["published", "modified", "nvd", "echelongraph", "epss"]).optional().describe("sort order (default: published)"),
|
|
1486
|
+
limit: z.number().int().min(1).max(50).optional().describe("page size (default 20, max 50)"),
|
|
1487
|
+
}),
|
|
1488
|
+
outputSchema: SEARCH_CVES_OUTPUT,
|
|
1489
|
+
annotations: ANNOTATIONS,
|
|
1490
|
+
}, async (a) => checked("search_cves", SEARCH_CVES_OUTPUT, await searchCVEs(a)));
|
|
1491
|
+
server.registerTool("get_cve", {
|
|
1492
|
+
title: "CVE detail",
|
|
1493
|
+
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}`,
|
|
1494
|
+
inputSchema: z.object({ cve_id: CVE_ID_ARG }),
|
|
1495
|
+
outputSchema: GET_CVE_OUTPUT,
|
|
1496
|
+
annotations: ANNOTATIONS,
|
|
1497
|
+
}, async ({ cve_id }) => checked("get_cve", GET_CVE_OUTPUT, await getCVE(cve_id)));
|
|
1498
|
+
server.registerTool("cve_exposure", {
|
|
1499
|
+
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.`,
|
|
1501
|
+
inputSchema: z.object({ cve_id: CVE_ID_ARG }),
|
|
1502
|
+
outputSchema: CVE_EXPOSURE_OUTPUT,
|
|
1503
|
+
annotations: ANNOTATIONS,
|
|
1504
|
+
}, async ({ cve_id }) => checked("cve_exposure", CVE_EXPOSURE_OUTPUT, await cveExposure(cve_id)));
|
|
1505
|
+
server.registerTool("exposure_radar", {
|
|
1506
|
+
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 no stats answer says when the services or records it counts were observed, so no count is presented as a dated measurement. freshness gives each radar's last_run_at (and shadow_ai's running) where the API serves one; coverage names the radars that answered; data is the relayed result above. The result's last text block repeats the structured result without data.`,
|
|
1508
|
+
outputSchema: EXPOSURE_RADAR_OUTPUT,
|
|
1509
|
+
annotations: ANNOTATIONS,
|
|
1510
|
+
}, async () => checked("exposure_radar", EXPOSURE_RADAR_OUTPUT, await exposureRadar()));
|
|
1511
|
+
return server;
|
|
1512
|
+
}
|
|
1513
|
+
// Both eras on stdio: a 2026-07-28 client's server/discover, and a 2025-era client's initialize.
|
|
1514
|
+
serveStdio(createServer, { legacy: "serve" });
|
|
986
1515
|
// MCP uses stdout for the protocol — diagnostics go to stderr.
|
|
987
|
-
console.error(`EchelonGraph CVE MCP server running (API: ${SHOWN_BASE}, timeout ${TIMEOUT_MS} ms)`);
|
|
1516
|
+
console.error(`EchelonGraph CVE MCP server ${NAME}@${VERSION} running (API: ${SHOWN_BASE}, timeout ${TIMEOUT_MS} ms)`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "echelongraph-mcp",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "2.1.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",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"build": "tsc",
|
|
17
17
|
"start": "node dist/index.js",
|
|
18
18
|
"smoke": "node test/smoke.mjs",
|
|
19
|
-
"test": "npm run build && node --test test
|
|
19
|
+
"test": "npm run build && node --test test/*.test.mjs"
|
|
20
20
|
},
|
|
21
21
|
"keywords": [
|
|
22
22
|
"mcp",
|
|
@@ -42,11 +42,11 @@
|
|
|
42
42
|
"url": "https://github.com/echelongraph/echelongraph-mcp/issues"
|
|
43
43
|
},
|
|
44
44
|
"engines": {
|
|
45
|
-
"node": ">=
|
|
45
|
+
"node": ">=20"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@modelcontextprotocol/
|
|
49
|
-
"zod": "
|
|
48
|
+
"@modelcontextprotocol/server": "2.1.0",
|
|
49
|
+
"zod": "4.6.5"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"@types/node": "^20.14.0",
|
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": "1.0
|
|
11
|
+
"version": "2.1.0",
|
|
12
12
|
"packages": [
|
|
13
13
|
{
|
|
14
14
|
"registryType": "npm",
|
|
15
15
|
"identifier": "echelongraph-mcp",
|
|
16
|
-
"version": "1.0
|
|
16
|
+
"version": "2.1.0",
|
|
17
17
|
"transport": {
|
|
18
18
|
"type": "stdio"
|
|
19
19
|
},
|