echelongraph-mcp 2.6.2 → 2.6.5
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/CHANGELOG.md +254 -0
- package/README.md +169 -35
- package/dist/http.js +111 -33
- package/dist/httpPolicy.js +160 -4
- package/dist/index.js +240 -66
- package/dist/prompts.js +60 -10
- package/dist/resources.js +62 -11
- package/dist/runtime.js +26 -4
- package/dist/textBudget.js +254 -0
- package/dist/tools/check_affected.js +64 -2
- package/dist/tools/check_sbom.js +105 -9
- package/dist/tools/cve_intel.js +2 -1
- package/dist/tools/epss_history.js +2 -1
- package/dist/tools/get_cwe.js +11 -2
- package/dist/tools/kev_recent.js +26 -2
- package/dist/tools/vendor_advisories.js +206 -17
- package/package.json +2 -2
- package/server.json +2 -2
package/dist/index.js
CHANGED
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
// relays each radar's answer cut to the fields it can label by what they count, and names
|
|
16
16
|
// what it left out (readKEVExposure, readExposedDatabases, readLeakedCredentials,
|
|
17
17
|
// readShadowAI and readMCPServers; #2307, #2313, #2315). content[2] is the envelope below,
|
|
18
|
-
// as JSON, less what content[0] and the note already say.
|
|
18
|
+
// as JSON, less what content[0] and the note already say. Past 30,000 characters of JSON,
|
|
19
|
+
// content[0] is that JSON cut to fit and the note ends by saying how (TEXT CUT); the
|
|
20
|
+
// envelope's data is always the answer whole (textBudget.ts, #2783).
|
|
19
21
|
// - failure (isError: true): the lookup did not complete — unreachable host, non-2xx,
|
|
20
22
|
// timeout, or a 2xx whose body is not a JSON object — named by tool, cause and base URL,
|
|
21
23
|
// in content[0]; content[1] is the envelope below, as JSON, less the message's sentences.
|
|
@@ -25,19 +27,23 @@
|
|
|
25
27
|
// Structured results (#2311, #2313). Every result, success or failure, also carries
|
|
26
28
|
// structuredContent: one envelope that says how the answer was measured — state (measured,
|
|
27
29
|
// not_assessed, failed, invalid_input), measured_at, method, coverage, freshness and notes —
|
|
28
|
-
// with, on a success, `data
|
|
29
|
-
//
|
|
30
|
+
// with, on a success, `data`, the answer whole, and on a failure an `error`. content[0] carries
|
|
31
|
+
// `data` as JSON up to 30,000 characters, and past them a cut of it that the note names (TEXT
|
|
32
|
+
// CUT, #2783), so past them content[0] is not equal to `data` (#2802). Each tool advertises the
|
|
33
|
+
// envelope as its outputSchema.
|
|
30
34
|
//
|
|
31
35
|
// The envelope in the text (#2440, #2467). Many clients pass only `content` to the model, so the
|
|
32
36
|
// last text block of every result is structuredContent serialized as JSON, less what an earlier
|
|
33
|
-
// text block already carries
|
|
34
|
-
// (the block just before, which structuredContent's notes end with), and method where
|
|
35
|
-
// quotes it. The rest — state, measured_at, coverage, freshness, the envelope's own notes
|
|
36
|
-
// on a failure, error — is copied key for key, so the text blocks together are the whole of
|
|
37
|
-
// structuredContent and cannot disagree with it. The
|
|
38
|
-
// Content) asks a tool that returns structured content to
|
|
39
|
-
// too; the JSON block stays first and the note second, as in
|
|
40
|
-
// content[0]
|
|
37
|
+
// text block already carries: `data` (content[0] on a success, whole or cut as above), the note's
|
|
38
|
+
// sentences (the block just before, which structuredContent's notes end with), and method where
|
|
39
|
+
// that note quotes it. The rest — state, measured_at, coverage, freshness, the envelope's own notes
|
|
40
|
+
// and, on a failure, error — is copied key for key, so the text blocks together are the whole of
|
|
41
|
+
// structuredContent, but for what a TEXT CUT leaves out of `data`, and cannot disagree with it. The
|
|
42
|
+
// MCP spec (2025-06-18, Tools, Structured Content) asks a tool that returns structured content to
|
|
43
|
+
// return it serialized in a text block too; the JSON block stays first and the note second, as in
|
|
44
|
+
// 1.x, so a client that parses content[0] still parses the answer's JSON, which past 30,000
|
|
45
|
+
// characters is a cut of it (fields, entries and string ends left out, no value changed) and no
|
|
46
|
+
// longer the whole answer: structuredContent.data is (#2802).
|
|
41
47
|
//
|
|
42
48
|
// Protocol eras (#2311). serveStdio answers both: a 2026-07-28 client's server/discover and
|
|
43
49
|
// per-request `_meta` envelope, and a 2025-era client's `initialize` handshake. The first
|
|
@@ -52,10 +58,11 @@ import { registerCheckAffected } from "./tools/check_affected.js";
|
|
|
52
58
|
import { registerCheckSbom } from "./tools/check_sbom.js";
|
|
53
59
|
import { registerCveIntel } from "./tools/cve_intel.js";
|
|
54
60
|
import { registerGetCwe } from "./tools/get_cwe.js";
|
|
55
|
-
import { isHttpEntrypoint, upstreamHeaders, upstreamUserAgent } from "./runtime.js";
|
|
61
|
+
import { holdRequest, isHttpEntrypoint, requestSignal, upstreamHeaders, upstreamUserAgent } from "./runtime.js";
|
|
56
62
|
import { registerVendorAdvisoryTools } from "./tools/vendor_advisories.js";
|
|
57
63
|
import { registerPrompts } from "./prompts.js";
|
|
58
64
|
import { registerResources } from "./resources.js";
|
|
65
|
+
import { dataText, GET_CVE_WHOLE, TEXT_BUDGET_DESCRIPTION } from "./textBudget.js";
|
|
59
66
|
// The package actually running, read from the package.json that ships beside dist/. The MCP
|
|
60
67
|
// handshake (serverInfo) and the User-Agent both carry its name and version, so the API's
|
|
61
68
|
// access log can count adoption per version without a code change per release.
|
|
@@ -169,16 +176,34 @@ function retryAfterSeconds(v) {
|
|
|
169
176
|
// can fail comes back as a typed Failure so the tool renders it as an error result. A 2xx whose
|
|
170
177
|
// body is not a JSON object (an SPA shell, an edge challenge page, a literal null) is a failure
|
|
171
178
|
// too — it is not data. init.headers carries what a caller typed (#1983: X-EG-* headers, never
|
|
172
|
-
// the URL); the User-Agent and Accept are this server's and are not overridden.
|
|
179
|
+
// the URL); the User-Agent and Accept are this server's and are not overridden. init.timeoutMs
|
|
180
|
+
// can only shorten the request's timeout below TIMEOUT_MS, never lengthen it: check_sbom passes
|
|
181
|
+
// what is left of its call's budget, so a slow batch cannot carry the call past it (#2756).
|
|
182
|
+
// The request is cancelled when init.signal aborts (check_sbom's call was cancelled) or, on the
|
|
183
|
+
// hosted endpoint, when the client it answers has gone (runtime.ts requestSignal, #2775): a
|
|
184
|
+
// network failure saying so, which nobody reads, since the SDK sends nothing for a cancelled call.
|
|
173
185
|
export async function api(path, init) {
|
|
186
|
+
const limitMs = init?.timeoutMs === undefined ? TIMEOUT_MS : Math.max(0, Math.min(TIMEOUT_MS, Math.floor(init.timeoutMs)));
|
|
187
|
+
const m = init?.method && init.method.toUpperCase() !== "GET" ? { method: init.method.toUpperCase() } : {};
|
|
188
|
+
const callers = [init?.signal, requestSignal()].filter((s) => s !== undefined);
|
|
189
|
+
const cancelledFailure = () => ({ ok: false, kind: "network", path, ...m, detail: "cancelled: the call was abandoned before the API answered" });
|
|
190
|
+
if (callers.some((s) => s.aborted))
|
|
191
|
+
return cancelledFailure();
|
|
174
192
|
const ctrl = new AbortController();
|
|
175
193
|
let timedOut = false;
|
|
194
|
+
let cancelled = false;
|
|
176
195
|
const timer = setTimeout(() => {
|
|
177
196
|
timedOut = true;
|
|
178
197
|
ctrl.abort();
|
|
179
|
-
},
|
|
180
|
-
const
|
|
181
|
-
|
|
198
|
+
}, limitMs);
|
|
199
|
+
const cancel = () => {
|
|
200
|
+
cancelled = true;
|
|
201
|
+
ctrl.abort();
|
|
202
|
+
};
|
|
203
|
+
for (const s of callers)
|
|
204
|
+
s.addEventListener("abort", cancel, { once: true });
|
|
205
|
+
const own = limitMs === TIMEOUT_MS ? {} : { timeoutMs: limitMs };
|
|
206
|
+
const timeout = () => ({ ok: false, kind: "timeout", path, ...m, ...own, detail: `no response within ${limitMs} ms` });
|
|
182
207
|
try {
|
|
183
208
|
let res;
|
|
184
209
|
try {
|
|
@@ -193,14 +218,14 @@ export async function api(path, init) {
|
|
|
193
218
|
});
|
|
194
219
|
}
|
|
195
220
|
catch (e) {
|
|
196
|
-
return timedOut ? timeout() : { ok: false, kind: "network", path, ...m, detail: describeError(e) };
|
|
221
|
+
return timedOut ? timeout() : cancelled ? cancelledFailure() : { ok: false, kind: "network", path, ...m, detail: describeError(e) };
|
|
197
222
|
}
|
|
198
223
|
let body;
|
|
199
224
|
try {
|
|
200
225
|
body = await res.text();
|
|
201
226
|
}
|
|
202
227
|
catch (e) {
|
|
203
|
-
return timedOut ? timeout() : { ok: false, kind: "network", path, ...m, detail: `reading the body failed: ${describeError(e)}` };
|
|
228
|
+
return timedOut ? timeout() : cancelled ? cancelledFailure() : { ok: false, kind: "network", path, ...m, detail: `reading the body failed: ${describeError(e)}` };
|
|
204
229
|
}
|
|
205
230
|
if (!res.ok) {
|
|
206
231
|
const retryAfter = retryAfterSeconds(res.headers.get("retry-after"));
|
|
@@ -221,6 +246,8 @@ export async function api(path, init) {
|
|
|
221
246
|
}
|
|
222
247
|
finally {
|
|
223
248
|
clearTimeout(timer);
|
|
249
|
+
for (const s of callers)
|
|
250
|
+
s.removeEventListener("abort", cancel);
|
|
224
251
|
}
|
|
225
252
|
}
|
|
226
253
|
const text = (t) => ({ type: "text", text: t });
|
|
@@ -241,7 +268,7 @@ export function describeFailure(f) {
|
|
|
241
268
|
case "network":
|
|
242
269
|
return `EchelonGraph at ${SHOWN_BASE} could not be reached for ${verb} ${f.path}: ${f.detail}.`;
|
|
243
270
|
case "timeout":
|
|
244
|
-
return `EchelonGraph at ${SHOWN_BASE} did not answer ${verb} ${f.path} within ${TIMEOUT_MS} ms.`;
|
|
271
|
+
return `EchelonGraph at ${SHOWN_BASE} did not answer ${verb} ${f.path} within ${f.timeoutMs ?? TIMEOUT_MS} ms.`;
|
|
245
272
|
case "http":
|
|
246
273
|
return `EchelonGraph answered HTTP ${f.status} ${where} — the API said: ${f.detail}.`;
|
|
247
274
|
case "not_json":
|
|
@@ -266,13 +293,15 @@ const REJECTED_INPUT = "The API rejected the request as invalid input, so nothin
|
|
|
266
293
|
// earlier text block already carries verbatim (#2467). 2.1.0 sent the whole envelope without
|
|
267
294
|
// `data`, so every note went out twice, once as prose and once as the notes array, and
|
|
268
295
|
// cve_exposure's method a third time.
|
|
269
|
-
// data left out: content[0] carries it verbatim
|
|
296
|
+
// data left out: content[0] carries it, verbatim up to DATA_TEXT_BUDGET and past it cut,
|
|
297
|
+
// with the note saying what the cut leaves out (TEXT CUT, #2783).
|
|
270
298
|
// notes only the envelope's own sentences (`own`), which no text block says; the rest of
|
|
271
299
|
// structuredContent's notes are the sentences of `said`, the text block just before
|
|
272
300
|
// this one (the note, or a failure's message). Left out when there are none.
|
|
273
301
|
// method left out when `said` quotes it verbatim (cve_exposure's note, "Method: …").
|
|
274
302
|
// Every other key is copied as is, in structuredContent's order, so the text blocks together are
|
|
275
|
-
// the whole of structuredContent
|
|
303
|
+
// the whole of structuredContent, but for what a TEXT CUT leaves out of data, which the note names,
|
|
304
|
+
// and cannot disagree with it (#2802).
|
|
276
305
|
const envelopeText = (structured, said, own) => {
|
|
277
306
|
const envelope = {};
|
|
278
307
|
for (const [k, v] of Object.entries(structured)) {
|
|
@@ -338,12 +367,15 @@ export const checked = (tool, schema, r) => {
|
|
|
338
367
|
const p = schema.safeParse(r.structuredContent);
|
|
339
368
|
return p.success ? r : unexpectedShape(tool, p.error);
|
|
340
369
|
};
|
|
341
|
-
// A success: content[0] is the data
|
|
342
|
-
// the envelope carries both, the note as sentences
|
|
343
|
-
|
|
370
|
+
// A success: content[0] is the data (cut to DATA_TEXT_BUDGET when it is larger, #2783), content[1]
|
|
371
|
+
// the note, content[2] the envelope less both; the envelope carries both, the note as sentences
|
|
372
|
+
// after any the envelope adds about itself. structuredContent.data is the data whole.
|
|
373
|
+
export const succeeded = (data, note, env, cut) => {
|
|
344
374
|
const own = env.notes ?? [];
|
|
345
|
-
const
|
|
346
|
-
|
|
375
|
+
const shown = dataText(data, cut);
|
|
376
|
+
const said = shown.said ? `${note} ${shown.said}` : note;
|
|
377
|
+
const structuredContent = { ...env, notes: [...own, ...sentences(said)], data };
|
|
378
|
+
return { content: [text(shown.text), text(said), envelopeText(structuredContent, said, own)], structuredContent };
|
|
347
379
|
};
|
|
348
380
|
export const okHead = (tool, status) => `${tool} OK: EchelonGraph answered${status === undefined ? "" : ` HTTP ${status}`} from ${SHOWN_BASE}.`;
|
|
349
381
|
// Tolerant readers for the note: a missing or renamed field degrades the sentence, never
|
|
@@ -519,6 +551,55 @@ function summaryPollerNote(d) {
|
|
|
519
551
|
const counters = quoted.length > 0 ? ` (${quoted.join(", ")})` : "";
|
|
520
552
|
return ` poller is one API instance's NVD poller, counted in that instance's memory since it last started and zeroed on every restart: report its counters${counters} as that instance's, never as the feed's size, intake or reliability, and poller.last_poll_at as that instance's last poll, never as the feed's freshness.`;
|
|
521
553
|
}
|
|
554
|
+
// ── poller's JSON types, never a reason to withhold summary (#2771) ──
|
|
555
|
+
//
|
|
556
|
+
// 2.6.2 typed poller's eight fields in the outputSchema (CVE_SUMMARY_OUTPUT), and every success
|
|
557
|
+
// goes through checked(), so one counter core-backend sent in another JSON type failed the whole
|
|
558
|
+
// call and withheld summary.total: cve/poller.go Stats returns a map[string]interface{}, so
|
|
559
|
+
// emitting interval as a time.Duration (1200000000000) instead of interval.String() compiles.
|
|
560
|
+
// The block is one instance's diagnostics, never the feed's, so it degrades on its own: a field of
|
|
561
|
+
// another type is left out of data and named in the note, a poller that is neither a JSON object
|
|
562
|
+
// nor null (an array, a string) is left out whole and named, and summary and the rest of the
|
|
563
|
+
// answer are relayed as sent. What the schema allows is kept as sent: null for a field (opt), null
|
|
564
|
+
// or an empty object for the block, and keys this version does not know. Installed 2.6.2 and
|
|
565
|
+
// 2.6.3 copies still fail on such a change, so core-backend's cve/poller_stats_wire_test.go pins
|
|
566
|
+
// Stats()'s eight keys to these JSON types.
|
|
567
|
+
const SUMMARY_POLLER_KINDS = {
|
|
568
|
+
cves_ingested: "number",
|
|
569
|
+
cves_skipped: "number",
|
|
570
|
+
http_retries: "number",
|
|
571
|
+
interval: "string",
|
|
572
|
+
last_poll_at: "string",
|
|
573
|
+
last_poll_dur_ms: "number",
|
|
574
|
+
poll_count: "number",
|
|
575
|
+
poll_errors: "number",
|
|
576
|
+
};
|
|
577
|
+
const pollerField = (k, description) => opt(SUMMARY_POLLER_KINDS[k] === "number" ? z.number() : z.string()).describe(description);
|
|
578
|
+
const jsonKind = (v) => v === null ? "null" : Array.isArray(v) ? "an array" : typeof v === "object" ? "an object" : typeof v === "number" ? "a number" : typeof v === "boolean" ? "a boolean" : typeof v === "string" ? "a string" : typeof v;
|
|
579
|
+
function summaryPollerShaped(d) {
|
|
580
|
+
const poller = field(d, "poller");
|
|
581
|
+
if (poller === undefined || poller === null)
|
|
582
|
+
return { data: d, note: "" };
|
|
583
|
+
const relayed = " The rest of the answer, summary with it, is relayed as sent.";
|
|
584
|
+
if (!isPlainObject(poller)) {
|
|
585
|
+
const { poller: _dropped, ...rest } = d;
|
|
586
|
+
return { data: rest, note: ` poller was left out of the data above because the answer sent it as ${jsonKind(poller)}, where this tool reads an object.${relayed}` };
|
|
587
|
+
}
|
|
588
|
+
const unusable = Object.keys(SUMMARY_POLLER_KINDS).filter((k) => {
|
|
589
|
+
const v = poller[k];
|
|
590
|
+
if (v === undefined || v === null)
|
|
591
|
+
return false;
|
|
592
|
+
return SUMMARY_POLLER_KINDS[k] === "number" ? !isCount(v) : typeof v !== "string";
|
|
593
|
+
});
|
|
594
|
+
if (unusable.length === 0)
|
|
595
|
+
return { data: d, note: "" };
|
|
596
|
+
const kept = Object.fromEntries(Object.entries(poller).filter(([k]) => !unusable.includes(k)));
|
|
597
|
+
const named = unusable.map((k) => `poller.${k} (${jsonKind(poller[k])}, where this tool reads ${SUMMARY_POLLER_KINDS[k] === "number" ? "a number" : "a string"})`);
|
|
598
|
+
return {
|
|
599
|
+
data: { ...d, poller: kept },
|
|
600
|
+
note: ` Left out of the data above because the answer sent ${unusable.length === 1 ? "it" : "them"} in a JSON type other than the one this tool reads: ${listed(named)}.${relayed}`,
|
|
601
|
+
};
|
|
602
|
+
}
|
|
522
603
|
async function cveSummary() {
|
|
523
604
|
const tool = "cve_summary";
|
|
524
605
|
try {
|
|
@@ -526,7 +607,9 @@ async function cveSummary() {
|
|
|
526
607
|
if (!r.ok)
|
|
527
608
|
return failed(tool, r);
|
|
528
609
|
const head = okHead(tool, r.status);
|
|
529
|
-
|
|
610
|
+
// #2771: a poller field of another JSON type is left out, never a failure of the call.
|
|
611
|
+
const { data, note: pollerShape } = summaryPollerShaped(r.data);
|
|
612
|
+
const updated = strAt(data, "summary", "last_updated");
|
|
530
613
|
const env = {
|
|
531
614
|
state: "measured",
|
|
532
615
|
measured_at: instantOrNull(updated),
|
|
@@ -540,23 +623,43 @@ async function cveSummary() {
|
|
|
540
623
|
NO_FEED_FRESHNESS,
|
|
541
624
|
],
|
|
542
625
|
};
|
|
543
|
-
const total = numAt(
|
|
544
|
-
// What summary.none (#2610), the NVD histogram and summary.rejected (#2641) count,
|
|
545
|
-
//
|
|
546
|
-
const labels = `${summaryNoneNote(
|
|
626
|
+
const total = numAt(data, "summary", "total");
|
|
627
|
+
// What summary.none (#2610), the NVD histogram and summary.rejected (#2641) count, what the
|
|
628
|
+
// poller block is (#2647), and what of it was left out (#2771).
|
|
629
|
+
const labels = `${summaryNoneNote(data)}${summaryNVDNote(data)}${summaryRejectedNote(data)}${summaryPollerNote(data)}${pollerShape}`;
|
|
547
630
|
if (total === undefined)
|
|
548
|
-
return succeeded(
|
|
631
|
+
return succeeded(data, `${head}${labels}`, env);
|
|
549
632
|
if (total === 0) {
|
|
550
|
-
return succeeded(
|
|
633
|
+
return succeeded(data, `${head} The feed reports 0 active CVEs — a measured empty result (we looked and found nothing), not a lookup failure.${labels}`, env);
|
|
551
634
|
}
|
|
552
635
|
// A stamp that is not a real instant stays in the JSON and is not repeated as prose.
|
|
553
636
|
const stamp = realInstant(updated) ? ` (last updated ${updated})` : "";
|
|
554
|
-
return succeeded(
|
|
637
|
+
return succeeded(data, `${head} The feed holds ${total} active CVEs${stamp}.${labels}`, env);
|
|
555
638
|
}
|
|
556
639
|
catch (e) {
|
|
557
640
|
return crashed(tool, e);
|
|
558
641
|
}
|
|
559
642
|
}
|
|
643
|
+
// #2783: a page of production rows is about 6,600 characters a row as pretty JSON (38 fields, the
|
|
644
|
+
// description, cpe_match, references and score_factors among them), so the default page of 20 was
|
|
645
|
+
// 133,300 characters of text. Past DATA_TEXT_BUDGET each row in the first text block keeps the
|
|
646
|
+
// fields the description promises and a few more a triage reads, the description cut to 200
|
|
647
|
+
// characters; then, for a page of 50, the fields the description promises with the description cut
|
|
648
|
+
// to 100. structuredContent.data keeps every field of every row; get_cve returns one record, whole in
|
|
649
|
+
// its structuredContent.data and in its own first text block with every field (its lists cut past
|
|
650
|
+
// the budget, #2802).
|
|
651
|
+
const SEARCH_CVES_TEXT = {
|
|
652
|
+
rows: "cves",
|
|
653
|
+
levels: [
|
|
654
|
+
{
|
|
655
|
+
keep: ["cve_id", "description", "severity", "cvss_v3_score", "echelongraph_score", "echelongraph_severity", "echelongraph_risk", "score_assessed", "score_unassessed_reason", "epss_score", "kev_listed", "kev_ransomware", "published", "vuln_status", "patch_available", "exploit_poc_available"],
|
|
656
|
+
clip: 200,
|
|
657
|
+
},
|
|
658
|
+
{ keep: ["cve_id", "description", "severity", "cvss_v3_score", "echelongraph_score", "score_assessed", "score_unassessed_reason", "epss_score", "kev_listed", "published"], clip: 100 },
|
|
659
|
+
],
|
|
660
|
+
whole: `${GET_CVE_WHOLE}.`,
|
|
661
|
+
page: (shown, _rows, d) => `To read the rows left out in the text, call search_cves again with the same arguments and offset ${(numAt(d, "offset") ?? 0) + shown}; a page of ${shown} rows or fewer like these fits the text without leaving rows out.`,
|
|
662
|
+
};
|
|
560
663
|
async function searchCVEs(a) {
|
|
561
664
|
const tool = "search_cves";
|
|
562
665
|
try {
|
|
@@ -571,6 +674,8 @@ async function searchCVEs(a) {
|
|
|
571
674
|
if (a.sort)
|
|
572
675
|
q.set("sort", a.sort);
|
|
573
676
|
q.set("limit", String(a.limit ?? 20));
|
|
677
|
+
if (a.offset)
|
|
678
|
+
q.set("offset", String(a.offset));
|
|
574
679
|
const r = await api(`/api/v1/public/cves?${q.toString()}`, a.search ? { headers: { "X-EG-Search": encodeURIComponent(a.search) } } : undefined);
|
|
575
680
|
if (!r.ok)
|
|
576
681
|
return failed(tool, r);
|
|
@@ -610,16 +715,16 @@ async function searchCVEs(a) {
|
|
|
610
715
|
const empty = shown === 0 && (offset ?? 0) === 0;
|
|
611
716
|
return succeeded(r.data, empty
|
|
612
717
|
? `${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}`
|
|
613
|
-
: `${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);
|
|
718
|
+
: `${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, SEARCH_CVES_TEXT);
|
|
614
719
|
}
|
|
615
720
|
const total = numAt(r.data, "total") ?? shown;
|
|
616
721
|
if (total === undefined)
|
|
617
|
-
return succeeded(r.data, `${head}${tail}`, env);
|
|
722
|
+
return succeeded(r.data, `${head}${tail}`, env, SEARCH_CVES_TEXT);
|
|
618
723
|
if (total === 0 && floor !== true) {
|
|
619
|
-
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);
|
|
724
|
+
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, SEARCH_CVES_TEXT);
|
|
620
725
|
}
|
|
621
726
|
const matched = floor === true ? `at least ${total} CVEs (total_is_lower_bound is true: the count stopped at that floor)` : `${total} CVEs`;
|
|
622
|
-
return succeeded(r.data, `${head} The query matched ${matched}${shown === undefined ? "" : `; ${shown} returned in this page`}.${tail}`, env);
|
|
727
|
+
return succeeded(r.data, `${head} The query matched ${matched}${shown === undefined ? "" : `; ${shown} returned in this page`}.${tail}`, env, SEARCH_CVES_TEXT);
|
|
623
728
|
}
|
|
624
729
|
catch (e) {
|
|
625
730
|
return crashed(tool, e);
|
|
@@ -882,6 +987,7 @@ const SHADOW_AI_STATS = new Set([
|
|
|
882
987
|
"total",
|
|
883
988
|
"by_category",
|
|
884
989
|
"visible_by_category",
|
|
990
|
+
"confirmed_window",
|
|
885
991
|
"last_observation",
|
|
886
992
|
"last_24h_count",
|
|
887
993
|
"last_24h_visible_count",
|
|
@@ -972,6 +1078,34 @@ function leftOut(radar, unknown, unusable) {
|
|
|
972
1078
|
out.push(`Left out of ${radar} because they were not in the expected shape: ${unusable.join(", ")}.`);
|
|
973
1079
|
return out;
|
|
974
1080
|
}
|
|
1081
|
+
function readWindow(v) {
|
|
1082
|
+
if (v === undefined || v === null)
|
|
1083
|
+
return { bad: false };
|
|
1084
|
+
if (!isPlainObject(v))
|
|
1085
|
+
return { bad: true };
|
|
1086
|
+
const from = strAt(v, "from");
|
|
1087
|
+
const to = strAt(v, "to");
|
|
1088
|
+
const undated = v.undated;
|
|
1089
|
+
if (!isCount(undated) || undated < 0 || !Number.isInteger(undated))
|
|
1090
|
+
return { bad: true };
|
|
1091
|
+
if (undated > 0)
|
|
1092
|
+
return { undated, bad: false };
|
|
1093
|
+
if (v.from === null && v.to === null)
|
|
1094
|
+
return { bad: false }; // nothing counted, so nothing dated
|
|
1095
|
+
if (!realInstant(from) || !realInstant(to) || Date.parse(from) > Date.parse(to))
|
|
1096
|
+
return { bad: true };
|
|
1097
|
+
return { window: { from, to }, bad: false };
|
|
1098
|
+
}
|
|
1099
|
+
// The sentence for a radar's window, or for the rows it leaves undated.
|
|
1100
|
+
function windowSentences(prefix, w, what) {
|
|
1101
|
+
if (w.window) {
|
|
1102
|
+
return [`${prefix}.window.from (${w.window.from}) and ${prefix}.window.to (${w.window.to}) are timestamps, not counts: ${what}. They span observations made at different times, not one observation time.`];
|
|
1103
|
+
}
|
|
1104
|
+
if (w.undated !== undefined) {
|
|
1105
|
+
return [`${prefix} carries no window: ${w.undated} of the rows it counts have no observation time on record, so a window over the rest would not describe them.`];
|
|
1106
|
+
}
|
|
1107
|
+
return [];
|
|
1108
|
+
}
|
|
975
1109
|
function readRadar(body, spec) {
|
|
976
1110
|
const src = isPlainObject(body) ? body : {};
|
|
977
1111
|
const data = {};
|
|
@@ -1001,9 +1135,18 @@ function readRadar(body, spec) {
|
|
|
1001
1135
|
else if (present(k) && typeof src[k] !== "string")
|
|
1002
1136
|
unusable.push(k);
|
|
1003
1137
|
}
|
|
1004
|
-
|
|
1138
|
+
let undated;
|
|
1139
|
+
if (spec.window) {
|
|
1140
|
+
const w = readWindow(src[spec.window]);
|
|
1141
|
+
if (w.window)
|
|
1142
|
+
data.window = w.window;
|
|
1143
|
+
else if (w.bad)
|
|
1144
|
+
unusable.push(spec.window);
|
|
1145
|
+
undated = w.undated;
|
|
1146
|
+
}
|
|
1147
|
+
const known = new Set([...spec.counts, ...Object.keys(spec.rows), ...spec.instants, ...(spec.window ? [spec.window] : [])]);
|
|
1005
1148
|
unknown.unshift(...Object.keys(src).filter((k) => !known.has(k)));
|
|
1006
|
-
return { data, unknown, unusable };
|
|
1149
|
+
return { data, unknown, unusable, undated };
|
|
1007
1150
|
}
|
|
1008
1151
|
// ── last_run_at: when a radar last COMPLETED a check (#2335) ──
|
|
1009
1152
|
//
|
|
@@ -1201,9 +1344,10 @@ const EXPOSED_DB_SPEC = {
|
|
|
1201
1344
|
top_countries: { required: { country: "string", hosts: "number" } },
|
|
1202
1345
|
},
|
|
1203
1346
|
instants: ["generated_at", "last_run_at"],
|
|
1347
|
+
window: "observed_window",
|
|
1204
1348
|
};
|
|
1205
1349
|
function readExposedDatabases(body) {
|
|
1206
|
-
const { data, unknown, unusable } = readRadar(body, EXPOSED_DB_SPEC);
|
|
1350
|
+
const { data, unknown, unusable, undated } = readRadar(body, EXPOSED_DB_SPEC);
|
|
1207
1351
|
const sentences = [];
|
|
1208
1352
|
const n = (k) => numIn(data, k);
|
|
1209
1353
|
const totals = [
|
|
@@ -1226,6 +1370,7 @@ function readExposedDatabases(body) {
|
|
|
1226
1370
|
if (gated.length) {
|
|
1227
1371
|
sentences.push(`${gated.join("; ")}. The names are index, database, table or field names in the Shodan or LeakIX banner or in the check's response, never record values, and the gate passes one unambiguous term (such as ssn or cardholder) or two distinct indicators. It is precision-first: a service whose names do not pass it is not counted, whatever it holds, so the other services are not shown to hold no personal or card data.`);
|
|
1228
1372
|
}
|
|
1373
|
+
sentences.push(...windowSentences("exposed_databases", { window: data.window, undated, bad: false }, "when EchelonGraph's check last confirmed the oldest and the newest of the services counted answering without authentication"));
|
|
1229
1374
|
sentences.push(...lastCheck("exposed_databases", data));
|
|
1230
1375
|
sentences.push(...leftOut("exposed_databases", unknown, unusable));
|
|
1231
1376
|
return { data, sentences };
|
|
@@ -1249,9 +1394,10 @@ const LEAKED_CREDS_SPEC = {
|
|
|
1249
1394
|
top_types: { required: { secret_type: "string", count: "number" } },
|
|
1250
1395
|
},
|
|
1251
1396
|
instants: ["generated_at", "last_run_at"],
|
|
1397
|
+
window: "observed_window",
|
|
1252
1398
|
};
|
|
1253
1399
|
function readLeakedCredentials(body) {
|
|
1254
|
-
const { data, unknown, unusable } = readRadar(body, LEAKED_CREDS_SPEC);
|
|
1400
|
+
const { data, unknown, unusable, undated } = readRadar(body, LEAKED_CREDS_SPEC);
|
|
1255
1401
|
const sentences = [];
|
|
1256
1402
|
const n = (k) => numIn(data, k);
|
|
1257
1403
|
const totals = [
|
|
@@ -1271,6 +1417,7 @@ function readLeakedCredentials(body) {
|
|
|
1271
1417
|
if (totals.length || rankings.length) {
|
|
1272
1418
|
sentences.push("None of these is validated: each is a credential-shaped string in a public commit that passed EchelonGraph's filters, at most structurally checked (a checksum or format decode) and never tested against its provider, so none of them is a count of working credentials.");
|
|
1273
1419
|
}
|
|
1420
|
+
sentences.push(...windowSentences("leaked_credentials", { window: data.window, undated, bad: false }, "when EchelonGraph's detector last found the oldest and the newest of the (repository, secret) pairs counted in a public commit"));
|
|
1274
1421
|
sentences.push(...lastCheck("leaked_credentials", data));
|
|
1275
1422
|
sentences.push(...leftOut("leaked_credentials", unknown, unusable));
|
|
1276
1423
|
return { data, sentences };
|
|
@@ -1295,6 +1442,13 @@ function readShadowAI(sa) {
|
|
|
1295
1442
|
const last24Visible = take("last_24h_visible_count", numAt(stats, "last_24h_visible_count"));
|
|
1296
1443
|
if (last24Visible !== undefined)
|
|
1297
1444
|
confirmed.last_24h = last24Visible;
|
|
1445
|
+
// When the confirmed rows were observed (#2438): the verifier's deciding probe times, over the
|
|
1446
|
+
// rows visible_by_category counts. Relayed only beside the count it dates.
|
|
1447
|
+
const confirmedWindow = readWindow(field(stats, "confirmed_window"));
|
|
1448
|
+
if (!confirmedWindow.bad && field(stats, "confirmed_window") !== undefined)
|
|
1449
|
+
used.add("confirmed_window");
|
|
1450
|
+
if (confirmedWindow.window && confirmed.total !== undefined)
|
|
1451
|
+
confirmed.window = confirmedWindow.window;
|
|
1298
1452
|
// Observed: every row of shadow_ct_observations, whatever its liveness.
|
|
1299
1453
|
const observed = {};
|
|
1300
1454
|
const total = take("total", numAt(stats, "total"));
|
|
@@ -1330,6 +1484,9 @@ function readShadowAI(sa) {
|
|
|
1330
1484
|
sentences.push(confirmed.total === undefined
|
|
1331
1485
|
? `Shadow AI: the answer carries no usable stats.visible_by_category, so it does not say how many services are confirmed exposed${last24Visible === undefined ? "" : `; confirmed_exposed.last_24h (${last24Visible}) counts confirmed-exposed services first recorded in the last 24 h`}.`
|
|
1332
1486
|
: `Shadow AI confirmed exposed: ${confirmed.total} (confirmed_exposed.total, the sum of confirmed_exposed.by_category: services EchelonGraph's probes found answering without an authentication gate, with liveness active or, during a re-check, rechecking)${last24Visible === undefined ? "" : `, ${last24Visible} of them first recorded in the last 24 h (confirmed_exposed.last_24h)`}. Of the shadow_ai numbers, only confirmed_exposed counts exposed services.`);
|
|
1487
|
+
if (confirmed.total !== undefined) {
|
|
1488
|
+
sentences.push(...windowSentences("shadow_ai.confirmed_exposed", { window: confirmedWindow.window, undated: confirmedWindow.undated, bad: false }, "when EchelonGraph's verifier ran the probe that last decided the oldest and the newest of the services confirmed_exposed counts"));
|
|
1489
|
+
}
|
|
1333
1490
|
if (total !== undefined) {
|
|
1334
1491
|
sentences.push(`Shadow AI observed: ${total} (observed.total) counts every Certificate Transparency or Shodan observation on record, whatever its verification state: that many observed, not that many exposed.`);
|
|
1335
1492
|
}
|
|
@@ -1621,9 +1778,9 @@ function readMCPServers(src) {
|
|
|
1621
1778
|
}
|
|
1622
1779
|
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. ` +
|
|
1623
1780
|
`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. ` +
|
|
1624
|
-
`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. ` +
|
|
1625
|
-
`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. ` +
|
|
1626
|
-
`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. ` +
|
|
1781
|
+
`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. exposed_databases.window.from and exposed_databases.window.to are timestamps, not counts: when EchelonGraph's check last confirmed the oldest and the newest of the services counted, a span of checks made at different times; with a counted service that has no such time on record there is no window. ` +
|
|
1782
|
+
`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. leaked_credentials.window.from and leaked_credentials.window.to are timestamps, not counts: when EchelonGraph's detector last found the oldest and the newest of the pairs counted. 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. ` +
|
|
1783
|
+
`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. confirmed_exposed.window.from and confirmed_exposed.window.to are timestamps, not counts: when EchelonGraph's verifier ran the probe that last decided the oldest and the newest of those services, a span of probes made at different times. 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. ` +
|
|
1627
1784
|
`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}`;
|
|
1628
1785
|
// exposure_radar's envelope. Every radar answered, but no stats answer says when the services
|
|
1629
1786
|
// or records it counts were observed: each count covers everything that radar still holds on
|
|
@@ -1646,7 +1803,7 @@ const EXPOSURE_RADAR_DESCRIPTION = `Aggregate totals from EchelonGraph's interne
|
|
|
1646
1803
|
// and the envelope's own note names it.
|
|
1647
1804
|
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.";
|
|
1648
1805
|
const RADAR_NAMES = RADARS.map(([name]) => name);
|
|
1649
|
-
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
|
|
1806
|
+
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 gives none, and exposed_databases, leaked_credentials, shadow_ai's confirmed_exposed and mcp_servers date what they count at most by a window (exposed_databases.window, leaked_credentials.window, shadow_ai.confirmed_exposed.window, mcp_servers.window), from the oldest observation among the rows counted 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.";
|
|
1650
1807
|
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.";
|
|
1651
1808
|
async function exposureRadar() {
|
|
1652
1809
|
const tool = "exposure_radar";
|
|
@@ -1800,8 +1957,9 @@ const CVERecord = z.looseObject({
|
|
|
1800
1957
|
});
|
|
1801
1958
|
// #2647: what cve_summary says the poller block is (summaryPollerNote). Like the #2641 strings
|
|
1802
1959
|
// beside SUMMARY_NONE_DESCRIPTION, it uses none of #2610's six words ("any", "not", "rating",
|
|
1803
|
-
// "scored", "source", "yet")
|
|
1804
|
-
|
|
1960
|
+
// "scored", "source", "yet"), #2771's sentence on a field of another JSON type among them. Declared
|
|
1961
|
+
// here, before the outputSchema that describes poller with it.
|
|
1962
|
+
const SUMMARY_POLLER_DESCRIPTION = "poller holds the in-memory counters of the NVD poller of the one API instance that answered (cves_ingested, cves_skipped, http_retries, poll_count, poll_errors, last_poll_at, last_poll_dur_ms, interval), counted since that instance last started and zeroed on every restart: they describe that instance, never the feed's size, intake, reliability or freshness. Whenever the answer carries poller the note says so. A poller field the answer sends in a JSON type other than the one described here is left out of data and named in the note, and a poller that is neither a JSON object nor null is left out whole: summary is relayed either way.";
|
|
1805
1963
|
// One of the NVD histogram's four labelled buckets (#2641).
|
|
1806
1964
|
const nvdBand = (label) => opt(z.number()).describe(`Of the active CVEs total counts, those whose NVD CVSS severity label is ${label}: NVD's label, as provenance, never EchelonGraph's severity band.`);
|
|
1807
1965
|
const CVE_SUMMARY_OUTPUT = envelopeSchema({
|
|
@@ -1828,16 +1986,18 @@ const CVE_SUMMARY_OUTPUT = envelopeSchema({
|
|
|
1828
1986
|
})
|
|
1829
1987
|
.optional(),
|
|
1830
1988
|
// #2647: one instance's counters, never the feed's (see summaryPollerNote).
|
|
1989
|
+
// #2771: each field's JSON type is SUMMARY_POLLER_KINDS', which summaryPollerShaped enforces
|
|
1990
|
+
// before this check, so a field of another type is left out, never a failed call.
|
|
1831
1991
|
poller: z
|
|
1832
1992
|
.looseObject({
|
|
1833
|
-
cves_ingested:
|
|
1834
|
-
cves_skipped:
|
|
1835
|
-
http_retries:
|
|
1836
|
-
interval:
|
|
1837
|
-
last_poll_at:
|
|
1838
|
-
last_poll_dur_ms:
|
|
1839
|
-
poll_count:
|
|
1840
|
-
poll_errors:
|
|
1993
|
+
cves_ingested: pollerField("cves_ingested", "CVE records this instance's NVD poller wrote since the instance last started: never the feed's size or intake."),
|
|
1994
|
+
cves_skipped: pollerField("cves_skipped", "CVE records this instance's NVD poller skipped since the instance last started."),
|
|
1995
|
+
http_retries: pollerField("http_retries", "HTTP retries this instance's NVD poller made since the instance last started."),
|
|
1996
|
+
interval: pollerField("interval", "How often this instance's NVD poller polls."),
|
|
1997
|
+
last_poll_at: pollerField("last_poll_at", "When this instance's NVD poller last polled: never the feed's freshness."),
|
|
1998
|
+
last_poll_dur_ms: pollerField("last_poll_dur_ms", "How long that poll took, in milliseconds."),
|
|
1999
|
+
poll_count: pollerField("poll_count", "Polls this instance's NVD poller made since the instance last started."),
|
|
2000
|
+
poll_errors: pollerField("poll_errors", "Polls of this instance's NVD poller that failed since the instance last started: never the feed's reliability."),
|
|
1841
2001
|
})
|
|
1842
2002
|
.nullable()
|
|
1843
2003
|
.optional()
|
|
@@ -1902,10 +2062,13 @@ const zRow = (spec) => z.strictObject({
|
|
|
1902
2062
|
...Object.fromEntries(Object.entries(spec.required).map(([k, kind]) => [k, zKind(kind)])),
|
|
1903
2063
|
...Object.fromEntries(Object.entries(spec.optional ?? {}).map(([k, kind]) => [k, zKind(kind).optional()])),
|
|
1904
2064
|
});
|
|
2065
|
+
// A relayed observation window (#2438): two instants, never a count.
|
|
2066
|
+
const Window = z.strictObject({ from: Instant, to: Instant });
|
|
1905
2067
|
const zRadar = (spec, rows = {}) => z.strictObject({
|
|
1906
2068
|
...Object.fromEntries(spec.counts.map((k) => [k, z.number().optional()])),
|
|
1907
2069
|
...Object.fromEntries(Object.entries(spec.rows).map(([k, rs]) => [k, (rows[k] ?? z.array(zRow(rs))).optional()])),
|
|
1908
2070
|
...Object.fromEntries(spec.instants.map((k) => [k, Instant.optional()])),
|
|
2071
|
+
...(spec.window ? { window: Window.optional() } : {}),
|
|
1909
2072
|
});
|
|
1910
2073
|
// A newest_kev row as newestKEVRow relays it: `tracked` is read, not relayed, and a count only
|
|
1911
2074
|
// when the row's exposure_state is not not_assessed.
|
|
@@ -1919,7 +2082,9 @@ const NewestKEVRow = z.strictObject({
|
|
|
1919
2082
|
const CountMap = z.record(z.string(), z.number());
|
|
1920
2083
|
const countRowsOf = (label) => z.array(z.strictObject({ [label]: z.string(), count: z.number() }));
|
|
1921
2084
|
const ShadowAIData = z.strictObject({
|
|
1922
|
-
confirmed_exposed: z
|
|
2085
|
+
confirmed_exposed: z
|
|
2086
|
+
.strictObject({ total: z.number().optional(), by_category: CountMap.optional(), last_24h: z.number().optional(), window: Window.optional() })
|
|
2087
|
+
.optional(),
|
|
1923
2088
|
observed: z
|
|
1924
2089
|
.strictObject({
|
|
1925
2090
|
total: z.number().optional(),
|
|
@@ -1986,6 +2151,7 @@ const INSTRUCTIONS = [
|
|
|
1986
2151
|
"Everything these tools return is public: EchelonGraph's CVE Pulse feed, and aggregate, host-redacted totals from its exposure radars.",
|
|
1987
2152
|
"Every result says how it was measured: state, measured_at, method, coverage, freshness and notes, in its structuredContent and again in its text.",
|
|
1988
2153
|
"Its last text block is that structuredContent as JSON, less what an earlier text block already gives verbatim: data, which is a success's first text block; the sentences of the text block just before it (the note, or a failure's message), with which structuredContent's notes end; and method, where that block quotes it.",
|
|
2154
|
+
"Past 30,000 characters of JSON, that first text block holds data cut to fit, and the note says what the cut leaves out and where to read it (TEXT CUT); structuredContent's data always holds it whole.",
|
|
1989
2155
|
"state is measured, not_assessed, failed or invalid_input.",
|
|
1990
2156
|
"not_assessed means the answer holds no dated measurement of what was asked, so no count in it is presented as one.",
|
|
1991
2157
|
"It can still relay a count, as what the source holds on record, undated, and its notes say what each count is: cve_exposure's exposed_hosts when its exposure_state is exposed, and exposure_radar's labelled totals, are such counts.",
|
|
@@ -2004,6 +2170,10 @@ export const ANNOTATIONS = { readOnlyHint: true, destructiveHint: false, idempot
|
|
|
2004
2170
|
const CVE_ID_ARG = z.string().describe("a CVE ID, e.g. CVE-2023-44487");
|
|
2005
2171
|
// What each description says about its structured result, naming only fields its schema holds.
|
|
2006
2172
|
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.";
|
|
2173
|
+
// #2771: cve_summary's own, since its data is the API's JSON less each poller field (or the whole
|
|
2174
|
+
// poller) summaryPollerShaped leaves out. Like SUMMARY_POLLER_DESCRIPTION it uses none of #2610's
|
|
2175
|
+
// six words ("any", "not", "rating", "scored", "source", "yet").
|
|
2176
|
+
const CVE_SUMMARY_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 less each poller field (or the whole poller) the note names as left out; 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.";
|
|
2007
2177
|
// #2610: what cve_summary says summary.none is (summaryNoneNote), one constant string for the same
|
|
2008
2178
|
// reason as SCORE_ASSESSED_DESCRIPTION below.
|
|
2009
2179
|
const SUMMARY_NONE_DESCRIPTION = "summary.none is not a severity rating of None: it counts the active CVEs with no severity band from any source, that is, CVEs not yet scored, and the answer may carry the same count again as summary.unscored. Whenever summary.none is above zero the note says so: report those CVEs as not yet scored, not as CVEs rated None.";
|
|
@@ -2026,40 +2196,42 @@ export function createServer() {
|
|
|
2026
2196
|
const server = new McpServer({ name: NAME, version: VERSION }, { instructions: INSTRUCTIONS, capabilities: { tools: { listChanged: false }, prompts: { listChanged: false }, resources: { listChanged: false } } });
|
|
2027
2197
|
server.registerTool("cve_summary", {
|
|
2028
2198
|
title: "CVE feed summary",
|
|
2029
|
-
description: `Summary of EchelonGraph's CVE Pulse feed: summary.total active CVEs, their counts by severity band (summary.critical, summary.high, summary.medium, summary.low), the count with no band (summary.none), and summary.last_updated, the newest modification time among those records. ${SUMMARY_NONE_DESCRIPTION} ${SUMMARY_NVD_DESCRIPTION} ${SUMMARY_REJECTED_DESCRIPTION} ${SUMMARY_POLLER_DESCRIPTION} The feed is polled from its sources on a schedule, so this is the state as of that update. ${
|
|
2199
|
+
description: `Summary of EchelonGraph's CVE Pulse feed: summary.total active CVEs, their counts by severity band (summary.critical, summary.high, summary.medium, summary.low), the count with no band (summary.none), and summary.last_updated, the newest modification time among those records. ${SUMMARY_NONE_DESCRIPTION} ${SUMMARY_NVD_DESCRIPTION} ${SUMMARY_REJECTED_DESCRIPTION} ${SUMMARY_POLLER_DESCRIPTION} The feed is polled from its sources on a schedule, so this is the state as of that update. ${CVE_SUMMARY_ENVELOPE} ${TEXT_BUDGET_DESCRIPTION}`,
|
|
2030
2200
|
outputSchema: CVE_SUMMARY_OUTPUT,
|
|
2031
2201
|
annotations: ANNOTATIONS,
|
|
2032
2202
|
}, async () => checked("cve_summary", CVE_SUMMARY_OUTPUT, await cveSummary()));
|
|
2033
2203
|
server.registerTool("search_cves", {
|
|
2034
2204
|
title: "Search CVEs",
|
|
2035
|
-
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.`,
|
|
2205
|
+
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; page with limit and offset. 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. ${TEXT_BUDGET_DESCRIPTION} Cut, each row keeps at least the fields named above and the first 200 characters of its description (100 on a page too long for that), or rows are left out and the note gives the offset to call next.`,
|
|
2036
2206
|
inputSchema: z.object({
|
|
2037
2207
|
search: z.string().optional().describe("free-text search (product, vendor, or keyword, e.g. 'tomcat')"),
|
|
2038
2208
|
severity: z.enum(["CRITICAL", "HIGH", "MEDIUM", "LOW"]).optional().describe("filter to one severity"),
|
|
2039
2209
|
min_cvss: z.number().min(0).max(10).optional().describe("minimum CVSS score"),
|
|
2040
2210
|
sort: z.enum(["published", "modified", "nvd", "echelongraph", "epss"]).optional().describe("sort order (default: published)"),
|
|
2041
2211
|
limit: z.number().int().min(1).max(50).optional().describe("page size (default 20, max 50)"),
|
|
2212
|
+
// #2783: the next page, which the note names when the text leaves rows out.
|
|
2213
|
+
offset: z.number().int().min(0).max(10000).optional().describe("rows to skip (default 0)"),
|
|
2042
2214
|
}),
|
|
2043
2215
|
outputSchema: SEARCH_CVES_OUTPUT,
|
|
2044
2216
|
annotations: ANNOTATIONS,
|
|
2045
2217
|
}, async (a) => checked("search_cves", SEARCH_CVES_OUTPUT, await searchCVEs(a)));
|
|
2046
2218
|
server.registerTool("get_cve", {
|
|
2047
2219
|
title: "CVE detail",
|
|
2048
|
-
description: `
|
|
2220
|
+
description: `One CVE's record: 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, cpe_match (the CPE criteria as one flat list) and cpe_configurations (NVD's configurations as NVD sent them, with each AND/OR operator, negate, versionStartExcluding and matchCriteriaId; absent where EchelonGraph has stored none, which is not a finding that no product is affected), 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} ${TEXT_BUDGET_DESCRIPTION} Cut, each list in the record keeps its first entries, and the note names each list cut with its full length.`,
|
|
2049
2221
|
inputSchema: z.object({ cve_id: CVE_ID_ARG }),
|
|
2050
2222
|
outputSchema: GET_CVE_OUTPUT,
|
|
2051
2223
|
annotations: ANNOTATIONS,
|
|
2052
2224
|
}, async ({ cve_id }) => checked("get_cve", GET_CVE_OUTPUT, await getCVE(cve_id)));
|
|
2053
2225
|
server.registerTool("cve_exposure", {
|
|
2054
2226
|
title: "Internet exposure for one CVE",
|
|
2055
|
-
description: `${CVE_EXPOSURE_DESCRIPTION} Its structured result's state is not_assessed with measured_at null: the per-CVE answer says when EchelonGraph last wrote a row (last_seen), not when any counted service was observed, so no count is presented as a dated measurement; the count is still relayed, labelled, as what the radar holds on record. exposure_state says what the count is: exposed, measured_zero, not_assessed or tracking_unknown; coverage.in_scope is the API's tracked verdict; freshness is null, since the per-CVE answer carries no last completed check; data is the API's JSON. The result's last text block repeats the structured result without data (the first text block), without the note's sentences (the text block before it), with which notes ends, and without method where the note quotes it verbatim ("Method: …")
|
|
2227
|
+
description: `${CVE_EXPOSURE_DESCRIPTION} Its structured result's state is not_assessed with measured_at null: the per-CVE answer says when EchelonGraph last wrote a row (last_seen), not when any counted service was observed, so no count is presented as a dated measurement; the count is still relayed, labelled, as what the radar holds on record. exposure_state says what the count is: exposed, measured_zero, not_assessed or tracking_unknown; coverage.in_scope is the API's tracked verdict; freshness is null, since the per-CVE answer carries no last completed check; data is the API's JSON. The result's last text block repeats the structured result without data (the first text block), without the note's sentences (the text block before it), with which notes ends, and without method where the note quotes it verbatim ("Method: …"). ${TEXT_BUDGET_DESCRIPTION}`,
|
|
2056
2228
|
inputSchema: z.object({ cve_id: CVE_ID_ARG }),
|
|
2057
2229
|
outputSchema: CVE_EXPOSURE_OUTPUT,
|
|
2058
2230
|
annotations: ANNOTATIONS,
|
|
2059
2231
|
}, async ({ cve_id }) => checked("cve_exposure", CVE_EXPOSURE_OUTPUT, await cveExposure(cve_id)));
|
|
2060
2232
|
server.registerTool("exposure_radar", {
|
|
2061
2233
|
title: "Exposure radar totals",
|
|
2062
|
-
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
|
|
2234
|
+
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. ${TEXT_BUDGET_DESCRIPTION}`,
|
|
2063
2235
|
outputSchema: EXPOSURE_RADAR_OUTPUT,
|
|
2064
2236
|
annotations: ANNOTATIONS,
|
|
2065
2237
|
}, async () => checked("exposure_radar", EXPOSURE_RADAR_OUTPUT, await exposureRadar()));
|
|
@@ -2067,16 +2239,18 @@ export function createServer() {
|
|
|
2067
2239
|
registerEPSSHistory(server, TOOL_KIT);
|
|
2068
2240
|
// #2716: its logic, schemas and description live in tools/check_affected.ts.
|
|
2069
2241
|
registerCheckAffected(server, { api, succeeded, failed, badInput, crashed, checked, okHead, envelopeSchema, annotations: ANNOTATIONS });
|
|
2070
|
-
// #2721: tools/check_sbom.ts, on this file's api(), envelope and failure contract.
|
|
2071
|
-
|
|
2242
|
+
// #2721: tools/check_sbom.ts, on this file's api(), envelope and failure contract. holdRequest
|
|
2243
|
+
// (#2775): on the hosted endpoint, a call keeps its request's admission and large-body slot
|
|
2244
|
+
// until it has stopped, not only until its answer, or its client, has gone.
|
|
2245
|
+
registerCheckSbom(server, { api, failed, describeFailure, badInput, crashed, checked, succeeded, okHead, envelopeSchema, annotations: ANNOTATIONS, hold: holdRequest });
|
|
2072
2246
|
registerCveIntel(server);
|
|
2073
2247
|
registerGetCwe(server);
|
|
2074
2248
|
// #2719: vendor_advisories_for_cve, get_vendor_advisory, search_vendor_advisories.
|
|
2075
|
-
registerVendorAdvisoryTools(server, { api, failed, badInput, crashed, succeeded, checked, okHead, envelopeSchema, annotations: ANNOTATIONS });
|
|
2249
|
+
registerVendorAdvisoryTools(server, { api, failed, describeFailure, badInput, crashed, succeeded, checked, okHead, envelopeSchema, annotations: ANNOTATIONS });
|
|
2076
2250
|
// #2722: prompts and resources, in prompts.ts and resources.ts. Both lists are fixed per
|
|
2077
2251
|
// connection, so listChanged is false above.
|
|
2078
2252
|
registerPrompts(server, { cveId: CVE_ID });
|
|
2079
|
-
registerResources(server, { api, cveId: CVE_ID, shownBase: SHOWN_BASE, getCve: async (id) => checked("get_cve", GET_CVE_OUTPUT, await getCVE(id)) });
|
|
2253
|
+
registerResources(server, { api, cveId: CVE_ID, shownBase: SHOWN_BASE, sentences, getCve: async (id) => checked("get_cve", GET_CVE_OUTPUT, await getCVE(id)) });
|
|
2080
2254
|
return server;
|
|
2081
2255
|
}
|
|
2082
2256
|
// The package identity and API settings, for the hosted HTTP entrypoint's health answer and
|