echelongraph-mcp 2.7.2 → 3.0.2
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 +219 -0
- package/README.md +163 -433
- package/dist/fixStatus.js +30 -0
- package/dist/httpPolicy.js +16 -1
- package/dist/index.js +287 -34
- package/dist/prompts.js +63 -33
- package/dist/requiredText.js +16 -1
- package/dist/resources.js +32 -6
- package/dist/textBudget.js +588 -22
- package/dist/tools/check_affected.js +45 -12
- package/dist/tools/check_sbom.js +158 -9
- package/dist/tools/cve_intel.js +63 -9
- package/dist/tools/cve_remediation.js +230 -16
- package/dist/tools/epss_history.js +10 -4
- package/dist/tools/get_cwe.js +7 -2
- package/dist/tools/kev_recent.js +22 -3
- package/dist/tools/match_batch.js +27 -7
- package/dist/tools/scan_manifest.js +35 -10
- package/dist/tools/vendor_advisories.js +47 -11
- 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 +167 -0
- package/package.json +2 -1
- package/server.json +2 -2
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// fix_status (#2841, #2518 item 3): the API's fix-status rule for a CVE (core-backend
|
|
2
|
+
// internal/cve/fixstatus.go, the rule CVE Pulse pages render), as get_cve's remediation summary
|
|
3
|
+
// (its first field) and cve_remediation's answer carry it. Relayed, never derived here.
|
|
4
|
+
//
|
|
5
|
+
// A module of its own, importing only zod: index.ts and tools/cve_remediation.ts import each
|
|
6
|
+
// other, so a const shared between them would be read before it is initialised whichever loads
|
|
7
|
+
// first.
|
|
8
|
+
import * as z from "zod";
|
|
9
|
+
export const FIX_STATES = ["available", "fix_referenced", "fix_merged", "none_confirmed", "unknown"];
|
|
10
|
+
export const FIX_STATUS_FIELD = "The API's fix-status rule for the CVE, the one CVE Pulse pages show: state is available (a fix is on record), fix_referenced (a reference tagged Patch, often a source commit, and no release confirmed), fix_merged (an upstream fix merged, not yet in a tagged release), none_confirmed (no fix evidence on record, which is not a finding that no fix exists) or unknown (an input could not be read). basis is the step of the rule that decided it; described_by is the CVE description's sentence naming the fix, solution_by the CVE record's solution sentence naming it (statement) and the CNA that wrote it (cna), stated_in_record that patch_evidence counts a fix the CVE record states with no sentence to quote, listed_in_patch_table that a fixed version or patch row is on record; unread_inputs names any input the API could not read. vendor_released (basis vendor_fix_released) is the vendor's own statement that a fix is released where the CVE record names no fixed version: its sentence, verbatim (statement, or null), never a version, so the fixed release is the vendor advisory's to name. solution_by's section is workarounds when the fix sentence is the record's workaround, not its solution.";
|
|
11
|
+
const opt = (t) => t.nullable().optional();
|
|
12
|
+
export function fixStatusSchema() {
|
|
13
|
+
return z
|
|
14
|
+
.looseObject({
|
|
15
|
+
// A state a later API adds is admitted, never a failed call.
|
|
16
|
+
state: opt(z.union([z.enum(FIX_STATES), z.string()])),
|
|
17
|
+
basis: opt(z.string()),
|
|
18
|
+
described_by: opt(z.string()),
|
|
19
|
+
// #3076 (merged from 24dad5eb): the CVE record's own fix sentence, and its CNA.
|
|
20
|
+
solution_by: opt(z.looseObject({ statement: opt(z.string()), cna: opt(z.string()), section: opt(z.string()) })),
|
|
21
|
+
stated_in_record: opt(z.boolean()),
|
|
22
|
+
listed_in_patch_table: opt(z.boolean()),
|
|
23
|
+
// #3076, third pass: the vendor's released-fix statement with no version (cause G).
|
|
24
|
+
vendor_released: opt(z.looseObject({ statement: opt(z.string()) })),
|
|
25
|
+
unread_inputs: opt(z.array(z.string())),
|
|
26
|
+
})
|
|
27
|
+
.nullable()
|
|
28
|
+
.optional()
|
|
29
|
+
.describe(FIX_STATUS_FIELD);
|
|
30
|
+
}
|
package/dist/httpPolicy.js
CHANGED
|
@@ -295,7 +295,7 @@ export class Slots {
|
|
|
295
295
|
// - V8's heap. In that sandbox (a cgroup v1 on a 16 GB host) Node 22 sized V8's heap from the
|
|
296
296
|
// host's memory, not the cgroup's (heap_size_limit 8,195 MiB in 256 MiB), so V8 left the
|
|
297
297
|
// garbage of the requests served (~1 MB each) uncollected until the kernel killed the
|
|
298
|
-
// instance. So the image caps V8's old space at
|
|
298
|
+
// instance. So the image caps V8's old space at 104 MiB (Dockerfile.http; 128 until 3.0.2), and V8 collects
|
|
299
299
|
// first. On Cloud Run this is not verified: an instance that shows Node only its 256 MiB gets
|
|
300
300
|
// a default heap near the cap already, and then the cap changes little. Its start-up line says
|
|
301
301
|
// which (heap_limit_mb; memory_limit_mb, null where Node is told no limit).
|
|
@@ -482,7 +482,22 @@ export function uaFamily(ua) {
|
|
|
482
482
|
// method name), so the message is never logged. It is matched against the SDK's own fixed
|
|
483
483
|
// prefixes and only the matching label is logged; anything unrecognised is "other". A
|
|
484
484
|
// ProtocolError also carries its numeric JSON-RPC code, which is content-free.
|
|
485
|
+
//
|
|
486
|
+
// #2923 (2.7.1 audit, 2026-10-10): 78 of 78 production lines were "other": the list held the legacy
|
|
487
|
+
// transport's messages, not those of the bundled SDK's createMcpHandler (@modelcontextprotocol/server
|
|
488
|
+
// 2.1.0 dist/index.mjs), which reports "Rejected inbound request (<cell>): …" (:1346, :1384, :1458),
|
|
489
|
+
// "Rejected 2025-era request on a modern-only endpoint (<cell>): …" (:1438), an
|
|
490
|
+
// UnsupportedProtocolVersionError, "Unsupported protocol version: <requested>", code -32022 (:1338;
|
|
491
|
+
// src-D-y6h4N7.mjs:3693), and a MissingRequiredClientCapabilityError, "Missing required client
|
|
492
|
+
// capabilities: …" (:1357; src-D-y6h4N7.mjs:3727). The legacy transport's own wording is
|
|
493
|
+
// "Bad Request: Unsupported protocol version: …" (index.mjs:896). All of these lead the list, each
|
|
494
|
+
// with its fixed prefix only (the cell and the rest can name the client's method or version).
|
|
485
495
|
const HANDLER_ERROR_CLASSES = [
|
|
496
|
+
["Rejected 2025-era request on a modern-only endpoint (", "rejected_legacy_on_modern_only"],
|
|
497
|
+
["Rejected inbound request (", "rejected_inbound_request"],
|
|
498
|
+
["Unsupported protocol version", "unsupported_protocol_version"],
|
|
499
|
+
["Bad Request: Unsupported protocol version", "unsupported_protocol_version"],
|
|
500
|
+
["Missing required client capabilities", "missing_client_capability"],
|
|
486
501
|
["Not Acceptable", "not_acceptable"],
|
|
487
502
|
["Unsupported Media Type", "unsupported_media_type"],
|
|
488
503
|
["Method not allowed", "method_not_allowed"],
|
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,13 +65,14 @@ 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";
|
|
68
|
-
import { SHODAN_ATTRIBUTION, SHODAN_OWNERSHIP } from "./requiredText.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";
|
|
75
|
+
import { PATCH_AVAILABLE_WITHDRAWN, 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
|
|
71
78
|
// access log can count adoption per version without a code change per release.
|
|
@@ -367,17 +374,41 @@ function unexpectedShape(tool, error) {
|
|
|
367
374
|
export const checked = (tool, schema, r) => {
|
|
368
375
|
if (r.isError)
|
|
369
376
|
return r;
|
|
370
|
-
|
|
377
|
+
// #3121: the published schema's success branch is open; this server's own answers are held to
|
|
378
|
+
// the closed one.
|
|
379
|
+
const p = (STRICT.get(schema) ?? schema).safeParse(r.structuredContent);
|
|
371
380
|
return p.success ? r : unexpectedShape(tool, p.error);
|
|
372
381
|
};
|
|
373
382
|
// A success: content[0] is the data (cut to DATA_TEXT_BUDGET when it is larger, #2783), content[1]
|
|
374
383
|
// 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
|
|
384
|
+
// after any the envelope adds about itself. structuredContent.data is the data whole, or (#3013),
|
|
385
|
+
// when the structured result would pass STRUCTURED_BUDGET and the call (answering()) does not say
|
|
386
|
+
// full: true, cut as the call's DataCut says, with data_truncated, truncated and the note's DATA CUT
|
|
387
|
+
// sentences saying how. content[0] is cut from the data whole either way.
|
|
376
388
|
export const succeeded = (data, note, env, cut) => {
|
|
377
389
|
const own = env.notes ?? [];
|
|
390
|
+
const call = answeringNow();
|
|
378
391
|
const shown = dataText(data, cut);
|
|
379
|
-
const
|
|
380
|
-
|
|
392
|
+
const structuredOf = (d, said, truncated) => ({
|
|
393
|
+
...env,
|
|
394
|
+
notes: [...own, ...sentences(said)],
|
|
395
|
+
data_truncated: truncated !== undefined,
|
|
396
|
+
...(truncated === undefined ? {} : { truncated }),
|
|
397
|
+
data: d,
|
|
398
|
+
});
|
|
399
|
+
let said = shown.said ? `${note} ${shown.said}` : note;
|
|
400
|
+
let structuredContent = structuredOf(data, said);
|
|
401
|
+
const wholeChars = JSON.stringify(structuredContent).length;
|
|
402
|
+
if (call?.full !== true && wholeChars > STRUCTURED_BUDGET) {
|
|
403
|
+
// The same text, with the sentences a first text block beside a cut data says.
|
|
404
|
+
const beside = dataText(data, cut, FIRST_TEXT_BLOCK_BESIDE_A_CUT).said;
|
|
405
|
+
const head = beside ? `${note} ${beside}` : note;
|
|
406
|
+
const c = cutData(data, call?.cut ?? NO_DATA_CUT, wholeChars, (d, truncated, dataSaid) => structuredOf(d, `${head} ${dataSaid}`, truncated));
|
|
407
|
+
if (c) {
|
|
408
|
+
said = `${head} ${c.said}`;
|
|
409
|
+
structuredContent = c.structured;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
381
412
|
return { content: [text(shown.text), text(said), envelopeText(structuredContent, said, own)], structuredContent };
|
|
382
413
|
};
|
|
383
414
|
export const okHead = (tool, status) => `${tool} OK: EchelonGraph answered${status === undefined ? "" : ` HTTP ${status}`} from ${SHOWN_BASE}.`;
|
|
@@ -663,6 +694,35 @@ const SEARCH_CVES_TEXT = {
|
|
|
663
694
|
whole: `${GET_CVE_WHOLE}.`,
|
|
664
695
|
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
696
|
};
|
|
697
|
+
// #3013: the structured result's cut. A row of production's answer is about 4,200 characters of
|
|
698
|
+
// compact JSON (score_factors, cpe_match and references, about 2,000 of them, are get_cve's), so the
|
|
699
|
+
// default page of 20 was 85,383 characters of structured result. Before any row leaves, every row
|
|
700
|
+
// is thinned: first to the first text cut level's fields, its description whole; then to the fields
|
|
701
|
+
// the description names, without the description. Only then do rows leave data from the end, and
|
|
702
|
+
// the note gives the offset to read them from, and the page size whose rows fit data whole. Through
|
|
703
|
+
// #3013's review that size was the number of thinned rows data kept, so a page of it came back
|
|
704
|
+
// thinned again (data_truncated true) while the note said it fit data whole: it is now counted on
|
|
705
|
+
// the rows whole (PageAt.wholeFit), the same call's envelope and note beside them.
|
|
706
|
+
const SEARCH_CVES_DATA = {
|
|
707
|
+
steps: [
|
|
708
|
+
{
|
|
709
|
+
lists: ["cves"],
|
|
710
|
+
thin: [
|
|
711
|
+
[
|
|
712
|
+
"cve_id", "description", "severity", "cvss_v3_score", "echelongraph_score", "echelongraph_severity", "echelongraph_risk", "score_assessed", "score_unassessed_reason", "epss_score",
|
|
713
|
+
"kev_listed", "kev_ransomware", "published", "vuln_status", "patch_available", "patch_evidence", "exploit_poc_available",
|
|
714
|
+
],
|
|
715
|
+
["cve_id", "severity", "cvss_v3_score", "echelongraph_score", "score_assessed", "score_unassessed_reason", "epss_score", "kev_listed", "kev_ransomware", "published", "patch_available", "patch_evidence"],
|
|
716
|
+
],
|
|
717
|
+
},
|
|
718
|
+
],
|
|
719
|
+
page: (t, d, at) => {
|
|
720
|
+
const k = t.cves?.returned;
|
|
721
|
+
if (!k || !t.cves || k === t.cves.total)
|
|
722
|
+
return "";
|
|
723
|
+
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"))}`;
|
|
724
|
+
},
|
|
725
|
+
};
|
|
666
726
|
async function searchCVEs(a) {
|
|
667
727
|
const tool = "search_cves";
|
|
668
728
|
try {
|
|
@@ -751,10 +811,79 @@ function kevTextNote(rec) {
|
|
|
751
811
|
return "";
|
|
752
812
|
const has = ["kev_required_action", "kev_short_description", "kev_notes_urls"].some((k) => field(rec, k) !== undefined && field(rec, k) !== null);
|
|
753
813
|
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."
|
|
814
|
+
? " 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
815
|
: "";
|
|
756
816
|
}
|
|
817
|
+
// #2880: what the record's CISA BOD 26-04 timeline is, where the scorer wrote it
|
|
818
|
+
// (core-backend/internal/cve/scoring/bod2604.go, algorithm v3.4+): given for both answers to "is
|
|
819
|
+
// the system publicly exposed?", because that is the reader's own system's, each input with its
|
|
820
|
+
// source. A record scored before v3.4, or a malformed block, gets no sentence: the JSON is relayed
|
|
821
|
+
// whole either way. What the SSVC decision is (EchelonGraph's run of CISA's table, never a
|
|
822
|
+
// decision CISA published, #3053) is in the outputSchema's echelongraph_ssvc_decision, not the
|
|
823
|
+
// note: get_cve's text is measured to the character (test/text-bound.mjs).
|
|
824
|
+
const BOD_TIMELINE = {
|
|
825
|
+
FSU: "Fix on system upgrade",
|
|
826
|
+
"60D": "60 days",
|
|
827
|
+
"14D": "14 days",
|
|
828
|
+
"3D": "3 days",
|
|
829
|
+
"3DF": "3 days & forensic triage",
|
|
830
|
+
};
|
|
831
|
+
const BOD_SOURCE = {
|
|
832
|
+
"cisa-adp": "CISA's Vulnrichment value",
|
|
833
|
+
cisa_kev: "CISA's KEV catalog",
|
|
834
|
+
cvss_v4_au: "the CVSS v4.0 Automatable metric",
|
|
835
|
+
cvss_v4_av_physical: "CVSS v4.0 attack vector Physical",
|
|
836
|
+
cvss_v4_vc_vi_high: "CVSS v4.0 confidentiality and integrity impact both High",
|
|
837
|
+
cvss_v4_vc_vi_not_high: "CVSS v4.0 confidentiality and integrity impact neither High",
|
|
838
|
+
cisa_bod_26_04_default: "CISA's BOD 26-04 default for a CVE not in KEV that nobody has assessed",
|
|
839
|
+
not_supplied: "not yet published by CISA, so every timeline it allows is listed",
|
|
840
|
+
};
|
|
841
|
+
function bodTimelines(v) {
|
|
842
|
+
if (!Array.isArray(v) || v.length === 0 || !v.every((k) => typeof k === "string" && BOD_TIMELINE[k]))
|
|
843
|
+
return null;
|
|
844
|
+
const w = v.map((k) => BOD_TIMELINE[k]);
|
|
845
|
+
return w.length === 1 ? w[0] : `${w.slice(0, -1).join(", ")} or ${w[w.length - 1]}`;
|
|
846
|
+
}
|
|
847
|
+
function bodNote(cve, rec) {
|
|
848
|
+
const b = field(rec, "score_factors", "bod_26_04");
|
|
849
|
+
const exposed = bodTimelines(field(b, "if_publicly_exposed"));
|
|
850
|
+
const internal = bodTimelines(field(b, "if_not_publicly_exposed"));
|
|
851
|
+
const kev = strAt(b, "kev", "value");
|
|
852
|
+
const input = (k, name) => {
|
|
853
|
+
const v = strAt(b, k, "value");
|
|
854
|
+
const src = strAt(b, k, "source");
|
|
855
|
+
return v && src ? `${name} ${v} (${BOD_SOURCE[src] ?? src})` : null;
|
|
856
|
+
};
|
|
857
|
+
const auto = input("automatable", "automatable");
|
|
858
|
+
const ti = input("technical_impact", "technical impact");
|
|
859
|
+
if (!(exposed && internal && (kev === "yes" || kev === "no") && auto && ti))
|
|
860
|
+
return "";
|
|
861
|
+
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.`;
|
|
862
|
+
}
|
|
757
863
|
// #2841: one sentence on the record's remediation summary, where the API sends one.
|
|
864
|
+
// #3070, #3076: the CNA's own date and fix sentence, named as the CNA's. Nothing when the record
|
|
865
|
+
// carries neither; a value of another JSON type is not quoted (the outputSchema refuses it).
|
|
866
|
+
function cnaRecordNote(cve, rec) {
|
|
867
|
+
const cna = field(rec, "cna");
|
|
868
|
+
const nameOf = (k) => (cna && typeof cna === "object" && typeof field(cna, k) === "string" ? String(field(cna, k)).trim() : "");
|
|
869
|
+
const who = nameOf("name") || nameOf("short_name");
|
|
870
|
+
// An appositive name is closed by a comma: "its CNA, Palo Alto Networks, Inc., states".
|
|
871
|
+
const by = who ? `its CNA, ${who},` : "its CNA";
|
|
872
|
+
const date = strAt(rec, "date_public");
|
|
873
|
+
const fix = strAt(rec, "cna_solution");
|
|
874
|
+
// #3178: a sentence of the record's workarounds is said to be its workaround.
|
|
875
|
+
const workaround = strAt(rec, "cna_solution_section") === "workaround";
|
|
876
|
+
const parts = [];
|
|
877
|
+
if (date)
|
|
878
|
+
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.`);
|
|
879
|
+
if (fix && workaround) {
|
|
880
|
+
parts.push(` cna_solution is a fix statement from the CVE record's workarounds (cna_solution_section workaround), not its solutions, written by ${by} and relayed verbatim: it names the fixed release, and is not EchelonGraph's advice.`);
|
|
881
|
+
}
|
|
882
|
+
else if (fix) {
|
|
883
|
+
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.`);
|
|
884
|
+
}
|
|
885
|
+
return parts.join("");
|
|
886
|
+
}
|
|
758
887
|
function remediationSummaryNote(cve, rec) {
|
|
759
888
|
const s = field(rec, "remediation");
|
|
760
889
|
if (s === null || typeof s !== "object")
|
|
@@ -764,8 +893,21 @@ function remediationSummaryNote(cve, rec) {
|
|
|
764
893
|
const failed = field(s, "failed_sections");
|
|
765
894
|
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
895
|
const byKind = counted.length ? ` Vendor advisories list ${counted.map(([k, n]) => `${k} (${n})`).join(", ")}, by the vendor's own category.` : "";
|
|
767
|
-
|
|
896
|
+
// #2841: fix_status is the API's, relayed; a state is named only as the API sent it.
|
|
897
|
+
const fs = field(s, "fix_status");
|
|
898
|
+
const state = strAt(fs, "state");
|
|
899
|
+
const basis = strAt(fs, "basis");
|
|
900
|
+
const fix = state
|
|
901
|
+
? ` 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" : ""}.`
|
|
902
|
+
: "";
|
|
903
|
+
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
904
|
}
|
|
905
|
+
// #3013: the structured result's cut. CVE-2021-44228's record is 64,170 characters of structured
|
|
906
|
+
// result, 41,091 of them cpe_match (396 entries) and 17,103 references (103): those two are cut
|
|
907
|
+
// first, each to at most the same number of its first entries, then cpe_configurations, then any
|
|
908
|
+
// other list at the top level of the record. The remediation summary (#2841), an object, is never
|
|
909
|
+
// cut, so its fix_status is whole at every level.
|
|
910
|
+
const GET_CVE_DATA = { steps: [{ lists: ["cpe_match", "references"] }, { lists: ["cpe_configurations"] }] };
|
|
769
911
|
async function getCVE(cve_id) {
|
|
770
912
|
const tool = "get_cve";
|
|
771
913
|
const id = cve_id.trim();
|
|
@@ -777,7 +919,7 @@ async function getCVE(cve_id) {
|
|
|
777
919
|
return failed(tool, r);
|
|
778
920
|
const written = strAt(r.data, "updated_at");
|
|
779
921
|
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)}`, {
|
|
922
|
+
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
923
|
state: "measured",
|
|
782
924
|
measured_at: instantOrNull(written),
|
|
783
925
|
method: CVE_FEED_METHOD,
|
|
@@ -1960,6 +2102,35 @@ const RADAR_METHOD = "Aggregate counts over what each radar holds on record: kev
|
|
|
1960
2102
|
export const RADAR_NAMES = RADARS.map(([name]) => name);
|
|
1961
2103
|
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.";
|
|
1962
2104
|
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.";
|
|
2105
|
+
// #3013: the structured result's cut. Production's answers of 2026-10-05 (fixtures/prod-answers) are
|
|
2106
|
+
// 33,278 characters of structured result, past the budget by notes that label every number (about
|
|
2107
|
+
// 20,000 characters) beside 12,733 of data. The notes say what each list counts, from the answer
|
|
2108
|
+
// whole, so the lists are cut, never a note: first the two series (shadow_ai.observed.trend_30d,
|
|
2109
|
+
// kev_exposure.trend), then every ranking to at most the same number of its first rows, then
|
|
2110
|
+
// kev_exposure.newest_kev, whose CVEs and exposure states the note names one by one. #3013's
|
|
2111
|
+
// review: the series arrive oldest first (a day, a week a row), and a cut that kept their first
|
|
2112
|
+
// entries showed the oldest, lowest weeks as the trend and left out the latest; each now keeps its
|
|
2113
|
+
// newest entries (keepNewest), and the note says so.
|
|
2114
|
+
const EXPOSURE_RADAR_DATA = {
|
|
2115
|
+
steps: [
|
|
2116
|
+
{ lists: ["shadow_ai.observed.trend_30d", "kev_exposure.trend"], keepNewest: true },
|
|
2117
|
+
{
|
|
2118
|
+
lists: [
|
|
2119
|
+
"kev_exposure.top_products",
|
|
2120
|
+
"kev_exposure.top_countries",
|
|
2121
|
+
"kev_exposure.top_cves",
|
|
2122
|
+
"exposed_databases.top_engines",
|
|
2123
|
+
"exposed_databases.top_countries",
|
|
2124
|
+
"leaked_credentials.top_providers",
|
|
2125
|
+
"leaked_credentials.top_types",
|
|
2126
|
+
"shadow_ai.observed.top_products",
|
|
2127
|
+
"shadow_ai.observed.top_countries",
|
|
2128
|
+
"shadow_ai.observed.top_issuers",
|
|
2129
|
+
],
|
|
2130
|
+
},
|
|
2131
|
+
{ lists: ["kev_exposure.newest_kev"] },
|
|
2132
|
+
],
|
|
2133
|
+
};
|
|
1963
2134
|
async function exposureRadar() {
|
|
1964
2135
|
const tool = "exposure_radar";
|
|
1965
2136
|
try {
|
|
@@ -2059,10 +2230,47 @@ export const NOTES = z.array(z.string()).describe("Caveats, one sentence each.")
|
|
|
2059
2230
|
// says both, and leaves what each count is to the notes and exposure_state, which say it per
|
|
2060
2231
|
// answer; it makes no blanket claim about the numbers, in either direction.
|
|
2061
2232
|
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.";
|
|
2233
|
+
// #3013: what a success says of its data's cut (textBudget.ts cutData), one constant string each.
|
|
2234
|
+
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.";
|
|
2235
|
+
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.";
|
|
2236
|
+
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() }));
|
|
2237
|
+
// #3121: the success branch is published open to fields it does not name, and this server holds
|
|
2238
|
+
// its own answers to the closed one (STRICT, read by checked). Through 3.0.0 both branches were
|
|
2239
|
+
// closed (additionalProperties false), and a client validates against the outputSchema it listed,
|
|
2240
|
+
// which the hosted endpoint never tells it to list again (stateless, listChanged false): a Claude
|
|
2241
|
+
// Code session that listed the tools under 2.7.2 failed its first check_sbom call once 3.0.0 went
|
|
2242
|
+
// live, "Structured content does not match the tool's output schema: data must NOT have additional
|
|
2243
|
+
// properties" (ajv's root is "data"; 3.0.0 added data_truncated and truncated to every success),
|
|
2244
|
+
// until it reconnected. So a field added beside them in a minor release would break every such
|
|
2245
|
+
// session too, against the README's semver promise. Opening the success branch keeps such a field
|
|
2246
|
+
// additive for a client holding an older list; the failure branch stays closed (a failure is never
|
|
2247
|
+
// read as data), and data keeps each tool's own schema. The suite validates answers against the
|
|
2248
|
+
// closed branch (test/strict-output.mjs), and checked() does here, so a field this server adds
|
|
2249
|
+
// without naming it in the schema still fails, as it did when the branch was closed.
|
|
2250
|
+
//
|
|
2251
|
+
// 3.0.2: every output schema is built once per process, and its strict copy with it. The hosted
|
|
2252
|
+
// endpoint builds a server for each request (http.ts: createMcpHandler's stateless leg calls
|
|
2253
|
+
// createServer), and through 3.0.1 the tool modules that take envelopeSchema from this file
|
|
2254
|
+
// (check_affected, check_sbom, epss_history, kev_recent, scan_manifest and the three vendor-advisory
|
|
2255
|
+
// tools) built their output schemas in their register functions, so once per request in flight.
|
|
2256
|
+
// 3.0.1's strict copy doubled those: 1,371 KB of heap per server, against 3.0.0's 1,168
|
|
2257
|
+
// (64 servers held at once, node --expose-gc). test/http-memory.test.mjs's "served as they come"
|
|
2258
|
+
// case holds 64 requests in flight beside two 5,000,000-character SBOMs; it failed the 3.0.1
|
|
2259
|
+
// release run at Dockerfile.http's 128 MiB, and passed here only at 128, where 3.0.0 passes at
|
|
2260
|
+
// 112. At 112 V8 aborted 3.5 s in, with 59 servers live and both documents still at their first
|
|
2261
|
+
// batches, so before either answer was cut (a heap snapshot near the limit). Each such module now
|
|
2262
|
+
// keeps its schemas per envelopeSchema, as cve_intel, cve_remediation and get_cwe already did
|
|
2263
|
+
// (schemasOnce): 611 KB a server, and the case passes at --max-old-space-size=80.
|
|
2264
|
+
const STRICT = new WeakMap();
|
|
2062
2265
|
export function envelopeSchema(o) {
|
|
2266
|
+
const published = envelopeOf(o, z.looseObject);
|
|
2267
|
+
STRICT.set(published, envelopeOf(o, z.strictObject));
|
|
2268
|
+
return published;
|
|
2269
|
+
}
|
|
2270
|
+
function envelopeOf(o, success) {
|
|
2063
2271
|
const coverage = o.coverage ? o.coverage.nullable() : z.null();
|
|
2064
2272
|
return z.discriminatedUnion("state", [
|
|
2065
|
-
|
|
2273
|
+
success({
|
|
2066
2274
|
state: z.enum(["measured", "not_assessed"]).describe(SUCCESS_STATE),
|
|
2067
2275
|
measured_at: Instant.nullable().describe("When the underlying observation was made, as the API states it; null when the answer does not say or holds no observation."),
|
|
2068
2276
|
method: z.string().describe("How the numbers were produced."),
|
|
@@ -2070,6 +2278,8 @@ export function envelopeSchema(o) {
|
|
|
2070
2278
|
freshness: (o.freshness ? o.freshness.nullable() : z.null()).describe("The producing radar's last completed check (last_run_at), where the API serves one."),
|
|
2071
2279
|
notes: NOTES,
|
|
2072
2280
|
...(o.extra ?? {}),
|
|
2281
|
+
data_truncated: z.boolean().describe(DATA_TRUNCATED_FIELD),
|
|
2282
|
+
truncated: TRUNCATED.optional().describe(TRUNCATED_FIELD),
|
|
2073
2283
|
data: o.data,
|
|
2074
2284
|
}),
|
|
2075
2285
|
z.strictObject({
|
|
@@ -2100,13 +2310,41 @@ const CPE_CONFIGURATIONS_DESCRIPTION = "NVD's configurations array as NVD sent i
|
|
|
2100
2310
|
// the GitHub API, a published release holding the fix; its HTML heuristics, the CISA KEV inference
|
|
2101
2311
|
// among them, are not evidence) and cna_patch_reference (the CNA's own CVE record tags a reference
|
|
2102
2312
|
// "patch"; NVD's capitalised "Patch" stays nvd_patch_reference).
|
|
2313
|
+
// #3076 / #2518 (rule 3): vendor_advisory_fix (a vendor's own structured "vendor fix" for the CVE:
|
|
2314
|
+
// Red Hat CSAF, Microsoft CVRF), cna_solution and description_fix (a sentence of the CVE record's
|
|
2315
|
+
// solutions, or of its description, that states a released fix in core-backend's fix-statement
|
|
2316
|
+
// templates, internal/cve/fix_statement.go). The last two are the record's own words: get_cve
|
|
2317
|
+
// relays the solution's sentence as cna_solution, and the description is in the record.
|
|
2103
2318
|
// One constant string each (the site's tool-claims check folds only constant strings).
|
|
2104
|
-
const PATCH_EVIDENCE_LABELS = ["vendor_patch", "fixed_version", "release_reference", "nvd_patch_reference", "cna_patch_reference"];
|
|
2105
|
-
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)
|
|
2319
|
+
const PATCH_EVIDENCE_LABELS = ["vendor_patch", "fixed_version", "release_reference", "vendor_advisory_fix", "nvd_patch_reference", "cna_patch_reference", "cna_solution", "description_fix", "vendor_fix_released"];
|
|
2320
|
+
const PATCH_EVIDENCE_SOURCES = "vendor_patch (a vendor patch on record, from Ubuntu or Red Hat), fixed_version (a fixed version recorded for an affected package, or the fixed bound of an affected version range), release_reference (a reference that EchelonGraph confirmed through the GitHub API is a published release containing the fix, or a pull request or commit that a published release contains), vendor_advisory_fix (a vendor advisory that lists a fix for the CVE under the vendor's own structured category: Red Hat's CSAF vendor_fix, Microsoft's Vendor Fix), nvd_patch_reference (an NVD reference tagged Patch), cna_patch_reference (a reference the CVE's CNA tagged patch in its CVE record), cna_solution (a sentence of the CVE record's solutions or workarounds, written by its CNA, that names a fixed version; relayed as cna_solution), description_fix (a sentence of the CVE description that names a fixed version) and vendor_fix_released (the vendor states, in the CVE record, that a fix is released and the record names no fixed version; check the vendor's advisory)";
|
|
2321
|
+
// #3070, #3076: what the CVE record's CNA wrote, as GET /cves/:id serves it (core-backend
|
|
2322
|
+
// cna_record.go). One constant string each.
|
|
2323
|
+
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.";
|
|
2324
|
+
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. " +
|
|
2325
|
+
CVE_CNA_ABSENT;
|
|
2326
|
+
const DATE_PUBLIC_SOURCE_FIELD = "Whose date date_public is: cna, the CVE record's CNA. Absent whenever date_public is.";
|
|
2327
|
+
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;
|
|
2328
|
+
const CNA_SOLUTION_FIELD = "The sentence of the CVE record's solutions or workarounds, written by its CNA, that names a released fix, verbatim: what makes cna_solution patch evidence. It is the CNA's text, not EchelonGraph's advice. cna_solution_section says which of the two sections it is. Absent where the record's solutions name no fixed version, as well as where the field itself is: " +
|
|
2329
|
+
CVE_CNA_ABSENT;
|
|
2330
|
+
// #3178: which section cna_solution is. MCP relays a field its outputSchema does not describe without saying what it
|
|
2331
|
+
// is, so the section is described here, beside the sentence.
|
|
2332
|
+
const CNA_SOLUTION_SECTION_FIELD = "Which section of the CVE record cna_solution was read from: solution (the record's solutions) or workaround (its workarounds, read only where its solutions name no fix: a temporary measure the CNA lists beside its fix). A workaround's sentence is the record's workaround, never its solution. Present whenever cna_solution is; absent from an API older than the field.";
|
|
2106
2333
|
// #2842: what the descriptions say; the sources, and that patch_available once true is never set
|
|
2107
2334
|
// back, are the outputSchema's (PATCH_AVAILABLE_FIELD, PATCH_EVIDENCE_FIELD).
|
|
2108
|
-
const PATCH_AVAILABLE_DESCRIPTION = "patch_available is true when EchelonGraph holds
|
|
2109
|
-
|
|
2335
|
+
const PATCH_AVAILABLE_DESCRIPTION = "patch_available is true when EchelonGraph holds, or once held, fix evidence for the CVE; patch_evidence is what it holds now, strongest first. patch_available false means no fix evidence is on record, which is not a finding that no fix exists.";
|
|
2336
|
+
// #3138: true says evidence was held at some point, not that it is held now: the sources held now
|
|
2337
|
+
// are patch_evidence's, and true beside an empty one is a withdrawal (requiredText.ts
|
|
2338
|
+
// PATCH_AVAILABLE_WITHDRAWN, pinned there). #3138's review: round 1 changed the field text only,
|
|
2339
|
+
// and this sentence, in get_cve's and search_cves' descriptions, still said true means
|
|
2340
|
+
// EchelonGraph holds evidence; it now says "holds, or once held", in 9 more characters than it
|
|
2341
|
+
// had, as get_cve's description had 9 left under #2924's headroom of 100 below the 2,048 cut
|
|
2342
|
+
// (get_cve 1,948 characters, search_cves 1,807; test/prompts-resources.test.mjs holds the
|
|
2343
|
+
// headroom, test/client-cut.mjs (b) the cut, and marketing-site lib/mcpToolClaims.test.ts the
|
|
2344
|
+
// word "for" in get_cve's description, which its /pulse/mcp row uses).
|
|
2345
|
+
const PATCH_AVAILABLE_FIELD = "true: EchelonGraph holds, or once held, evidence of a fix for the CVE; patch_evidence names the sources it holds now. " +
|
|
2346
|
+
PATCH_AVAILABLE_WITHDRAWN +
|
|
2347
|
+
" false: no fix evidence is on record, which is not a finding that no fix exists. An answer with no patch_evidence (an API older than that field) does not name the evidence, and a false there is not a finding that no fix exists either.";
|
|
2110
2348
|
const PATCH_EVIDENCE_FIELD = "The sources of the fix evidence EchelonGraph holds for the CVE, strongest first: " +
|
|
2111
2349
|
PATCH_EVIDENCE_SOURCES +
|
|
2112
2350
|
". Empty where none was found when EchelonGraph last computed it. Absent (an API older than the field): the answer does not name the evidence.";
|
|
@@ -2114,14 +2352,14 @@ const PATCH_EVIDENCE_FIELD = "The sources of the fix evidence EchelonGraph holds
|
|
|
2114
2352
|
// while kev_listed. Relayed as CISA's, verbatim; the required action is CISA's directive to US
|
|
2115
2353
|
// federal civilian agencies, not EchelonGraph's advice. Constant strings, like the others here.
|
|
2116
2354
|
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).";
|
|
2117
|
-
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. " +
|
|
2355
|
+
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. " +
|
|
2118
2356
|
KEV_TEXT_ABSENT;
|
|
2119
2357
|
const KEV_SHORT_DESCRIPTION_FIELD = "CISA's shortDescription of the vulnerability, verbatim from its Known Exploited Vulnerabilities catalog. " + KEV_TEXT_ABSENT;
|
|
2120
2358
|
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. " +
|
|
2121
2359
|
KEV_TEXT_ABSENT;
|
|
2122
2360
|
// #2841: what get_cve's remediation is. One constant string (the site's tool-claims check folds
|
|
2123
2361
|
// only constant strings).
|
|
2124
|
-
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.";
|
|
2362
|
+
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.";
|
|
2125
2363
|
const CVERecord = z.looseObject({
|
|
2126
2364
|
cve_id: opt(z.string()),
|
|
2127
2365
|
description: opt(z.string()),
|
|
@@ -2136,6 +2374,9 @@ const CVERecord = z.looseObject({
|
|
|
2136
2374
|
score_confidence: opt(z.string()).describe("How confident EchelonGraph is in its score: NONE when score_assessed is false."),
|
|
2137
2375
|
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."),
|
|
2138
2376
|
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."),
|
|
2377
|
+
// #2880: the SSVC decision, and CISA BOD 26-04's timeline (bodNote words it in the note).
|
|
2378
|
+
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."),
|
|
2379
|
+
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."),
|
|
2139
2380
|
epss_score: opt(z.number()),
|
|
2140
2381
|
epss_percentile: opt(z.number()),
|
|
2141
2382
|
kev_listed: opt(z.boolean()),
|
|
@@ -2149,12 +2390,20 @@ const CVERecord = z.looseObject({
|
|
|
2149
2390
|
// #2861: fix evidence. A label the API adds later is admitted, never a failed call.
|
|
2150
2391
|
patch_available: opt(z.boolean()).describe(PATCH_AVAILABLE_FIELD),
|
|
2151
2392
|
patch_evidence: opt(z.array(z.union([z.enum(PATCH_EVIDENCE_LABELS), z.string()]))).describe(PATCH_EVIDENCE_FIELD),
|
|
2393
|
+
// #3070, #3076: what the CVE record's CNA wrote (see cnaRecordNote).
|
|
2394
|
+
date_public: opt(z.string()).describe(DATE_PUBLIC_FIELD),
|
|
2395
|
+
date_public_source: opt(z.string()).describe(DATE_PUBLIC_SOURCE_FIELD),
|
|
2396
|
+
cna: opt(z.looseObject({ short_name: opt(z.string()), name: opt(z.string()) })).describe(CNA_FIELD),
|
|
2397
|
+
cna_solution: opt(z.string()).describe(CNA_SOLUTION_FIELD),
|
|
2398
|
+
// #3178: a section the API adds later is relayed, never a failed call (as patch_evidence's labels).
|
|
2399
|
+
cna_solution_section: opt(z.union([z.enum(["solution", "workaround"]), z.string()])).describe(CNA_SOLUTION_SECTION_FIELD),
|
|
2152
2400
|
references: z.unknown().optional(),
|
|
2153
2401
|
// #2720: NVD's configurations, losslessly, beside the flat list (see cpeConfigurationsNote).
|
|
2154
2402
|
cpe_match: z.unknown().optional().describe(CPE_MATCH_DESCRIPTION),
|
|
2155
2403
|
cpe_configurations: opt(z.array(z.unknown())).describe(CPE_CONFIGURATIONS_DESCRIPTION),
|
|
2156
|
-
// #2841: a capped summary of how the CVE is fixed; cve_remediation returns the whole.
|
|
2157
|
-
|
|
2404
|
+
// #2841: a capped summary of how the CVE is fixed; cve_remediation returns the whole. Its first
|
|
2405
|
+
// field, fix_status, is the API's fix-status rule, relayed (src/fixStatus.ts).
|
|
2406
|
+
remediation: opt(z.looseObject({ fix_status: fixStatusSchema() })).describe(REMEDIATION_SUMMARY_FIELD),
|
|
2158
2407
|
published: opt(z.string()),
|
|
2159
2408
|
modified: opt(z.string()),
|
|
2160
2409
|
updated_at: opt(z.string()),
|
|
@@ -2409,9 +2658,10 @@ export function createServer() {
|
|
|
2409
2658
|
server.registerTool("cve_summary", {
|
|
2410
2659
|
title: "CVE feed summary",
|
|
2411
2660
|
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}`,
|
|
2661
|
+
inputSchema: z.object({ full: FULL_ARG }),
|
|
2412
2662
|
outputSchema: CVE_SUMMARY_OUTPUT,
|
|
2413
2663
|
annotations: ANNOTATIONS,
|
|
2414
|
-
}, async () => checked("cve_summary", CVE_SUMMARY_OUTPUT, await cveSummary()));
|
|
2664
|
+
}, async (a) => checked("cve_summary", CVE_SUMMARY_OUTPUT, await answering(a.full, NO_DATA_CUT, () => cveSummary())));
|
|
2415
2665
|
server.registerTool("search_cves", {
|
|
2416
2666
|
title: "Search CVEs",
|
|
2417
2667
|
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.`,
|
|
@@ -2423,30 +2673,32 @@ export function createServer() {
|
|
|
2423
2673
|
limit: z.number().int().min(1).max(50).optional().describe("page size (default 20, max 50)"),
|
|
2424
2674
|
// #2783: the next page, which the note names when the text leaves rows out.
|
|
2425
2675
|
offset: z.number().int().min(0).max(10000).optional().describe("rows to skip (default 0)"),
|
|
2676
|
+
full: FULL_ARG,
|
|
2426
2677
|
}),
|
|
2427
2678
|
outputSchema: SEARCH_CVES_OUTPUT,
|
|
2428
2679
|
annotations: ANNOTATIONS,
|
|
2429
|
-
}, async (a) => checked("search_cves", SEARCH_CVES_OUTPUT, await searchCVEs(a)));
|
|
2680
|
+
}, async (a) => checked("search_cves", SEARCH_CVES_OUTPUT, await answering(a.full, SEARCH_CVES_DATA, () => searchCVEs(a))));
|
|
2430
2681
|
server.registerTool("get_cve", {
|
|
2431
2682
|
title: "CVE detail",
|
|
2432
2683
|
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.`,
|
|
2433
|
-
inputSchema: z.object({ cve_id: CVE_ID_ARG }),
|
|
2684
|
+
inputSchema: z.object({ cve_id: CVE_ID_ARG, full: FULL_ARG }),
|
|
2434
2685
|
outputSchema: GET_CVE_OUTPUT,
|
|
2435
2686
|
annotations: ANNOTATIONS,
|
|
2436
|
-
}, async ({ cve_id }) => checked("get_cve", GET_CVE_OUTPUT, await getCVE(cve_id)));
|
|
2687
|
+
}, async ({ cve_id, full }) => checked("get_cve", GET_CVE_OUTPUT, await answering(full, GET_CVE_DATA, () => getCVE(cve_id))));
|
|
2437
2688
|
server.registerTool("cve_exposure", {
|
|
2438
2689
|
title: "Internet exposure for one CVE",
|
|
2439
2690
|
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}`,
|
|
2440
|
-
inputSchema: z.object({ cve_id: CVE_ID_ARG }),
|
|
2691
|
+
inputSchema: z.object({ cve_id: CVE_ID_ARG, full: FULL_ARG }),
|
|
2441
2692
|
outputSchema: CVE_EXPOSURE_OUTPUT,
|
|
2442
2693
|
annotations: ANNOTATIONS,
|
|
2443
|
-
}, async ({ cve_id }) => checked("cve_exposure", CVE_EXPOSURE_OUTPUT, await cveExposure(cve_id)));
|
|
2694
|
+
}, async ({ cve_id, full }) => checked("cve_exposure", CVE_EXPOSURE_OUTPUT, await answering(full, NO_DATA_CUT, () => cveExposure(cve_id))));
|
|
2444
2695
|
server.registerTool("exposure_radar", {
|
|
2445
2696
|
title: "Exposure radar totals",
|
|
2446
2697
|
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}`,
|
|
2698
|
+
inputSchema: z.object({ full: FULL_ARG }),
|
|
2447
2699
|
outputSchema: EXPOSURE_RADAR_OUTPUT,
|
|
2448
2700
|
annotations: ANNOTATIONS,
|
|
2449
|
-
}, async () => checked("exposure_radar", EXPOSURE_RADAR_OUTPUT, await exposureRadar()));
|
|
2701
|
+
}, async (a) => checked("exposure_radar", EXPOSURE_RADAR_OUTPUT, await answering(a.full, EXPOSURE_RADAR_DATA, () => exposureRadar())));
|
|
2450
2702
|
registerKevRecent(server, { api, succeeded, failed, badInput, crashed, checked, envelopeSchema, okHead, annotations: ANNOTATIONS });
|
|
2451
2703
|
registerEPSSHistory(server, TOOL_KIT);
|
|
2452
2704
|
// #2716: its logic, schemas and description live in tools/check_affected.ts.
|
|
@@ -2467,7 +2719,8 @@ export function createServer() {
|
|
|
2467
2719
|
// #2722: prompts and resources, in prompts.ts and resources.ts. Both lists are fixed per
|
|
2468
2720
|
// connection, so listChanged is false above.
|
|
2469
2721
|
registerPrompts(server, { cveId: CVE_ID });
|
|
2470
|
-
|
|
2722
|
+
// #3013: cve:// cuts get_cve's data itself (DATA CUT, resources.ts), so it reads it whole.
|
|
2723
|
+
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))) });
|
|
2471
2724
|
return server;
|
|
2472
2725
|
}
|
|
2473
2726
|
// The package identity and API settings, for the hosted HTTP entrypoint's health answer and
|