echelongraph-mcp 3.0.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +695 -2
- package/README.md +85 -74
- package/dist/fixStatus.js +4 -2
- package/dist/http.js +73 -81
- package/dist/httpPolicy.js +213 -11
- package/dist/index.js +516 -52
- package/dist/logLines.js +86 -0
- package/dist/prompts.js +126 -21
- package/dist/readBody.js +140 -0
- package/dist/requiredText.js +38 -3
- package/dist/resources.js +3 -3
- package/dist/runtime.js +66 -1
- package/dist/schemaRefusal.js +138 -0
- package/dist/textBudget.js +1082 -78
- package/dist/tools/check_affected.js +23 -7
- package/dist/tools/check_sbom.js +287 -8
- package/dist/tools/cve_intel.js +163 -8
- package/dist/tools/cve_remediation.js +174 -18
- package/dist/tools/epss_history.js +45 -6
- package/dist/tools/kev_recent.js +4 -1
- package/dist/tools/match_batch.js +78 -15
- package/dist/tools/packageRecords.js +58 -0
- package/dist/tools/scan_manifest.js +26 -7
- package/dist/tools/vendor_advisories.js +40 -8
- package/docs/exposure-radar.md +4 -0
- package/docs/rate-limits.md +6 -3
- package/docs/structured-results.md +15 -3
- package/docs/tools.md +149 -12
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/index.js
CHANGED
|
@@ -67,12 +67,13 @@ import { registerCveIntel } from "./tools/cve_intel.js";
|
|
|
67
67
|
import { registerCveRemediation } from "./tools/cve_remediation.js";
|
|
68
68
|
import { fixStatusSchema } from "./fixStatus.js";
|
|
69
69
|
import { registerGetCwe } from "./tools/get_cwe.js";
|
|
70
|
-
import { holdRequest, isHttpEntrypoint, requestSignal, upstreamHeaders, upstreamUserAgent } from "./runtime.js";
|
|
70
|
+
import { holdRequest, isHttpEntrypoint, networkFailureClass, requestSignal, upstreamFailed, upstreamHeaders, upstreamUserAgent } from "./runtime.js";
|
|
71
71
|
import { registerVendorAdvisoryTools } from "./tools/vendor_advisories.js";
|
|
72
72
|
import { registerPrompts } from "./prompts.js";
|
|
73
73
|
import { registerResources } from "./resources.js";
|
|
74
|
+
import { withSchemaRefusal } from "./schemaRefusal.js";
|
|
74
75
|
import { answering, answeringNow, cutData, dataText, FIRST_TEXT_BLOCK_BESIDE_A_CUT, FULL_ARG, GET_CVE_WHOLE, NO_DATA_CUT, STRUCTURED_BUDGET, TEXT_BUDGET_DESCRIPTION, wholePage, } from "./textBudget.js";
|
|
75
|
-
import { SHODAN_ATTRIBUTION, SHODAN_OWNERSHIP } from "./requiredText.js";
|
|
76
|
+
import { KEV_CATALOG_FLAG, KEV_LISTED_DEPRECATED, KEV_OBSERVATION_FLAG, PATCH_AVAILABLE_WITHDRAWN, SHODAN_ATTRIBUTION, SHODAN_OWNERSHIP } from "./requiredText.js";
|
|
76
77
|
// The package actually running, read from the package.json that ships beside dist/. The MCP
|
|
77
78
|
// handshake (serverInfo) and the User-Agent both carry its name and version, so the API's
|
|
78
79
|
// access log can count adoption per version without a code change per release.
|
|
@@ -192,6 +193,8 @@ function retryAfterSeconds(v) {
|
|
|
192
193
|
// The request is cancelled when init.signal aborts (check_sbom's call was cancelled) or, on the
|
|
193
194
|
// hosted endpoint, when the client it answers has gone (runtime.ts requestSignal, #2775): a
|
|
194
195
|
// network failure saying so, which nobody reads, since the SDK sends nothing for a cancelled call.
|
|
196
|
+
// Every other failure is reported (runtime.ts upstreamFailed, #2948): on the hosted endpoint, one
|
|
197
|
+
// mcp_upstream_failed line; on stdio, nothing.
|
|
195
198
|
export async function api(path, init) {
|
|
196
199
|
const limitMs = init?.timeoutMs === undefined ? TIMEOUT_MS : Math.max(0, Math.min(TIMEOUT_MS, Math.floor(init.timeoutMs)));
|
|
197
200
|
const m = init?.method && init.method.toUpperCase() !== "GET" ? { method: init.method.toUpperCase() } : {};
|
|
@@ -199,6 +202,11 @@ export async function api(path, init) {
|
|
|
199
202
|
const cancelledFailure = () => ({ ok: false, kind: "network", path, ...m, detail: "cancelled: the call was abandoned before the API answered" });
|
|
200
203
|
if (callers.some((s) => s.aborted))
|
|
201
204
|
return cancelledFailure();
|
|
205
|
+
const t0 = Date.now();
|
|
206
|
+
const reported = (f, failureClass) => {
|
|
207
|
+
upstreamFailed({ failureClass, path, status: f.status ?? null, afterMs: Date.now() - t0 });
|
|
208
|
+
return f;
|
|
209
|
+
};
|
|
202
210
|
const ctrl = new AbortController();
|
|
203
211
|
let timedOut = false;
|
|
204
212
|
let cancelled = false;
|
|
@@ -213,7 +221,7 @@ export async function api(path, init) {
|
|
|
213
221
|
for (const s of callers)
|
|
214
222
|
s.addEventListener("abort", cancel, { once: true });
|
|
215
223
|
const own = limitMs === TIMEOUT_MS ? {} : { timeoutMs: limitMs };
|
|
216
|
-
const timeout = () => ({ ok: false, kind: "timeout", path, ...m, ...own, detail: `no response within ${limitMs} ms` });
|
|
224
|
+
const timeout = () => reported({ ok: false, kind: "timeout", path, ...m, ...own, detail: `no response within ${limitMs} ms` }, "timeout");
|
|
217
225
|
try {
|
|
218
226
|
let res;
|
|
219
227
|
try {
|
|
@@ -228,18 +236,22 @@ export async function api(path, init) {
|
|
|
228
236
|
});
|
|
229
237
|
}
|
|
230
238
|
catch (e) {
|
|
231
|
-
return timedOut ? timeout() : cancelled ? cancelledFailure() : { ok: false, kind: "network", path, ...m, detail: describeError(e) };
|
|
239
|
+
return timedOut ? timeout() : cancelled ? cancelledFailure() : reported({ ok: false, kind: "network", path, ...m, detail: describeError(e) }, networkFailureClass(e));
|
|
232
240
|
}
|
|
233
241
|
let body;
|
|
234
242
|
try {
|
|
235
243
|
body = await res.text();
|
|
236
244
|
}
|
|
237
245
|
catch (e) {
|
|
238
|
-
return timedOut
|
|
246
|
+
return timedOut
|
|
247
|
+
? timeout()
|
|
248
|
+
: cancelled
|
|
249
|
+
? cancelledFailure()
|
|
250
|
+
: reported({ ok: false, kind: "network", path, ...m, detail: `reading the body failed: ${describeError(e)}` }, networkFailureClass(e));
|
|
239
251
|
}
|
|
240
252
|
if (!res.ok) {
|
|
241
253
|
const retryAfter = retryAfterSeconds(res.headers.get("retry-after"));
|
|
242
|
-
return { ok: false, kind: "http", path, ...m, status: res.status, ...(retryAfter === undefined ? {} : { retryAfter }), detail: apiMessage(body) };
|
|
254
|
+
return reported({ ok: false, kind: "http", path, ...m, status: res.status, ...(retryAfter === undefined ? {} : { retryAfter }), detail: apiMessage(body) }, "http");
|
|
243
255
|
}
|
|
244
256
|
let data;
|
|
245
257
|
try {
|
|
@@ -247,10 +259,10 @@ export async function api(path, init) {
|
|
|
247
259
|
}
|
|
248
260
|
catch {
|
|
249
261
|
const ctype = res.headers.get("content-type") ?? "no content-type";
|
|
250
|
-
return { ok: false, kind: "not_json", path, ...m, status: res.status, detail: `${ctype}; body starts: ${snippet(body)}` };
|
|
262
|
+
return reported({ ok: false, kind: "not_json", path, ...m, status: res.status, detail: `${ctype}; body starts: ${snippet(body)}` }, "not_json");
|
|
251
263
|
}
|
|
252
264
|
if (data === null || typeof data !== "object") {
|
|
253
|
-
return { ok: false, kind: "not_object", path, ...m, status: res.status, detail: `body was ${snippet(body)}` };
|
|
265
|
+
return reported({ ok: false, kind: "not_object", path, ...m, status: res.status, detail: `body was ${snippet(body)}` }, "not_object");
|
|
254
266
|
}
|
|
255
267
|
return { ok: true, status: res.status, data };
|
|
256
268
|
}
|
|
@@ -331,9 +343,21 @@ const envelopeText = (structured, said, own) => {
|
|
|
331
343
|
// describeFailure already quotes (a redacted cause, the API's own message). Every note is a
|
|
332
344
|
// sentence of the message, so the text envelope carries none.
|
|
333
345
|
export const failure = (state, message, error, coverage = null) => {
|
|
346
|
+
reportUnusable(error);
|
|
347
|
+
return failureResult(state, message, error, coverage);
|
|
348
|
+
};
|
|
349
|
+
const failureResult = (state, message, error, coverage) => {
|
|
334
350
|
const structuredContent = { state, measured_at: null, method: null, coverage, freshness: null, notes: sentences(message), error };
|
|
335
351
|
return { content: [text(message), envelopeText(structuredContent, message, [])], structuredContent, isError: true };
|
|
336
352
|
};
|
|
353
|
+
// #2948: an answer the API sent that the tool could not use (a 2xx whose fields are not what it
|
|
354
|
+
// reads: a schema mismatch, exposure_radar's mcp_servers counts, cve_intel's sections) is an
|
|
355
|
+
// upstream failure too, reported here once per answer; api() reported every other kind itself.
|
|
356
|
+
function reportUnusable(error) {
|
|
357
|
+
const unusable = error.kind === "radars" ? (error.radars ?? []).filter((r) => r.kind === "unexpected_shape") : error.kind === "unexpected_shape" ? [error] : [];
|
|
358
|
+
for (const u of unusable)
|
|
359
|
+
upstreamFailed({ failureClass: "unexpected_shape", path: u.path, status: u.status, afterMs: null });
|
|
360
|
+
}
|
|
337
361
|
const errorOf = (f) => ({ kind: f.kind, path: f.path, status: f.status ?? null, message: f.detail });
|
|
338
362
|
export const failed = (tool, f) => {
|
|
339
363
|
if (f.kind === "http" && f.status === 400) {
|
|
@@ -363,18 +387,23 @@ function unexpectedShape(tool, error) {
|
|
|
363
387
|
.map((i) => `${i.path.map(String).join(".") || "(root)"}: ${i.message}`)
|
|
364
388
|
.join("; ");
|
|
365
389
|
const detail = `the answer did not match this tool's output schema (${issues})`;
|
|
366
|
-
|
|
390
|
+
// #2948's review: this server's own schema refused what it built, which is no upstream failure: reported as
|
|
391
|
+
// output_schema (http.ts logs it at ERROR), not as unexpected_shape. The client's error kind is unchanged.
|
|
392
|
+
upstreamFailed({ failureClass: "output_schema", path: null, status: null, afterMs: null });
|
|
393
|
+
return failureResult("failed", `${tool} FAILED: EchelonGraph at ${SHOWN_BASE} answered, but ${detail}, so it is not relayed. ${NOT_A_FINDING}`, {
|
|
367
394
|
kind: "unexpected_shape",
|
|
368
395
|
path: null,
|
|
369
396
|
status: null,
|
|
370
397
|
message: detail,
|
|
371
|
-
});
|
|
398
|
+
}, null);
|
|
372
399
|
}
|
|
373
400
|
// Every success is checked against the tool's own outputSchema before it is returned.
|
|
374
401
|
export const checked = (tool, schema, r) => {
|
|
375
402
|
if (r.isError)
|
|
376
403
|
return r;
|
|
377
|
-
|
|
404
|
+
// #3121: the published schema's success branch is open; this server's own answers are held to
|
|
405
|
+
// the closed one.
|
|
406
|
+
const p = (STRICT.get(schema) ?? schema).safeParse(r.structuredContent);
|
|
378
407
|
return p.success ? r : unexpectedShape(tool, p.error);
|
|
379
408
|
};
|
|
380
409
|
// A success: content[0] is the data (cut to DATA_TEXT_BUDGET when it is larger, #2783), content[1]
|
|
@@ -439,13 +468,18 @@ export const realInstant = (s) => s !== undefined && Date.parse(s) > 0;
|
|
|
439
468
|
// measured_at is the time the answer itself gives for its records, where it gives one:
|
|
440
469
|
// summary.last_updated (MAX(modified) over the active records, core-backend cve/store.go
|
|
441
470
|
// Summary) for cve_summary, the record's updated_at (when EchelonGraph last wrote it) for
|
|
442
|
-
// get_cve, and none for a search page, whose rows each carry their own.
|
|
443
|
-
//
|
|
444
|
-
//
|
|
445
|
-
//
|
|
446
|
-
//
|
|
471
|
+
// get_cve, and none for a search page, whose rows each carry their own. freshness is the
|
|
472
|
+
// summary's nvd_poll block, the FLEET's last completed NVD poll, where the answer carries a usable
|
|
473
|
+
// one (nvdPollFreshness, #3276); a search page and a record carry no poll-completion time, so
|
|
474
|
+
// theirs is null. The summary's poller.last_poll_at is the answering instance's own NVD poller
|
|
475
|
+
// (cve/poller.go Stats, in memory), and is relayed in data but never presented as the feed's
|
|
476
|
+
// freshness; the note says what the whole poller block is (summaryPollerNote, #2647).
|
|
447
477
|
const CVE_FEED_METHOD = "EchelonGraph's CVE Pulse feed: CVE records compiled from NVD, MITRE-CNA pre-NVD records, CISA-KEV, EPSS and GitHub GHSA, each polled from its source on a schedule.";
|
|
448
|
-
|
|
478
|
+
// #3276: search_cves' and get_cve's, whose answers carry no poll-completion time. Through 3.0.2 it
|
|
479
|
+
// said "the CVE feed's answer" carries none, read as the feed's, while cve_summary's answer carries
|
|
480
|
+
// the fleet's last completed NVD poll; it now says where that is. Within 3 characters of the old
|
|
481
|
+
// sentence's length, so get_cve's structured cut, at its 30,000-character budget, is unchanged.
|
|
482
|
+
const NO_FEED_FRESHNESS = "freshness is null: this answer carries no poll-completion time; the CVE feed summary gives the fleet's last completed NVD poll.";
|
|
449
483
|
function scoreStateOf(rec) {
|
|
450
484
|
const v = boolAt(rec, "score_assessed");
|
|
451
485
|
return v === true ? "assessed" : v === false ? "not_assessed" : "unstated";
|
|
@@ -632,6 +666,96 @@ function summaryPollerShaped(d) {
|
|
|
632
666
|
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}`,
|
|
633
667
|
};
|
|
634
668
|
}
|
|
669
|
+
// ── nvd_poll, the fleet's last completed NVD poll: cve_summary's freshness (#3276) ──
|
|
670
|
+
//
|
|
671
|
+
// Since #3242 (core-backend cve/handler.go nvdPollBlock) the summary answer carries nvd_poll beside
|
|
672
|
+
// poller: last_completed_at, when the FLEET last completed an NVD poll (the durable
|
|
673
|
+
// KeyNVDWindowCompleted row, the end of the last window fetched and stored without an error), in
|
|
674
|
+
// RFC 3339; scope "fleet"; and interval, the poller's configured interval as Go's Duration.String
|
|
675
|
+
// ("20m0s"), left out where the answering instance runs no poller. The API leaves the whole block
|
|
676
|
+
// out when that time is unknown (none on record, or the read failed), and never puts the answering
|
|
677
|
+
// instance's own counters in its place. Through 3.0.2 freshness was null and the note said the feed
|
|
678
|
+
// served no poll-completion time, while the same answer's data carried one (production,
|
|
679
|
+
// 2026-10-17T22:48Z: nvd_poll.last_completed_at 2026-10-17T22:45:09Z, scope fleet, interval 20m0s).
|
|
680
|
+
//
|
|
681
|
+
// So freshness.nvd_poll is that block, read as exposure_radar reads a radar's last_run_at: the time
|
|
682
|
+
// as sent, relayed only when it is an RFC 3339 instant on a calendar date that exists, after the Go
|
|
683
|
+
// zero time, and at most NVD_POLL_SKEW_MS after this server's clock (a later one is a clock or a
|
|
684
|
+
// bug, never a poll that has completed), on a block whose scope is fleet; interval as sent where it
|
|
685
|
+
// is a Go duration, else null. Anything else leaves freshness null, and the envelope's own note
|
|
686
|
+
// says why: the answer carries no nvd_poll block (an older API, or one that could not read the
|
|
687
|
+
// time), or the block it carries gives no such time. Nothing is filled in, and data relays the
|
|
688
|
+
// block as sent either way.
|
|
689
|
+
const NVD_POLL_SKEW_MS = 5 * 60_000;
|
|
690
|
+
const RFC3339_INSTANT = /^(\d{4})-(\d{2})-(\d{2})T([01]\d|2[0-3]):[0-5]\d:[0-5]\d(\.\d{1,9})?(Z|[+-]([01]\d|2[0-3]):[0-5]\d)$/;
|
|
691
|
+
const GO_DURATION = /^(\d+(\.\d+)?(ns|us|µs|ms|s|m|h))+$/;
|
|
692
|
+
// A Go duration's length in milliseconds ("20m0s" is 1,200,000), or undefined when it is not one.
|
|
693
|
+
const GO_UNIT_MS = { ns: 1e-6, us: 1e-3, "µs": 1e-3, ms: 1, s: 1_000, m: 60_000, h: 3_600_000 };
|
|
694
|
+
function goDurationMs(s) {
|
|
695
|
+
if (!GO_DURATION.test(s))
|
|
696
|
+
return undefined;
|
|
697
|
+
let ms = 0;
|
|
698
|
+
for (const [, n, , unit] of s.matchAll(/(\d+(\.\d+)?)(ns|us|µs|ms|s|m|h)/g))
|
|
699
|
+
ms += Number(n) * GO_UNIT_MS[unit];
|
|
700
|
+
return ms;
|
|
701
|
+
}
|
|
702
|
+
// A last completed poll older than this many intervals (or than STALE_POLL_FALLBACK_MS when the
|
|
703
|
+
// answer gives no usable interval) is said to be old: the CVE counts may lag NVD by that much.
|
|
704
|
+
const STALE_POLL_INTERVALS = 3;
|
|
705
|
+
const STALE_POLL_FALLBACK_MS = 60 * 60_000;
|
|
706
|
+
// The instant's epoch milliseconds when s is an RFC 3339 date-time (its T and Z in either case, as
|
|
707
|
+
// RFC 3339 allows) on a calendar date that exists, after the Unix epoch (so never the Go zero time); else undefined. Date.parse
|
|
708
|
+
// alone takes 2026-02-30 as 2026-03-02.
|
|
709
|
+
function rfc3339Ms(s) {
|
|
710
|
+
const t = s.toUpperCase();
|
|
711
|
+
const m = RFC3339_INSTANT.exec(t);
|
|
712
|
+
if (!m)
|
|
713
|
+
return undefined;
|
|
714
|
+
const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
715
|
+
const day = new Date(Date.UTC(y, mo - 1, d));
|
|
716
|
+
if (day.getUTCFullYear() !== y || day.getUTCMonth() !== mo - 1 || day.getUTCDate() !== d)
|
|
717
|
+
return undefined;
|
|
718
|
+
const ms = Date.parse(t);
|
|
719
|
+
return Number.isFinite(ms) && ms > 0 ? ms : undefined;
|
|
720
|
+
}
|
|
721
|
+
const NO_NVD_POLL = "freshness is null: this answer carries no nvd_poll block (an older API, or the API holds no fleet NVD poll it could read).";
|
|
722
|
+
// cve_summary's freshness from its answer, and the envelope's sentence about it. `now` is the clock
|
|
723
|
+
// last_completed_at is held against: this server's, when the answer is composed.
|
|
724
|
+
function nvdPollFreshness(d, now) {
|
|
725
|
+
const block = field(d, "nvd_poll");
|
|
726
|
+
if (block === undefined || block === null)
|
|
727
|
+
return { freshness: null, note: NO_NVD_POLL };
|
|
728
|
+
const unusable = (why) => ({
|
|
729
|
+
freshness: null,
|
|
730
|
+
note: `freshness is null: ${why}, so this answer gives no usable time at which the fleet last completed an NVD poll; data relays the block as sent.`,
|
|
731
|
+
});
|
|
732
|
+
if (!isPlainObject(block))
|
|
733
|
+
return unusable(`the answer's nvd_poll is ${jsonKind(block)}, where this tool reads an object`);
|
|
734
|
+
if (block.scope !== "fleet")
|
|
735
|
+
return unusable("the answer's nvd_poll.scope is not fleet");
|
|
736
|
+
const at = block.last_completed_at;
|
|
737
|
+
if (at === undefined || at === null)
|
|
738
|
+
return unusable("the answer's nvd_poll carries no last_completed_at");
|
|
739
|
+
const ms = typeof at === "string" ? rfc3339Ms(at) : undefined;
|
|
740
|
+
if (typeof at !== "string" || ms === undefined)
|
|
741
|
+
return unusable("the answer's nvd_poll.last_completed_at is not a real RFC 3339 time");
|
|
742
|
+
if (ms > now + NVD_POLL_SKEW_MS)
|
|
743
|
+
return unusable(`the answer's nvd_poll.last_completed_at is more than ${NVD_POLL_SKEW_MS / 60_000} minutes after this server's clock`);
|
|
744
|
+
const interval = typeof block.interval === "string" && GO_DURATION.test(block.interval) ? block.interval : null;
|
|
745
|
+
const every = interval === null ? "the answer gives no usable nvd_poll.interval, so interval is null" : `the poll runs at an interval of ${interval} (nvd_poll.interval)`;
|
|
746
|
+
// #3276 review: an old poll is said to be old, as exposure_radar says of a radar past its
|
|
747
|
+
// threshold (#3010), instead of being relayed as freshness with nothing said.
|
|
748
|
+
const intervalMs = interval === null ? undefined : goDurationMs(interval);
|
|
749
|
+
const limit = intervalMs !== undefined && intervalMs > 0 ? STALE_POLL_INTERVALS * intervalMs : STALE_POLL_FALLBACK_MS;
|
|
750
|
+
const stale = now - ms > limit
|
|
751
|
+
? `freshness.nvd_poll.last_run_at is about ${age(now - ms)} before this answer, more than ${intervalMs !== undefined && intervalMs > 0 ? `${STALE_POLL_INTERVALS} times the poll's interval` : "an hour (the answer gives no usable interval)"}, so the CVE counts may lag NVD by about that much.`
|
|
752
|
+
: undefined;
|
|
753
|
+
return {
|
|
754
|
+
stale,
|
|
755
|
+
freshness: { nvd_poll: { last_run_at: at, scope: "fleet", interval } },
|
|
756
|
+
note: `freshness.nvd_poll is the fleet's last completed NVD poll: last_run_at is data.nvd_poll.last_completed_at, the end of the window the fleet's last completed NVD poll stored (whichever API instance ran it), never the answering instance's poller.last_poll_at, and ${every}.`,
|
|
757
|
+
};
|
|
758
|
+
}
|
|
635
759
|
async function cveSummary() {
|
|
636
760
|
const tool = "cve_summary";
|
|
637
761
|
try {
|
|
@@ -642,17 +766,20 @@ async function cveSummary() {
|
|
|
642
766
|
// #2771: a poller field of another JSON type is left out, never a failure of the call.
|
|
643
767
|
const { data, note: pollerShape } = summaryPollerShaped(r.data);
|
|
644
768
|
const updated = strAt(data, "summary", "last_updated");
|
|
769
|
+
// #3276: the fleet's last completed NVD poll, where the answer carries a usable nvd_poll.
|
|
770
|
+
const fresh = nvdPollFreshness(data, Date.now());
|
|
645
771
|
const env = {
|
|
646
772
|
state: "measured",
|
|
647
773
|
measured_at: instantOrNull(updated),
|
|
648
774
|
method: CVE_FEED_METHOD,
|
|
649
775
|
coverage: null,
|
|
650
|
-
freshness:
|
|
776
|
+
freshness: fresh.freshness,
|
|
651
777
|
notes: [
|
|
652
778
|
realInstant(updated)
|
|
653
779
|
? "measured_at is summary.last_updated: the newest modification time among the active CVE records these counts include."
|
|
654
780
|
: "measured_at is null: the answer carries no real summary.last_updated.",
|
|
655
|
-
|
|
781
|
+
fresh.note,
|
|
782
|
+
...(fresh.stale ? [fresh.stale] : []),
|
|
656
783
|
],
|
|
657
784
|
};
|
|
658
785
|
const total = numAt(data, "summary", "total");
|
|
@@ -858,6 +985,38 @@ function bodNote(cve, rec) {
|
|
|
858
985
|
return "";
|
|
859
986
|
return ` score_factors.bod_26_04 is CISA BOD 26-04's remediation timeline for ${cve}: ${exposed} if the affected system is publicly exposed, ${internal} if it is not; whether a system is publicly exposed is its owner's to say. Its inputs: in CISA KEV ${kev}, ${auto}, ${ti}. BOD 26-04 binds US Federal Civilian Executive Branch agencies; for anyone else it is CISA's published timeline, not EchelonGraph's advice.`;
|
|
860
987
|
}
|
|
988
|
+
// #2879: what the scorer's components (core-backend scoring v4.0) say that the score does not, as
|
|
989
|
+
// at most two sentences, each only where the record carries what it reads. Nothing for a record
|
|
990
|
+
// scored before v4.0, so every answer before it reads as it did.
|
|
991
|
+
// - The score above the impact severity: echelongraph_score 9.0 on a CVSS 5.4 CVE is a KEV
|
|
992
|
+
// listing's floor (or an EPSS uplift), exploitation and not impact, and a model handed only
|
|
993
|
+
// "9.0 CRITICAL" reports a critical-impact flaw. Said only when the score is higher, naming the
|
|
994
|
+
// rule that raised it (score_factors.rule_triggered); a KEV CVE with no CVSS gets the
|
|
995
|
+
// "severity not yet published" form.
|
|
996
|
+
// - A null eg_likelihood.probability: no EPSS on record, never a probability of 0.
|
|
997
|
+
function componentsNote(cve, rec) {
|
|
998
|
+
const out = [];
|
|
999
|
+
const score = numAt(rec, "echelongraph_score");
|
|
1000
|
+
const rule = strAt(rec, "score_factors", "rule_triggered");
|
|
1001
|
+
const by = rule === "kev_exploitation" ? "a CISA KEV listing floors it at 9.0" : rule === "epss_uplift" ? "EPSS lifts it toward 9.0" : null;
|
|
1002
|
+
const sev = field(rec, "eg_severity");
|
|
1003
|
+
if (sev !== null && typeof sev === "object" && score !== undefined && by) {
|
|
1004
|
+
const s = numAt(sev, "score");
|
|
1005
|
+
const band = strAt(sev, "severity");
|
|
1006
|
+
const who = strAt(sev, "provider");
|
|
1007
|
+
if (boolAt(sev, "assessed") === true && s !== undefined && band && score > s) {
|
|
1008
|
+
out.push(` eg_severity is the impact severity of ${cve} alone: CVSS ${s.toFixed(1)} ${band}${who ? ` (${who})` : ""}. echelongraph_score ${score.toFixed(1)} is higher because ${by}: that is exploitation, not impact.`);
|
|
1009
|
+
}
|
|
1010
|
+
else if (boolAt(sev, "assessed") === false && strAt(sev, "unassessed_reason") === "kev_listed_no_cvss") {
|
|
1011
|
+
out.push(` eg_severity is not assessed: ${cve} is exploited (CISA KEV) and no CVSS severity is published yet, so echelongraph_score ${score.toFixed(1)} is the floor for confirmed exploitation, not an impact rating.`);
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
const lik = field(rec, "eg_likelihood");
|
|
1015
|
+
if (lik !== null && typeof lik === "object" && field(lik, "probability") === null && strAt(lik, "null_reason")) {
|
|
1016
|
+
out.push(` eg_likelihood.probability is null: no FIRST EPSS probability is on record for ${cve}, so report its likelihood as unknown, not low.`);
|
|
1017
|
+
}
|
|
1018
|
+
return out.join("");
|
|
1019
|
+
}
|
|
861
1020
|
// #2841: one sentence on the record's remediation summary, where the API sends one.
|
|
862
1021
|
// #3070, #3076: the CNA's own date and fix sentence, named as the CNA's. Nothing when the record
|
|
863
1022
|
// carries neither; a value of another JSON type is not quoted (the outputSchema refuses it).
|
|
@@ -869,11 +1028,17 @@ function cnaRecordNote(cve, rec) {
|
|
|
869
1028
|
const by = who ? `its CNA, ${who},` : "its CNA";
|
|
870
1029
|
const date = strAt(rec, "date_public");
|
|
871
1030
|
const fix = strAt(rec, "cna_solution");
|
|
1031
|
+
// #3178: a sentence of the record's workarounds is said to be its workaround.
|
|
1032
|
+
const workaround = strAt(rec, "cna_solution_section") === "workaround";
|
|
872
1033
|
const parts = [];
|
|
873
1034
|
if (date)
|
|
874
1035
|
parts.push(` date_public is when ${cve} was made public as ${by} states it in the CVE record (datePublic), not the record's publication date.`);
|
|
875
|
-
if (fix)
|
|
1036
|
+
if (fix && workaround) {
|
|
1037
|
+
parts.push(` cna_solution is a fix statement from the CVE record's workarounds (cna_solution_section workaround), not its solutions, written by ${by} and relayed verbatim: it names the fixed release, and is not EchelonGraph's advice.`);
|
|
1038
|
+
}
|
|
1039
|
+
else if (fix) {
|
|
876
1040
|
parts.push(` cna_solution is the CVE record's own fix statement, written by ${by} and relayed verbatim: it names the fixed release, and is not EchelonGraph's advice.`);
|
|
1041
|
+
}
|
|
877
1042
|
return parts.join("");
|
|
878
1043
|
}
|
|
879
1044
|
function remediationSummaryNote(cve, rec) {
|
|
@@ -911,7 +1076,7 @@ async function getCVE(cve_id) {
|
|
|
911
1076
|
return failed(tool, r);
|
|
912
1077
|
const written = strAt(r.data, "updated_at");
|
|
913
1078
|
const cve = strAt(r.data, "cve_id") ?? id;
|
|
914
|
-
return succeeded(r.data, `${okHead(tool, r.status)} Returned the record for ${cve}.${cveScoreNote(cve, r.data)}${bodNote(cve, r.data)}${kevTextNote(r.data)}${cnaRecordNote(cve, r.data)}${remediationSummaryNote(cve, r.data)}`, {
|
|
1079
|
+
return succeeded(r.data, `${okHead(tool, r.status)} Returned the record for ${cve}.${cveScoreNote(cve, r.data)}${componentsNote(cve, r.data)}${bodNote(cve, r.data)}${kevTextNote(r.data)}${cnaRecordNote(cve, r.data)}${remediationSummaryNote(cve, r.data)}`, {
|
|
915
1080
|
state: "measured",
|
|
916
1081
|
measured_at: instantOrNull(written),
|
|
917
1082
|
method: CVE_FEED_METHOD,
|
|
@@ -1246,19 +1411,31 @@ function readWindow(v) {
|
|
|
1246
1411
|
const undated = v.undated;
|
|
1247
1412
|
if (!isCount(undated) || undated < 0 || !Number.isInteger(undated))
|
|
1248
1413
|
return { bad: true };
|
|
1249
|
-
if (undated > 0)
|
|
1250
|
-
|
|
1414
|
+
if (undated > 0) {
|
|
1415
|
+
// Over the dated rows only. Every row undated (from and to null) or a span that is not one
|
|
1416
|
+
// relays no dated_window; the note still says how many rows carry no time.
|
|
1417
|
+
const span = realInstant(from) && realInstant(to) && Date.parse(from) <= Date.parse(to);
|
|
1418
|
+
return span ? { dated: { from, to, undated }, undated, bad: false } : { undated, bad: false };
|
|
1419
|
+
}
|
|
1251
1420
|
if (v.from === null && v.to === null)
|
|
1252
1421
|
return { bad: false }; // nothing counted, so nothing dated
|
|
1253
1422
|
if (!realInstant(from) || !realInstant(to) || Date.parse(from) > Date.parse(to))
|
|
1254
1423
|
return { bad: true };
|
|
1255
1424
|
return { window: { from, to }, bad: false };
|
|
1256
1425
|
}
|
|
1257
|
-
// The sentence for a radar's window, or for the rows it leaves undated.
|
|
1258
|
-
|
|
1426
|
+
// The sentence for a radar's window, or for the rows it leaves undated. `total`, where the answer
|
|
1427
|
+
// carries it, is how many rows the counts count, so the dated ones can be named too.
|
|
1428
|
+
function windowSentences(prefix, w, what, total) {
|
|
1259
1429
|
if (w.window) {
|
|
1260
1430
|
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.`];
|
|
1261
1431
|
}
|
|
1432
|
+
if (w.dated) {
|
|
1433
|
+
const d = w.dated;
|
|
1434
|
+
const of = total !== undefined && total >= d.undated ? `${total - d.undated} of the ${total} rows it counts` : "the rows it counts that carry an observation time";
|
|
1435
|
+
return [
|
|
1436
|
+
`${prefix} carries no window over every row it counts: ${d.undated} of them have no observation time on record (${prefix}.dated_window.undated). ${prefix}.dated_window.from (${d.from}) and ${prefix}.dated_window.to (${d.to}) are timestamps, not counts, over ${of} only: ${what}. They span observations made at different times, not one observation time, and do not describe the undated rows.`,
|
|
1437
|
+
];
|
|
1438
|
+
}
|
|
1262
1439
|
if (w.undated !== undefined) {
|
|
1263
1440
|
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.`];
|
|
1264
1441
|
}
|
|
@@ -1298,6 +1475,8 @@ function readRadar(body, spec) {
|
|
|
1298
1475
|
const w = readWindow(src[spec.window]);
|
|
1299
1476
|
if (w.window)
|
|
1300
1477
|
data.window = w.window;
|
|
1478
|
+
else if (w.dated)
|
|
1479
|
+
data.dated_window = w.dated;
|
|
1301
1480
|
else if (w.bad)
|
|
1302
1481
|
unusable.push(spec.window);
|
|
1303
1482
|
undated = w.undated;
|
|
@@ -1558,7 +1737,7 @@ function readKEVExposure(body, now) {
|
|
|
1558
1737
|
}
|
|
1559
1738
|
sentences.push("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 (0 to 1), absent when the record has none: scores, not counts.");
|
|
1560
1739
|
}
|
|
1561
|
-
sentences.push(...windowSentences("kev_exposure", { window: data.window, undated, bad: false }, "when Shodan collected the banners of the oldest and the newest of the service×CVE pairs counted, Shodan's own times, not when EchelonGraph stored or re-checked a pair"));
|
|
1740
|
+
sentences.push(...windowSentences("kev_exposure", { window: data.window, dated: data.dated_window, undated, bad: false }, "when Shodan collected the banners of the oldest and the newest of the service×CVE pairs counted, Shodan's own times, not when EchelonGraph stored or re-checked a pair", n("correlations")));
|
|
1562
1741
|
sentences.push(...lastCheck("kev_exposure", data));
|
|
1563
1742
|
const stale = staleCheck("kev_exposure", data, now);
|
|
1564
1743
|
sentences.push(...stale);
|
|
@@ -1615,7 +1794,7 @@ function readExposedDatabases(body, now) {
|
|
|
1615
1794
|
if (gated.length) {
|
|
1616
1795
|
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.`);
|
|
1617
1796
|
}
|
|
1618
|
-
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"));
|
|
1797
|
+
sentences.push(...windowSentences("exposed_databases", { window: data.window, dated: data.dated_window, undated, bad: false }, "when EchelonGraph's check last confirmed the oldest and the newest of the services counted answering without authentication"));
|
|
1619
1798
|
sentences.push(...lastCheck("exposed_databases", data));
|
|
1620
1799
|
const stale = staleCheck("exposed_databases", data, now);
|
|
1621
1800
|
sentences.push(...stale);
|
|
@@ -1664,7 +1843,7 @@ function readLeakedCredentials(body, now) {
|
|
|
1664
1843
|
if (totals.length || rankings.length) {
|
|
1665
1844
|
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.");
|
|
1666
1845
|
}
|
|
1667
|
-
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"));
|
|
1846
|
+
sentences.push(...windowSentences("leaked_credentials", { window: data.window, dated: data.dated_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"));
|
|
1668
1847
|
sentences.push(...lastCheck("leaked_credentials", data));
|
|
1669
1848
|
const stale = staleCheck("leaked_credentials", data, now);
|
|
1670
1849
|
sentences.push(...stale);
|
|
@@ -1698,6 +1877,8 @@ function readShadowAI(sa, now) {
|
|
|
1698
1877
|
used.add("confirmed_window");
|
|
1699
1878
|
if (confirmedWindow.window && confirmed.total !== undefined)
|
|
1700
1879
|
confirmed.window = confirmedWindow.window;
|
|
1880
|
+
else if (confirmedWindow.dated && confirmed.total !== undefined)
|
|
1881
|
+
confirmed.dated_window = confirmedWindow.dated;
|
|
1701
1882
|
// Observed: every row of shadow_ct_observations, whatever its liveness.
|
|
1702
1883
|
const observed = {};
|
|
1703
1884
|
const total = take("total", numAt(stats, "total"));
|
|
@@ -1734,7 +1915,7 @@ function readShadowAI(sa, now) {
|
|
|
1734
1915
|
? `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`}.`
|
|
1735
1916
|
: `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.`);
|
|
1736
1917
|
if (confirmed.total !== undefined) {
|
|
1737
|
-
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"));
|
|
1918
|
+
sentences.push(...windowSentences("shadow_ai.confirmed_exposed", { window: confirmedWindow.window, dated: confirmedWindow.dated, 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"));
|
|
1738
1919
|
}
|
|
1739
1920
|
if (total !== undefined) {
|
|
1740
1921
|
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.`);
|
|
@@ -2062,12 +2243,12 @@ function readMCPServers(src, now) {
|
|
|
2062
2243
|
// below), and the result's note labels every number relayed in the same words. Each text below
|
|
2063
2244
|
// that names Shodan carries SHODAN_OWNERSHIP (#2306).
|
|
2064
2245
|
const EXPOSURE_RADAR_DESCRIPTION = `Aggregate totals from EchelonGraph's internet-exposure radars: kev_exposure, internet-facing services running actively-exploited (CISA-KEV) CVEs, plus the ransomware-linked subset, derived from Shodan data; exposed_databases, 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. ${SHODAN_OWNERSHIP} 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. Service counts are distinct ip:port services, not machines. The result's note, and the outputSchema's description of each radar, label every number by what it counts. Of the shadow_ai numbers, only confirmed_exposed counts exposed services. A kev_exposure.newest_kev CVE whose exposure_state is not_assessed has no measurement in the answer: its 0 is not one.`;
|
|
2065
|
-
const RADAR_KEV_FIELDS = `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 this answer does not say whether the radar tracks that CVE: tracking is answered per CVE. 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. kev_exposure.window.from and kev_exposure.window.to are timestamps, not counts: when Shodan collected the banners of the oldest and the newest of the service×CVE pairs counted, a span of observations made at different times; with a counted pair that has no such time on record there is no window. ${SHODAN_OWNERSHIP}`;
|
|
2066
|
-
const RADAR_DB_FIELDS = `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.`;
|
|
2067
|
-
const RADAR_LEAKED_FIELDS = `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.`;
|
|
2246
|
+
const RADAR_KEV_FIELDS = `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 this answer does not say whether the radar tracks that CVE: tracking is answered per CVE. 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. kev_exposure.window.from and kev_exposure.window.to are timestamps, not counts: when Shodan collected the banners of the oldest and the newest of the service×CVE pairs counted, a span of observations made at different times; with a counted pair that has no such time on record there is no window, and kev_exposure.dated_window.from and kev_exposure.dated_window.to span the dated pairs only, while kev_exposure.dated_window.undated counts the pairs with none. ${SHODAN_OWNERSHIP}`;
|
|
2247
|
+
const RADAR_DB_FIELDS = `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, and exposed_databases.dated_window.from and exposed_databases.dated_window.to span the dated services only, while exposed_databases.dated_window.undated counts those with none.`;
|
|
2248
|
+
const RADAR_LEAKED_FIELDS = `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; with a counted pair that has no such time on record there is no window, and leaked_credentials.dated_window.from and leaked_credentials.dated_window.to span the dated pairs only, while leaked_credentials.dated_window.undated counts those with none.`;
|
|
2068
2249
|
// What the three stats radars' last_run_at is, and what is left out: the outputSchema's description of data.
|
|
2069
2250
|
const RADAR_LAST_RUN_FIELDS = `A field this version cannot label is left out of data and named in the note. Each of the kev_exposure, exposed_databases and leaked_credentials 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. ${SHODAN_OWNERSHIP}`;
|
|
2070
|
-
const RADAR_SHADOW_AI_FIELDS = `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. ${SHODAN_OWNERSHIP}`;
|
|
2251
|
+
const RADAR_SHADOW_AI_FIELDS = `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; with one that has no such time there is no window, and confirmed_exposed.dated_window.from and confirmed_exposed.dated_window.to span the dated ones only, while confirmed_exposed.dated_window.undated counts those with none. shadow_ai.observed counts every Certificate Transparency or Shodan observation on record, whatever its verification state: its numbers are observed, not exposed. They are observed.total; observed.by_category (the same observations by category); observed.last_24h (those first recorded in the last 24 h); observed.trend_30d (observations per UTC day over the last 30 days); and observed.top_products, observed.top_countries and observed.top_issuers (up to ten products, countries and issuers ranked by observations, where an issuer is the certificate's CA for a Certificate Transparency observation and the hosting operator Shodan reports for a Shodan one). shadow_ai.authentication counts observations by probe outcome: authentication.observed where a probe observed an authentication gate (a 401/403, a login page or an auth marker), and authentication.not_determined where the service answered but no probe could tell. Neither authentication count is part of confirmed_exposed, and observed.total minus confirmed_exposed.total is not a count of secured services. The shadow-AI poller block carries only running and last_run_at: last_run_at is when the radar's leader last completed a Certificate Transparency (crt.sh) cycle, and its running is true only when that was within ${SHADOW_AI_SILENT_AFTER} of the answer. ${SHODAN_OWNERSHIP}`;
|
|
2071
2252
|
const RADAR_MCP_FIELDS = `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.`;
|
|
2072
2253
|
// exposure_radar's envelope. Every radar answered, but no stats answer says when the services
|
|
2073
2254
|
// or records it counts were observed: each count covers everything that radar still holds on
|
|
@@ -2226,10 +2407,191 @@ const SUCCESS_STATE = "measured: a measurement of what was asked; an exposure co
|
|
|
2226
2407
|
const DATA_TRUNCATED_FIELD = "false: data is the answer whole. true: the structured result would pass 30,000 characters of JSON with data whole, so data is cut (DATA CUT): the entries of some of its lists keep only some of their fields, or the lists only some of their entries, truncated gives each, and no string is shortened. The same call with full: true returns data whole.";
|
|
2227
2408
|
const TRUNCATED_FIELD = "Present when data_truncated is true: each list of data that was cut, by its path in data (a dot between the names of an object and a list inside it), with total, its entries in the answer, returned, the entries data holds, and thinned, true when each of them keeps only some of its fields.";
|
|
2228
2409
|
const TRUNCATED = z.record(z.string(), z.strictObject({ total: z.number().int().min(0), returned: z.number().int().min(0), thinned: z.literal(true).optional() }));
|
|
2410
|
+
// #3121: the success branch is published open to fields it does not name, and this server holds
|
|
2411
|
+
// its own answers to the closed one (STRICT, read by checked). Through 3.0.0 both branches were
|
|
2412
|
+
// closed (additionalProperties false), and a client validates against the outputSchema it listed,
|
|
2413
|
+
// which the hosted endpoint never tells it to list again (stateless, listChanged false): a Claude
|
|
2414
|
+
// Code session that listed the tools under 2.7.2 failed its first check_sbom call once 3.0.0 went
|
|
2415
|
+
// live, "Structured content does not match the tool's output schema: data must NOT have additional
|
|
2416
|
+
// properties" (ajv's root is "data"; 3.0.0 added data_truncated and truncated to every success),
|
|
2417
|
+
// until it reconnected. So a field added beside them in a minor release would break every such
|
|
2418
|
+
// session too, against the README's semver promise. Opening the success branch keeps such a field
|
|
2419
|
+
// additive for a client holding an older list; the failure branch stays closed (a failure is never
|
|
2420
|
+
// read as data), and data keeps each tool's own schema. The suite validates answers against the
|
|
2421
|
+
// closed branch (test/strict-output.mjs), and checked() does here, so a field this server adds
|
|
2422
|
+
// without naming it in the schema still fails, as it did when the branch was closed.
|
|
2423
|
+
//
|
|
2424
|
+
// 3.0.2: every output schema is built once per process, and its strict copy with it. The hosted
|
|
2425
|
+
// endpoint builds a server for each request (http.ts: createMcpHandler's stateless leg calls
|
|
2426
|
+
// createServer), and through 3.0.1 the tool modules that take envelopeSchema from this file
|
|
2427
|
+
// (check_affected, check_sbom, epss_history, kev_recent, scan_manifest and the three vendor-advisory
|
|
2428
|
+
// tools) built their output schemas in their register functions, so once per request in flight.
|
|
2429
|
+
// 3.0.1's strict copy doubled those: 1,371 KB of heap per server, against 3.0.0's 1,168
|
|
2430
|
+
// (64 servers held at once, node --expose-gc). test/http-memory.test.mjs's "served as they come"
|
|
2431
|
+
// case holds 64 requests in flight beside two 5,000,000-character SBOMs; it failed the 3.0.1
|
|
2432
|
+
// release run at Dockerfile.http's 128 MiB, and passed here only at 128, where 3.0.0 passes at
|
|
2433
|
+
// 112. At 112 V8 aborted 3.5 s in, with 59 servers live and both documents still at their first
|
|
2434
|
+
// batches, so before either answer was cut (a heap snapshot near the limit). Each such module now
|
|
2435
|
+
// keeps its schemas per envelopeSchema, as cve_intel, cve_remediation and get_cwe already did
|
|
2436
|
+
// (schemasOnce): 611 KB a server, and the case passes at --max-old-space-size=80.
|
|
2437
|
+
//
|
|
2438
|
+
// #3268: and every level of the success branch is published open, not only its root. #3121 opened
|
|
2439
|
+
// the root alone: 3.0.2 still published 86 objects below it with additionalProperties false (40 in
|
|
2440
|
+
// data, among them exposure_radar's and cve_intel's data itself, 23 in coverage, 7 in freshness, 16
|
|
2441
|
+
// in truncated) and 12 string enums with no fallback (scan_manifest's manifest.files[].format and
|
|
2442
|
+
// not_checked[].reason, exposure_radar's newest_kev[].exposure_state among them). A field added
|
|
2443
|
+
// under any of them, or a value added to any of them, fails a client holding the older list the
|
|
2444
|
+
// same way, and 2.6.3 and 2.7.0 had each added such fields. So the JSON Schema a client is served
|
|
2445
|
+
// (tools/list, through the SDK's ~standard.jsonSchema.output) is the one zod builds, with every
|
|
2446
|
+
// object of the success branch open (additionalProperties {}, as z.looseObject publishes it) and
|
|
2447
|
+
// every string enum or const in it open as each tool's open enums already are (anyOf: the known
|
|
2448
|
+
// values, or any string), the values still listed: openSuccess, in one place here rather than at
|
|
2449
|
+
// each schema. `state` stays closed: it is what tells a success from a failure. The failure branch
|
|
2450
|
+
// is left as built, closed at every level (#3121's decision). What this server holds its own
|
|
2451
|
+
// answers to is unchanged: checked() reads STRICT, closed wherever its tool closed it, and so does
|
|
2452
|
+
// the suite (closedOutputSchema, test/strict-output.mjs). The opened schema is built once per
|
|
2453
|
+
// process and target and shared, frozen, by every server (each HTTP request's included), so it adds
|
|
2454
|
+
// nothing per request: the SDK asks for it each time it registers a tool on a server and for each
|
|
2455
|
+
// tools/list, and is handed the same object, where zod built a new one each time before.
|
|
2456
|
+
// test/open-success.test.mjs walks every tool's tools/list answer for any closed object or enum in a
|
|
2457
|
+
// success branch, with controls, and test/schema-compat.mjs compares a candidate with the last
|
|
2458
|
+
// release.
|
|
2459
|
+
const STRICT = new WeakMap();
|
|
2460
|
+
// Every envelope built in this process (16, one a tool, each built once), for closedOutputSchema.
|
|
2461
|
+
const ENVELOPES = new Set();
|
|
2229
2462
|
export function envelopeSchema(o) {
|
|
2463
|
+
const published = envelopeOf(o, z.looseObject);
|
|
2464
|
+
STRICT.set(published, envelopeOf(o, z.strictObject));
|
|
2465
|
+
publishOpen(published);
|
|
2466
|
+
ENVELOPES.add(published);
|
|
2467
|
+
return published;
|
|
2468
|
+
}
|
|
2469
|
+
const standardOf = (s) => s["~standard"];
|
|
2470
|
+
// The target the SDK converts every outputSchema with (standardSchemaToJsonSchema).
|
|
2471
|
+
const OUTPUT_TARGET = "draft-2020-12";
|
|
2472
|
+
// The published schema's JSON Schema output, opened by openSuccess, made once per target and shared.
|
|
2473
|
+
function publishOpen(published) {
|
|
2474
|
+
const std = standardOf(published);
|
|
2475
|
+
const made = new Map();
|
|
2476
|
+
const output = (o) => {
|
|
2477
|
+
if (o?.libraryOptions)
|
|
2478
|
+
return openSuccess(std.jsonSchema.output(o));
|
|
2479
|
+
let json = made.get(o?.target);
|
|
2480
|
+
if (!json)
|
|
2481
|
+
made.set(o?.target, (json = deepFreeze(openSuccess(std.jsonSchema.output(o)))));
|
|
2482
|
+
return json;
|
|
2483
|
+
};
|
|
2484
|
+
Object.defineProperty(published, "~standard", {
|
|
2485
|
+
value: { ...std, jsonSchema: { ...std.jsonSchema, output } },
|
|
2486
|
+
configurable: true,
|
|
2487
|
+
writable: true,
|
|
2488
|
+
enumerable: false,
|
|
2489
|
+
});
|
|
2490
|
+
}
|
|
2491
|
+
const isNode = (x) => x !== null && typeof x === "object" && !Array.isArray(x);
|
|
2492
|
+
const isSuccessBranch = (b) => {
|
|
2493
|
+
const values = isNode(b) && isNode(b.properties) && isNode(b.properties.state) ? b.properties.state.enum : undefined;
|
|
2494
|
+
return Array.isArray(values) && values.includes("measured");
|
|
2495
|
+
};
|
|
2496
|
+
// A string enum or const: one value of a list, or the one value.
|
|
2497
|
+
const isClosedEnum = (n) => (Array.isArray(n.enum) && n.enum.length > 0 && n.enum.every((v) => typeof v === "string")) || typeof n.const === "string";
|
|
2498
|
+
// { type: "string" } and nothing else that constrains it: the fallback of an open enum.
|
|
2499
|
+
const isAnyString = (n) => isNode(n) && n.type === "string" && Object.keys(n).every((k) => k === "type" || k === "description");
|
|
2500
|
+
// `json` with each success branch (state measured or not_assessed) open at every level: each object
|
|
2501
|
+
// admits properties it does not name, and each string enum or const admits any string, its values
|
|
2502
|
+
// kept as the first branch of an anyOf. A branch's own `state` is left closed, and so is every
|
|
2503
|
+
// failure branch. Edits `json` in place, which zod made for this call.
|
|
2504
|
+
//
|
|
2505
|
+
// The envelope's `coverage` and `freshness` slots too: a tool built with neither (envelopeOf's
|
|
2506
|
+
// z.null()) published { type: "null" } there, 13 freshness slots and 2 coverage slots (cve_summary,
|
|
2507
|
+
// get_cve) after #3276 gave cve_summary's freshness an object. A client holding such a list refuses
|
|
2508
|
+
// the object a later release puts in the slot, as 3.0.2's clients refuse cve_summary's freshness
|
|
2509
|
+
// (#3276) and epss_history's coverage fields. So a success branch publishes each null-only slot as
|
|
2510
|
+
// null or an open object (OPEN_SLOT). Nothing it answers changes: checked() and the suite still hold
|
|
2511
|
+
// the slot to null (STRICT), so this server sends null there until a change gives the slot a schema.
|
|
2512
|
+
// The failure branch keeps its null.
|
|
2513
|
+
const OPEN_SLOTS = ["coverage", "freshness"];
|
|
2514
|
+
const OPEN_SLOT = () => [{ type: "object", additionalProperties: {} }, { type: "null" }];
|
|
2515
|
+
const onlyNull = (n) => isNode(n) && n.type === "null" && ["anyOf", "oneOf", "allOf", "enum", "const"].every((k) => n[k] === undefined);
|
|
2516
|
+
export function openSuccess(json) {
|
|
2517
|
+
for (const k of ["oneOf", "anyOf"]) {
|
|
2518
|
+
const branches = json[k];
|
|
2519
|
+
if (!Array.isArray(branches))
|
|
2520
|
+
continue;
|
|
2521
|
+
for (const b of branches) {
|
|
2522
|
+
if (!isSuccessBranch(b))
|
|
2523
|
+
continue;
|
|
2524
|
+
openNode(b, true);
|
|
2525
|
+
const props = b.properties;
|
|
2526
|
+
for (const slot of OPEN_SLOTS) {
|
|
2527
|
+
const n = props[slot];
|
|
2528
|
+
if (!onlyNull(n))
|
|
2529
|
+
continue;
|
|
2530
|
+
const { type: _type, ...rest } = n;
|
|
2531
|
+
props[slot] = { anyOf: OPEN_SLOT(), ...rest };
|
|
2532
|
+
}
|
|
2533
|
+
}
|
|
2534
|
+
}
|
|
2535
|
+
return json;
|
|
2536
|
+
}
|
|
2537
|
+
function openNode(n, isBranch = false) {
|
|
2538
|
+
if (n.additionalProperties === false)
|
|
2539
|
+
n.additionalProperties = {};
|
|
2540
|
+
else if (isNode(n.additionalProperties))
|
|
2541
|
+
n.additionalProperties = openNode(n.additionalProperties);
|
|
2542
|
+
for (const k of ["properties", "patternProperties", "$defs", "definitions"]) {
|
|
2543
|
+
const m = n[k];
|
|
2544
|
+
if (isNode(m))
|
|
2545
|
+
for (const [name, s] of Object.entries(m))
|
|
2546
|
+
if (isNode(s) && !(isBranch && k === "properties" && name === "state"))
|
|
2547
|
+
m[name] = openNode(s);
|
|
2548
|
+
}
|
|
2549
|
+
for (const k of ["items", "propertyNames", "contains", "not", "if", "then", "else"])
|
|
2550
|
+
if (isNode(n[k]))
|
|
2551
|
+
n[k] = openNode(n[k]);
|
|
2552
|
+
for (const k of ["anyOf", "oneOf", "allOf", "prefixItems"]) {
|
|
2553
|
+
const list = n[k];
|
|
2554
|
+
if (!Array.isArray(list))
|
|
2555
|
+
continue;
|
|
2556
|
+
// An enum already beside a plain string (z.union([z.enum(...), z.string()])) is open as it is.
|
|
2557
|
+
const open = k === "anyOf" && list.some(isAnyString);
|
|
2558
|
+
n[k] = list.map((s) => (!isNode(s) || (open && isClosedEnum(s)) ? s : openNode(s)));
|
|
2559
|
+
}
|
|
2560
|
+
if (!isClosedEnum(n))
|
|
2561
|
+
return n;
|
|
2562
|
+
const { type: _type, enum: values, const: one, ...rest } = n;
|
|
2563
|
+
const known = values !== undefined ? { type: "string", enum: values } : { type: "string", const: one };
|
|
2564
|
+
const anyOf = [known, { type: "string" }];
|
|
2565
|
+
return rest.anyOf === undefined ? { ...rest, anyOf } : { ...rest, allOf: [...(rest.allOf ?? []), { anyOf }] };
|
|
2566
|
+
}
|
|
2567
|
+
function deepFreeze(x) {
|
|
2568
|
+
if (x !== null && typeof x === "object" && !Object.isFrozen(x)) {
|
|
2569
|
+
for (const v of Object.values(x))
|
|
2570
|
+
deepFreeze(v);
|
|
2571
|
+
Object.freeze(x);
|
|
2572
|
+
}
|
|
2573
|
+
return x;
|
|
2574
|
+
}
|
|
2575
|
+
// For the suite (test/strict-output.mjs): the closed JSON Schema this server holds its own answers
|
|
2576
|
+
// to (STRICT, as checked() reads it) for `published`, an outputSchema as tools/list serves it, or
|
|
2577
|
+
// undefined when no envelope of this process publishes it. Every tool's envelope is built by its
|
|
2578
|
+
// first registration, so call createServer() once before.
|
|
2579
|
+
export function closedOutputSchema(published) {
|
|
2580
|
+
if (!isNode(published))
|
|
2581
|
+
return undefined;
|
|
2582
|
+
const want = JSON.stringify(published.oneOf);
|
|
2583
|
+
for (const e of ENVELOPES) {
|
|
2584
|
+
if (JSON.stringify(standardOf(e).jsonSchema.output({ target: OUTPUT_TARGET }).oneOf) !== want)
|
|
2585
|
+
continue;
|
|
2586
|
+
const strict = STRICT.get(e);
|
|
2587
|
+
return strict ? { type: "object", ...standardOf(strict).jsonSchema.output({ target: OUTPUT_TARGET }) } : undefined;
|
|
2588
|
+
}
|
|
2589
|
+
return undefined;
|
|
2590
|
+
}
|
|
2591
|
+
function envelopeOf(o, success) {
|
|
2230
2592
|
const coverage = o.coverage ? o.coverage.nullable() : z.null();
|
|
2231
2593
|
return z.discriminatedUnion("state", [
|
|
2232
|
-
|
|
2594
|
+
success({
|
|
2233
2595
|
state: z.enum(["measured", "not_assessed"]).describe(SUCCESS_STATE),
|
|
2234
2596
|
measured_at: Instant.nullable().describe("When the underlying observation was made, as the API states it; null when the answer does not say or holds no observation."),
|
|
2235
2597
|
method: z.string().describe("How the numbers were produced."),
|
|
@@ -2275,8 +2637,8 @@ const CPE_CONFIGURATIONS_DESCRIPTION = "NVD's configurations array as NVD sent i
|
|
|
2275
2637
|
// templates, internal/cve/fix_statement.go). The last two are the record's own words: get_cve
|
|
2276
2638
|
// relays the solution's sentence as cna_solution, and the description is in the record.
|
|
2277
2639
|
// One constant string each (the site's tool-claims check folds only constant strings).
|
|
2278
|
-
const PATCH_EVIDENCE_LABELS = ["vendor_patch", "fixed_version", "release_reference", "vendor_advisory_fix", "nvd_patch_reference", "cna_patch_reference", "cna_solution", "description_fix"];
|
|
2279
|
-
const PATCH_EVIDENCE_SOURCES = "vendor_patch (a vendor patch on record, from Ubuntu or Red Hat), fixed_version (a fixed version recorded for an affected package, or the fixed bound of an affected version range), release_reference (a reference that EchelonGraph confirmed through the GitHub API is a published release containing the fix, or a pull request or commit that a published release contains), vendor_advisory_fix (a vendor advisory that lists a fix for the CVE under the vendor's own structured category: Red Hat's CSAF vendor_fix, Microsoft's Vendor Fix), nvd_patch_reference (an NVD reference tagged Patch), cna_patch_reference (a reference the CVE's CNA tagged patch in its CVE record), cna_solution (a sentence of the CVE record's solutions, written by its CNA, that names a fixed version; relayed as cna_solution)
|
|
2640
|
+
const PATCH_EVIDENCE_LABELS = ["vendor_patch", "fixed_version", "release_reference", "vendor_advisory_fix", "nvd_patch_reference", "cna_patch_reference", "cna_solution", "description_fix", "vendor_fix_released"];
|
|
2641
|
+
const PATCH_EVIDENCE_SOURCES = "vendor_patch (a vendor patch on record, from Ubuntu or Red Hat), fixed_version (a fixed version recorded for an affected package, or the fixed bound of an affected version range), release_reference (a reference that EchelonGraph confirmed through the GitHub API is a published release containing the fix, or a pull request or commit that a published release contains), vendor_advisory_fix (a vendor advisory that lists a fix for the CVE under the vendor's own structured category: Red Hat's CSAF vendor_fix, Microsoft's Vendor Fix), nvd_patch_reference (an NVD reference tagged Patch), cna_patch_reference (a reference the CVE's CNA tagged patch in its CVE record), cna_solution (a sentence of the CVE record's solutions or workarounds, written by its CNA, that names a fixed version; relayed as cna_solution), description_fix (a sentence of the CVE description that names a fixed version) and vendor_fix_released (the vendor states, in the CVE record, that a fix is released and the record names no fixed version; check the vendor's advisory)";
|
|
2280
2642
|
// #3070, #3076: what the CVE record's CNA wrote, as GET /cves/:id serves it (core-backend
|
|
2281
2643
|
// cna_record.go). One constant string each.
|
|
2282
2644
|
const CVE_CNA_ABSENT = "Absent where EchelonGraph has not read the CVE record's CNA container yet, where the record has none, for a REJECTED CVE, and from an API older than the field.";
|
|
@@ -2284,12 +2646,26 @@ const DATE_PUBLIC_FIELD = "When the vulnerability was made public, as the CVE re
|
|
|
2284
2646
|
CVE_CNA_ABSENT;
|
|
2285
2647
|
const DATE_PUBLIC_SOURCE_FIELD = "Whose date date_public is: cna, the CVE record's CNA. Absent whenever date_public is.";
|
|
2286
2648
|
const CNA_FIELD = "The CNA that wrote the CVE record: short_name as the record gives it, and name, the CVE Program's name for that CNA (the short name where its list does not hold it). " + CVE_CNA_ABSENT;
|
|
2287
|
-
const CNA_SOLUTION_FIELD = "The sentence of the CVE record's solutions, written by its CNA, that names a released fix, verbatim: what makes cna_solution patch evidence. It is the CNA's text, not EchelonGraph's advice. Absent where the record's solutions name no fixed version, as well as where the field itself is: " +
|
|
2649
|
+
const CNA_SOLUTION_FIELD = "The sentence of the CVE record's solutions or workarounds, written by its CNA, that names a released fix, verbatim: what makes cna_solution patch evidence. It is the CNA's text, not EchelonGraph's advice. cna_solution_section says which of the two sections it is. Absent where the record's solutions name no fixed version, as well as where the field itself is: " +
|
|
2288
2650
|
CVE_CNA_ABSENT;
|
|
2651
|
+
// #3178: which section cna_solution is. MCP relays a field its outputSchema does not describe without saying what it
|
|
2652
|
+
// is, so the section is described here, beside the sentence.
|
|
2653
|
+
const CNA_SOLUTION_SECTION_FIELD = "Which section of the CVE record cna_solution was read from: solution (the record's solutions) or workaround (its workarounds, read only where its solutions name no fix: a temporary measure the CNA lists beside its fix). A workaround's sentence is the record's workaround, never its solution. Present whenever cna_solution is; absent from an API older than the field.";
|
|
2289
2654
|
// #2842: what the descriptions say; the sources, and that patch_available once true is never set
|
|
2290
2655
|
// back, are the outputSchema's (PATCH_AVAILABLE_FIELD, PATCH_EVIDENCE_FIELD).
|
|
2291
|
-
const PATCH_AVAILABLE_DESCRIPTION = "patch_available is true when EchelonGraph holds
|
|
2292
|
-
|
|
2656
|
+
const PATCH_AVAILABLE_DESCRIPTION = "patch_available is true when EchelonGraph holds, or once held, fix evidence for the CVE; patch_evidence is what it holds now, strongest first. patch_available false means no fix evidence is on record, which is not a finding that no fix exists.";
|
|
2657
|
+
// #3138: true says evidence was held at some point, not that it is held now: the sources held now
|
|
2658
|
+
// are patch_evidence's, and true beside an empty one is a withdrawal (requiredText.ts
|
|
2659
|
+
// PATCH_AVAILABLE_WITHDRAWN, pinned there). #3138's review: round 1 changed the field text only,
|
|
2660
|
+
// and this sentence, in get_cve's and search_cves' descriptions, still said true means
|
|
2661
|
+
// EchelonGraph holds evidence; it now says "holds, or once held", in 9 more characters than it
|
|
2662
|
+
// had, as get_cve's description had 9 left under #2924's headroom of 100 below the 2,048 cut
|
|
2663
|
+
// (get_cve 1,948 characters, search_cves 1,807; test/prompts-resources.test.mjs holds the
|
|
2664
|
+
// headroom, test/client-cut.mjs (b) the cut, and marketing-site lib/mcpToolClaims.test.ts the
|
|
2665
|
+
// word "for" in get_cve's description, which its /pulse/mcp row uses).
|
|
2666
|
+
const PATCH_AVAILABLE_FIELD = "true: EchelonGraph holds, or once held, evidence of a fix for the CVE; patch_evidence names the sources it holds now. " +
|
|
2667
|
+
PATCH_AVAILABLE_WITHDRAWN +
|
|
2668
|
+
" false: no fix evidence is on record, which is not a finding that no fix exists. An answer with no patch_evidence (an API older than that field) does not name the evidence, and a false there is not a finding that no fix exists either.";
|
|
2293
2669
|
const PATCH_EVIDENCE_FIELD = "The sources of the fix evidence EchelonGraph holds for the CVE, strongest first: " +
|
|
2294
2670
|
PATCH_EVIDENCE_SOURCES +
|
|
2295
2671
|
". Empty where none was found when EchelonGraph last computed it. Absent (an API older than the field): the answer does not name the evidence.";
|
|
@@ -2305,6 +2681,13 @@ const KEV_NOTES_URLS_FIELD = "The https links in CISA's notes for the CVE, in CI
|
|
|
2305
2681
|
// #2841: what get_cve's remediation is. One constant string (the site's tool-claims check folds
|
|
2306
2682
|
// only constant strings).
|
|
2307
2683
|
const REMEDIATION_SUMMARY_FIELD = "A capped summary (at most 1,500 characters of JSON) of how the CVE is fixed, as its sources state it: fix_status first (the API's fix-status rule: state, basis, described_by, solution_by, stated_in_record, listed_in_patch_table and unread_inputs), vendor_remediation_kinds (per kind, the vendor advisories listing one for this CVE, by the vendor's own category), vendor_advisories, vendors_not_parsed, kev_required_action (CISA's, verbatim), fixed_branches_count, fixed_versions (per package, fixed versions on record), fix_references (URLs tagged Patch or Mitigation by NVD, or patch or mitigation by the CNA), failed_sections, truncated, and see, the name of the tool that returns the whole, with each text. A kind missing from vendor_remediation_kinds is not a finding that no such remediation exists. Absent for a REJECTED CVE and from an API older than the field.";
|
|
2684
|
+
// #2879: the scorer's three components (core-backend scoring v4.0, components.go), which GET
|
|
2685
|
+
// /cves/:id serves at the top level. One constant string each (the site's tool-claims check folds
|
|
2686
|
+
// only constant strings). eg_likelihood is FIRST's EPSS until EchelonGraph's calibrated model
|
|
2687
|
+
// (#2874) replaces it, and says so: it is never presented as EchelonGraph's own probability.
|
|
2688
|
+
const EG_SEVERITY_FIELD = "Impact severity alone (scorer v4.0): the CVSS base score from the best-provenance CVSS EchelonGraph holds, which no CISA KEV listing, EPSS probability or EG-KEV tier raises. score (0-10) and severity (the CVSS band), with metric cvss_base, cvss_version, vector, source and provider. assessed false, with score and severity null and unassessed_reason, where no CVSS of any version is published: kev_listed_no_cvss reads exploited, severity not yet published. Absent from a record scored before v4.0.";
|
|
2689
|
+
const EG_LIKELIHOOD_FIELD = "A probability with a horizon (scorer v4.0): probability is FIRST's EPSS probability of exploitation activity in the next 30 days (source first_epss, horizon_days 30, with epss_percentile, epss_score_date and epss_model_version where stored), not EchelonGraph's own model. null, with null_reason no_epss_on_record, where no EPSS is on record: the likelihood is unknown, not low. exploited (true when CISA KEV lists the CVE or EG-KEV rates it Tier 1) and exploited_sources report confirmed exploitation apart from it; it is never folded into probability. Absent from a record scored before v4.0.";
|
|
2690
|
+
const EG_PRIORITY_FIELD = "The decision (scorer v4.0): ssvc, the decision CISA's SSVC table gives as EchelonGraph applies it (decision at medium mission impact, as echelongraph_ssvc_decision, and by_mission), null with null_reason not_assessed for a CVE EchelonGraph has not assessed; bod_26_04, CISA BOD 26-04's timeline if_publicly_exposed and if_not_publicly_exposed (CERT/CC's keys 3DF, 3D, 14D, 60D, FSU); inputs, kev, exploitation, automatable and technical_impact, each with its source. Absent from a record scored before v4.0.";
|
|
2308
2691
|
const CVERecord = z.looseObject({
|
|
2309
2692
|
cve_id: opt(z.string()),
|
|
2310
2693
|
description: opt(z.string()),
|
|
@@ -2313,15 +2696,38 @@ const CVERecord = z.looseObject({
|
|
|
2313
2696
|
cvss_v4_score: opt(z.number()),
|
|
2314
2697
|
cvss_v4_severity: opt(z.string()),
|
|
2315
2698
|
// #2535: the score is a score only beside score_assessed true (see cveScoreNote).
|
|
2316
|
-
|
|
2699
|
+
// #2879: what the number is, since the scorer publishes the impact severity apart (eg_severity).
|
|
2700
|
+
echelongraph_score: opt(z.number()).describe("EchelonGraph's 0-10 score for the CVE: severity, escalated by confirmed exploitation (a CISA KEV listing floors it at 9.0) and lifted toward 9.0 by EPSS; eg_severity is the impact severity alone. A score only when score_assessed is true. With score_assessed false it is a placeholder, not a rating."),
|
|
2317
2701
|
echelongraph_severity: opt(z.string()).describe("The severity band of echelongraph_score: a rating only when score_assessed is true. With score_assessed false it is a placeholder (NONE)."),
|
|
2318
|
-
echelongraph_risk: opt(z.number()).describe("EchelonGraph's 0-100 risk priority, fused from the score, exploitation and automatability: a rating only when score_assessed is true. With score_assessed false it is a placeholder."),
|
|
2702
|
+
echelongraph_risk: opt(z.number()).describe("EchelonGraph's 0-100 risk priority, fused from impact severity (eg_severity, from scorer v4.0; the score before it), exploitation and automatability, with EchelonGraph's hand-set weights: a rating only when score_assessed is true. With score_assessed false it is a placeholder."),
|
|
2703
|
+
// #2879: the three components, relayed as the API sends them (componentsNote words them).
|
|
2704
|
+
eg_severity: opt(z.looseObject({
|
|
2705
|
+
assessed: opt(z.boolean()),
|
|
2706
|
+
score: opt(z.number()),
|
|
2707
|
+
severity: opt(z.string()),
|
|
2708
|
+
source: opt(z.string()),
|
|
2709
|
+
provider: opt(z.string()),
|
|
2710
|
+
unassessed_reason: opt(z.string()),
|
|
2711
|
+
})).describe(EG_SEVERITY_FIELD),
|
|
2712
|
+
eg_likelihood: opt(z.looseObject({
|
|
2713
|
+
probability: opt(z.number()),
|
|
2714
|
+
null_reason: opt(z.string()),
|
|
2715
|
+
horizon_days: opt(z.number()),
|
|
2716
|
+
source: opt(z.string()),
|
|
2717
|
+
exploited: opt(z.boolean()),
|
|
2718
|
+
exploited_sources: opt(z.array(z.string())),
|
|
2719
|
+
})).describe(EG_LIKELIHOOD_FIELD),
|
|
2720
|
+
eg_priority: opt(z.looseObject({
|
|
2721
|
+
ssvc: opt(z.looseObject({ decision: opt(z.string()), mission: opt(z.string()) })),
|
|
2722
|
+
bod_26_04: opt(z.looseObject({ if_publicly_exposed: opt(z.array(z.string())), if_not_publicly_exposed: opt(z.array(z.string())) })),
|
|
2723
|
+
inputs: z.unknown().optional(),
|
|
2724
|
+
})).describe(EG_PRIORITY_FIELD),
|
|
2319
2725
|
score_confidence: opt(z.string()).describe("How confident EchelonGraph is in its score: NONE when score_assessed is false."),
|
|
2320
2726
|
score_assessed: opt(z.boolean()).describe("Whether EchelonGraph has scored the CVE. true: echelongraph_score, echelongraph_severity and echelongraph_risk are its score. false: the CVE is NOT YET SCORED, not scored 0, and any of those three it carries is a placeholder, not a rating; the API may leave them out. Absent (an API older than the field): the answer does not say whether the CVE was scored."),
|
|
2321
2727
|
score_unassessed_reason: opt(z.string()).describe("Why score_assessed is false: no_signal, no source has yet published severity data EchelonGraph can score; or rejected, the record was withdrawn by its numbering authority and is never scored. Absent when the CVE is scored."),
|
|
2322
2728
|
// #2880: the SSVC decision, and CISA BOD 26-04's timeline (bodNote words it in the note).
|
|
2323
2729
|
echelongraph_ssvc_decision: opt(z.string()).describe("The SSVC decision (Act, Attend, Track* or Track) EchelonGraph computes with CISA's decision table at medium mission impact. CISA publishes the table and, for some CVEs, its inputs, not a decision for a CVE. score_factors.ssvc gives the decision at low, medium and high mission impact, with each input and its source."),
|
|
2324
|
-
score_factors: z.unknown().optional().describe("EchelonGraph's evidence for its score. ssvc: the SSVC decision at every mission impact level, each input with its source. bod_26_04 (scorer v3.4 and later): CISA BOD 26-04's remediation timeline, if_publicly_exposed and if_not_publicly_exposed, each a list of CERT/CC's keys (3DF, 3D, 14D, 60D, FSU) named in timelines, with kev, automatable and technical_impact, each with its source; source cisa_bod_26_04_default is CISA's own default for a CVE not in KEV that nobody has assessed."),
|
|
2730
|
+
score_factors: z.unknown().optional().describe("EchelonGraph's evidence for its score. ssvc: the SSVC decision at every mission impact level, each input with its source. bod_26_04 (scorer v3.4 and later): CISA BOD 26-04's remediation timeline, if_publicly_exposed and if_not_publicly_exposed, each a list of CERT/CC's keys (3DF, 3D, 14D, 60D, FSU) named in timelines, with kev, automatable and technical_impact, each with its source; source cisa_bod_26_04_default is CISA's own default for a CVE not in KEV that nobody has assessed. eg_severity, eg_likelihood and eg_priority (scorer v4.0): the same objects as the record's top-level fields. eg_risk.severity_source (v4.0): what EG Risk's severity term read, eg_severity, vendor_severity, or unknown_kev_most_urgent (no severity published for a CISA KEV CVE, so the most urgent value)."),
|
|
2325
2731
|
epss_score: opt(z.number()),
|
|
2326
2732
|
epss_percentile: opt(z.number()),
|
|
2327
2733
|
kev_listed: opt(z.boolean()),
|
|
@@ -2340,6 +2746,8 @@ const CVERecord = z.looseObject({
|
|
|
2340
2746
|
date_public_source: opt(z.string()).describe(DATE_PUBLIC_SOURCE_FIELD),
|
|
2341
2747
|
cna: opt(z.looseObject({ short_name: opt(z.string()), name: opt(z.string()) })).describe(CNA_FIELD),
|
|
2342
2748
|
cna_solution: opt(z.string()).describe(CNA_SOLUTION_FIELD),
|
|
2749
|
+
// #3178: a section the API adds later is relayed, never a failed call (as patch_evidence's labels).
|
|
2750
|
+
cna_solution_section: opt(z.union([z.enum(["solution", "workaround"]), z.string()])).describe(CNA_SOLUTION_SECTION_FIELD),
|
|
2343
2751
|
references: z.unknown().optional(),
|
|
2344
2752
|
// #2720: NVD's configurations, losslessly, beside the flat list (see cpeConfigurationsNote).
|
|
2345
2753
|
cpe_match: z.unknown().optional().describe(CPE_MATCH_DESCRIPTION),
|
|
@@ -2361,6 +2769,18 @@ const SUMMARY_NVD_FIELDS = "summary.nvd_critical, summary.nvd_high, summary.nvd_
|
|
|
2361
2769
|
// outputSchema's, names each counter and says what is left out.
|
|
2362
2770
|
const SUMMARY_POLLER_HEAD = "poller holds the in-memory counters of the NVD poller of the one API instance that answered, 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.";
|
|
2363
2771
|
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.";
|
|
2772
|
+
// #3276: what data.nvd_poll is, and cve_summary's freshness, which relays it (nvdPollFreshness). A
|
|
2773
|
+
// constant string, as every schema text here is; its 5 minutes are NVD_POLL_SKEW_MS.
|
|
2774
|
+
const NVD_POLL_DATA_FIELD = "When the fleet of API instances last completed an NVD poll: last_completed_at (RFC 3339; the end of the last window fetched and stored without an error), scope fleet, and interval, the poll's interval. The answer leaves it out where that time is unknown. freshness.nvd_poll relays it where scope is fleet and last_completed_at is a real RFC 3339 time at most 5 minutes after this server's clock.";
|
|
2775
|
+
const NVD_POLL_FRESHNESS = z.strictObject({
|
|
2776
|
+
nvd_poll: z
|
|
2777
|
+
.strictObject({
|
|
2778
|
+
last_run_at: Instant.describe("data.nvd_poll.last_completed_at as the API sent it: the end of the window the fleet's last completed NVD poll stored, whichever API instance ran it; never the answering instance's poller.last_poll_at."),
|
|
2779
|
+
scope: z.literal("fleet").describe("Whose poll it is: the fleet's, as data.nvd_poll.scope says."),
|
|
2780
|
+
interval: z.string().nullable().describe("data.nvd_poll.interval as the API sent it, a Go duration such as 20m0s: the interval the NVD poll runs at; null where the answer gives none in that form."),
|
|
2781
|
+
})
|
|
2782
|
+
.describe("The fleet's last completed NVD poll, from data.nvd_poll. freshness is null where the answer carries no nvd_poll, or one without such a time."),
|
|
2783
|
+
});
|
|
2364
2784
|
// One of the NVD histogram's four labelled buckets (#2641).
|
|
2365
2785
|
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.`);
|
|
2366
2786
|
const CVE_SUMMARY_OUTPUT = envelopeSchema({
|
|
@@ -2404,9 +2824,24 @@ const CVE_SUMMARY_OUTPUT = envelopeSchema({
|
|
|
2404
2824
|
.nullable()
|
|
2405
2825
|
.optional()
|
|
2406
2826
|
.describe(SUMMARY_POLLER_DESCRIPTION),
|
|
2827
|
+
// #3276: the fleet's last completed NVD poll, which nvdPollFreshness reads into freshness. Its
|
|
2828
|
+
// fields are named and described but untyped, and a block that is no object is admitted too:
|
|
2829
|
+
// as #2771 holds for poller, one of another JSON type leaves freshness null and is relayed as
|
|
2830
|
+
// sent, never a failed call.
|
|
2831
|
+
nvd_poll: z
|
|
2832
|
+
.union([
|
|
2833
|
+
z.looseObject({
|
|
2834
|
+
last_completed_at: z.unknown().optional().describe("When the fleet last completed an NVD poll, in RFC 3339: the end of the last window fetched and stored without an error."),
|
|
2835
|
+
scope: z.unknown().optional().describe("Whose poll it is: fleet, every API instance's, never the answering instance's alone."),
|
|
2836
|
+
interval: z.unknown().optional().describe("The poll's configured interval, a Go duration such as 20m0s."),
|
|
2837
|
+
}),
|
|
2838
|
+
z.unknown(),
|
|
2839
|
+
])
|
|
2840
|
+
.optional()
|
|
2841
|
+
.describe(NVD_POLL_DATA_FIELD),
|
|
2407
2842
|
}),
|
|
2408
2843
|
coverage: null,
|
|
2409
|
-
freshness:
|
|
2844
|
+
freshness: NVD_POLL_FRESHNESS,
|
|
2410
2845
|
});
|
|
2411
2846
|
const SEARCH_CVES_OUTPUT = envelopeSchema({
|
|
2412
2847
|
data: z.looseObject({
|
|
@@ -2431,15 +2866,22 @@ const SEARCH_CVES_OUTPUT = envelopeSchema({
|
|
|
2431
2866
|
});
|
|
2432
2867
|
const GET_CVE_OUTPUT = envelopeSchema({ data: CVERecord, coverage: null, freshness: null });
|
|
2433
2868
|
const CountryRows = z.array(z.looseObject({ country: opt(z.string()), hosts: opt(z.number()) }));
|
|
2869
|
+
// #3016: what each KEV flag is (requiredText.ts KEV_LISTED_DEPRECATED, which says why). The note
|
|
2870
|
+
// says what the record holds in words (exposureNote's kev sentence); these say it beside each field,
|
|
2871
|
+
// for a client that reads data alone. kev_catalog_listed is cves.kev_listed, which the KEV fetcher
|
|
2872
|
+
// sets only on a CVE row that exists, and the API answers false where there is none.
|
|
2873
|
+
const KEV_LISTED_FIELD = `${KEV_LISTED_DEPRECATED} ${KEV_OBSERVATION_FLAG}: false for a CISA-KEV CVE the radar holds no service for (CVE-2024-3400 on 2026-10-10: kev_listed false, kev_catalog_listed true). kev_catalog_listed is the catalog flag.`;
|
|
2874
|
+
const KEV_SEEN_FIELD = `true: at least one of the radar's service rows for the CVE carries the CISA-KEV flag copied in when the radar matched that service; false where none does, as for a CVE the radar holds no service for. ${KEV_OBSERVATION_FLAG}.`;
|
|
2875
|
+
const KEV_CATALOG_FIELD = `${KEV_CATALOG_FLAG} true when EchelonGraph's CVE record marks the CVE as CISA-KEV-listed. false also where EchelonGraph holds no record of the CVE, so a false says what EchelonGraph's data holds, not that the CVE is outside CISA's catalog.`;
|
|
2434
2876
|
const CVE_EXPOSURE_OUTPUT = envelopeSchema({
|
|
2435
2877
|
data: z.looseObject({
|
|
2436
2878
|
cve_id: opt(z.string()),
|
|
2437
2879
|
exposed_hosts: opt(z.number()),
|
|
2438
2880
|
countries: opt(z.number()),
|
|
2439
2881
|
tracked: opt(z.boolean()),
|
|
2440
|
-
kev_listed: opt(z.boolean()),
|
|
2441
|
-
kev_catalog_listed: opt(z.boolean()),
|
|
2442
|
-
kev_seen_in_observations: opt(z.boolean()),
|
|
2882
|
+
kev_listed: opt(z.boolean()).describe(KEV_LISTED_FIELD),
|
|
2883
|
+
kev_catalog_listed: opt(z.boolean()).describe(KEV_CATALOG_FIELD),
|
|
2884
|
+
kev_seen_in_observations: opt(z.boolean()).describe(KEV_SEEN_FIELD),
|
|
2443
2885
|
ransomware: opt(z.boolean()),
|
|
2444
2886
|
top_countries: opt(CountryRows),
|
|
2445
2887
|
top_products: opt(z.array(z.looseObject({ product: opt(z.string()), hosts: opt(z.number()) }))),
|
|
@@ -2466,11 +2908,20 @@ const zRow = (spec) => z.strictObject({
|
|
|
2466
2908
|
});
|
|
2467
2909
|
// A relayed observation window (#2438): two instants, never a count.
|
|
2468
2910
|
const Window = z.strictObject({ from: Instant, to: Instant });
|
|
2911
|
+
// The window over the dated rows only, beside how many rows carry no time (#2438, 2026-10-18):
|
|
2912
|
+
// relayed when undated is above 0, never as `window`.
|
|
2913
|
+
const DatedWindow = z
|
|
2914
|
+
.strictObject({
|
|
2915
|
+
from: Instant,
|
|
2916
|
+
to: Instant,
|
|
2917
|
+
undated: z.number().int().min(1).describe("How many of the rows the counts count have no observation time on record. from and to do not describe them."),
|
|
2918
|
+
})
|
|
2919
|
+
.describe("Timestamps over the dated rows only, relayed when some counted rows carry no observation time: a span of observations made at different times, never one observation time and never a count.");
|
|
2469
2920
|
const zRadar = (spec, rows = {}) => z.strictObject({
|
|
2470
2921
|
...Object.fromEntries(spec.counts.map((k) => [k, z.number().optional()])),
|
|
2471
2922
|
...Object.fromEntries(Object.entries(spec.rows).map(([k, rs]) => [k, (rows[k] ?? z.array(zRow(rs))).optional()])),
|
|
2472
2923
|
...Object.fromEntries(spec.instants.map((k) => [k, Instant.optional()])),
|
|
2473
|
-
...(spec.window ? { window: Window.optional() } : {}),
|
|
2924
|
+
...(spec.window ? { window: Window.optional(), dated_window: DatedWindow.optional() } : {}),
|
|
2474
2925
|
});
|
|
2475
2926
|
// A newest_kev row as newestKEVRow relays it: `tracked` is read, not relayed, and a count only
|
|
2476
2927
|
// when the row's exposure_state is not not_assessed.
|
|
@@ -2485,7 +2936,7 @@ const CountMap = z.record(z.string(), z.number());
|
|
|
2485
2936
|
const countRowsOf = (label) => z.array(z.strictObject({ [label]: z.string(), count: z.number() }));
|
|
2486
2937
|
const ShadowAIData = z.strictObject({
|
|
2487
2938
|
confirmed_exposed: z
|
|
2488
|
-
.strictObject({ total: z.number().optional(), by_category: CountMap.optional(), last_24h: z.number().optional(), window: Window.optional() })
|
|
2939
|
+
.strictObject({ total: z.number().optional(), by_category: CountMap.optional(), last_24h: z.number().optional(), window: Window.optional(), dated_window: DatedWindow.optional() })
|
|
2489
2940
|
.optional(),
|
|
2490
2941
|
observed: z
|
|
2491
2942
|
.strictObject({
|
|
@@ -2573,11 +3024,19 @@ const CVE_ID_ARG = z.string().describe("a CVE ID, e.g. CVE-2023-44487");
|
|
|
2573
3024
|
// What each description says about its structured result, naming only fields its schema holds.
|
|
2574
3025
|
// #2842: the generic rule for the result's last text block is in the README and the methodology
|
|
2575
3026
|
// resource, not in each description.
|
|
2576
|
-
|
|
3027
|
+
// #3276: search_cves' and get_cve's. Through 3.0.2 it said "the feed serves no poll-completion
|
|
3028
|
+
// time", false since cve_summary's answer carries the fleet's last completed NVD poll (#3242). Its
|
|
3029
|
+
// length is the old one's: get_cve's description sits at the #2924 headroom floor.
|
|
3030
|
+
const FEED_ENVELOPE = "freshness is null: this answer carries no poll-completion time.";
|
|
2577
3031
|
// #2771: cve_summary's own, since its data is the API's JSON less each poller field (or the whole
|
|
2578
3032
|
// poller) summaryPollerShaped leaves out. Like SUMMARY_POLLER_DESCRIPTION it uses none of #2610's
|
|
2579
3033
|
// six words ("any", "not", "rating", "scored", "source", "yet").
|
|
2580
|
-
|
|
3034
|
+
// #3276: freshness is the answer's nvd_poll (nvdPollFreshness). It replaces "The feed is polled from
|
|
3035
|
+
// its sources on a schedule; this is the state as of that update." too, which fit the #2924
|
|
3036
|
+
// headroom only with it gone; CVE_FEED_METHOD still says each source is polled on a schedule. Its
|
|
3037
|
+
// "from" is the one outside SUMMARY_NONE_DESCRIPTION that backs the /pulse/mcp row's "from" (the
|
|
3038
|
+
// site's #2610 control fails without it).
|
|
3039
|
+
const CVE_SUMMARY_ENVELOPE = "Its structured result's state is measured, with freshness.nvd_poll the fleet's last completed NVD poll and its interval, from the answer's nvd_poll (freshness null without one), and data equal to the API's JSON less each poller field (or the whole poller) the note names as left out.";
|
|
2581
3040
|
// #2610: what cve_summary says summary.none is (summaryNoneNote), one constant string for the same
|
|
2582
3041
|
// reason as SCORE_ASSESSED_DESCRIPTION below.
|
|
2583
3042
|
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, and names those CVEs not yet scored, not CVEs rated None.";
|
|
@@ -2598,9 +3057,14 @@ const TOOL_KIT = { api, succeeded, failed, badInput, crashed, checked, okHead, e
|
|
|
2598
3057
|
// tools/list answers in registration order, which is the order below.
|
|
2599
3058
|
export function createServer() {
|
|
2600
3059
|
const server = new McpServer({ name: NAME, version: VERSION }, { instructions: INSTRUCTIONS, capabilities: { tools: { listChanged: false }, prompts: { listChanged: false }, resources: { listChanged: false } } });
|
|
3060
|
+
// #3015: arguments that break a tool's inputSchema get badInput's envelope (state invalid_input,
|
|
3061
|
+
// notes naming each argument and its rule), not the SDK's bare "Input validation error" text.
|
|
3062
|
+
// Before the first registerTool, so every tool below and in src/tools/ is covered; tools/list's
|
|
3063
|
+
// inputSchemas are unchanged (schemaRefusal.ts).
|
|
3064
|
+
withSchemaRefusal(server, badInput);
|
|
2601
3065
|
server.registerTool("cve_summary", {
|
|
2602
3066
|
title: "CVE feed summary",
|
|
2603
|
-
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_HEAD}
|
|
3067
|
+
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_HEAD} ${CVE_SUMMARY_ENVELOPE} ${TEXT_BUDGET_DESCRIPTION}`,
|
|
2604
3068
|
inputSchema: z.object({ full: FULL_ARG }),
|
|
2605
3069
|
outputSchema: CVE_SUMMARY_OUTPUT,
|
|
2606
3070
|
annotations: ANNOTATIONS,
|
|
@@ -2658,7 +3122,7 @@ export function createServer() {
|
|
|
2658
3122
|
registerCveRemediation(server);
|
|
2659
3123
|
registerGetCwe(server);
|
|
2660
3124
|
// #2719: vendor_advisories_for_cve, get_vendor_advisory, search_vendor_advisories.
|
|
2661
|
-
registerVendorAdvisoryTools(server, { api, failed, describeFailure, badInput, crashed, succeeded, checked, okHead, envelopeSchema, annotations: ANNOTATIONS });
|
|
3125
|
+
registerVendorAdvisoryTools(server, { api, failed, describeFailure, badInput, failure, crashed, succeeded, checked, okHead, envelopeSchema, annotations: ANNOTATIONS });
|
|
2662
3126
|
// #2722: prompts and resources, in prompts.ts and resources.ts. Both lists are fixed per
|
|
2663
3127
|
// connection, so listChanged is false above.
|
|
2664
3128
|
registerPrompts(server, { cveId: CVE_ID });
|