echelongraph-mcp 2.6.3 → 2.6.6
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 +299 -2
- package/README.md +174 -40
- package/dist/http.js +183 -39
- package/dist/httpPolicy.js +160 -4
- package/dist/index.js +150 -46
- package/dist/prompts.js +72 -11
- package/dist/resources.js +61 -12
- package/dist/runtime.js +26 -4
- package/dist/textBudget.js +11 -4
- package/dist/tools/check_affected.js +26 -16
- package/dist/tools/check_sbom.js +45 -9
- package/dist/tools/cve_intel.js +69 -2
- package/dist/tools/get_cwe.js +2 -2
- package/dist/tools/kev_recent.js +3 -2
- package/dist/tools/vendor_advisories.js +54 -17
- package/package.json +2 -2
- package/server.json +2 -2
package/dist/index.js
CHANGED
|
@@ -27,19 +27,23 @@
|
|
|
27
27
|
// Structured results (#2311, #2313). Every result, success or failure, also carries
|
|
28
28
|
// structuredContent: one envelope that says how the answer was measured — state (measured,
|
|
29
29
|
// not_assessed, failed, invalid_input), measured_at, method, coverage, freshness and notes —
|
|
30
|
-
// with, on a success, `data
|
|
31
|
-
//
|
|
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.
|
|
32
34
|
//
|
|
33
35
|
// The envelope in the text (#2440, #2467). Many clients pass only `content` to the model, so the
|
|
34
36
|
// last text block of every result is structuredContent serialized as JSON, less what an earlier
|
|
35
|
-
// text block already carries
|
|
36
|
-
// (the block just before, which structuredContent's notes end with), and method where
|
|
37
|
-
// quotes it. The rest — state, measured_at, coverage, freshness, the envelope's own notes
|
|
38
|
-
// on a failure, error — is copied key for key, so the text blocks together are the whole of
|
|
39
|
-
// structuredContent and cannot disagree with it. The
|
|
40
|
-
// Content) asks a tool that returns structured content to
|
|
41
|
-
// too; the JSON block stays first and the note second, as in
|
|
42
|
-
// 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).
|
|
43
47
|
//
|
|
44
48
|
// Protocol eras (#2311). serveStdio answers both: a 2026-07-28 client's server/discover and
|
|
45
49
|
// per-request `_meta` envelope, and a 2025-era client's `initialize` handshake. The first
|
|
@@ -54,11 +58,11 @@ import { registerCheckAffected } from "./tools/check_affected.js";
|
|
|
54
58
|
import { registerCheckSbom } from "./tools/check_sbom.js";
|
|
55
59
|
import { registerCveIntel } from "./tools/cve_intel.js";
|
|
56
60
|
import { registerGetCwe } from "./tools/get_cwe.js";
|
|
57
|
-
import { isHttpEntrypoint, upstreamHeaders, upstreamUserAgent } from "./runtime.js";
|
|
61
|
+
import { holdRequest, isHttpEntrypoint, requestSignal, upstreamHeaders, upstreamUserAgent } from "./runtime.js";
|
|
58
62
|
import { registerVendorAdvisoryTools } from "./tools/vendor_advisories.js";
|
|
59
63
|
import { registerPrompts } from "./prompts.js";
|
|
60
64
|
import { registerResources } from "./resources.js";
|
|
61
|
-
import { dataText, TEXT_BUDGET_DESCRIPTION } from "./textBudget.js";
|
|
65
|
+
import { dataText, GET_CVE_WHOLE, TEXT_BUDGET_DESCRIPTION } from "./textBudget.js";
|
|
62
66
|
// The package actually running, read from the package.json that ships beside dist/. The MCP
|
|
63
67
|
// handshake (serverInfo) and the User-Agent both carry its name and version, so the API's
|
|
64
68
|
// access log can count adoption per version without a code change per release.
|
|
@@ -175,15 +179,29 @@ function retryAfterSeconds(v) {
|
|
|
175
179
|
// the URL); the User-Agent and Accept are this server's and are not overridden. init.timeoutMs
|
|
176
180
|
// can only shorten the request's timeout below TIMEOUT_MS, never lengthen it: check_sbom passes
|
|
177
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.
|
|
178
185
|
export async function api(path, init) {
|
|
179
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();
|
|
180
192
|
const ctrl = new AbortController();
|
|
181
193
|
let timedOut = false;
|
|
194
|
+
let cancelled = false;
|
|
182
195
|
const timer = setTimeout(() => {
|
|
183
196
|
timedOut = true;
|
|
184
197
|
ctrl.abort();
|
|
185
198
|
}, limitMs);
|
|
186
|
-
const
|
|
199
|
+
const cancel = () => {
|
|
200
|
+
cancelled = true;
|
|
201
|
+
ctrl.abort();
|
|
202
|
+
};
|
|
203
|
+
for (const s of callers)
|
|
204
|
+
s.addEventListener("abort", cancel, { once: true });
|
|
187
205
|
const own = limitMs === TIMEOUT_MS ? {} : { timeoutMs: limitMs };
|
|
188
206
|
const timeout = () => ({ ok: false, kind: "timeout", path, ...m, ...own, detail: `no response within ${limitMs} ms` });
|
|
189
207
|
try {
|
|
@@ -200,14 +218,14 @@ export async function api(path, init) {
|
|
|
200
218
|
});
|
|
201
219
|
}
|
|
202
220
|
catch (e) {
|
|
203
|
-
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) };
|
|
204
222
|
}
|
|
205
223
|
let body;
|
|
206
224
|
try {
|
|
207
225
|
body = await res.text();
|
|
208
226
|
}
|
|
209
227
|
catch (e) {
|
|
210
|
-
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)}` };
|
|
211
229
|
}
|
|
212
230
|
if (!res.ok) {
|
|
213
231
|
const retryAfter = retryAfterSeconds(res.headers.get("retry-after"));
|
|
@@ -228,6 +246,8 @@ export async function api(path, init) {
|
|
|
228
246
|
}
|
|
229
247
|
finally {
|
|
230
248
|
clearTimeout(timer);
|
|
249
|
+
for (const s of callers)
|
|
250
|
+
s.removeEventListener("abort", cancel);
|
|
231
251
|
}
|
|
232
252
|
}
|
|
233
253
|
const text = (t) => ({ type: "text", text: t });
|
|
@@ -273,13 +293,15 @@ const REJECTED_INPUT = "The API rejected the request as invalid input, so nothin
|
|
|
273
293
|
// earlier text block already carries verbatim (#2467). 2.1.0 sent the whole envelope without
|
|
274
294
|
// `data`, so every note went out twice, once as prose and once as the notes array, and
|
|
275
295
|
// cve_exposure's method a third time.
|
|
276
|
-
// 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).
|
|
277
298
|
// notes only the envelope's own sentences (`own`), which no text block says; the rest of
|
|
278
299
|
// structuredContent's notes are the sentences of `said`, the text block just before
|
|
279
300
|
// this one (the note, or a failure's message). Left out when there are none.
|
|
280
301
|
// method left out when `said` quotes it verbatim (cve_exposure's note, "Method: …").
|
|
281
302
|
// Every other key is copied as is, in structuredContent's order, so the text blocks together are
|
|
282
|
-
// 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).
|
|
283
305
|
const envelopeText = (structured, said, own) => {
|
|
284
306
|
const envelope = {};
|
|
285
307
|
for (const [k, v] of Object.entries(structured)) {
|
|
@@ -529,6 +551,55 @@ function summaryPollerNote(d) {
|
|
|
529
551
|
const counters = quoted.length > 0 ? ` (${quoted.join(", ")})` : "";
|
|
530
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.`;
|
|
531
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
|
+
}
|
|
532
603
|
async function cveSummary() {
|
|
533
604
|
const tool = "cve_summary";
|
|
534
605
|
try {
|
|
@@ -536,7 +607,9 @@ async function cveSummary() {
|
|
|
536
607
|
if (!r.ok)
|
|
537
608
|
return failed(tool, r);
|
|
538
609
|
const head = okHead(tool, r.status);
|
|
539
|
-
|
|
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");
|
|
540
613
|
const env = {
|
|
541
614
|
state: "measured",
|
|
542
615
|
measured_at: instantOrNull(updated),
|
|
@@ -550,18 +623,18 @@ async function cveSummary() {
|
|
|
550
623
|
NO_FEED_FRESHNESS,
|
|
551
624
|
],
|
|
552
625
|
};
|
|
553
|
-
const total = numAt(
|
|
554
|
-
// What summary.none (#2610), the NVD histogram and summary.rejected (#2641) count,
|
|
555
|
-
//
|
|
556
|
-
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}`;
|
|
557
630
|
if (total === undefined)
|
|
558
|
-
return succeeded(
|
|
631
|
+
return succeeded(data, `${head}${labels}`, env);
|
|
559
632
|
if (total === 0) {
|
|
560
|
-
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);
|
|
561
634
|
}
|
|
562
635
|
// A stamp that is not a real instant stays in the JSON and is not repeated as prose.
|
|
563
636
|
const stamp = realInstant(updated) ? ` (last updated ${updated})` : "";
|
|
564
|
-
return succeeded(
|
|
637
|
+
return succeeded(data, `${head} The feed holds ${total} active CVEs${stamp}.${labels}`, env);
|
|
565
638
|
}
|
|
566
639
|
catch (e) {
|
|
567
640
|
return crashed(tool, e);
|
|
@@ -572,7 +645,9 @@ async function cveSummary() {
|
|
|
572
645
|
// 133,300 characters of text. Past DATA_TEXT_BUDGET each row in the first text block keeps the
|
|
573
646
|
// fields the description promises and a few more a triage reads, the description cut to 200
|
|
574
647
|
// characters; then, for a page of 50, the fields the description promises with the description cut
|
|
575
|
-
// to 100. structuredContent.data keeps every field of every row; get_cve returns one record whole
|
|
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).
|
|
576
651
|
const SEARCH_CVES_TEXT = {
|
|
577
652
|
rows: "cves",
|
|
578
653
|
levels: [
|
|
@@ -582,7 +657,7 @@ const SEARCH_CVES_TEXT = {
|
|
|
582
657
|
},
|
|
583
658
|
{ keep: ["cve_id", "description", "severity", "cvss_v3_score", "echelongraph_score", "score_assessed", "score_unassessed_reason", "epss_score", "kev_listed", "published"], clip: 100 },
|
|
584
659
|
],
|
|
585
|
-
whole:
|
|
660
|
+
whole: `${GET_CVE_WHOLE}.`,
|
|
586
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.`,
|
|
587
662
|
};
|
|
588
663
|
async function searchCVEs(a) {
|
|
@@ -1492,8 +1567,9 @@ function readShadowAI(sa) {
|
|
|
1492
1567
|
//
|
|
1493
1568
|
// core-backend/internal/aiexposure (handler.go MCPPublicCounts, store.go): the AI-exposure
|
|
1494
1569
|
// radar's MCP rows, one per hostname, each counted once by its latest verdict on record, with
|
|
1495
|
-
// EchelonGraph's own control servers left out and counted in own_controls_excluded
|
|
1496
|
-
//
|
|
1570
|
+
// EchelonGraph's own control servers left out and counted in own_controls_excluded, and every
|
|
1571
|
+
// hostname whose owner opted out of scanning left out and counted in withheld_opted_out (#2822).
|
|
1572
|
+
// Counts, timestamps and one constant (service "mcp"); no hostname, version or server name.
|
|
1497
1573
|
// total protected + pending_readjudication + not_assessed
|
|
1498
1574
|
// protected the /mcp endpoint answered 401/403 and its RFC 9728 protected-resource
|
|
1499
1575
|
// metadata validated (prm.go); prm_via divides exactly these rows by
|
|
@@ -1506,6 +1582,12 @@ function readShadowAI(sa) {
|
|
|
1506
1582
|
// (a challenge recorded before #2312), no_http_answer, not_identified_as_mcp
|
|
1507
1583
|
// era, transport divide every counted row; a row written before #2314 recorded an era
|
|
1508
1584
|
// is not_measured (era.go says what each value rests on)
|
|
1585
|
+
// withheld_opted_out hostnames a verified scan opt-out covered when the radar last reached
|
|
1586
|
+
// them (store.go opted_out_at, #2363): not re-checked while it stands, so
|
|
1587
|
+
// their last verdict is frozen, and in no other count (#2822). The unparameterised
|
|
1588
|
+
// answer withholds the same rows under the same key. An API older than
|
|
1589
|
+
// #2822 carries no such field and counted those rows; the note says so
|
|
1590
|
+
// rather than reading the absence as 0.
|
|
1509
1591
|
// window from/to: the oldest and newest last-check time over the counted rows
|
|
1510
1592
|
// enabled, last_run_at exactly as the unparameterised answer computes them: the AI-exposure
|
|
1511
1593
|
// radar's fleet-wide liveness and last completed check, over every AI
|
|
@@ -1561,7 +1643,7 @@ const MCP_PARTITIONS = {
|
|
|
1561
1643
|
};
|
|
1562
1644
|
const MCP_PARTITION_NAMES = Object.keys(MCP_PARTITIONS);
|
|
1563
1645
|
const MCP_INSTANTS = ["last_run_at", "counted_at"];
|
|
1564
|
-
const MCP_KNOWN = new Set(["service", ...MCP_HEADLINE, ...MCP_PARTITION_NAMES, "own_controls_excluded", "window", "enabled", ...MCP_INSTANTS]);
|
|
1646
|
+
const MCP_KNOWN = new Set(["service", ...MCP_HEADLINE, ...MCP_PARTITION_NAMES, "own_controls_excluded", "withheld_opted_out", "window", "enabled", ...MCP_INSTANTS]);
|
|
1565
1647
|
// What each challenge reason code says (prm.go prmReasonCodes), for the note.
|
|
1566
1648
|
const MCP_CHALLENGE_MEANS = {
|
|
1567
1649
|
resource_mismatch: "the document's resource is absent or not identical to the server's identifier",
|
|
@@ -1629,6 +1711,12 @@ function readMCPServers(src) {
|
|
|
1629
1711
|
data.own_controls_excluded = src.own_controls_excluded;
|
|
1630
1712
|
else
|
|
1631
1713
|
unusable.push("own_controls_excluded");
|
|
1714
|
+
// An API older than #2822 carries no withheld_opted_out, and counted those rows (the note says
|
|
1715
|
+
// so); one that carries it in another shape has it left out and named.
|
|
1716
|
+
if (isWhole(src.withheld_opted_out))
|
|
1717
|
+
data.withheld_opted_out = src.withheld_opted_out;
|
|
1718
|
+
else if (src.withheld_opted_out !== undefined)
|
|
1719
|
+
unusable.push("withheld_opted_out");
|
|
1632
1720
|
// window: from and to, both real instants, from not after to. With no row counted the API has
|
|
1633
1721
|
// no window to give (null, or from and to null), and none is relayed.
|
|
1634
1722
|
const w = src.window;
|
|
@@ -1677,6 +1765,12 @@ function readMCPServers(src) {
|
|
|
1677
1765
|
if (data.own_controls_excluded !== undefined) {
|
|
1678
1766
|
sentences.push(`mcp_servers.own_controls_excluded (${n("own_controls_excluded")}) counts EchelonGraph's own control servers, which are left out of every other mcp_servers number.`);
|
|
1679
1767
|
}
|
|
1768
|
+
if (data.withheld_opted_out !== undefined) {
|
|
1769
|
+
sentences.push(`mcp_servers.withheld_opted_out (${n("withheld_opted_out")}) counts hostnames whose owner opted out of EchelonGraph's scanning: they are not checked again while the opt-out stands, so their last verdict cannot be re-checked, and they are left out of every other mcp_servers number.`);
|
|
1770
|
+
}
|
|
1771
|
+
else if (src.withheld_opted_out === undefined) {
|
|
1772
|
+
sentences.push("The answer does not say how many hostnames whose owner opted out of scanning it withheld (mcp_servers.withheld_opted_out), so its counts may include such a hostname's last verdict, which is not re-checked while the opt-out stands.");
|
|
1773
|
+
}
|
|
1680
1774
|
if (data.window) {
|
|
1681
1775
|
sentences.push(`mcp_servers.window says when the verdicts counted were last checked: the oldest at ${from} (mcp_servers.window.from) and the newest at ${to} (mcp_servers.window.to), so the counts are each hostname's latest verdict, not one sweep at one time.`);
|
|
1682
1776
|
}
|
|
@@ -1706,7 +1800,7 @@ const EXPOSURE_RADAR_DESCRIPTION = `Aggregate totals from EchelonGraph's interne
|
|
|
1706
1800
|
`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. ` +
|
|
1707
1801
|
`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. ` +
|
|
1708
1802
|
`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. ` +
|
|
1709
|
-
`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}`;
|
|
1803
|
+
`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; hostnames whose owner opted out of scanning are not checked again while the opt-out stands, so their last verdict is left out too, and mcp_servers.withheld_opted_out 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}`;
|
|
1710
1804
|
// exposure_radar's envelope. Every radar answered, but no stats answer says when the services
|
|
1711
1805
|
// or records it counts were observed: each count covers everything that radar still holds on
|
|
1712
1806
|
// record, and the answer carries only generated_at (when the API computed the totals) and
|
|
@@ -1882,8 +1976,9 @@ const CVERecord = z.looseObject({
|
|
|
1882
1976
|
});
|
|
1883
1977
|
// #2647: what cve_summary says the poller block is (summaryPollerNote). Like the #2641 strings
|
|
1884
1978
|
// beside SUMMARY_NONE_DESCRIPTION, it uses none of #2610's six words ("any", "not", "rating",
|
|
1885
|
-
// "scored", "source", "yet")
|
|
1886
|
-
|
|
1979
|
+
// "scored", "source", "yet"), #2771's sentence on a field of another JSON type among them. Declared
|
|
1980
|
+
// here, before the outputSchema that describes poller with it.
|
|
1981
|
+
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.";
|
|
1887
1982
|
// One of the NVD histogram's four labelled buckets (#2641).
|
|
1888
1983
|
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.`);
|
|
1889
1984
|
const CVE_SUMMARY_OUTPUT = envelopeSchema({
|
|
@@ -1910,16 +2005,18 @@ const CVE_SUMMARY_OUTPUT = envelopeSchema({
|
|
|
1910
2005
|
})
|
|
1911
2006
|
.optional(),
|
|
1912
2007
|
// #2647: one instance's counters, never the feed's (see summaryPollerNote).
|
|
2008
|
+
// #2771: each field's JSON type is SUMMARY_POLLER_KINDS', which summaryPollerShaped enforces
|
|
2009
|
+
// before this check, so a field of another type is left out, never a failed call.
|
|
1913
2010
|
poller: z
|
|
1914
2011
|
.looseObject({
|
|
1915
|
-
cves_ingested:
|
|
1916
|
-
cves_skipped:
|
|
1917
|
-
http_retries:
|
|
1918
|
-
interval:
|
|
1919
|
-
last_poll_at:
|
|
1920
|
-
last_poll_dur_ms:
|
|
1921
|
-
poll_count:
|
|
1922
|
-
poll_errors:
|
|
2012
|
+
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."),
|
|
2013
|
+
cves_skipped: pollerField("cves_skipped", "CVE records this instance's NVD poller skipped since the instance last started."),
|
|
2014
|
+
http_retries: pollerField("http_retries", "HTTP retries this instance's NVD poller made since the instance last started."),
|
|
2015
|
+
interval: pollerField("interval", "How often this instance's NVD poller polls."),
|
|
2016
|
+
last_poll_at: pollerField("last_poll_at", "When this instance's NVD poller last polled: never the feed's freshness."),
|
|
2017
|
+
last_poll_dur_ms: pollerField("last_poll_dur_ms", "How long that poll took, in milliseconds."),
|
|
2018
|
+
poll_count: pollerField("poll_count", "Polls this instance's NVD poller made since the instance last started."),
|
|
2019
|
+
poll_errors: pollerField("poll_errors", "Polls of this instance's NVD poller that failed since the instance last started: never the feed's reliability."),
|
|
1923
2020
|
})
|
|
1924
2021
|
.nullable()
|
|
1925
2022
|
.optional()
|
|
@@ -2028,6 +2125,7 @@ const MCPServersData = z.strictObject({
|
|
|
2028
2125
|
...Object.fromEntries(MCP_HEADLINE.map((k) => [k, Whole])),
|
|
2029
2126
|
...Object.fromEntries(MCP_PARTITION_NAMES.map((k) => [k, z.strictObject(Object.fromEntries(MCP_PARTITIONS[k].buckets.map((b) => [b, Whole]))).optional()])),
|
|
2030
2127
|
own_controls_excluded: Whole.optional(),
|
|
2128
|
+
withheld_opted_out: Whole.optional(),
|
|
2031
2129
|
window: z.strictObject({ from: Instant, to: Instant }).optional(),
|
|
2032
2130
|
enabled: z.boolean().optional(),
|
|
2033
2131
|
...Object.fromEntries(MCP_INSTANTS.map((k) => [k, Instant.optional()])),
|
|
@@ -2092,6 +2190,10 @@ export const ANNOTATIONS = { readOnlyHint: true, destructiveHint: false, idempot
|
|
|
2092
2190
|
const CVE_ID_ARG = z.string().describe("a CVE ID, e.g. CVE-2023-44487");
|
|
2093
2191
|
// What each description says about its structured result, naming only fields its schema holds.
|
|
2094
2192
|
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.";
|
|
2193
|
+
// #2771: cve_summary's own, since its data is the API's JSON less each poller field (or the whole
|
|
2194
|
+
// poller) summaryPollerShaped leaves out. Like SUMMARY_POLLER_DESCRIPTION it uses none of #2610's
|
|
2195
|
+
// six words ("any", "not", "rating", "scored", "source", "yet").
|
|
2196
|
+
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.";
|
|
2095
2197
|
// #2610: what cve_summary says summary.none is (summaryNoneNote), one constant string for the same
|
|
2096
2198
|
// reason as SCORE_ASSESSED_DESCRIPTION below.
|
|
2097
2199
|
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.";
|
|
@@ -2114,7 +2216,7 @@ export function createServer() {
|
|
|
2114
2216
|
const server = new McpServer({ name: NAME, version: VERSION }, { instructions: INSTRUCTIONS, capabilities: { tools: { listChanged: false }, prompts: { listChanged: false }, resources: { listChanged: false } } });
|
|
2115
2217
|
server.registerTool("cve_summary", {
|
|
2116
2218
|
title: "CVE feed summary",
|
|
2117
|
-
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. ${
|
|
2219
|
+
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}`,
|
|
2118
2220
|
outputSchema: CVE_SUMMARY_OUTPUT,
|
|
2119
2221
|
annotations: ANNOTATIONS,
|
|
2120
2222
|
}, async () => checked("cve_summary", CVE_SUMMARY_OUTPUT, await cveSummary()));
|
|
@@ -2135,7 +2237,7 @@ export function createServer() {
|
|
|
2135
2237
|
}, async (a) => checked("search_cves", SEARCH_CVES_OUTPUT, await searchCVEs(a)));
|
|
2136
2238
|
server.registerTool("get_cve", {
|
|
2137
2239
|
title: "CVE detail",
|
|
2138
|
-
description: `
|
|
2240
|
+
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.`,
|
|
2139
2241
|
inputSchema: z.object({ cve_id: CVE_ID_ARG }),
|
|
2140
2242
|
outputSchema: GET_CVE_OUTPUT,
|
|
2141
2243
|
annotations: ANNOTATIONS,
|
|
@@ -2157,8 +2259,10 @@ export function createServer() {
|
|
|
2157
2259
|
registerEPSSHistory(server, TOOL_KIT);
|
|
2158
2260
|
// #2716: its logic, schemas and description live in tools/check_affected.ts.
|
|
2159
2261
|
registerCheckAffected(server, { api, succeeded, failed, badInput, crashed, checked, okHead, envelopeSchema, annotations: ANNOTATIONS });
|
|
2160
|
-
// #2721: tools/check_sbom.ts, on this file's api(), envelope and failure contract.
|
|
2161
|
-
|
|
2262
|
+
// #2721: tools/check_sbom.ts, on this file's api(), envelope and failure contract. holdRequest
|
|
2263
|
+
// (#2775): on the hosted endpoint, a call keeps its request's admission and large-body slot
|
|
2264
|
+
// until it has stopped, not only until its answer, or its client, has gone.
|
|
2265
|
+
registerCheckSbom(server, { api, failed, describeFailure, badInput, crashed, checked, succeeded, okHead, envelopeSchema, annotations: ANNOTATIONS, hold: holdRequest });
|
|
2162
2266
|
registerCveIntel(server);
|
|
2163
2267
|
registerGetCwe(server);
|
|
2164
2268
|
// #2719: vendor_advisories_for_cve, get_vendor_advisory, search_vendor_advisories.
|
|
@@ -2166,7 +2270,7 @@ export function createServer() {
|
|
|
2166
2270
|
// #2722: prompts and resources, in prompts.ts and resources.ts. Both lists are fixed per
|
|
2167
2271
|
// connection, so listChanged is false above.
|
|
2168
2272
|
registerPrompts(server, { cveId: CVE_ID });
|
|
2169
|
-
registerResources(server, { api, cveId: CVE_ID, shownBase: SHOWN_BASE, getCve: async (id) => checked("get_cve", GET_CVE_OUTPUT, await getCVE(id)) });
|
|
2273
|
+
registerResources(server, { api, cveId: CVE_ID, shownBase: SHOWN_BASE, sentences, getCve: async (id) => checked("get_cve", GET_CVE_OUTPUT, await getCVE(id)) });
|
|
2170
2274
|
return server;
|
|
2171
2275
|
}
|
|
2172
2276
|
// The package identity and API settings, for the hosted HTTP entrypoint's health answer and
|
package/dist/prompts.js
CHANGED
|
@@ -2,6 +2,16 @@ import * as z from "zod";
|
|
|
2
2
|
import { MAX_COMPONENTS, MAX_PURLS, MAX_SBOM_CHARS } from "./tools/check_sbom.js";
|
|
3
3
|
export const KEV_BRIEF_DEFAULT_DAYS = 7;
|
|
4
4
|
export const KEV_BRIEF_MAX_DAYS = 365;
|
|
5
|
+
// The page the brief reads kev_recent in (#2799). The brief puts kev_vuln_name and kev_due_date on
|
|
6
|
+
// every line, and a model whose client passes only `content` reads them from the first text block,
|
|
7
|
+
// which past 30,000 characters keeps fewer fields a row (src/tools/kev_recent.ts kevText). Through
|
|
8
|
+
// 2.6.3 the brief asked for limit 200: on 2026-10-04 the hosted endpoint's text for 365 days at 200
|
|
9
|
+
// (306 matching) kept cve_id, kev_added_date, kev_vendor, kev_product and kev_ransomware a row, and
|
|
10
|
+
// neither field. A page of 50, kev_recent's default, is sent whole (27,514 characters of production's
|
|
11
|
+
// 50 newest), and its first cut level, which keeps both, fits 85 production-shaped rows in the
|
|
12
|
+
// budget, so at 50 rows three quarters longer than production's still keep them.
|
|
13
|
+
// test/prompt-fields.test.mjs runs the brief's call at this limit.
|
|
14
|
+
export const KEV_BRIEF_LIMIT = 50;
|
|
5
15
|
// The envelope rules every prompt carries, word for word.
|
|
6
16
|
export const ENVELOPE_RULES = [
|
|
7
17
|
"Before you use any figure, read that result's evidence envelope: its structuredContent (where your client shows text blocks alone, the last text block) carries state, measured_at, method, coverage, freshness and notes.",
|
|
@@ -14,22 +24,39 @@ export const ENVELOPE_RULES = [
|
|
|
14
24
|
"Do not add facts the results do not carry: where a result says nothing about a point, write that it does not say.",
|
|
15
25
|
].join(" ");
|
|
16
26
|
const text = (t) => ({ messages: [{ role: "user", content: { type: "text", text: t } }] });
|
|
27
|
+
// triage_cve reads kev_due_date from get_cve's record (#2799 review). Through 2.6.3 its step 6 read
|
|
28
|
+
// it from kev_recent with since and until set to the CVE's kev_added_date, with no limit and no
|
|
29
|
+
// paging, so on a day CISA added more than a page the CVE's row could be on no page it read: on the
|
|
30
|
+
// hosted endpoint (2.6.3, 2026-10-04) that call for 2021-11-03 answered total 287, 50 rows from
|
|
31
|
+
// CVE-2010-5326 to CVE-2019-0797 and has_more true, without CVE-2021-40444, added that day; get_cve
|
|
32
|
+
// for CVE-2021-40444 carried kev_due_date 2021-11-17T00:00:00Z in its first text block. The record
|
|
33
|
+
// carries kev_due_date wherever CISA gives one (core-backend cve/store.go, omitempty), and get_cve's
|
|
34
|
+
// first text block keeps every field of it.
|
|
35
|
+
//
|
|
36
|
+
// Its Patch line (#2817): cve_intel's fixed_version is one value per (CVE, ecosystem, package), the
|
|
37
|
+
// advisory's last range's (core-backend cve/advisory.go UpsertAffectedPackages), and triage_cve has
|
|
38
|
+
// no installed version to hold it against: for CVE-2021-44228 production's cve_intel gives
|
|
39
|
+
// log4j-core 2.12.2 (2026-10-04, 2.6.3), below an affected 2.14.1. Since #2817 each
|
|
40
|
+
// affected_packages row also carries fixed_branches, every range with its fix (core-backend
|
|
41
|
+
// cve/fixed_branches.go), so the line gives those, range by range, and gives fixed_version only
|
|
42
|
+
// where a row has no fixed_branches, as what cve_intel records for the package, never as the fix
|
|
43
|
+
// for every affected branch, sending the reader to get_cve's references tagged Vendor Advisory or
|
|
44
|
+
// Patch for a branch's fix.
|
|
17
45
|
export function triageCveText(cve) {
|
|
18
46
|
return [
|
|
19
47
|
`Triage ${cve} using the EchelonGraph MCP tools. Call them in this order, each with cve_id ${cve}:`,
|
|
20
|
-
"1. get_cve: the record. Read score_assessed before echelongraph_score (false: not yet scored, which is not a score of 0); read severity, cvss_v3_score, epss_score and epss_percentile, and the CISA-KEV fields kev_listed, kev_added_date and kev_ransomware.",
|
|
21
|
-
"2. cve_intel: weaknesses (cwes), public exploit code (exploits and exploits_total; verified_status is the label stored with each reference, not a guarantee that it works, and an empty list is not evidence that no exploit exists), affected_packages and fixed_versions.",
|
|
48
|
+
"1. get_cve: the record. Read score_assessed before echelongraph_score (false: not yet scored, which is not a score of 0); read severity, cvss_v3_score, epss_score and epss_percentile, and the CISA-KEV fields kev_listed, kev_added_date, kev_due_date and kev_ransomware.",
|
|
49
|
+
"2. cve_intel: weaknesses (cwes), public exploit code (exploits and exploits_total; verified_status is the label stored with each reference, not a guarantee that it works, and an empty list is not evidence that no exploit exists), affected_packages (each package's affected ranges with their fixes in fixed_branches) and fixed_versions.",
|
|
22
50
|
"3. vendor_advisories_for_cve: the vendor advisories that name it. Call get_vendor_advisory for one whose remediation you need in full.",
|
|
23
51
|
"4. epss_history: how its EPSS score changed, one point per recorded change (series_kind change_only): before series_starts_at a missing point means not recorded, not unchanged.",
|
|
24
52
|
"5. cve_exposure: internet exposure on record. exposure_state says what the count is; exposed_hosts counts distinct ip:port services, not machines; the result is not_assessed with measured_at null, so present any count as undated and on record, and exposure_state not_assessed as not assessed.",
|
|
25
|
-
`6. If get_cve says kev_listed is true: kev_recent with since and until both set to the date part (YYYY-MM-DD) of its kev_added_date (for 2024-04-12T00:00:00Z, 2024-04-12), to read kev_due_date from the row for ${cve}.`,
|
|
26
53
|
ENVELOPE_RULES,
|
|
27
54
|
"Then answer with this template, one line each, naming the tool and the measured_at behind every line:",
|
|
28
55
|
"- Exploited? CISA-KEV listing and its date, known ransomware use, and public exploit code on record.",
|
|
29
56
|
"- Likelihood: EPSS score and percentile, and how it has moved.",
|
|
30
57
|
"- Reachable? exposure_state and the count on record, or not assessed.",
|
|
31
|
-
|
|
32
|
-
"- Deadline: kev_due_date when the CVE is KEV-listed (CISA's due date for US federal civilian agencies); otherwise write that the results give no CISA deadline.",
|
|
58
|
+
`- Patch: fixed versions and vendor advisories; where the results hold none, write "none on record", which is not a finding that no fix exists. For each affected package whose cve_intel row carries fixed_branches, give every range with its fix: from introduced ("0" is the first version) up to its fixed, or up to and including last_affected, where no fix is on record for that range; never one range's fix as the fix for every affected branch or the version every install should move to. cve_intel keeps one fixed_version per ecosystem and package, its advisory's last range's as a rule, which need not be the fix for every affected version of the package: where a row has no fixed_branches (null or absent), give fixed_version as the fixed version cve_intel records for that package, never as the fix for every affected branch, and for the fix on a given branch point to get_cve's references tagged Vendor Advisory or Patch, where it has them, and to the vendor advisories.`,
|
|
59
|
+
"- Deadline: get_cve's kev_due_date when the CVE is KEV-listed (CISA's due date for US federal civilian agencies); otherwise write that the results give no CISA deadline.",
|
|
33
60
|
"- Decision: patch now, schedule, or monitor, with a one-line reason drawn from the lines above.",
|
|
34
61
|
].join("\n");
|
|
35
62
|
}
|
|
@@ -37,7 +64,7 @@ export function kevWeeklyBriefText(days) {
|
|
|
37
64
|
return [
|
|
38
65
|
`Write a brief of the CVEs CISA added to its Known Exploited Vulnerabilities (KEV) catalog in the last ${days} days, using the EchelonGraph MCP tools.`,
|
|
39
66
|
`1. Compute since: today's date in UTC minus ${days} days, as YYYY-MM-DD. State the since date you used, and today's date.`,
|
|
40
|
-
|
|
67
|
+
`2. Call kev_recent with since set to that date and limit ${KEV_BRIEF_LIMIT}. While its coverage.has_more is true, call kev_recent again with the same since and limit and cursor set to the previous answer's next_cursor.`,
|
|
41
68
|
ENVELOPE_RULES,
|
|
42
69
|
"Then write the brief:",
|
|
43
70
|
"- Header: the date range, the number of CVEs added (total), and measured_at, EchelonGraph's last successful fetch of CISA's feed, with catalog_version.",
|
|
@@ -61,15 +88,49 @@ export function amIAffectedText(a) {
|
|
|
61
88
|
"Where capped, candidates_capped or degraded is true, say the list may be incomplete and why.",
|
|
62
89
|
].join("\n");
|
|
63
90
|
}
|
|
91
|
+
// sbom_review (#2799). The purls a call did not send: data.not_sent_purls lists them, and where the
|
|
92
|
+
// first text block cuts that list to its first entries (check_sbom's TEXT CUT says so), the model
|
|
93
|
+
// rebuilds them from the document, from the position check_sbom's note gives, in the order it
|
|
94
|
+
// gives (check_sbom.ts READ_ORDER).
|
|
95
|
+
//
|
|
96
|
+
// Fixed versions (#2799 review, #2817, #2830). cve_intel's fixed_version is one value per (CVE, ecosystem,
|
|
97
|
+
// package), the advisory's last range's (core-backend cve/advisory.go UpsertAffectedPackages), so
|
|
98
|
+
// it need not be the fix for the installed version's branch: for CVE-2021-44228, production's
|
|
99
|
+
// cve_intel gives log4j-core 2.12.2 (2026-10-04, 2.6.3), older than an installed 2.14.1, and
|
|
100
|
+
// check_sbom's matches for 2.14.1 say it "is listed verbatim in the advisory's affected versions",
|
|
101
|
+
// which names no fix. So a fixed version is given only where it is strictly greater than the
|
|
102
|
+
// installed version, and otherwise the line says the results give none for the component's branch
|
|
103
|
+
// and points to the advisory. The advisory interval in a match's match_reason (core-backend
|
|
104
|
+
// cve/pkgmatch.go renderInterval) is preferred where the text keeps it (its first cut level;
|
|
105
|
+
// a Juice Shop SBOM of 50 components is cut past it). Since #2830 its closing bracket carries the
|
|
106
|
+
// bound's meaning: "[A, B)" is a fixed bound, B the first version not affected, so B is given as
|
|
107
|
+
// the fix; "[A, B] (B is the last affected version, not a fix)" is an OSV last_affected bound,
|
|
108
|
+
// B still affected, so B (or any version at or below it) is never given as a fix; "[A, ∞)" has no
|
|
109
|
+
// end on record; a text clipped before the closing bracket ("[A, B…") gives no fix from it.
|
|
110
|
+
// Through 2.6.5 core-backend rendered a last_affected bound as "[A, B)" too, and the prompt
|
|
111
|
+
// hedged B as "the end of that interval". Since #2817 cve_intel's affected_packages rows carry
|
|
112
|
+
// fixed_branches, every range with its fix, so after the match_reason interval the rule reads the
|
|
113
|
+
// range that holds the installed version (2.14.1: [2.13.0, 2.15.0), so 2.15.0), and falls back to
|
|
114
|
+
// fixed_version only for a row without fixed_branches.
|
|
115
|
+
// A cut text keeps index, purl, verdict, not_assessed_reason and cve_ids on a row, not its
|
|
116
|
+
// ecosystem, package or version, so the model reads those from the purl, as core-backend's
|
|
117
|
+
// matcher does (cve/matchbatch.go purlNameVersion, shared/pkg/purlosv): step 2 lists every type
|
|
118
|
+
// purlosv maps (its unversioned table, and deb, apk, alpine and rpm by the distro qualifier, of
|
|
119
|
+
// the three kinds osvDistro reads, Alpine cut to major.minor and Debian to its major), the types
|
|
120
|
+
// purlNameVersion names with their namespace, and says any other is unknown, with no fixed version.
|
|
121
|
+
// test/prompt-fields.test.mjs fromPurl holds the same tables.
|
|
64
122
|
export function sbomReviewText(sbom) {
|
|
65
123
|
return [
|
|
66
124
|
"Review the SBOM below for known vulnerabilities, using the EchelonGraph MCP tools.",
|
|
67
|
-
`1. Call check_sbom with sbom set to the document below, as given. It reads the document's purls and checks up to ${MAX_PURLS} distinct purls per call, sending them in batches of ${MAX_COMPONENTS}; a document with more is refused, not truncated. If it is refused, say why from the result and do not guess at the components. If coverage.not_sent is above 0, the API's rate limit or the call's time budget stopped it early: say how many purls were not sent and coverage.not_sent_reason,
|
|
68
|
-
"2.
|
|
125
|
+
`1. Call check_sbom with sbom set to the document below, as given. It reads the document's purls and checks up to ${MAX_PURLS} distinct purls per call, sending them in batches of ${MAX_COMPONENTS}; a document with more is refused, not truncated. If it is refused, say why from the result and do not guess at the components. If coverage.not_sent is above 0, the API's rate limit or the call's time budget stopped it early: say how many purls were not sent and coverage.not_sent_reason, and that they are not checked (never clean). After a minute, call check_sbom once more with purls set to the purls not sent, and merge what it returns. data.not_sent_purls lists them; where the note says the first text block cuts not_sent_purls to its first entries, rebuild the list from the document instead, as the note says: the document's distinct purls from the position the note gives on, counted in the order it gives.`,
|
|
126
|
+
"2. Read each component's ecosystem, package and version from its purl, which every row of data.results in the text carries (the leanest cut of the text leaves out the row's own ecosystem, package and version), as check_sbom maps them. A purl is pkg:type/namespace/name@version?qualifiers, each part percent-decoded (%40 is @), its type and distro qualifier in any case. The version is what follows the last @, up to any ? or #. The ecosystem follows from the type: npm is npm, pypi PyPI, maven Maven, golang Go, cargo crates.io, gem RubyGems, nuget NuGet, composer Packagist, hex Hex, pub Pub, swift SwiftURL, hackage Hackage, cran CRAN, bitnami Bitnami, conan ConanCenter and githubact GitHub Actions. For deb, apk, alpine and rpm it is the release the distro qualifier names, which must be one of these three kinds: distro=debian-N is Debian:N, the major release alone (debian-12 and debian-12.5 are Debian:12); distro=ubuntu-V is Ubuntu:V as given (ubuntu-22.04 is Ubuntu:22.04); distro=alpine-X.Y is Alpine:vX.Y, cut to major.minor (alpine-3.20 and alpine-3.20.10 are Alpine:v3.20; alpine-3 names none). For any other type, and a deb, apk, alpine or rpm purl without such a qualifier, the ecosystem is unknown: check_sbom does not check the component (not_assessed, never clean), and the fix list gives no version for it. The package is namespace:name for maven (pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1 is the package org.apache.logging.log4j:log4j-core in Maven, at version 2.14.1), namespace/name for npm, golang, composer, swift and githubact, and the name alone for every other type (pkg:deb/debian/openssl@3.0.15-1~deb12u1?distro=debian-12 is openssl in Debian:12).",
|
|
127
|
+
"3. For each distinct cve_id on a component whose verdict is affected, call get_cve, to read kev_listed, epss_score, score_assessed, echelongraph_score and cvss_v3_score, and cve_intel, to read fixed_branches and fixed_version from its affected_packages rows, and fixed_version from its fixed_versions rows, for the component's ecosystem and package (package_name). If there are more than 30 distinct CVEs, do this for 30 of them and list the rest as not looked up, unranked.",
|
|
69
128
|
ENVELOPE_RULES,
|
|
70
129
|
"Read each component's verdict: not_affected is the one clean verdict; undetermined and not_assessed are not clean, and not_assessed_reason says why (for example distro_release_unknown: a deb, apk or rpm purl without a distro qualifier, which EchelonGraph does not guess).",
|
|
71
130
|
"Then write a prioritised fix list of the affected components. State the ordering keys, in this order: 1. CISA-KEV listed (kev_listed true) first; 2. then EPSS score, highest first; 3. then echelongraph_score where score_assessed is true, else cvss_v3_score, highest first. A CVE with no value for a key sorts after those with one, and its line says which key was missing.",
|
|
72
|
-
"For each line give the component (purl), its cve_ids, the key values used, and fixed
|
|
131
|
+
"For each line give the component (purl), its cve_ids, the key values used, and for each CVE a fixed version where the results support one, by this rule. Never give a fixed version that is not strictly greater than the component's installed version (the version in its purl), compared in the ecosystem's version order: release numbers part by part, as numbers, not as text (2.9.1 is below 2.12.2, and 2.12.2 is below 2.14.1); where you are not sure of the order (pre-releases, qualifiers and distro revisions have their own rules), say so and do not give it. Prefer the match's match_reason where the text keeps it: \"falls inside the advisory interval [A, B)\", closed by a parenthesis, says B is the advisory's fixed version for the range that holds the installed version, so give B as the fixed version, quoting it (\"[A, ∞)\": the range has no end on record and names no fixed version). \"falls inside the advisory interval [A, B]\", closed by a square bracket, says B is the range's last affected version, still affected, and the range records no fix (core-backend adds \"(B is the last affected version, not a fix)\", which a cut text can clip, so the bracket alone decides): never give B, or any version at or below it, as a fixed version. Where the text is cut before the interval's closing bracket (\"[A, B…\", neither \")\" nor \"]\"), it does not say which bound B is: give no fixed version from that match_reason. Otherwise, where cve_intel's affected_packages row for the component's ecosystem and package carries fixed_branches, give the fixed of the range that holds the installed version: at or above its introduced (\"0\" is the first version) and below its fixed, or at or below its last_affected, in the same version order; a range with last_affected and no fixed has no fix on record, and a version in no range is given no fixed version from them; where fixed_branches is there, fixed_version is not used. Where it is not (null or absent), give cve_intel's fixed_version for the component's ecosystem and package (or the recorded fix a match_reason names, the same value): cve_intel keeps one fixed_version per package, its advisory's last range's, which need not be the fix on the component's branch, so say so beside it. Where no version meets the rule (\"listed verbatim in the advisory's affected versions\" names none), write that the results give no fixed version for the component's branch, which is not a finding that no fix exists, and point to the CVE's advisory for it: get_cve's references tagged Vendor Advisory or Patch, where it has them.",
|
|
132
|
+
"After the list, give data.summary's counts, and list the undetermined and not_assessed components with their reasons as not checked, never as clean.",
|
|
133
|
+
"Where the note says that rows of data.results are left out of the first text block, list the components its rows carry, say how many affected components the text leaves out (data.summary.affected, less those listed), and give the undetermined and not_assessed components it leaves out as data.summary's counts, by not_assessed_reason, as not checked, never as clean.",
|
|
73
134
|
"",
|
|
74
135
|
"SBOM:",
|
|
75
136
|
"```json",
|
|
@@ -80,7 +141,7 @@ export function sbomReviewText(sbom) {
|
|
|
80
141
|
export function registerPrompts(server, kit) {
|
|
81
142
|
server.registerPrompt("triage_cve", {
|
|
82
143
|
title: "Triage a CVE",
|
|
83
|
-
description: "Triage one CVE: get_cve
|
|
144
|
+
description: "Triage one CVE: get_cve (with CISA's due date when the CVE is KEV-listed), cve_intel, vendor_advisories_for_cve, epss_history and cve_exposure, then a decision template: exploited, likelihood, reachable, patch, deadline. Each line cites its tool and measured_at, and not_assessed is never reported as clean.",
|
|
84
145
|
argsSchema: z.object({
|
|
85
146
|
cve_id: z
|
|
86
147
|
.string()
|
|
@@ -125,7 +186,7 @@ export function registerPrompts(server, kit) {
|
|
|
125
186
|
}, (a) => text(amIAffectedText(a)));
|
|
126
187
|
server.registerPrompt("sbom_review", {
|
|
127
188
|
title: "SBOM review",
|
|
128
|
-
description: `Review an SBOM (CycloneDX JSON or SPDX JSON text, up to ${MAX_SBOM_CHARS} characters): it is passed to check_sbom, which checks up to ${MAX_PURLS} distinct purls per call in batches of ${MAX_COMPONENTS}, then get_cve for each affected CVE, and a prioritised fix list ordered by CISA-KEV listing, then EPSS, then score, each key stated. undetermined and not_assessed components are listed as not checked, never as clean.`,
|
|
189
|
+
description: `Review an SBOM (CycloneDX JSON or SPDX JSON text, up to ${MAX_SBOM_CHARS} characters): it is passed to check_sbom, which checks up to ${MAX_PURLS} distinct purls per call in batches of ${MAX_COMPONENTS}, then get_cve and cve_intel for each affected CVE, and a prioritised fix list ordered by CISA-KEV listing, then EPSS, then score, each key stated. undetermined and not_assessed components are listed as not checked, never as clean.`,
|
|
129
190
|
argsSchema: z.object({
|
|
130
191
|
sbom: z
|
|
131
192
|
.string()
|