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/CHANGELOG.md +144 -0
- package/README.md +156 -440
- package/dist/fixStatus.js +28 -0
- package/dist/index.js +374 -72
- package/dist/prompts.js +55 -31
- package/dist/requiredText.js +1 -1
- package/dist/resources.js +33 -7
- package/dist/textBudget.js +328 -16
- package/dist/tools/check_affected.js +41 -11
- package/dist/tools/check_sbom.js +47 -8
- package/dist/tools/cve_intel.js +30 -8
- package/dist/tools/cve_remediation.js +156 -16
- package/dist/tools/epss_history.js +6 -3
- package/dist/tools/get_cwe.js +7 -2
- package/dist/tools/kev_recent.js +18 -2
- package/dist/tools/match_batch.js +10 -6
- package/dist/tools/scan_manifest.js +20 -7
- package/dist/tools/vendor_advisories.js +34 -7
- package/docs/exposure-radar.md +181 -0
- package/docs/rate-limits.md +50 -0
- package/docs/structured-results.md +43 -0
- package/docs/tools.md +163 -0
- package/package.json +2 -1
- package/server.json +2 -2
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);
|
|
20
|
-
// envelope's data is
|
|
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
|
|
31
|
-
//
|
|
32
|
-
// CUT, #2783), so past them content[0] is not equal to `data` (#2802).
|
|
33
|
-
//
|
|
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
|
|
41
|
-
// structuredContent, but for what a TEXT CUT leaves out
|
|
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
|
|
380
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|