echelongraph-mcp 2.7.1 → 3.0.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/dist/index.js CHANGED
@@ -16,8 +16,9 @@
16
16
  // what it left out (readKEVExposure, readExposedDatabases, readLeakedCredentials,
17
17
  // readShadowAI and readMCPServers; #2307, #2313, #2315). content[2] is the envelope below,
18
18
  // as JSON, less what content[0] and the note already say. Past 30,000 characters of JSON,
19
- // content[0] is that JSON cut to fit and the note ends by saying how (TEXT CUT); the
20
- // envelope's data is always the answer whole (textBudget.ts, #2783).
19
+ // content[0] is that JSON cut to fit and the note ends by saying how (TEXT CUT, #2783); past
20
+ // 30,000 characters of structured result, the envelope's data is cut too, unless the call
21
+ // says full: true, and the note says how (DATA CUT, #3013; both in textBudget.ts).
21
22
  // - failure (isError: true): the lookup did not complete — unreachable host, non-2xx,
22
23
  // timeout, or a 2xx whose body is not a JSON object — named by tool, cause and base URL,
23
24
  // in content[0]; content[1] is the envelope below, as JSON, less the message's sentences.
@@ -27,18 +28,23 @@
27
28
  // Structured results (#2311, #2313). Every result, success or failure, also carries
28
29
  // structuredContent: one envelope that says how the answer was measured — state (measured,
29
30
  // not_assessed, failed, invalid_input), measured_at, method, coverage, freshness and notes —
30
- // with, on a success, `data`, the answer whole, and on a failure an `error`. content[0] carries
31
- // `data` as JSON up to 30,000 characters, and past them a cut of it that the note names (TEXT
32
- // CUT, #2783), so past them content[0] is not equal to `data` (#2802). Each tool advertises the
33
- // envelope as its outputSchema.
31
+ // with, on a success, `data`, the answer, and on a failure an `error`. content[0] carries the
32
+ // answer as JSON up to 30,000 characters, and past them a cut of it that the note names (TEXT
33
+ // CUT, #2783), so past them content[0] is not equal to `data` (#2802). `data` is the answer whole
34
+ // unless the whole structured result would pass 30,000 characters of JSON: then, unless the call
35
+ // says full: true, its lists are cut in the order the tool names (DATA CUT, #3013), data_truncated
36
+ // is true and truncated gives each list cut (data_truncated is false otherwise). Each tool
37
+ // advertises the envelope as its outputSchema, which `data` validates against cut or whole.
34
38
  //
35
39
  // The envelope in the text (#2440, #2467). Many clients pass only `content` to the model, so the
36
40
  // last text block of every result is structuredContent serialized as JSON, less what an earlier
37
41
  // text block already carries: `data` (content[0] on a success, whole or cut as above), the note's
38
42
  // sentences (the block just before, which structuredContent's notes end with), and method where
39
43
  // that note quotes it. The rest — state, measured_at, coverage, freshness, the envelope's own notes
40
- // and, on a failure, error — is copied key for key, so the text blocks together are the whole of
41
- // structuredContent, but for what a TEXT CUT leaves out of `data`, and cannot disagree with it. The
44
+ // and, on a failure, error; on a success data_truncated and truncated — is copied key for key, so
45
+ // the text blocks together are the whole of structuredContent, but for what a TEXT CUT leaves out
46
+ // of the answer, and cannot disagree with it. Beside a DATA CUT (#3013) content[0] is still cut from
47
+ // the answer whole, not from the cut data, so it can hold entries data leaves out. The
42
48
  // MCP spec (2025-06-18, Tools, Structured Content) asks a tool that returns structured content to
43
49
  // return it serialized in a text block too; the JSON block stays first and the note second, as in
44
50
  // 1.x, so a client that parses content[0] still parses the answer's JSON, which past 30,000
@@ -59,12 +65,13 @@ import { registerCheckSbom } from "./tools/check_sbom.js";
59
65
  import { registerScanManifest } from "./tools/scan_manifest.js";
60
66
  import { registerCveIntel } from "./tools/cve_intel.js";
61
67
  import { registerCveRemediation } from "./tools/cve_remediation.js";
68
+ import { fixStatusSchema } from "./fixStatus.js";
62
69
  import { registerGetCwe } from "./tools/get_cwe.js";
63
70
  import { holdRequest, isHttpEntrypoint, requestSignal, upstreamHeaders, upstreamUserAgent } from "./runtime.js";
64
71
  import { registerVendorAdvisoryTools } from "./tools/vendor_advisories.js";
65
72
  import { registerPrompts } from "./prompts.js";
66
73
  import { registerResources } from "./resources.js";
67
- import { dataText, GET_CVE_WHOLE, TEXT_BUDGET_DESCRIPTION } from "./textBudget.js";
74
+ 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";
68
75
  import { SHODAN_ATTRIBUTION, SHODAN_OWNERSHIP } from "./requiredText.js";
69
76
  // The package actually running, read from the package.json that ships beside dist/. The MCP
70
77
  // handshake (serverInfo) and the User-Agent both carry its name and version, so the API's
@@ -372,12 +379,34 @@ export const checked = (tool, schema, r) => {
372
379
  };
373
380
  // A success: content[0] is the data (cut to DATA_TEXT_BUDGET when it is larger, #2783), content[1]
374
381
  // the note, content[2] the envelope less both; the envelope carries both, the note as sentences
375
- // after any the envelope adds about itself. structuredContent.data is the data whole.
382
+ // after any the envelope adds about itself. structuredContent.data is the data whole, or (#3013),
383
+ // when the structured result would pass STRUCTURED_BUDGET and the call (answering()) does not say
384
+ // full: true, cut as the call's DataCut says, with data_truncated, truncated and the note's DATA CUT
385
+ // sentences saying how. content[0] is cut from the data whole either way.
376
386
  export const succeeded = (data, note, env, cut) => {
377
387
  const own = env.notes ?? [];
388
+ const call = answeringNow();
378
389
  const shown = dataText(data, cut);
379
- const said = shown.said ? `${note} ${shown.said}` : note;
380
- const structuredContent = { ...env, notes: [...own, ...sentences(said)], data };
390
+ const structuredOf = (d, said, truncated) => ({
391
+ ...env,
392
+ notes: [...own, ...sentences(said)],
393
+ data_truncated: truncated !== undefined,
394
+ ...(truncated === undefined ? {} : { truncated }),
395
+ data: d,
396
+ });
397
+ let said = shown.said ? `${note} ${shown.said}` : note;
398
+ let structuredContent = structuredOf(data, said);
399
+ const wholeChars = JSON.stringify(structuredContent).length;
400
+ if (call?.full !== true && wholeChars > STRUCTURED_BUDGET) {
401
+ // The same text, with the sentences a first text block beside a cut data says.
402
+ const beside = dataText(data, cut, FIRST_TEXT_BLOCK_BESIDE_A_CUT).said;
403
+ const head = beside ? `${note} ${beside}` : note;
404
+ const c = cutData(data, call?.cut ?? NO_DATA_CUT, wholeChars, (d, truncated, dataSaid) => structuredOf(d, `${head} ${dataSaid}`, truncated));
405
+ if (c) {
406
+ said = `${head} ${c.said}`;
407
+ structuredContent = c.structured;
408
+ }
409
+ }
381
410
  return { content: [text(shown.text), text(said), envelopeText(structuredContent, said, own)], structuredContent };
382
411
  };
383
412
  export const okHead = (tool, status) => `${tool} OK: EchelonGraph answered${status === undefined ? "" : ` HTTP ${status}`} from ${SHOWN_BASE}.`;
@@ -663,6 +692,35 @@ const SEARCH_CVES_TEXT = {
663
692
  whole: `${GET_CVE_WHOLE}.`,
664
693
  page: (shown, _rows, d) => `To read the rows left out in the text, call search_cves again with the same arguments and offset ${(numAt(d, "offset") ?? 0) + shown}; a page of ${shown} rows or fewer like these fits the text without leaving rows out.`,
665
694
  };
695
+ // #3013: the structured result's cut. A row of production's answer is about 4,200 characters of
696
+ // compact JSON (score_factors, cpe_match and references, about 2,000 of them, are get_cve's), so the
697
+ // default page of 20 was 85,383 characters of structured result. Before any row leaves, every row
698
+ // is thinned: first to the first text cut level's fields, its description whole; then to the fields
699
+ // the description names, without the description. Only then do rows leave data from the end, and
700
+ // the note gives the offset to read them from, and the page size whose rows fit data whole. Through
701
+ // #3013's review that size was the number of thinned rows data kept, so a page of it came back
702
+ // thinned again (data_truncated true) while the note said it fit data whole: it is now counted on
703
+ // the rows whole (PageAt.wholeFit), the same call's envelope and note beside them.
704
+ const SEARCH_CVES_DATA = {
705
+ steps: [
706
+ {
707
+ lists: ["cves"],
708
+ thin: [
709
+ [
710
+ "cve_id", "description", "severity", "cvss_v3_score", "echelongraph_score", "echelongraph_severity", "echelongraph_risk", "score_assessed", "score_unassessed_reason", "epss_score",
711
+ "kev_listed", "kev_ransomware", "published", "vuln_status", "patch_available", "patch_evidence", "exploit_poc_available",
712
+ ],
713
+ ["cve_id", "severity", "cvss_v3_score", "echelongraph_score", "score_assessed", "score_unassessed_reason", "epss_score", "kev_listed", "kev_ransomware", "published", "patch_available", "patch_evidence"],
714
+ ],
715
+ },
716
+ ],
717
+ page: (t, d, at) => {
718
+ const k = t.cves?.returned;
719
+ if (!k || !t.cves || k === t.cves.total)
720
+ return "";
721
+ return `To read the rows past the first ${k} in data, call search_cves again with the same arguments and offset ${(numAt(d, "offset") ?? 0) + k}; ${wholePage(at.wholeFit("cves"))}`;
722
+ },
723
+ };
666
724
  async function searchCVEs(a) {
667
725
  const tool = "search_cves";
668
726
  try {
@@ -751,10 +809,73 @@ function kevTextNote(rec) {
751
809
  return "";
752
810
  const has = ["kev_required_action", "kev_short_description", "kev_notes_urls"].some((k) => field(rec, k) !== undefined && field(rec, k) !== null);
753
811
  return has
754
- ? " kev_required_action, kev_short_description and kev_notes_urls are CISA's own text from its KEV catalog, relayed verbatim: CISA's required action is directed at US federal civilian agencies under Binding Operational Directive 22-01 and is not EchelonGraph's advice."
812
+ ? " kev_required_action, kev_short_description and kev_notes_urls are CISA's own text from its KEV catalog, relayed verbatim: CISA's required action is directed at US federal civilian agencies under Binding Operational Directive 26-04 (which replaced BOD 22-01 on 10 June 2026) and is not EchelonGraph's advice."
755
813
  : "";
756
814
  }
815
+ // #2880: what the record's CISA BOD 26-04 timeline is, where the scorer wrote it
816
+ // (core-backend/internal/cve/scoring/bod2604.go, algorithm v3.4+): given for both answers to "is
817
+ // the system publicly exposed?", because that is the reader's own system's, each input with its
818
+ // source. A record scored before v3.4, or a malformed block, gets no sentence: the JSON is relayed
819
+ // whole either way. What the SSVC decision is (EchelonGraph's run of CISA's table, never a
820
+ // decision CISA published, #3053) is in the outputSchema's echelongraph_ssvc_decision, not the
821
+ // note: get_cve's text is measured to the character (test/text-bound.mjs).
822
+ const BOD_TIMELINE = {
823
+ FSU: "Fix on system upgrade",
824
+ "60D": "60 days",
825
+ "14D": "14 days",
826
+ "3D": "3 days",
827
+ "3DF": "3 days & forensic triage",
828
+ };
829
+ const BOD_SOURCE = {
830
+ "cisa-adp": "CISA's Vulnrichment value",
831
+ cisa_kev: "CISA's KEV catalog",
832
+ cvss_v4_au: "the CVSS v4.0 Automatable metric",
833
+ cvss_v4_av_physical: "CVSS v4.0 attack vector Physical",
834
+ cvss_v4_vc_vi_high: "CVSS v4.0 confidentiality and integrity impact both High",
835
+ cvss_v4_vc_vi_not_high: "CVSS v4.0 confidentiality and integrity impact neither High",
836
+ cisa_bod_26_04_default: "CISA's BOD 26-04 default for a CVE not in KEV that nobody has assessed",
837
+ not_supplied: "not yet published by CISA, so every timeline it allows is listed",
838
+ };
839
+ function bodTimelines(v) {
840
+ if (!Array.isArray(v) || v.length === 0 || !v.every((k) => typeof k === "string" && BOD_TIMELINE[k]))
841
+ return null;
842
+ const w = v.map((k) => BOD_TIMELINE[k]);
843
+ return w.length === 1 ? w[0] : `${w.slice(0, -1).join(", ")} or ${w[w.length - 1]}`;
844
+ }
845
+ function bodNote(cve, rec) {
846
+ const b = field(rec, "score_factors", "bod_26_04");
847
+ const exposed = bodTimelines(field(b, "if_publicly_exposed"));
848
+ const internal = bodTimelines(field(b, "if_not_publicly_exposed"));
849
+ const kev = strAt(b, "kev", "value");
850
+ const input = (k, name) => {
851
+ const v = strAt(b, k, "value");
852
+ const src = strAt(b, k, "source");
853
+ return v && src ? `${name} ${v} (${BOD_SOURCE[src] ?? src})` : null;
854
+ };
855
+ const auto = input("automatable", "automatable");
856
+ const ti = input("technical_impact", "technical impact");
857
+ if (!(exposed && internal && (kev === "yes" || kev === "no") && auto && ti))
858
+ return "";
859
+ 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
+ }
757
861
  // #2841: one sentence on the record's remediation summary, where the API sends one.
862
+ // #3070, #3076: the CNA's own date and fix sentence, named as the CNA's. Nothing when the record
863
+ // carries neither; a value of another JSON type is not quoted (the outputSchema refuses it).
864
+ function cnaRecordNote(cve, rec) {
865
+ const cna = field(rec, "cna");
866
+ const nameOf = (k) => (cna && typeof cna === "object" && typeof field(cna, k) === "string" ? String(field(cna, k)).trim() : "");
867
+ const who = nameOf("name") || nameOf("short_name");
868
+ // An appositive name is closed by a comma: "its CNA, Palo Alto Networks, Inc., states".
869
+ const by = who ? `its CNA, ${who},` : "its CNA";
870
+ const date = strAt(rec, "date_public");
871
+ const fix = strAt(rec, "cna_solution");
872
+ const parts = [];
873
+ if (date)
874
+ 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)
876
+ 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.`);
877
+ return parts.join("");
878
+ }
758
879
  function remediationSummaryNote(cve, rec) {
759
880
  const s = field(rec, "remediation");
760
881
  if (s === null || typeof s !== "object")
@@ -764,8 +885,21 @@ function remediationSummaryNote(cve, rec) {
764
885
  const failed = field(s, "failed_sections");
765
886
  const notRead = Array.isArray(failed) && failed.length ? ` The summary could not read ${failed.join(", ")} (failed_sections), so a missing count there is not a finding of none.` : "";
766
887
  const byKind = counted.length ? ` Vendor advisories list ${counted.map(([k, n]) => `${k} (${n})`).join(", ")}, by the vendor's own category.` : "";
767
- return ` remediation summarises how ${cve} is fixed, as its sources state it; cve_remediation returns each vendor's text, CISA's action and every fixed range.${byKind}${notRead}`;
888
+ // #2841: fix_status is the API's, relayed; a state is named only as the API sent it.
889
+ const fs = field(s, "fix_status");
890
+ const state = strAt(fs, "state");
891
+ const basis = strAt(fs, "basis");
892
+ const fix = state
893
+ ? ` remediation.fix_status is ${state}${basis ? ` (basis ${basis})` : ""}, the API's fix-status rule${state === "none_confirmed" ? ", which is not a finding that no fix exists" : ""}.`
894
+ : "";
895
+ return ` remediation summarises how ${cve} is fixed, as its sources state it; cve_remediation returns each vendor's text, CISA's action and every fixed range.${fix}${byKind}${notRead}`;
768
896
  }
897
+ // #3013: the structured result's cut. CVE-2021-44228's record is 64,170 characters of structured
898
+ // result, 41,091 of them cpe_match (396 entries) and 17,103 references (103): those two are cut
899
+ // first, each to at most the same number of its first entries, then cpe_configurations, then any
900
+ // other list at the top level of the record. The remediation summary (#2841), an object, is never
901
+ // cut, so its fix_status is whole at every level.
902
+ const GET_CVE_DATA = { steps: [{ lists: ["cpe_match", "references"] }, { lists: ["cpe_configurations"] }] };
769
903
  async function getCVE(cve_id) {
770
904
  const tool = "get_cve";
771
905
  const id = cve_id.trim();
@@ -777,7 +911,7 @@ async function getCVE(cve_id) {
777
911
  return failed(tool, r);
778
912
  const written = strAt(r.data, "updated_at");
779
913
  const cve = strAt(r.data, "cve_id") ?? id;
780
- return succeeded(r.data, `${okHead(tool, r.status)} Returned the record for ${cve}.${cveScoreNote(cve, r.data)}${kevTextNote(r.data)}${remediationSummaryNote(cve, r.data)}`, {
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)}`, {
781
915
  state: "measured",
782
916
  measured_at: instantOrNull(written),
783
917
  method: CVE_FEED_METHOD,
@@ -1206,6 +1340,86 @@ function lastCheck(radar, data) {
1206
1340
  `${radar}.last_run_at is not the time of every record the ${radar} numbers count, which cover everything still on record, not only what that check found; nor is it ${radar}.generated_at, when the API computed those numbers.`,
1207
1341
  ];
1208
1342
  }
1343
+ // ── Staleness: a radar whose last completed check is older than its cycle allows (#3010) ──
1344
+ //
1345
+ // Through 2.7.1 the note flagged only the two radars whose answer carries a liveness verdict
1346
+ // (shadow_ai's poller.running, mcp_servers.enabled), and every answer ended "Each radar refreshes
1347
+ // on its own schedule.". So during the #2975 scanning pause exposed_databases (77 h since its last
1348
+ // completed check) and leaked_credentials (68 h) read as current. Each radar now has a threshold
1349
+ // keyed to the cadence core-backend runs it at (cmd/server/main.go builds each poller; an interval
1350
+ // of 0 takes the package's defaultInterval):
1351
+ // kev_exposure kevexposure/poller.go defaultInterval, 12 h: 24 h, two cycles
1352
+ // exposed_databases exposeddb/poller.go defaultInterval, 12 h: 24 h, two cycles
1353
+ // leaked_credentials leakedcreds/poller.go defaultInterval, 10 min: 1 h, six cycles, so a stopped
1354
+ // radar shows within the hour and a slow cycle or two never trips it
1355
+ // shadow_ai the API's own poller.running (shadowctlog pollerSilentAfter, 30 min) first;
1356
+ // only when the answer gives no verdict, 30 minutes by poller.last_run_at
1357
+ // mcp_servers the API's own enabled (aiexposure liveWindow, 45 min) first; only when the
1358
+ // answer gives no verdict, 45 minutes by last_run_at
1359
+ // These are copies of numbers core-backend owns, so test/radar-staleness.test.mjs reads the Go files
1360
+ // and fails when a threshold is under twice the cadence it parses, when a cycle named here is not
1361
+ // the one it parses, when a radar has no entry, or when a constant can no longer be parsed.
1362
+ //
1363
+ // The age is taken against this server's clock when the answer is composed, never against a
1364
+ // radar's generated_at: that is when the API's stats cache computed the totals, and the cache is
1365
+ // stale-while-revalidate (core-backend internal/cache/swr.go keeps serving its last value while a
1366
+ // refresh fails), so it can lag the answer by hours and would understate the age. last_run_at is
1367
+ // read fresh (the API omits it when its last good read is more than two minutes old). The note
1368
+ // gives the age and the cycle, says the numbers may be stale, and gives no cause: the answer gives
1369
+ // none.
1370
+ const MINUTE = 60_000;
1371
+ const HOUR = 60 * MINUTE;
1372
+ export const STALE_AFTER = {
1373
+ kev_exposure: { after: 24 * HOUR, cycle: 12 * HOUR },
1374
+ exposed_databases: { after: 24 * HOUR, cycle: 12 * HOUR },
1375
+ leaked_credentials: { after: HOUR, cycle: 10 * MINUTE },
1376
+ shadow_ai: { after: 30 * MINUTE },
1377
+ mcp_servers: { after: 45 * MINUTE },
1378
+ };
1379
+ const counted = (n, unit) => `${n} ${unit}${n === 1 ? "" : "s"}`;
1380
+ // A threshold or a cycle: "24 hours", "1 hour", "45 minutes"; as an adjective, "12-hour".
1381
+ const span = (ms) => (ms % HOUR === 0 ? counted(ms / HOUR, "hour") : counted(Math.round(ms / MINUTE), "minute"));
1382
+ const spanAdj = (ms) => (ms % HOUR === 0 ? `${ms / HOUR}-hour` : `${Math.round(ms / MINUTE)}-minute`);
1383
+ // An age, rounded: minutes under two hours, hours under a week, days past that.
1384
+ const age = (ms) => ms < 2 * HOUR ? counted(Math.round(ms / MINUTE), "minute") : ms < 168 * HOUR ? counted(Math.round(ms / HOUR), "hour") : counted(Math.round(ms / (24 * HOUR)), "day");
1385
+ // How long before `now` the instant `at` was, when that is past the radar's threshold; otherwise
1386
+ // (or when `at` is not a real instant) undefined.
1387
+ function staleBy(radar, at, now) {
1388
+ if (typeof at !== "string" || !realInstant(at))
1389
+ return undefined;
1390
+ const ms = now - Date.parse(at);
1391
+ return ms > STALE_AFTER[radar].after ? ms : undefined;
1392
+ }
1393
+ // The note's sentence for a stats radar whose relayed last_run_at is past its threshold. It does not
1394
+ // repeat the stamp, which lastCheck prints once.
1395
+ function staleCheck(radar, data, now) {
1396
+ const ms = staleBy(radar, data.last_run_at, now);
1397
+ if (ms === undefined)
1398
+ return [];
1399
+ const { after, cycle } = STALE_AFTER[radar];
1400
+ const times = after / cycle === 2 ? "twice" : `${after / cycle} times`;
1401
+ return [`${radar}.last_run_at is about ${age(ms)} before this answer, more than ${times} the radar's ${spanAdj(cycle)} cycle, so the ${radar} numbers may be stale.`];
1402
+ }
1403
+ // The last sentence of every answer. "Each radar refreshes on its own schedule." said the radars
1404
+ // were refreshing whatever their last completed checks said. This says only what the answer
1405
+ // shows: each radar has a cycle of its own (not that it is running on it), and either the radars
1406
+ // that may be stale, the same ones whose own sentences above say so, or that nothing shows one past
1407
+ // its threshold, with each threshold named. "Nothing shows" stays true when the API serves no
1408
+ // last_run_at (#2335: the note then says nothing about a last completed check), and the envelope's
1409
+ // own note (RADAR_FRESHNESS_NOTE) already points to freshness on every answer.
1410
+ function freshnessClose(stale) {
1411
+ if (stale.length)
1412
+ return `Each radar has its own cycle, and ${stale.length} of the ${RADARS.length} radars may be stale: ${listed(stale)}.`;
1413
+ const byAfter = new Map();
1414
+ for (const [name] of RADARS) {
1415
+ const s = STALE_AFTER[name];
1416
+ if (s.cycle !== undefined)
1417
+ byAfter.set(s.after, [...(byAfter.get(s.after) ?? []), name]);
1418
+ }
1419
+ const timed = [...byAfter].map(([after, names]) => `${span(after)} for ${listed(names)}`);
1420
+ const verdicts = `for shadow_ai and mcp_servers, the API's own poller.running and enabled verdicts, or ${span(STALE_AFTER.shadow_ai.after)} and ${span(STALE_AFTER.mcp_servers.after)} where the answer gives none`;
1421
+ return `Each radar has its own cycle, and neither freshness nor anything else in this answer shows a radar past its threshold for calling its numbers stale (${timed.join(", ")}; ${verdicts}).`;
1422
+ }
1209
1423
  // The first row of a relayed ranking, for the note: "(first: http_server, 9120 services)".
1210
1424
  function firstOf(rows, label, count, unit) {
1211
1425
  const top = Array.isArray(rows) ? rows[0] : undefined;
@@ -1288,7 +1502,7 @@ function newestKEVRow(r) {
1288
1502
  row.exposed_hosts = hosts;
1289
1503
  return { row, state, outside: tracked === false };
1290
1504
  }
1291
- function readKEVExposure(body) {
1505
+ function readKEVExposure(body, now) {
1292
1506
  const { data, unknown, unusable, undated } = readRadar(body, KEV_SPEC);
1293
1507
  const sentences = [];
1294
1508
  const n = (k) => numIn(data, k);
@@ -1346,8 +1560,10 @@ function readKEVExposure(body) {
1346
1560
  }
1347
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"));
1348
1562
  sentences.push(...lastCheck("kev_exposure", data));
1563
+ const stale = staleCheck("kev_exposure", data, now);
1564
+ sentences.push(...stale);
1349
1565
  sentences.push(...leftOut("kev_exposure", unknown, unusable));
1350
- return { data, sentences };
1566
+ return { data, sentences, stale: stale.length > 0 };
1351
1567
  }
1352
1568
  // ── exposed_databases: GET /api/v1/public/exposed-databases/stats (#2313 item 7) ──
1353
1569
  //
@@ -1375,7 +1591,7 @@ const EXPOSED_DB_SPEC = {
1375
1591
  instants: ["generated_at", "last_run_at"],
1376
1592
  window: "observed_window",
1377
1593
  };
1378
- function readExposedDatabases(body) {
1594
+ function readExposedDatabases(body, now) {
1379
1595
  const { data, unknown, unusable, undated } = readRadar(body, EXPOSED_DB_SPEC);
1380
1596
  const sentences = [];
1381
1597
  const n = (k) => numIn(data, k);
@@ -1401,8 +1617,10 @@ function readExposedDatabases(body) {
1401
1617
  }
1402
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"));
1403
1619
  sentences.push(...lastCheck("exposed_databases", data));
1620
+ const stale = staleCheck("exposed_databases", data, now);
1621
+ sentences.push(...stale);
1404
1622
  sentences.push(...leftOut("exposed_databases", unknown, unusable));
1405
- return { data, sentences };
1623
+ return { data, sentences, stale: stale.length > 0 };
1406
1624
  }
1407
1625
  // ── leaked_credentials: GET /api/v1/public/leaked-credentials/stats (#2313 item 7) ──
1408
1626
  //
@@ -1425,7 +1643,7 @@ const LEAKED_CREDS_SPEC = {
1425
1643
  instants: ["generated_at", "last_run_at"],
1426
1644
  window: "observed_window",
1427
1645
  };
1428
- function readLeakedCredentials(body) {
1646
+ function readLeakedCredentials(body, now) {
1429
1647
  const { data, unknown, unusable, undated } = readRadar(body, LEAKED_CREDS_SPEC);
1430
1648
  const sentences = [];
1431
1649
  const n = (k) => numIn(data, k);
@@ -1448,10 +1666,12 @@ function readLeakedCredentials(body) {
1448
1666
  }
1449
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"));
1450
1668
  sentences.push(...lastCheck("leaked_credentials", data));
1669
+ const stale = staleCheck("leaked_credentials", data, now);
1670
+ sentences.push(...stale);
1451
1671
  sentences.push(...leftOut("leaked_credentials", unknown, unusable));
1452
- return { data, sentences };
1672
+ return { data, sentences, stale: stale.length > 0 };
1453
1673
  }
1454
- function readShadowAI(sa) {
1674
+ function readShadowAI(sa, now) {
1455
1675
  const sentences = [];
1456
1676
  const stats = field(sa, "stats");
1457
1677
  const used = new Set();
@@ -1574,6 +1794,10 @@ function readShadowAI(sa) {
1574
1794
  data.authentication = authentication;
1575
1795
  const lastRun = strAt(sa, "poller", "last_run_at");
1576
1796
  const running = boolAt(sa, "poller", "running");
1797
+ // #3010: running is the API's own verdict and decides; only a block with a real last_run_at and
1798
+ // no verdict is judged by STALE_AFTER.shadow_ai, the API's own window, and that time is still
1799
+ // not relayed (the block stays left out) or repeated: the note gives its age.
1800
+ const unjudged = running === undefined ? staleBy("shadow_ai", lastRun, now) : undefined;
1577
1801
  if (realInstant(lastRun) && running === true) {
1578
1802
  data.poller = { running, last_run_at: lastRun };
1579
1803
  sentences.push(`Shadow-AI radar freshness: the radar's leader last completed a Certificate Transparency (crt.sh) discovery cycle at ${lastRun} (poller.running is true: that was within ${SHADOW_AI_SILENT_AFTER} of the API's answer).`);
@@ -1587,10 +1811,13 @@ function readShadowAI(sa) {
1587
1811
  }
1588
1812
  else {
1589
1813
  sentences.push("Shadow-AI radar freshness is unknown: the answer's poller block does not give both a real time at which the radar's leader last completed a cycle (poller.last_run_at) and a running verdict (poller.running), so that block was left out of the data above.");
1814
+ if (unjudged !== undefined) {
1815
+ sentences.push(`The shadow-AI poller block gives no running verdict, but its last_run_at is about ${age(unjudged)} before this answer, more than the ${span(STALE_AFTER.shadow_ai.after)} after which the API reports the radar not running, so the shadow-AI figures may be stale.`);
1816
+ }
1590
1817
  }
1591
1818
  if (realInstant(lastObservation))
1592
1819
  sentences.push(`Latest shadow-AI observation on record: ${lastObservation}.`);
1593
- return { data, sentences };
1820
+ return { data, sentences, stale: (realInstant(lastRun) && running === false) || unjudged !== undefined };
1594
1821
  }
1595
1822
  // ── mcp_servers: GET /api/v1/public/ai-exposure/stats?service=mcp (#2315) ──
1596
1823
  //
@@ -1716,7 +1943,7 @@ function mcpRefusal(body) {
1716
1943
  }
1717
1944
  return undefined;
1718
1945
  }
1719
- function readMCPServers(src) {
1946
+ function readMCPServers(src, now) {
1720
1947
  const data = {};
1721
1948
  const unusable = [];
1722
1949
  const unsummed = [];
@@ -1807,12 +2034,18 @@ function readMCPServers(src) {
1807
2034
  if (typeof at === "string") {
1808
2035
  sentences.push(`mcp_servers last completed check: ${at} (mcp_servers.last_run_at, a timestamp, not a count).`, "mcp_servers.last_run_at is when the AI-exposure radar, which checks other AI services as well as MCP servers, last finished a cycle whose reads succeeded and whose scan opt-out register answered.", "mcp_servers.last_run_at is not the time of every verdict the mcp_servers numbers count (mcp_servers.window says when those were checked); nor is it mcp_servers.counted_at, when the API read those numbers.");
1809
2036
  }
2037
+ // #3010: enabled is the API's own verdict and decides; only an answer without a usable one is
2038
+ // judged by STALE_AFTER.mcp_servers, the API's own window, on the last_run_at relayed above.
2039
+ const unjudged = data.enabled === undefined ? staleBy("mcp_servers", data.last_run_at, now) : undefined;
1810
2040
  if (data.enabled === true) {
1811
2041
  sentences.push(`mcp_servers.enabled is true: the API reports the AI-exposure radar running, a check having completed within ${MCP_ENABLED_WITHIN} of its answer.`);
1812
2042
  }
1813
2043
  else if (data.enabled === false) {
1814
2044
  sentences.push(`mcp_servers.enabled is false: the API does not report the AI-exposure radar running (no check completed within ${MCP_ENABLED_WITHIN} of its answer), so these numbers may be stale.`);
1815
2045
  }
2046
+ else if (unjudged !== undefined) {
2047
+ sentences.push(`The answer gives no usable mcp_servers.enabled verdict, but mcp_servers.last_run_at is about ${age(unjudged)} before this answer, more than the ${span(STALE_AFTER.mcp_servers.after)} within which the API reports the AI-exposure radar running, so the mcp_servers numbers may be stale.`);
2048
+ }
1816
2049
  if (typeof data.counted_at === "string") {
1817
2050
  sentences.push(`mcp_servers.counted_at (${data.counted_at}) is when the API read these counts: a timestamp, not a count, and not when any verdict was checked.`);
1818
2051
  }
@@ -1822,7 +2055,7 @@ function readMCPServers(src) {
1822
2055
  if (unnamed) {
1823
2056
  sentences.push(`Also left out of mcp_servers: ${unnamed} field${unnamed === 1 ? "" : "s"} whose name is not shaped like a field name, not repeated here, since a name could itself identify a server.`);
1824
2057
  }
1825
- return { data, sentences };
2058
+ return { data, sentences, stale: data.enabled === false || unjudged !== undefined };
1826
2059
  }
1827
2060
  // #2842: the description is a head that fits the client cut (requiredText.ts); what each radar's
1828
2061
  // numbers count, field by field, is the outputSchema's description of that radar (RADAR_*_FIELDS
@@ -1858,9 +2091,38 @@ const RADAR_MCP_FIELDS = `mcp_servers: counts and timestamps only, no hostname.
1858
2091
  // not_assessed with measured_at null, the window is relayed in data and labelled by the note,
1859
2092
  // and the envelope's own note names it.
1860
2093
  const RADAR_METHOD = "Aggregate counts over what each radar holds on record: kev_exposure, Shodan banners whose version maps to a CISA-KEV-listed CVE; exposed_databases, services found through Shodan (LeakIX when Shodan query credits run low) and confirmed by EchelonGraph's own identified check; leaked_credentials, credential-shaped strings in public GitHub push events; shadow_ai, services found through Certificate Transparency logs and Shodan and checked by EchelonGraph's identified probes; mcp_servers, the latest verdict on record per hostname named like an MCP server in EchelonGraph's own Certificate Transparency feed, from EchelonGraph's identified MCP probe (server/discover, and initialize only if that is refused; never tools/call), where protected means the endpoint's RFC 9728 protected-resource metadata validated.";
1861
- const RADAR_NAMES = RADARS.map(([name]) => name);
2094
+ export const RADAR_NAMES = RADARS.map(([name]) => name);
1862
2095
  const RADAR_STATE_NOTE = "state is not_assessed: every radar answered, but none gives one time at which what it counts was observed: kev_exposure, exposed_databases, leaked_credentials, shadow_ai's confirmed_exposed and mcp_servers date what they count at most by a window (kev_exposure.window, exposed_databases.window, leaked_credentials.window, shadow_ai.confirmed_exposed.window, mcp_servers.window), from the oldest observation among the rows counted to the newest, so no count here is presented as a dated measurement and measured_at is null. Each count is what that radar holds on record, and the note labels each one by what it counts.";
1863
2096
  const RADAR_FRESHNESS_NOTE = "freshness gives each radar's last completed check where the API serves one (freshness.kev_exposure.last_run_at, freshness.exposed_databases.last_run_at, freshness.leaked_credentials.last_run_at, freshness.shadow_ai.last_run_at with freshness.shadow_ai.running, and freshness.mcp_servers.last_run_at with freshness.mcp_servers.enabled), and null where it does not.";
2097
+ // #3013: the structured result's cut. Production's answers of 2026-10-05 (fixtures/prod-answers) are
2098
+ // 33,278 characters of structured result, past the budget by notes that label every number (about
2099
+ // 20,000 characters) beside 12,733 of data. The notes say what each list counts, from the answer
2100
+ // whole, so the lists are cut, never a note: first the two series (shadow_ai.observed.trend_30d,
2101
+ // kev_exposure.trend), then every ranking to at most the same number of its first rows, then
2102
+ // kev_exposure.newest_kev, whose CVEs and exposure states the note names one by one. #3013's
2103
+ // review: the series arrive oldest first (a day, a week a row), and a cut that kept their first
2104
+ // entries showed the oldest, lowest weeks as the trend and left out the latest; each now keeps its
2105
+ // newest entries (keepNewest), and the note says so.
2106
+ const EXPOSURE_RADAR_DATA = {
2107
+ steps: [
2108
+ { lists: ["shadow_ai.observed.trend_30d", "kev_exposure.trend"], keepNewest: true },
2109
+ {
2110
+ lists: [
2111
+ "kev_exposure.top_products",
2112
+ "kev_exposure.top_countries",
2113
+ "kev_exposure.top_cves",
2114
+ "exposed_databases.top_engines",
2115
+ "exposed_databases.top_countries",
2116
+ "leaked_credentials.top_providers",
2117
+ "leaked_credentials.top_types",
2118
+ "shadow_ai.observed.top_products",
2119
+ "shadow_ai.observed.top_countries",
2120
+ "shadow_ai.observed.top_issuers",
2121
+ ],
2122
+ },
2123
+ { lists: ["kev_exposure.newest_kev"] },
2124
+ ],
2125
+ };
1864
2126
  async function exposureRadar() {
1865
2127
  const tool = "exposure_radar";
1866
2128
  try {
@@ -1891,43 +2153,50 @@ async function exposureRadar() {
1891
2153
  const radars = failures.map(([radar, f]) => ({ radar, kind: f.kind, path: f.path, status: f.status ?? null, message: f.detail }));
1892
2154
  return failure("failed", message, { kind: "radars", path: null, status: null, message: `${failures.length} of ${RADARS.length} radars could not be read`, radars }, { radars: RADAR_NAMES, answered, failed: failures.map(([name]) => name) });
1893
2155
  }
1894
- // Each radar's answer is relayed cut to the fields its reader can label, in RADARS order.
1895
- const read = {
1896
- kev_exposure: readKEVExposure(data.kev_exposure),
1897
- exposed_databases: readExposedDatabases(data.exposed_databases),
1898
- leaked_credentials: readLeakedCredentials(data.leaked_credentials),
1899
- shadow_ai: readShadowAI(data.shadow_ai),
1900
- mcp_servers: readMCPServers(data.mcp_servers),
1901
- };
1902
- const relayed = {};
1903
- const said = [];
1904
- for (const [name] of RADARS) {
1905
- relayed[name] = read[name].data;
1906
- said.push(...read[name].sentences);
1907
- }
1908
- // Only what the readers relayed: a last_run_at that is not a real instant never got there.
1909
- const stamp = (radar, ...keys) => instantOrNull(strAt(relayed[radar], ...keys));
1910
- const running = boolAt(relayed.shadow_ai, "poller", "running");
1911
- const enabled = boolAt(relayed.mcp_servers, "enabled");
1912
- return succeeded(relayed, `${okHead(tool)} All ${RADARS.length} radars answered: ${RADARS.map(([name]) => name).join(", ")}. Every number relayed is labelled below by what it counts, and a field this version cannot label is left out and named. ${said.join(" ")} ${RADAR_SOURCES} ${SHODAN_OWNERSHIP} Each radar refreshes on its own schedule.`, {
1913
- state: "not_assessed",
1914
- measured_at: null,
1915
- method: RADAR_METHOD,
1916
- coverage: { radars: RADAR_NAMES, answered: RADAR_NAMES, failed: [] },
1917
- freshness: {
1918
- kev_exposure: { last_run_at: stamp("kev_exposure", "last_run_at") },
1919
- exposed_databases: { last_run_at: stamp("exposed_databases", "last_run_at") },
1920
- leaked_credentials: { last_run_at: stamp("leaked_credentials", "last_run_at") },
1921
- shadow_ai: { last_run_at: stamp("shadow_ai", "poller", "last_run_at"), running: running ?? null },
1922
- mcp_servers: { last_run_at: stamp("mcp_servers", "last_run_at"), enabled: enabled ?? null },
1923
- },
1924
- notes: [RADAR_STATE_NOTE, RADAR_FRESHNESS_NOTE],
1925
- });
2156
+ return radarAnswer(data, Date.now());
1926
2157
  }
1927
2158
  catch (e) {
1928
2159
  return crashed(tool, e);
1929
2160
  }
1930
2161
  }
2162
+ // exposure_radar's answer once every radar has answered (and mcp_servers has passed mcpRefusal):
2163
+ // each radar's answer relayed cut to the fields its reader can label, in RADARS order, and the
2164
+ // note. `now` is the clock the radars' last completed checks are aged against (#3010; see
2165
+ // STALE_AFTER), a parameter so that the suite can set it.
2166
+ export function radarAnswer(data, now) {
2167
+ const read = {
2168
+ kev_exposure: readKEVExposure(data.kev_exposure, now),
2169
+ exposed_databases: readExposedDatabases(data.exposed_databases, now),
2170
+ leaked_credentials: readLeakedCredentials(data.leaked_credentials, now),
2171
+ shadow_ai: readShadowAI(data.shadow_ai, now),
2172
+ mcp_servers: readMCPServers(data.mcp_servers, now),
2173
+ };
2174
+ const relayed = {};
2175
+ const said = [];
2176
+ for (const [name] of RADARS) {
2177
+ relayed[name] = read[name].data;
2178
+ said.push(...read[name].sentences);
2179
+ }
2180
+ const stale = RADAR_NAMES.filter((name) => read[name].stale);
2181
+ // Only what the readers relayed: a last_run_at that is not a real instant never got there.
2182
+ const stamp = (radar, ...keys) => instantOrNull(strAt(relayed[radar], ...keys));
2183
+ const running = boolAt(relayed.shadow_ai, "poller", "running");
2184
+ const enabled = boolAt(relayed.mcp_servers, "enabled");
2185
+ return succeeded(relayed, `${okHead("exposure_radar")} All ${RADARS.length} radars answered: ${RADARS.map(([name]) => name).join(", ")}. Every number relayed is labelled below by what it counts, and a field this version cannot label is left out and named. ${said.join(" ")} ${RADAR_SOURCES} ${SHODAN_OWNERSHIP} ${freshnessClose(stale)}`, {
2186
+ state: "not_assessed",
2187
+ measured_at: null,
2188
+ method: RADAR_METHOD,
2189
+ coverage: { radars: RADAR_NAMES, answered: RADAR_NAMES, failed: [] },
2190
+ freshness: {
2191
+ kev_exposure: { last_run_at: stamp("kev_exposure", "last_run_at") },
2192
+ exposed_databases: { last_run_at: stamp("exposed_databases", "last_run_at") },
2193
+ leaked_credentials: { last_run_at: stamp("leaked_credentials", "last_run_at") },
2194
+ shadow_ai: { last_run_at: stamp("shadow_ai", "poller", "last_run_at"), running: running ?? null },
2195
+ mcp_servers: { last_run_at: stamp("mcp_servers", "last_run_at"), enabled: enabled ?? null },
2196
+ },
2197
+ notes: [RADAR_STATE_NOTE, RADAR_FRESHNESS_NOTE],
2198
+ });
2199
+ }
1931
2200
  // ── outputSchema: the envelope, per tool (#2311, #2313) ──
1932
2201
  //
1933
2202
  // A discriminated union on state: a success (measured, not_assessed) carries data and a method;
@@ -1953,6 +2222,10 @@ export const NOTES = z.array(z.string()).describe("Caveats, one sentence each.")
1953
2222
  // says both, and leaves what each count is to the notes and exposure_state, which say it per
1954
2223
  // answer; it makes no blanket claim about the numbers, in either direction.
1955
2224
  const SUCCESS_STATE = "measured: a measurement of what was asked; an exposure count is measured only with measured_at and method. not_assessed: the answer holds no dated measurement of what was asked, so no count in it is presented as one; it can still relay a count, as what the source holds on record, undated, and its notes (and exposure_state, where the result carries it) say what each count is.";
2225
+ // #3013: what a success says of its data's cut (textBudget.ts cutData), one constant string each.
2226
+ 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
+ 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
+ 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() }));
1956
2229
  export function envelopeSchema(o) {
1957
2230
  const coverage = o.coverage ? o.coverage.nullable() : z.null();
1958
2231
  return z.discriminatedUnion("state", [
@@ -1964,6 +2237,8 @@ export function envelopeSchema(o) {
1964
2237
  freshness: (o.freshness ? o.freshness.nullable() : z.null()).describe("The producing radar's last completed check (last_run_at), where the API serves one."),
1965
2238
  notes: NOTES,
1966
2239
  ...(o.extra ?? {}),
2240
+ data_truncated: z.boolean().describe(DATA_TRUNCATED_FIELD),
2241
+ truncated: TRUNCATED.optional().describe(TRUNCATED_FIELD),
1967
2242
  data: o.data,
1968
2243
  }),
1969
2244
  z.strictObject({
@@ -1994,9 +2269,23 @@ const CPE_CONFIGURATIONS_DESCRIPTION = "NVD's configurations array as NVD sent i
1994
2269
  // the GitHub API, a published release holding the fix; its HTML heuristics, the CISA KEV inference
1995
2270
  // among them, are not evidence) and cna_patch_reference (the CNA's own CVE record tags a reference
1996
2271
  // "patch"; NVD's capitalised "Patch" stays nvd_patch_reference).
2272
+ // #3076 / #2518 (rule 3): vendor_advisory_fix (a vendor's own structured "vendor fix" for the CVE:
2273
+ // Red Hat CSAF, Microsoft CVRF), cna_solution and description_fix (a sentence of the CVE record's
2274
+ // solutions, or of its description, that states a released fix in core-backend's fix-statement
2275
+ // templates, internal/cve/fix_statement.go). The last two are the record's own words: get_cve
2276
+ // relays the solution's sentence as cna_solution, and the description is in the record.
1997
2277
  // One constant string each (the site's tool-claims check folds only constant strings).
1998
- const PATCH_EVIDENCE_LABELS = ["vendor_patch", "fixed_version", "release_reference", "nvd_patch_reference", "cna_patch_reference"];
1999
- 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), nvd_patch_reference (an NVD reference tagged Patch) and cna_patch_reference (a reference the CVE's CNA tagged patch in its CVE record)";
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) and description_fix (a sentence of the CVE description that names a fixed version)";
2280
+ // #3070, #3076: what the CVE record's CNA wrote, as GET /cves/:id serves it (core-backend
2281
+ // cna_record.go). One constant string each.
2282
+ 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.";
2283
+ const DATE_PUBLIC_FIELD = "When the vulnerability was made public, as the CVE record's CNA states it (the record's datePublic: \"if known, the date/time the vulnerability was disclosed publicly\"). It is the CNA's date, not the record's own publication date, which is published. " +
2284
+ CVE_CNA_ABSENT;
2285
+ const DATE_PUBLIC_SOURCE_FIELD = "Whose date date_public is: cna, the CVE record's CNA. Absent whenever date_public is.";
2286
+ 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: " +
2288
+ CVE_CNA_ABSENT;
2000
2289
  // #2842: what the descriptions say; the sources, and that patch_available once true is never set
2001
2290
  // back, are the outputSchema's (PATCH_AVAILABLE_FIELD, PATCH_EVIDENCE_FIELD).
2002
2291
  const PATCH_AVAILABLE_DESCRIPTION = "patch_available is true when EchelonGraph holds evidence of a fix for the CVE, and patch_evidence names its sources, strongest first. patch_available false means no fix evidence is on record, which is not a finding that no fix exists.";
@@ -2008,14 +2297,14 @@ const PATCH_EVIDENCE_FIELD = "The sources of the fix evidence EchelonGraph holds
2008
2297
  // while kev_listed. Relayed as CISA's, verbatim; the required action is CISA's directive to US
2009
2298
  // federal civilian agencies, not EchelonGraph's advice. Constant strings, like the others here.
2010
2299
  const KEV_TEXT_ABSENT = "Absent where the CVE is not CISA-KEV-listed, where CISA's entry has none, or where EchelonGraph has not stored it yet (an API older than the field serves none).";
2011
- const KEV_REQUIRED_ACTION_FIELD = "CISA's requiredAction for the CVE, verbatim from its Known Exploited Vulnerabilities catalog: the action CISA requires of US federal civilian agencies under Binding Operational Directive 22-01, by kev_due_date. It is CISA's text, not EchelonGraph's advice. " +
2300
+ const KEV_REQUIRED_ACTION_FIELD = "CISA's requiredAction for the CVE, verbatim from its Known Exploited Vulnerabilities catalog: the action CISA requires of US federal civilian agencies under Binding Operational Directive 26-04 (which replaced BOD 22-01 on 10 June 2026), by kev_due_date. It is CISA's text, not EchelonGraph's advice. " +
2012
2301
  KEV_TEXT_ABSENT;
2013
2302
  const KEV_SHORT_DESCRIPTION_FIELD = "CISA's shortDescription of the vulnerability, verbatim from its Known Exploited Vulnerabilities catalog. " + KEV_TEXT_ABSENT;
2014
2303
  const KEV_NOTES_URLS_FIELD = "The https links in CISA's notes for the CVE, in CISA's order, de-duplicated, at most 10: the references CISA's catalog entry gives, such as the vendor's advisory or instructions and NVD's page. " +
2015
2304
  KEV_TEXT_ABSENT;
2016
2305
  // #2841: what get_cve's remediation is. One constant string (the site's tool-claims check folds
2017
2306
  // only constant strings).
2018
- 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: 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), 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.";
2307
+ 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.";
2019
2308
  const CVERecord = z.looseObject({
2020
2309
  cve_id: opt(z.string()),
2021
2310
  description: opt(z.string()),
@@ -2030,6 +2319,9 @@ const CVERecord = z.looseObject({
2030
2319
  score_confidence: opt(z.string()).describe("How confident EchelonGraph is in its score: NONE when score_assessed is false."),
2031
2320
  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."),
2032
2321
  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
+ // #2880: the SSVC decision, and CISA BOD 26-04's timeline (bodNote words it in the note).
2323
+ 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."),
2033
2325
  epss_score: opt(z.number()),
2034
2326
  epss_percentile: opt(z.number()),
2035
2327
  kev_listed: opt(z.boolean()),
@@ -2043,12 +2335,18 @@ const CVERecord = z.looseObject({
2043
2335
  // #2861: fix evidence. A label the API adds later is admitted, never a failed call.
2044
2336
  patch_available: opt(z.boolean()).describe(PATCH_AVAILABLE_FIELD),
2045
2337
  patch_evidence: opt(z.array(z.union([z.enum(PATCH_EVIDENCE_LABELS), z.string()]))).describe(PATCH_EVIDENCE_FIELD),
2338
+ // #3070, #3076: what the CVE record's CNA wrote (see cnaRecordNote).
2339
+ date_public: opt(z.string()).describe(DATE_PUBLIC_FIELD),
2340
+ date_public_source: opt(z.string()).describe(DATE_PUBLIC_SOURCE_FIELD),
2341
+ cna: opt(z.looseObject({ short_name: opt(z.string()), name: opt(z.string()) })).describe(CNA_FIELD),
2342
+ cna_solution: opt(z.string()).describe(CNA_SOLUTION_FIELD),
2046
2343
  references: z.unknown().optional(),
2047
2344
  // #2720: NVD's configurations, losslessly, beside the flat list (see cpeConfigurationsNote).
2048
2345
  cpe_match: z.unknown().optional().describe(CPE_MATCH_DESCRIPTION),
2049
2346
  cpe_configurations: opt(z.array(z.unknown())).describe(CPE_CONFIGURATIONS_DESCRIPTION),
2050
- // #2841: a capped summary of how the CVE is fixed; cve_remediation returns the whole.
2051
- remediation: opt(z.looseObject({})).describe(REMEDIATION_SUMMARY_FIELD),
2347
+ // #2841: a capped summary of how the CVE is fixed; cve_remediation returns the whole. Its first
2348
+ // field, fix_status, is the API's fix-status rule, relayed (src/fixStatus.ts).
2349
+ remediation: opt(z.looseObject({ fix_status: fixStatusSchema() })).describe(REMEDIATION_SUMMARY_FIELD),
2052
2350
  published: opt(z.string()),
2053
2351
  modified: opt(z.string()),
2054
2352
  updated_at: opt(z.string()),
@@ -2303,9 +2601,10 @@ export function createServer() {
2303
2601
  server.registerTool("cve_summary", {
2304
2602
  title: "CVE feed summary",
2305
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} The feed is polled from its sources on a schedule; this is the state as of that update. ${CVE_SUMMARY_ENVELOPE} ${TEXT_BUDGET_DESCRIPTION}`,
2604
+ inputSchema: z.object({ full: FULL_ARG }),
2306
2605
  outputSchema: CVE_SUMMARY_OUTPUT,
2307
2606
  annotations: ANNOTATIONS,
2308
- }, async () => checked("cve_summary", CVE_SUMMARY_OUTPUT, await cveSummary()));
2607
+ }, async (a) => checked("cve_summary", CVE_SUMMARY_OUTPUT, await answering(a.full, NO_DATA_CUT, () => cveSummary())));
2309
2608
  server.registerTool("search_cves", {
2310
2609
  title: "Search CVEs",
2311
2610
  description: `Search/list CVEs from EchelonGraph's CVE feed (NVD + MITRE-CNA pre-NVD + CISA-KEV + EPSS + GitHub GHSA, each polled on a schedule). Filter by severity, minimum CVSS, free text, and sort; page with limit and offset. Returns cves, each with cve_id, severity, cvss_v3_score, echelongraph_score and score_assessed (whether EchelonGraph has scored it), epss_score and kev_listed where the record has them, and the list's total, total_counted (false: total is not a count), total_is_lower_bound (true: at least total), search_relaxed, limit and offset. ${SCORE_ASSESSED_DESCRIPTION} ${PATCH_AVAILABLE_DESCRIPTION} ${FEED_ENVELOPE} ${TEXT_BUDGET_DESCRIPTION} Cut, each row keeps at least the fields named above and the first 200 characters of its description (100 on a page too long for that), or rows are left out and the note gives the offset to call next.`,
@@ -2317,30 +2616,32 @@ export function createServer() {
2317
2616
  limit: z.number().int().min(1).max(50).optional().describe("page size (default 20, max 50)"),
2318
2617
  // #2783: the next page, which the note names when the text leaves rows out.
2319
2618
  offset: z.number().int().min(0).max(10000).optional().describe("rows to skip (default 0)"),
2619
+ full: FULL_ARG,
2320
2620
  }),
2321
2621
  outputSchema: SEARCH_CVES_OUTPUT,
2322
2622
  annotations: ANNOTATIONS,
2323
- }, async (a) => checked("search_cves", SEARCH_CVES_OUTPUT, await searchCVEs(a)));
2623
+ }, async (a) => checked("search_cves", SEARCH_CVES_OUTPUT, await answering(a.full, SEARCH_CVES_DATA, () => searchCVEs(a))));
2324
2624
  server.registerTool("get_cve", {
2325
2625
  title: "CVE detail",
2326
2626
  description: `One CVE's record: description, severity, cvss_v3_score, cvss_v4_score and cvss_v4_severity, echelongraph_score, echelongraph_severity and score_confidence, epss_score and epss_percentile, CISA-KEV status (kev_listed, kev_added_date, kev_due_date, kev_ransomware) and, if KEV-listed, CISA's own text relayed verbatim (kev_required_action, not EchelonGraph's advice; kev_short_description; kev_notes_urls), ghsa_id, references, cpe_match (one flat list) and cpe_configurations (NVD's configurations as NVD sent them, with each AND/OR operator; absent where none is stored, which is not a finding that no product is affected), remediation (a capped summary of its fix), published, modified and updated_at; each field only where the record has it. Pass a CVE ID like CVE-2023-44487. ${SCORE_ASSESSED_DESCRIPTION} ${PATCH_AVAILABLE_DESCRIPTION} ${FEED_ENVELOPE} ${TEXT_BUDGET_DESCRIPTION} Cut, each list in the record keeps its first entries, and the note names each list cut with its full length.`,
2327
- inputSchema: z.object({ cve_id: CVE_ID_ARG }),
2627
+ inputSchema: z.object({ cve_id: CVE_ID_ARG, full: FULL_ARG }),
2328
2628
  outputSchema: GET_CVE_OUTPUT,
2329
2629
  annotations: ANNOTATIONS,
2330
- }, async ({ cve_id }) => checked("get_cve", GET_CVE_OUTPUT, await getCVE(cve_id)));
2630
+ }, async ({ cve_id, full }) => checked("get_cve", GET_CVE_OUTPUT, await answering(full, GET_CVE_DATA, () => getCVE(cve_id))));
2331
2631
  server.registerTool("cve_exposure", {
2332
2632
  title: "Internet exposure for one CVE",
2333
2633
  description: `${CVE_EXPOSURE_DESCRIPTION} Its structured result's state is not_assessed with measured_at null, since the answer dates no observation; the count is still relayed, labelled, as what the radar holds on record. exposure_state says what the count is: exposed, measured_zero, not_assessed or tracking_unknown; coverage.in_scope is the API's tracked verdict; freshness is null; data is the API's JSON. ${TEXT_BUDGET_DESCRIPTION}`,
2334
- inputSchema: z.object({ cve_id: CVE_ID_ARG }),
2634
+ inputSchema: z.object({ cve_id: CVE_ID_ARG, full: FULL_ARG }),
2335
2635
  outputSchema: CVE_EXPOSURE_OUTPUT,
2336
2636
  annotations: ANNOTATIONS,
2337
- }, async ({ cve_id }) => checked("cve_exposure", CVE_EXPOSURE_OUTPUT, await cveExposure(cve_id)));
2637
+ }, async ({ cve_id, full }) => checked("cve_exposure", CVE_EXPOSURE_OUTPUT, await answering(full, NO_DATA_CUT, () => cveExposure(cve_id))));
2338
2638
  server.registerTool("exposure_radar", {
2339
2639
  title: "Exposure radar totals",
2340
2640
  description: `${EXPOSURE_RADAR_DESCRIPTION} Its structured result's state is not_assessed with measured_at null, since no radar dates what it counts; each count is still relayed as what that radar holds on record. freshness gives each radar's last_run_at where the API serves one; coverage names the radars that answered. ${TEXT_BUDGET_DESCRIPTION}`,
2641
+ inputSchema: z.object({ full: FULL_ARG }),
2341
2642
  outputSchema: EXPOSURE_RADAR_OUTPUT,
2342
2643
  annotations: ANNOTATIONS,
2343
- }, async () => checked("exposure_radar", EXPOSURE_RADAR_OUTPUT, await exposureRadar()));
2644
+ }, async (a) => checked("exposure_radar", EXPOSURE_RADAR_OUTPUT, await answering(a.full, EXPOSURE_RADAR_DATA, () => exposureRadar())));
2344
2645
  registerKevRecent(server, { api, succeeded, failed, badInput, crashed, checked, envelopeSchema, okHead, annotations: ANNOTATIONS });
2345
2646
  registerEPSSHistory(server, TOOL_KIT);
2346
2647
  // #2716: its logic, schemas and description live in tools/check_affected.ts.
@@ -2361,7 +2662,8 @@ export function createServer() {
2361
2662
  // #2722: prompts and resources, in prompts.ts and resources.ts. Both lists are fixed per
2362
2663
  // connection, so listChanged is false above.
2363
2664
  registerPrompts(server, { cveId: CVE_ID });
2364
- registerResources(server, { api, cveId: CVE_ID, shownBase: SHOWN_BASE, sentences, getCve: async (id) => checked("get_cve", GET_CVE_OUTPUT, await getCVE(id)) });
2665
+ // #3013: cve:// cuts get_cve's data itself (DATA CUT, resources.ts), so it reads it whole.
2666
+ registerResources(server, { api, cveId: CVE_ID, shownBase: SHOWN_BASE, sentences, getCve: async (id) => checked("get_cve", GET_CVE_OUTPUT, await answering(true, GET_CVE_DATA, () => getCVE(id))) });
2365
2667
  return server;
2366
2668
  }
2367
2669
  // The package identity and API settings, for the hosted HTTP entrypoint's health answer and