echelongraph-mcp 3.0.2 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +603 -5
- package/README.md +77 -82
- package/dist/http.js +73 -81
- package/dist/httpPolicy.js +197 -10
- package/dist/index.js +451 -44
- package/dist/logLines.js +86 -0
- package/dist/prompts.js +121 -22
- package/dist/readBody.js +140 -0
- package/dist/requiredText.js +23 -3
- package/dist/resources.js +3 -3
- package/dist/runtime.js +66 -1
- package/dist/schemaRefusal.js +138 -0
- package/dist/textBudget.js +826 -76
- package/dist/tools/check_affected.js +19 -6
- package/dist/tools/check_sbom.js +191 -22
- package/dist/tools/cve_intel.js +145 -35
- package/dist/tools/cve_remediation.js +101 -19
- package/dist/tools/epss_history.js +41 -5
- package/dist/tools/match_batch.js +57 -10
- package/dist/tools/packageRecords.js +58 -0
- package/dist/tools/scan_manifest.js +11 -4
- package/dist/tools/vendor_advisories.js +27 -4
- package/docs/exposure-radar.md +4 -0
- package/docs/rate-limits.md +6 -3
- package/docs/structured-results.md +15 -3
- package/docs/tools.md +139 -6
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/logLines.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Every line the hosted endpoint (http.ts) writes to stdout, and exactly the keys each carries besides `severity`
|
|
2
|
+
// and `message` (#2863). /privacy Section 11 and the README's "Privacy: what is sent where" say what the hosted
|
|
3
|
+
// endpoint logs, and through 2.7 only a marketing-site test (privacyMcpEndpoint.test.ts) held http.ts to them: a
|
|
4
|
+
// client address and the whole User-Agent added to mcp_request passed this package's tests, its npm publish and
|
|
5
|
+
// the mcp-remote deploy.
|
|
6
|
+
//
|
|
7
|
+
// This list is the one place a line or a key is added:
|
|
8
|
+
// * http.ts's log() is typed against it, so a log call whose message is not here, or whose fields are not
|
|
9
|
+
// exactly its keys, does not compile (npm test builds first);
|
|
10
|
+
// * test/http.test.mjs requires every stdout line its servers write to carry exactly these keys, every line
|
|
11
|
+
// here to be written by them (or to be one of the two it names that no request can make), no other writer of
|
|
12
|
+
// stdout or stderr under dist/, and the README to name every key of the access line.
|
|
13
|
+
// A key added here is a change to what EchelonGraph logs about a request: say it in /privacy Section 11 and in the
|
|
14
|
+
// README first (marketing-site/lib/privacyMcpEndpoint.test.ts's ACCESS_FIELDS / OTHER_LINES review the same calls).
|
|
15
|
+
//
|
|
16
|
+
// `optional` keys may be absent from a line: tool and prompt come only with tools/call and prompts/get (BodyFacts),
|
|
17
|
+
// and a handler error's error_code only when the error carries a JSON-RPC code (JSON.stringify drops undefined).
|
|
18
|
+
export const LOG_LINES = {
|
|
19
|
+
// The access line: one per request (http.ts finish()).
|
|
20
|
+
mcp_request: {
|
|
21
|
+
keys: [
|
|
22
|
+
// The HTTP method, one of httpPolicy.ts HTTP_METHODS (GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS, CONNECT,
|
|
23
|
+
// TRACE), else "(other)": over h2c the client's own :method token, never logged (3.1.0's final review).
|
|
24
|
+
"method",
|
|
25
|
+
"path",
|
|
26
|
+
"status",
|
|
27
|
+
"duration_ms",
|
|
28
|
+
// A method MCP defines for a client (httpPolicy.ts MCP_METHODS), else "(other)"; "(none)", "(unparseable)",
|
|
29
|
+
// "(batch)" or "(response)" for a body with no one method. Never the client's own string (#2923's review).
|
|
30
|
+
"rpc_method",
|
|
31
|
+
"protocol_version",
|
|
32
|
+
"origin_present",
|
|
33
|
+
"body_bytes",
|
|
34
|
+
"throttled",
|
|
35
|
+
"refused",
|
|
36
|
+
"ua_family",
|
|
37
|
+
"client_public",
|
|
38
|
+
"client_closed",
|
|
39
|
+
"in_flight",
|
|
40
|
+
"queued_ms",
|
|
41
|
+
// #2775: how long a large body waited for a large-body slot (0 when it found one free, or needed none).
|
|
42
|
+
"slot_wait_ms",
|
|
43
|
+
],
|
|
44
|
+
optional: ["tool", "prompt"],
|
|
45
|
+
},
|
|
46
|
+
// Start-up, before any request.
|
|
47
|
+
mcp_remote_refusing_to_start: { keys: ["reason"] },
|
|
48
|
+
mcp_remote_heap_unbounded: { keys: ["heap_limit_mb", "memory_limit_mb", "reason"] },
|
|
49
|
+
mcp_remote_listening: {
|
|
50
|
+
keys: [
|
|
51
|
+
"port",
|
|
52
|
+
"protocol",
|
|
53
|
+
"name",
|
|
54
|
+
"version",
|
|
55
|
+
"api_base",
|
|
56
|
+
"api_timeout_ms",
|
|
57
|
+
"forward_token_configured",
|
|
58
|
+
"rate_limit_per_min",
|
|
59
|
+
"max_body_bytes",
|
|
60
|
+
"max_sbom_body_bytes",
|
|
61
|
+
"large_body_slots",
|
|
62
|
+
"large_body_wait_ms",
|
|
63
|
+
"max_in_flight",
|
|
64
|
+
"admission_wait_ms",
|
|
65
|
+
"heap_limit_mb",
|
|
66
|
+
"memory_limit_mb",
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
// #2923: a fixed-vocabulary class and a ProtocolError's numeric code; never the SDK's message. And the request it
|
|
70
|
+
// arose in, by its fixed labels (runtime.ts requestLabels): its JSON-RPC method (httpPolicy.ts MCP_METHODS, else
|
|
71
|
+
// "(other)"), its protocol version.
|
|
72
|
+
mcp_handler_error: { keys: ["error_name", "error_class", "rpc_method", "protocol_version"], optional: ["error_code"] },
|
|
73
|
+
mcp_request_failed: { keys: ["error_name"] },
|
|
74
|
+
mcp_large_body_busy: { keys: ["slots"] },
|
|
75
|
+
mcp_busy: { keys: ["max_in_flight", "waiting"] },
|
|
76
|
+
// The one line that names a client: its rate-limit key (an IPv4 address, or an IPv6 /64), which the README and
|
|
77
|
+
// /privacy Section 11 both say.
|
|
78
|
+
mcp_rate_limited: { keys: ["client", "limit"] },
|
|
79
|
+
// #2775: when the server learned its client went, whether the answer had started, an h2 stream's reset code (one
|
|
80
|
+
// RFC 9113 defines, else "(other)": httpPolicy.ts rstCodeLabel; null over HTTP/1.1).
|
|
81
|
+
mcp_client_gone: { keys: ["after_ms", "answer_started", "rst_code"] },
|
|
82
|
+
// #2948: one per failed API call: the class, the API route as a template (never the path or query), the status,
|
|
83
|
+
// how long the call took, the request's shape-checked tool and its JSON-RPC method (MCP_METHODS or "(other)"), and
|
|
84
|
+
// whether it was the synthetic's.
|
|
85
|
+
mcp_upstream_failed: { keys: ["tool", "rpc_method", "route", "failure_class", "status", "after_ms", "synthetic"] },
|
|
86
|
+
};
|
package/dist/prompts.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as z from "zod";
|
|
2
|
-
import { MAX_COMPONENTS, MAX_PURLS, MAX_SBOM_CHARS } from "./tools/check_sbom.js";
|
|
2
|
+
import { MAX_COMPONENTS, MAX_PURLS, MAX_SBOM_CHARS, RECHECK_CALLS } from "./tools/check_sbom.js";
|
|
3
3
|
export const KEV_BRIEF_DEFAULT_DAYS = 7;
|
|
4
4
|
export const KEV_BRIEF_MAX_DAYS = 365;
|
|
5
5
|
// The page the brief reads kev_recent in (#2799). The brief puts kev_vuln_name and kev_due_date on
|
|
@@ -41,20 +41,31 @@ const text = (t) => ({ messages: [{ role: "user", content: { type: "text", text:
|
|
|
41
41
|
// cve/fixed_branches.go), so the line gives those, range by range, and gives fixed_version only
|
|
42
42
|
// where a row has no fixed_branches, as what cve_intel records for the package, never as the fix
|
|
43
43
|
// for every affected branch, sending the reader to get_cve's references tagged Vendor Advisory or
|
|
44
|
-
// Patch for a branch's fix.
|
|
44
|
+
// Patch for a branch's fix. #3285 (3.1.0's lane P): cve_intel lists a package once per stored
|
|
45
|
+
// ecosystem string, so CVE-2025-48013 has drupal/quick_node_block under
|
|
46
|
+
// Packagist:https://packages.drupal.org/8 ([0, 2.0.1)) and Packagist ([0, 2.0.0)), and a line
|
|
47
|
+
// giving each row's ranges apart called 2.0.0 the Packagist fix, which the Drupal row still holds
|
|
48
|
+
// affected. The line now reads such rows as one package, as the matcher does (core-backend
|
|
49
|
+
// registryRecordKey, src/tools/packageRecords.ts) and as cve_intel's note says, and never a
|
|
50
|
+
// release suffix so.
|
|
45
51
|
//
|
|
46
52
|
// Its remediation (#2840, #2841): cve_remediation gives, for one CVE, each vendor advisory's
|
|
47
53
|
// remediation as the vendor categorises it (remediation_kinds, remediation_state) and the record's
|
|
48
54
|
// references tagged Patch or Mitigation, each text relayed as its source states it. The Patch line
|
|
49
55
|
// gives them as the vendor's, untested, and a not_parsed or none_in_source state as no finding that
|
|
50
56
|
// the vendor lists no fix. vendor_advisories_for_cve rows carry remediation_kinds too, so step 4
|
|
51
|
-
// sends the model to get_vendor_advisory where a row lists a workaround or a mitigation.
|
|
57
|
+
// sends the model to get_vendor_advisory where a row lists a workaround or a mitigation. #3011: it
|
|
58
|
+
// names the field to pass, the row's vendor_advisory_id. It named none, and a row's advisory_id is
|
|
59
|
+
// EchelonGraph's internal UUID, which get_vendor_advisory does not take (the API answered 404).
|
|
52
60
|
//
|
|
53
61
|
// Its exploit lines (#3039): cve_intel's exploits hold Nuclei detection templates beside exploit
|
|
54
62
|
// code, and a template checks whether a system is affected; it is not necessarily exploit code
|
|
55
63
|
// (core-backend egkev.ExploitCodeKinds, #3038). Until #3039 step 2 called the whole list "public
|
|
56
64
|
// exploit code" and the template asked for "public exploit code on record", so an agent reported
|
|
57
|
-
// exploit code for the ~2,800 CVEs EchelonGraph holds only a template for.
|
|
65
|
+
// exploit code for the ~2,800 CVEs EchelonGraph holds only a template for. #3221: a github_checker
|
|
66
|
+
// row, a GitHub repository named or described as a checker or scanner (#3040), is the other
|
|
67
|
+
// detection kind, and the Exploited? line's "GitHub or vendor proofs of concept" read as taking
|
|
68
|
+
// one in; both lines now name it beside the template, in /pulse's words.
|
|
58
69
|
// #3013's review: the Patch line has the model state a vendor's workaround or mitigation step,
|
|
59
70
|
// whose wording is each item's text, and cve_remediation's cut can leave that text out of data
|
|
60
71
|
// (its second thinning level) or shorten it in the text (its TEXT CUT); no string is shortened in
|
|
@@ -64,17 +75,17 @@ export function triageCveText(cve) {
|
|
|
64
75
|
return [
|
|
65
76
|
`Triage ${cve} using the EchelonGraph MCP tools. Call them in this order, each with cve_id ${cve}:`,
|
|
66
77
|
"1. get_cve: the record. Read score_assessed before echelongraph_score (false: not yet scored, which is not a score of 0); read severity, cvss_v3_score, epss_score and epss_percentile, and the CISA-KEV fields kev_listed, kev_added_date, kev_due_date and kev_ransomware, and, where the record carries it, kev_required_action, CISA's own text.",
|
|
67
|
-
"2. cve_intel: weaknesses (cwes), public exploit code and detection
|
|
78
|
+
"2. cve_intel: weaknesses (cwes), public exploit code and detection checks (exploits, exploits_total and exploits_by_kind; a nuclei_template row is a Nuclei detection template, and a github_checker row a GitHub repository named or described as a checker or scanner: each checks whether a system is affected and is not necessarily exploit code; verified_status is the label stored with each reference, not a guarantee that it works, and an empty list is not evidence that no exploit exists), affected_packages (each package's affected ranges with their fixes in fixed_branches) and fixed_versions.",
|
|
68
79
|
"3. cve_remediation: how it is fixed, as its sources state it: fixed_branches (each affected package's ranges with their fixes), vendor_remediations (each vendor advisory naming the CVE, with its vendor, vendor_advisory_id, remediation_state and remediation_kinds, each kind the vendor's own category) and fix_references (the record's references tagged Patch or Mitigation).",
|
|
69
|
-
"4. vendor_advisories_for_cve: the vendor advisories that name it, with remediation_kinds and remediation_state where a row carries them. Call get_vendor_advisory for one whose remediation you need in full, such as one whose remediation_kinds include workaround or mitigation.",
|
|
80
|
+
"4. vendor_advisories_for_cve: the vendor advisories that name it, with remediation_kinds and remediation_state where a row carries them. Call get_vendor_advisory with a row's vendor and its vendor_advisory_id as advisory_id (never the row's advisory_id, EchelonGraph's internal ID) for one whose remediation you need in full, such as one whose remediation_kinds include workaround or mitigation.",
|
|
70
81
|
"5. epss_history: how its EPSS score changed, one point per recorded change (series_kind change_only): before series_starts_at a missing point means not recorded, not unchanged.",
|
|
71
82
|
"6. cve_exposure: internet exposure on record. exposure_state says what the count is; exposed_hosts counts distinct ip:port services, not machines; the result is not_assessed with measured_at null, so present any count as undated and on record, and exposure_state not_assessed as not assessed.",
|
|
72
83
|
ENVELOPE_RULES,
|
|
73
84
|
"Then answer with this template, one line each, naming the tool and the measured_at behind every line:",
|
|
74
|
-
"- Exploited? CISA-KEV listing and its date, known ransomware use, and public exploit code on record (Metasploit modules, Exploit-DB entries, GitHub or vendor proofs of concept); give Nuclei detection templates apart, as detection checks, never as exploit code.",
|
|
85
|
+
"- Exploited? CISA-KEV listing and its date, known ransomware use, and public exploit code on record (Metasploit modules, Exploit-DB entries, GitHub or vendor proofs of concept); give Nuclei detection templates and GitHub checker repositories apart, as detection checks, never as exploit code.",
|
|
75
86
|
"- Likelihood: EPSS score and percentile, and how it has moved.",
|
|
76
87
|
"- Reachable? exposure_state and the count on record, or not assessed.",
|
|
77
|
-
`- Patch: fixed versions, vendor remediation and fix references; where the results hold none, write "none on record", which is not a finding that no fix exists. For each affected package whose cve_intel row carries fixed_branches, give every range with its fix: from introduced ("0" is the first version) up to its fixed, or up to and including last_affected, where no fix is on record for that range; never one range's fix as the fix for every affected branch or the version every install should move to. cve_intel keeps one fixed_version per ecosystem and package, its advisory's last range's as a rule, which need not be the fix for every affected version of the package: where a row has no fixed_branches (null or absent), give fixed_version as the fixed version cve_intel records for that package, never as the fix for every affected branch, and for the fix on a given branch point to get_cve's references tagged Vendor Advisory or Patch, where it has them, and to the vendor advisories. From cve_remediation, give each vendor_remediations advisory by vendor and vendor_advisory_id with its remediation_kinds, a workaround or mitigation as the step that vendor states, which EchelonGraph has not tested and which is not a fix, and its fix_references; a remediation_state of not_parsed or none_in_source is not a finding that the vendor lists no fix. ${VENDOR_STEP_ELSEWHERE}`,
|
|
88
|
+
`- Patch: fixed versions, vendor remediation and fix references; where the results hold none, write "none on record", which is not a finding that no fix exists. For each affected package whose cve_intel row carries fixed_branches, give every range with its fix: from introduced ("0" is the first version) up to its fixed, or up to and including last_affected, where no fix is on record for that range; never one range's fix as the fix for every affected branch or the version every install should move to. cve_intel rows of one package_name under Packagist or Maven, bare or with a repository URL after ":" (such as Packagist and Packagist:https://packages.drupal.org/8), are one package, as cve_intel's note says: give their ranges together, and never a range's fixed that another of those ranges still holds as a fix, since that version is still affected; rows of two releases (Debian:11 and Debian:12) are two packages. cve_intel keeps one fixed_version per ecosystem and package, its advisory's last range's as a rule, which need not be the fix for every affected version of the package: where a row has no fixed_branches (null or absent), give fixed_version as the fixed version cve_intel records for that package, never as the fix for every affected branch, and for the fix on a given branch point to get_cve's references tagged Vendor Advisory or Patch, where it has them, and to the vendor advisories. From cve_remediation, give each vendor_remediations advisory by vendor and vendor_advisory_id with its remediation_kinds, a workaround or mitigation as the step that vendor states, which EchelonGraph has not tested and which is not a fix, and its fix_references; a remediation_state of not_parsed or none_in_source is not a finding that the vendor lists no fix. ${VENDOR_STEP_ELSEWHERE}`,
|
|
78
89
|
"- Deadline: get_cve's kev_due_date when the CVE is KEV-listed (CISA's due date for US federal civilian agencies); otherwise write that the results give no CISA deadline. Where get_cve carries kev_required_action, quote it, attributed to CISA, as the action CISA requires of those agencies, not as EchelonGraph's advice.",
|
|
79
90
|
"- Decision: patch now, schedule, or monitor, with a one-line reason drawn from the lines above.",
|
|
80
91
|
].join("\n");
|
|
@@ -138,7 +149,13 @@ export function amIAffectedText(a) {
|
|
|
138
149
|
// walks, as core-backend's fixed_in does: while another range holds the fixed version, it moves to
|
|
139
150
|
// that range's fixed, and a range on the way with no fixed ends it with none. So the version given
|
|
140
151
|
// is the first one above the installed version that no range of the row holds, never simply the
|
|
141
|
-
// fixed of the range that holds it.
|
|
152
|
+
// fixed of the range that holds it. #3285 (3.1.0's lane P): the row "for the component's ecosystem
|
|
153
|
+
// and package" was one row, while cve_intel lists a package once per stored ecosystem string:
|
|
154
|
+
// CVE-2025-48013's Packagist drupal/quick_node_block row ([0, 2.0.0)) gave 1.0.0 the fix 2.0.0,
|
|
155
|
+
// which its Packagist:https://packages.drupal.org/8 row ([0, 2.0.1)) still holds affected. For a
|
|
156
|
+
// Packagist or Maven component the rule now takes those rows as one, their ranges together, as
|
|
157
|
+
// the matcher does (core-backend registryRecordKey; packageRecords.ts) and cve_intel's note says,
|
|
158
|
+
// which gives 2.0.1; rows of two releases (Debian:11, Debian:12) never are.
|
|
142
159
|
// A cut text keeps index, purl, verdict, not_assessed_reason and cve_ids on a row, not its
|
|
143
160
|
// ecosystem, package or version, so the model reads those from the purl, as core-backend's
|
|
144
161
|
// matcher does (cve/matchbatch.go purlNameVersion, shared/pkg/purlosv): step 2 lists every type
|
|
@@ -159,12 +176,18 @@ export function amIAffectedText(a) {
|
|
|
159
176
|
// #2834 replaced with fixed_in, so the rule no longer reads it (and since #3074 a "[A, B)" names one
|
|
160
177
|
// interval's B, which another can still hold). The cve_intel rule is for a component whose matches
|
|
161
178
|
// the cut text leaves out (its leanest level keeps cve_ids, not matches), and for a match without
|
|
162
|
-
// fixed_in (an API before #2834). fixed_in_reason
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
179
|
+
// fixed_in (an API before #2834). fixed_in_reason says why fixed_in is null: through 2.7.x
|
|
180
|
+
// check_sbom's cut levels kept fixed_in alone; since #3013 every level of the text that keeps a
|
|
181
|
+
// match keeps fixed_in_reason too (check_sbom.ts sbomText), so a null fixed_in is written with its
|
|
182
|
+
// fixed_in_reason, relayed as given and never read for a version (#2834's last review: the rule
|
|
183
|
+
// named no reason). The structured result's compact level (#3269, check_sbom.ts SBOM_ROW_COMPACT)
|
|
184
|
+
// keeps fixed_in without fixed_in_reason: kept, the reason cost the 201-purl probe a row and the
|
|
185
|
+
// image mixes affected rows at their floor (test/check_sbom-large-rows.test.mjs). So the clause
|
|
186
|
+
// applies where a match carries fixed_in_reason, and a null fixed_in without it is read as a
|
|
187
|
+
// reason the cut left out, never as no fix: the agent checks the purl again with full: true, or
|
|
188
|
+
// reads the CVE's fix from cve_remediation or get_cve. fixed_in_detail (#3074), which names the
|
|
189
|
+
// overlapping intervals fixed_in moved past, is not named: no cut level of check_sbom's text keeps
|
|
190
|
+
// it. test/prompt-fields.test.mjs holds every field a prompt names to the text its tools return.
|
|
168
191
|
//
|
|
169
192
|
// Upstream rows (#2836): a deb, apk or alpine purl with an upstream qualifier is matched by the
|
|
170
193
|
// source package it names, which is how Debian and Alpine file advisories (core-backend
|
|
@@ -174,22 +197,40 @@ export function amIAffectedText(a) {
|
|
|
174
197
|
// purl. The leanest cut leaves matched_package and matched_via out of a row, and step 2 says how to
|
|
175
198
|
// read both from the purl.
|
|
176
199
|
//
|
|
200
|
+
// The rows a DATA CUT leaves out (#3269's third review): sbom_review had no step for the affected
|
|
201
|
+
// and undetermined rows the structured cut leaves out of data (Claude Code reads data, not the
|
|
202
|
+
// text), so a cut SBOM's fix list lacked them unsaid. Step 1 now checks them again by index, as
|
|
203
|
+
// workload_triage's step 6 does, at most RECHECK_CALLS calls, and lists the rest as unchecked.
|
|
204
|
+
//
|
|
205
|
+
// #3269's fourth review: step 1's index rule counted the purls check_sbom reads, and said nothing of
|
|
206
|
+
// metadata.component, which fromCycloneDX does not walk (Trivy puts the image's pkg:oci purl there),
|
|
207
|
+
// so an agent counting it had every index one off; it now says the subject is not counted and has
|
|
208
|
+
// the agent check its mapping against the rows data keeps, which carry index and purl, before any
|
|
209
|
+
// re-check call. 3.1.0's phase B review: read as general, "the document's own subject is not
|
|
210
|
+
// counted" had an agent skip an SPDX image SBOM's root package, which fromSPDX counts (it walks
|
|
211
|
+
// doc.packages, every package with a purl): the clause now says an SPDX document has no
|
|
212
|
+
// metadata.component and its root package is counted. And past 30 distinct CVEs step 3 looked up "30 of them", any 30, so on a large SBOM
|
|
213
|
+
// the KEV-listed ones could be among those not looked up, while the matches already say which they
|
|
214
|
+
// are: it now takes those whose matches carry kev_listed true, or that matches_truncated's
|
|
215
|
+
// kev_listed_cve_ids names, first, then the highest epss_score, read from the matches, not a
|
|
216
|
+
// get_cve per CVE.
|
|
217
|
+
//
|
|
177
218
|
// Remediation (#2841): for the first CVEs of the list, cve_remediation gives each vendor's own
|
|
178
219
|
// remediation, as the vendor categorises it; the list gives it as the vendor's, untested, and reads
|
|
179
220
|
// a step whose text a cut leaves out from get_vendor_advisory (VENDOR_STEP_ELSEWHERE, #3013's review).
|
|
180
221
|
export function sbomReviewText(sbom) {
|
|
181
222
|
return [
|
|
182
223
|
"Review the SBOM below for known vulnerabilities, using the EchelonGraph MCP tools.",
|
|
183
|
-
`1. Call check_sbom with sbom set to the document below, as given. It reads the document's purls and checks up to ${MAX_PURLS} distinct purls per call, sending them in batches of ${MAX_COMPONENTS}; a document with more is refused, not truncated. If it is refused, say why from the result and do not guess at the components. If coverage.not_sent is above 0, the API's rate limit or the call's time budget stopped it early: say how many purls were not sent and coverage.not_sent_reason, and that they are not checked (never clean). After a minute, call check_sbom once more with purls set to the purls not sent, and merge what it returns. data.not_sent_purls lists them whole with full: true; where the note says the first text block cuts not_sent_purls to its first entries, or a DATA CUT cuts that list in data, rebuild the list from the document instead, as the note says: the document's distinct purls from the position the note gives on, counted in the order it gives.`,
|
|
224
|
+
`1. Call check_sbom with sbom set to the document below, as given. It reads the document's purls and checks up to ${MAX_PURLS} distinct purls per call, sending them in batches of ${MAX_COMPONENTS}; a document with more is refused, not truncated. If it is refused, say why from the result and do not guess at the components. If coverage.not_sent is above 0, the API's rate limit or the call's time budget stopped it early: say how many purls were not sent and coverage.not_sent_reason, and that they are not checked (never clean). After a minute, call check_sbom once more with purls set to the purls not sent, and merge what it returns. data.not_sent_purls lists them whole with full: true; where the note says the first text block cuts not_sent_purls to its first entries, or a DATA CUT cuts that list in data, rebuild the list from the document instead, as the note says: the document's distinct purls from the position the note gives on, counted in the order it gives. Where a DATA CUT sentence names affected or undetermined rows of results left out of data, by index, those with a KEV-listed or ransomware match first and apart, call check_sbom again with purls set to the purls at those indices (each index the position, from 0, of a purl among the document's distinct purls, counted in the order check_sbom reads them: components or packages in document order, each component's nested components right after it, each purl at its first place; metadata.component, the document's own subject, is not counted, so its purl, such as an image's pkg:oci, has no index; an SPDX document has no metadata.component: each of its packages with a purl is counted, its root package among them; check your mapping against the rows data keeps, each of which carries its index and its purl, before you send these calls), in the order the note names them and in calls of the sizes its re-check sentence gives ("Re-check these K purls in this order, in calls of …"; a purl it sends to a call of its own goes alone), at most ${RECHECK_CALLS} such calls, and review their rows with the others; list the affected and undetermined rows not checked again, by index and by count, as unchecked, never as clean.`,
|
|
184
225
|
"2. Read each component's ecosystem, package and version from its purl, which every row of data.results in the text carries (the leanest cut of the text leaves out the row's own ecosystem, package and version), as check_sbom maps them. A purl is pkg:type/namespace/name@version?qualifiers, each part percent-decoded (%40 is @), its type and distro qualifier in any case. The version is what follows the last @, up to any ? or #. The ecosystem follows from the type: npm is npm, pypi PyPI, maven Maven, golang Go, cargo crates.io, gem RubyGems, nuget NuGet, composer Packagist, hex Hex, pub Pub, swift SwiftURL, hackage Hackage, cran CRAN, bitnami Bitnami, conan ConanCenter and githubact GitHub Actions. For deb, apk, alpine and rpm it is the release the distro qualifier names, which must be one of these three kinds: distro=debian-N is Debian:N, the major release alone (debian-12 and debian-12.5 are Debian:12); distro=ubuntu-V is Ubuntu:V as given (ubuntu-22.04 is Ubuntu:22.04); distro=alpine-X.Y is Alpine:vX.Y, cut to major.minor (alpine-3.20 and alpine-3.20.10 are Alpine:v3.20; alpine-3 names none). For any other type, and a deb, apk, alpine or rpm purl without such a qualifier, the ecosystem is unknown: check_sbom does not check the component (not_assessed, never clean), and the fix list gives no version for it. The package is namespace:name for maven (pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1 is the package org.apache.logging.log4j:log4j-core in Maven, at version 2.14.1), namespace/name for npm, golang, composer, swift and githubact, and the name alone for every other type (pkg:deb/debian/openssl@3.0.15-1~deb12u1?distro=debian-12 is openssl in Debian:12). A deb, apk or alpine purl with an upstream qualifier (upstream=name, or upstream=name@version) is checked as the source package it names, which is how Debian and Alpine file advisories: its package is that name, and its version is the qualifier's where it gives one, else the purl's, and its row says matched_via upstream, with that name as matched_package (pkg:apk/alpine/libcrypto3@3.3.6-r0?distro=alpine-3.20&upstream=openssl is openssl in Alpine:v3.20, at 3.3.6-r0). For any other purl check_sbom checks, matched_via is purl_name and matched_package is the package read as above.",
|
|
185
|
-
"3. For each distinct cve_id on a component whose verdict is affected, call get_cve, to read kev_listed, epss_score, score_assessed, echelongraph_score and cvss_v3_score, and cve_intel, to read fixed_branches and fixed_version from its affected_packages rows, and fixed_version from its fixed_versions rows, for the component's ecosystem and package (package_name). If there are more than 30 distinct CVEs, do this for 30 of them
|
|
226
|
+
"3. For each distinct cve_id on a component whose verdict is affected, call get_cve, to read kev_listed, epss_score, score_assessed, echelongraph_score and cvss_v3_score, and cve_intel, to read fixed_branches and fixed_version from its affected_packages rows, and fixed_version from its fixed_versions rows, for the component's ecosystem and package (package_name). If there are more than 30 distinct CVEs, do this for 30 of them, chosen KEV-listed first: those whose check_sbom matches carry kev_listed true, or that a row's matches_truncated.kev_listed_cve_ids names, then the others by highest epss_score, each read for this choice from the check_sbom matches where the text or data keeps them, not from a get_cve call per CVE; list the rest as not looked up, unranked.",
|
|
186
227
|
ENVELOPE_RULES,
|
|
187
228
|
"Read each component's verdict: not_affected is the one clean verdict; undetermined and not_assessed are not clean, and not_assessed_reason says why (for example distro_release_unknown: a deb, apk or rpm purl without a distro qualifier, which EchelonGraph does not guess).",
|
|
188
229
|
"Then write a prioritised fix list of the affected components. State the ordering keys, in this order: 1. CISA-KEV listed (kev_listed true) first; 2. then EPSS score, highest first; 3. then echelongraph_score where score_assessed is true, else cvss_v3_score, highest first. A CVE with no value for a key sorts after those with one, and its line says which key was missing.",
|
|
189
|
-
"For each line give the component (its purl, and, where its row's matched_via is upstream, its matched_package: the source package its CVEs were matched by), its cve_ids, the key values used, and for each CVE a fixed version where the results support one, by this rule. Where the text keeps the component's match for the CVE and the match carries fixed_in, read fixed_in alone: a version there is the first version above the installed one that no affected interval of the advisory holds, so give it as the fixed version; null says no fixed version is on record for the installed version's range, so give none for that CVE on that component
|
|
230
|
+
"For each line give the component (its purl, and, where its row's matched_via is upstream, its matched_package: the source package its CVEs were matched by), its cve_ids, the key values used, and for each CVE a fixed version where the results support one, by this rule. Where the text keeps the component's match for the CVE and the match carries fixed_in, read fixed_in alone: a version there is the first version above the installed one that no affected interval of the advisory holds, so give it as the fixed version; null says no fixed version is on record for the installed version's range, so give none for that CVE on that component: where the match carries fixed_in_reason, write \"no fix on record\" and that fixed_in_reason, which says why, as given (a version it names is not a fix); this is not a finding that no fix exists. A null fixed_in on a match without fixed_in_reason is one whose reason a DATA CUT left out for size (its matches keep fixed_in, not fixed_in_reason): never write \"no fix\" for it; say its reason was cut, and call check_sbom again for that purl with full: true, or read the CVE's fix from cve_remediation or get_cve. Where the text or data keeps no match for that CVE on the component (a cut can keep a row's cve_ids alone, or fewer matches than cve_ids), or the match has no fixed_in field, the rest of this rule applies. Never give a fixed version that is not strictly greater than the component's installed version (its version, as step 2 reads it from the purl), compared in the ecosystem's version order: release numbers part by part, as numbers, not as text (2.9.1 is below 2.12.2, and 2.12.2 is below 2.14.1); where you are not sure of the order (pre-releases, qualifiers and distro revisions have their own rules), say so and do not give it. Where cve_intel's affected_packages row for the component's ecosystem and package carries fixed_branches (for a Packagist or Maven component, the rows of its package_name under that ecosystem, bare and with a repository URL after \":\", such as Packagist and Packagist:https://packages.drupal.org/8, are one row, their ranges all together, as cve_intel's note says; rows of two releases, such as Debian:11 and Debian:12, never are), give the fixed of the range that holds the installed version: at or above its introduced (\"0\" is the first version) and below its fixed, or at or below its last_affected, in the same version order; while another range of the row holds that fixed version, it is still affected, so move to that range's fixed; a range on the way with last_affected and no fixed, or with no end, has no fix on record, so give none, and a version in no range is given no fixed version from them; where fixed_branches is there, fixed_version is not used. Where it is not (null or absent), give cve_intel's fixed_version for the component's ecosystem and package: cve_intel keeps one fixed_version per package, its advisory's last range's, which need not be the fix on the component's branch, so say so beside it. Where no version meets the rule, write that the results give no fixed version for the component's branch, which is not a finding that no fix exists, and point to the CVE's advisory for it: get_cve's references tagged Vendor Advisory or Patch, where it has them.",
|
|
190
231
|
`Then, for the first 5 CVEs of the list (fewer if it has fewer), call cve_remediation with the CVE's cve_id, and under the CVE's line give each vendor_remediations advisory by vendor and vendor_advisory_id with its remediation_kinds, a workaround or mitigation as the step that vendor states, which EchelonGraph has not tested and which is not a fix; a remediation_state of not_parsed or none_in_source is not a finding that the vendor lists no fix. ${VENDOR_STEP_ELSEWHERE}`,
|
|
191
232
|
"After the list, give data.summary's counts, and list the undetermined and not_assessed components with their reasons as not checked, never as clean.",
|
|
192
|
-
"Where the note says that rows of data.results are left out of the first text block, list the components its rows carry, say how many affected components the text leaves out (data.summary.affected, less those listed), and give the undetermined and not_assessed components it leaves out as data.summary
|
|
233
|
+
"Where the note says that rows of data.results are left out of the first text block, list the components its rows carry, say how many affected components the text leaves out (data.summary.affected, less those listed), and give the undetermined and not_assessed components it leaves out as data.summary.undetermined and data.summary.not_assessed (by not_assessed_by_reason), less those listed, as not checked, never as clean. The components with a KEV-listed or ransomware match leave the text last, and the note names by index each of them it leaves out (\"Of the rows left out of it, K have a KEV-listed or ransomware match: at index …\"): list those as KEV-bearing components not checked, by index, never as clean.",
|
|
193
234
|
"",
|
|
194
235
|
"SBOM:",
|
|
195
236
|
"```json",
|
|
@@ -210,10 +251,66 @@ export function sbomReviewText(sbom) {
|
|
|
210
251
|
// What it reads is what check_sbom's text keeps: kev_listed, ransomware, epss_score,
|
|
211
252
|
// effective_score, score_assessed and fixed_in on each match while matches are kept; past its
|
|
212
253
|
// leanest cut a row keeps cve_ids alone, so the agent reads those CVEs from get_cve
|
|
213
|
-
// (test/prompt-fields.test.mjs).
|
|
254
|
+
// (test/prompt-fields.test.mjs). #3270: the structured result's cut (3.0.2, #3132) can also keep a
|
|
255
|
+
// row's matches capped, which step 5 read as the row's whole: a KEV-listed CVE past the cap was
|
|
256
|
+
// named only in matches_truncated.kev_listed_cve_ids, which no prompt read, and the rows still had
|
|
257
|
+
// matches, so the get_cve fallback never fired. Step 5 now reads matches_truncated's KEV-listed and
|
|
258
|
+
// ransomware IDs, and calls get_cve for every CVE kept without its match and every match without a
|
|
259
|
+
// field it ranks by (check_sbom.ts keeps them at every level that keeps matches, and its capped
|
|
260
|
+
// rows keep KEV-listed and ransomware matches first). #3269's review: at the leanest cut
|
|
261
|
+
// matches_truncated keeps total and returned alone, so step 5 says that neither list is there and
|
|
262
|
+
// the get_cve fallback reads those CVEs; and since #3269 the clean and not-assessed rows leave data
|
|
263
|
+
// before an affected row's matches are capped, so a workload's counts of not_affected and
|
|
264
|
+
// not_assessed rows can lack rows the cut left out: step 6 takes the call's totals from
|
|
265
|
+
// data.summary, which no cut touches. #3269's second review: round 2's step 6 then had the agent
|
|
266
|
+
// check every purl left out again, at the page size the note names, which is often small (332 calls
|
|
267
|
+
// on a realistic Debian SBOM, nearly all of them for not_affected rows). Now the counts are
|
|
268
|
+
// data.summary's, a call per image where several images' rows were left out; a left-out
|
|
269
|
+
// not_affected or not_assessed row is not checked again (summary counts it, and it holds no CVE);
|
|
270
|
+
// only the affected and undetermined rows that left data, which the DATA CUT sentence names by
|
|
271
|
+
// index, are checked again, in calls of those purls alone, and past RECHECK_CALLS (check_sbom.ts)
|
|
272
|
+
// such calls the agent stops and reports the rest as unchecked. #3269's third review: round 3's
|
|
273
|
+
// step 6 checked them again in index order, at the page sentence's N, and both were wrong for it:
|
|
274
|
+
// on old unpatched Debian images the rows with a KEV-listed match came last, so 119 of 255 were in
|
|
275
|
+
// view after its 5 calls, and N holds for the first N purls sent, while the rows left out are the
|
|
276
|
+
// heavy ones, so its calls dropped 504 of 2,540 rows on realistic images. The note now names the
|
|
277
|
+
// rows with a KEV-listed or ransomware match first and apart, and gives the sizes of calls of them
|
|
278
|
+
// that keep every row, counted for those rows (textBudget.ts PageAt.againFit): 238 of 255 in view,
|
|
279
|
+
// none dropped. sbom_review checks the same rows again the same way. #3269's fourth review: the
|
|
280
|
+
// first text block cuts its rows on its own terms (textBudget.ts dataText: rows leave from the end,
|
|
281
|
+
// in input order), while data keeps the KEV-bearing rows first, so a client that reads the text
|
|
282
|
+
// alone lost the rows the text left out, KEV-bearing ones among them, unsaid: workload_triage had no
|
|
283
|
+
// step for them. Its last line now says, as sbom_review's does, that they are in
|
|
284
|
+
// structuredContent.data, and, read from the text alone, has them counted from data.summary as
|
|
285
|
+
// unchecked. 3.1.0's phase B review: "those rows are in structuredContent.data" was false where a
|
|
286
|
+
// DATA CUT applies too, the large-image case the line is for (the text is cut from the whole
|
|
287
|
+
// answer, FIRST_TEXT_BLOCK_BESIDE_A_CUT): through the stdio server, 700 affected purls with the
|
|
288
|
+
// KEV-listed ones late kept 186 rows in the text and 56 in data, and 458 of the 514 the text left
|
|
289
|
+
// out were not in default data, 44 of them with a KEV-listed match. The line now says such rows
|
|
290
|
+
// are named in the note by index for step 6's re-check, and sends the agent to steps 5 and 6.
|
|
291
|
+
// The same review: read from the text alone, it gave the undetermined and not_assessed rows the
|
|
292
|
+
// text leaves out "as data.summary's counts", which count every such row, those the text still
|
|
293
|
+
// lists among them (undetermined rows leave from the end mixed with affected ones); it now gives
|
|
294
|
+
// data.summary.undetermined and data.summary.not_assessed less those listed, as it gives the
|
|
295
|
+
// affected count, and sbom_review's last line does the same. #3284: the first text block now keeps
|
|
296
|
+
// the rows with a KEV-listed or ransomware match last to leave, as data does, and names by index
|
|
297
|
+
// each of them it leaves out (textBudget.ts leaveOutLast), so a reader of the text alone no longer
|
|
298
|
+
// loses them unsaid (on the reviewer's 36 realistic images, 95 of 413 KEV-bearing rows at
|
|
299
|
+
// d42d4697, none since): both last lines quote that sentence and list those rows as KEV-bearing and
|
|
300
|
+
// not checked, where the line said a row the text leaves out "can hold a KEV-listed CVE that the
|
|
301
|
+
// text does not name". OS packages: a deb or apk purl without an upstream qualifier is
|
|
214
302
|
// looked up by its binary name while Debian and Alpine file advisories under the source package, so
|
|
215
303
|
// such rows can come back not_assessed (production, 2026-10-05: libssl3 at Debian:12 answers
|
|
216
304
|
// package_not_in_advisory_corpus); the prompt has the agent count and state them, never as clean.
|
|
305
|
+
// #3283: the kubernetes exposure rule named neither of two standard paths to a pod: a container's
|
|
306
|
+
// hostPort (spec.containers[].ports[].hostPort, which binds the port on the node's IP: a hostPort
|
|
307
|
+
// ingress controller or agent DaemonSet) and a Service's spec.externalIPs (nodes accept the
|
|
308
|
+
// Service's traffic on them whatever its type, ClusterIP included). An agent that read the pod's
|
|
309
|
+
// spec and found none of the listed conditions wrote "not exposed (caller-asserted)", not
|
|
310
|
+
// "unknown", and the pod sorted last within its KEV group. Both are now exposed, and "not exposed"
|
|
311
|
+
// requires having read the containers' ports and the Services' externalIPs. The blog's "Triage
|
|
312
|
+
// your pods" section states the same rule (marketing-site lib/mcpPromptClaims.test.ts holds it to
|
|
313
|
+
// this text).
|
|
217
314
|
export const WORKLOAD_SOURCES = ["kubernetes", "github_actions", "images"];
|
|
218
315
|
// A CVE count past which the agent stops calling get_cve, as in sbom_review.
|
|
219
316
|
const WORKLOAD_GET_CVE_MAX = 30;
|
|
@@ -223,7 +320,7 @@ const WORKLOAD_LIST = {
|
|
|
223
320
|
images: "Images: the image references the user gives you. Resolve each tag to its digest (for example crane digest or skopeo inspect), and say that you did.",
|
|
224
321
|
};
|
|
225
322
|
const WORKLOAD_EXPOSURE = {
|
|
226
|
-
kubernetes: "In a cluster, exposed means a Service of type LoadBalancer or NodePort selects the pod, an Ingress (or a Gateway route) sends traffic to a Service that selects it, or the pod sets hostNetwork: true; not exposed means you read the pod's spec and the cluster's Services, Ingresses and routes, and none of these holds; unknown means you could not read them, or the user tells you of a path you cannot see (a load balancer outside the cluster, for example).",
|
|
323
|
+
kubernetes: "In a cluster, exposed means a Service of type LoadBalancer or NodePort selects the pod, a Service that selects it lists externalIPs (spec.externalIPs, on which nodes accept its traffic whatever its type, ClusterIP included), an Ingress (or a Gateway route) sends traffic to a Service that selects it, a container of the pod sets a hostPort (spec.containers[].ports[].hostPort, which binds that port on the node's IP), or the pod sets hostNetwork: true; not exposed means you read the pod's spec, its containers' ports among it, and the cluster's Services, with their type and externalIPs, Ingresses and routes, and none of these holds; unknown means you could not read them, or the user tells you of a path you cannot see (a load balancer outside the cluster, for example).",
|
|
227
324
|
github_actions: "For a CI job's container and services, exposure is unknown unless the user tells you otherwise; write what the user told you, as caller-asserted.",
|
|
228
325
|
images: "For an image the user names, exposure is unknown unless the user tells you how it is reached; write what the user told you, as caller-asserted.",
|
|
229
326
|
};
|
|
@@ -242,12 +339,14 @@ export function workloadTriageText(source) {
|
|
|
242
339
|
`2. Decide each workload's exposure yourself, from the same data, as exposed, not exposed or unknown, and label it caller-asserted. ${each(WORKLOAD_EXPOSURE)} Unknown is never treated as not exposed. EchelonGraph measured none of this: no EchelonGraph tool reports the exposure of your workloads.`,
|
|
243
340
|
"3. Make an SBOM of each distinct digest with your own tools, for example syft <image>@sha256:<digest> -o cyclonedx-json, or trivy image --format cyclonedx <image>@sha256:<digest>. Take the purls of its components, leave out the purl of the image itself (pkg:oci or pkg:docker), and keep your own map from each purl to the images that carry it.",
|
|
244
341
|
`4. Call check_sbom with purls set to one image's purls, or to several images' purls together, up to ${MAX_PURLS} distinct purls per call. Pass purls, not the SBOM document: a document's metadata can name the image. data.results holds one row per purl sent, each with its purl, which your map ties to its images. If coverage.not_sent is above 0, say how many purls were not sent and coverage.not_sent_reason, and that they are not checked (never clean); after a minute, call check_sbom once more with purls set to the purls not sent: data.not_sent_purls, or, where the note says the first text block cuts that list or a DATA CUT cuts it in data, the distinct purls you sent from the position the note gives on, in the order you sent them.`,
|
|
245
|
-
`5. For each affected row, read kev_listed, ransomware, epss_score, effective_score, score_assessed and fixed_in from its matches where the text keeps them. Where the
|
|
342
|
+
`5. For each affected row, read kev_listed, ransomware, epss_score, effective_score, score_assessed and fixed_in from its matches where the text or data keeps them. A row whose matches a DATA CUT shortened or left out carries matches_truncated. Where matches_truncated has kev_listed_cve_ids and ransomware_cve_ids, they name every KEV-listed and every ransomware CVE among all of the row's matches, those cut included, so count each of them as a KEV-listed (or ransomware) CVE of the row; where it has total and returned and neither list (the leanest cut), nothing in the answer names the row's KEV-listed or ransomware CVEs, and the get_cve calls below read them. Call get_cve for each CVE of an affected row that the text or data keeps without its match (a row with cve_ids and no matches, or with fewer matches than cve_ids, as matches_truncated says), and for each match that leaves out one of the fields above, to read kev_listed, kev_ransomware, epss_score, score_assessed, echelongraph_score and cvss_v3_score; past ${WORKLOAD_GET_CVE_MAX} such CVEs, list the rest as not looked up, unranked.`,
|
|
343
|
+
`6. data.summary counts every row of a call whatever the cut: affected, not_affected, undetermined and not_assessed, and not_assessed_by_reason. Take each image's counts from it, and a workload's from its images': where the note says rows of results are left out of data (a DATA CUT sentence saying "results keeps K of its N entries") and the call carried several images' purls, call check_sbom with one image's purls per call for its counts. A row left out whose verdict is not_affected or not_assessed is not checked again: data.summary counts it, and it holds no CVE; where not_assessed rows are left out, state the image's not_assessed count and not_assessed_by_reason from data.summary instead of its OS-package rows, and say so. The affected and undetermined rows that left data are the ones checked again: the DATA CUT sentence names them by index, those with a KEV-listed or ransomware match first and apart ("affected or undetermined rows: the K with a KEV-listed or ransomware match at index …; the other K at index …"), each index the position, from 0, of a purl among the distinct purls you sent, in the order you sent them. Call check_sbom again with those purls alone as purls, in the order the note names them, so those with a KEV-listed or ransomware match first, in calls of the sizes its re-check sentence gives ("Re-check these K purls in this order, in calls of …"; a purl it sends to a call of its own goes alone), not of the page sentence's N, which holds for the first N purls you sent and not for these; read their rows as in step 5; each call's own note says what its data leaves out. If that takes more than ${RECHECK_CALLS} calls, stop after ${RECHECK_CALLS} and report the affected and undetermined rows not checked again, by purl where the note names them and by count, as unchecked, never as clean.`,
|
|
246
344
|
ENVELOPE_RULES,
|
|
247
345
|
"Read each row's verdict: not_affected is the one clean verdict; undetermined and not_assessed are not clean, and not_assessed_reason says why. Packages from an image's operating system (deb, apk and rpm purls) can come back not_assessed: one without a distro qualifier (distro_release_unknown), and a deb or apk purl without an upstream qualifier, which is looked up by its binary package name while Debian and Alpine file advisories under the source package. For each image, state how many of its OS-package rows are not_assessed, and never count them as clean.",
|
|
248
346
|
`Then write one row per workload (${sources.map((k) => WORKLOAD_ROW[k]).join("; ")}), with: the image digests it runs; its KEV-listed CVEs, each with RANSOMWARE where ransomware (or get_cve's kev_ransomware) is true, and the match's fixed_in where it is a version (null or absent: the results give no fixed version for that branch, which is not a finding that no fix exists); its highest epss_score; its exposure, labelled caller-asserted, with the line of the user's data you read it from; and its counts of affected, undetermined and not_assessed rows.`,
|
|
249
347
|
"Order the rows by these keys, and state them: 1. a workload with a KEV-listed CVE (kev_listed true) first; 2. then exposure: exposed, then unknown, then not exposed; 3. then the highest epss_score, highest first; 4. then the highest score: effective_score, or get_cve's echelongraph_score, where score_assessed is true, else cvss_v3_score, highest first. A workload with no value for a key sorts after those with one, and its row says which key was missing.",
|
|
250
348
|
"Say plainly that every exposure label is caller-asserted: it came from the user's own cluster or CI data, read by you, and EchelonGraph measured none of it. List the images you could not resolve, scan or check, with why, as not checked, never as clean.",
|
|
349
|
+
"Where the note says that rows of data.results are left out of the first text block, those rows are in structuredContent.data, or, where a DATA CUT leaves rows out of data too, named there by index for step 6's re-check (the note says data holds them whole with full: true): where you can read it, read them there as in steps 5 and 6. Where you read the text alone, list the rows it carries, say how many affected rows it leaves out (data.summary.affected, less those listed), and give the undetermined and not_assessed rows it leaves out as data.summary.undetermined and data.summary.not_assessed (by not_assessed_by_reason), less those listed, as unchecked, never as clean. The rows with a KEV-listed or ransomware match leave the text last, and the note names by index each of them it leaves out (\"Of the rows left out of it, K have a KEV-listed or ransomware match: at index …\"): list those as KEV-bearing rows not checked, by index, never as clean.",
|
|
251
350
|
].join("\n");
|
|
252
351
|
}
|
|
253
352
|
export function registerPrompts(server, kit) {
|
package/dist/readBody.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { largeBodyAdmissible } from "./httpPolicy.js";
|
|
2
|
+
export function header(req, name) {
|
|
3
|
+
const v = req.headers[name];
|
|
4
|
+
return Array.isArray(v) ? v.join(", ") : v;
|
|
5
|
+
}
|
|
6
|
+
// Whether the request has closed or is closing, so that a 'close' listener attached now may never be called (#2775).
|
|
7
|
+
// HTTP/1.1: IncomingMessage.destroyed is set the moment it is destroyed; .closed only once that has finished, with
|
|
8
|
+
// 'close' on a later tick (Node 20 and 22 alike). h2c: node:http2's compatibility request is never destroyed itself
|
|
9
|
+
// (after a reset its .destroyed and .closed stay false), so its stream is read.
|
|
10
|
+
function closedAlready(req) {
|
|
11
|
+
const stream = req.stream;
|
|
12
|
+
return req.destroyed || stream?.destroyed === true || stream?.closed === true;
|
|
13
|
+
}
|
|
14
|
+
// readBody over these slots (http.ts: the instance's large-body slots) and these limits.
|
|
15
|
+
export function bodyReader(largeBodies, limits) {
|
|
16
|
+
const { maxBodyBytes: MAX_BODY_BYTES, maxSbomBodyBytes: MAX_SBOM_BODY_BYTES, largeBodyWaitMs: LARGE_BODY_WAIT_MS } = limits;
|
|
17
|
+
return readBody;
|
|
18
|
+
async function readBody(req, gone, timing) {
|
|
19
|
+
const mayBeLarge = largeBodyAdmissible(header(req, "mcp-method"), header(req, "mcp-name"));
|
|
20
|
+
const cap = mayBeLarge ? MAX_SBOM_BODY_BYTES : MAX_BODY_BYTES;
|
|
21
|
+
const declared = Number(req.headers["content-length"]);
|
|
22
|
+
if (Number.isFinite(declared) && declared > cap) {
|
|
23
|
+
req.resume();
|
|
24
|
+
return "too_large";
|
|
25
|
+
}
|
|
26
|
+
// The wait for a slot stops when the client goes (the response closes) or the request closes.
|
|
27
|
+
const waitOver = new AbortController();
|
|
28
|
+
const stopWaiting = () => waitOver.abort();
|
|
29
|
+
gone.addEventListener("abort", stopWaiting, { once: true });
|
|
30
|
+
req.once("close", stopWaiting);
|
|
31
|
+
// #2775 (3.1.0's release dry run, 2026-10-25): an abort or a close that came before these listeners is never
|
|
32
|
+
// emitted again, so it is read from the signal and the request as they are now. Without this, a reader started
|
|
33
|
+
// after its client went would take a place among the waiters (to the end of its wait), or a free slot, which
|
|
34
|
+
// readBodyHolding would then hold for good. http.ts never starts one late: it calls readBody in the request's own
|
|
35
|
+
// event, and a reset or a close sent with the request's headers still reaches these listeners (3.1.0's review).
|
|
36
|
+
// test/read-body.test.mjs calls it late.
|
|
37
|
+
if (gone.aborted || closedAlready(req))
|
|
38
|
+
stopWaiting();
|
|
39
|
+
const waitForSlot = async () => {
|
|
40
|
+
// A free slot is no wait (slot_wait_ms stays 0), however the clock falls; nor is a refusal at once (two already
|
|
41
|
+
// wait): only a real wait is timed. Timing the at-once refusal across its await logged slot_wait_ms 1 when the
|
|
42
|
+
// clock's millisecond turned under load (a full npm test run, 2026-10-18).
|
|
43
|
+
if (largeBodies.tryAcquire())
|
|
44
|
+
return true;
|
|
45
|
+
if (!largeBodies.wouldWait(LARGE_BODY_WAIT_MS) || waitOver.signal.aborted)
|
|
46
|
+
return false;
|
|
47
|
+
const t = Date.now();
|
|
48
|
+
const got = await largeBodies.acquire(LARGE_BODY_WAIT_MS, waitOver.signal);
|
|
49
|
+
timing.slotWaitMs += Date.now() - t;
|
|
50
|
+
return got;
|
|
51
|
+
};
|
|
52
|
+
let slot = false;
|
|
53
|
+
try {
|
|
54
|
+
if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
|
|
55
|
+
if (!(await waitForSlot())) {
|
|
56
|
+
if (waitOver.signal.aborted)
|
|
57
|
+
return "gone";
|
|
58
|
+
req.resume();
|
|
59
|
+
return "busy";
|
|
60
|
+
}
|
|
61
|
+
slot = true;
|
|
62
|
+
}
|
|
63
|
+
return await readBodyHolding(req, cap, slot, waitForSlot, waitOver.signal);
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
gone.removeEventListener("abort", stopWaiting);
|
|
67
|
+
req.off("close", stopWaiting);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function readBodyHolding(req, cap, holding, waitForSlot, waitOver) {
|
|
71
|
+
let slot = holding;
|
|
72
|
+
return new Promise((resolve, reject) => {
|
|
73
|
+
const chunks = [];
|
|
74
|
+
let size = 0;
|
|
75
|
+
let done = false;
|
|
76
|
+
const stop = (r) => {
|
|
77
|
+
done = true;
|
|
78
|
+
chunks.length = 0;
|
|
79
|
+
if (slot)
|
|
80
|
+
largeBodies.release();
|
|
81
|
+
slot = false;
|
|
82
|
+
resolve(r);
|
|
83
|
+
};
|
|
84
|
+
// The client went (waitOver: readBody's listeners, which also read whether it had gone before they were
|
|
85
|
+
// attached), however long ago: a request that closed first emits no 'data', 'end' or 'close' for the listeners
|
|
86
|
+
// below, and would hold its slot for good (#2775).
|
|
87
|
+
const onGone = () => {
|
|
88
|
+
if (!done)
|
|
89
|
+
stop("gone");
|
|
90
|
+
};
|
|
91
|
+
if (waitOver.aborted)
|
|
92
|
+
return onGone();
|
|
93
|
+
waitOver.addEventListener("abort", onGone, { once: true });
|
|
94
|
+
req.on("data", (chunk) => {
|
|
95
|
+
if (done)
|
|
96
|
+
return;
|
|
97
|
+
size += chunk.length;
|
|
98
|
+
if (size > cap)
|
|
99
|
+
return stop("too_large");
|
|
100
|
+
if (size > MAX_BODY_BYTES && !slot) {
|
|
101
|
+
// A body with no Content-Length crossed MAX_BODY_BYTES: it waits for a slot holding what it has read (at most
|
|
102
|
+
// MAX_BODY_BYTES and this chunk), and reads on once it has one.
|
|
103
|
+
req.pause();
|
|
104
|
+
chunks.push(chunk);
|
|
105
|
+
void waitForSlot().then((got) => {
|
|
106
|
+
if (done) {
|
|
107
|
+
if (got)
|
|
108
|
+
largeBodies.release();
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (!got)
|
|
112
|
+
return stop(waitOver.aborted ? "gone" : "busy");
|
|
113
|
+
slot = true;
|
|
114
|
+
req.resume();
|
|
115
|
+
});
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
chunks.push(chunk);
|
|
119
|
+
});
|
|
120
|
+
req.on("end", () => {
|
|
121
|
+
if (done)
|
|
122
|
+
return;
|
|
123
|
+
done = true;
|
|
124
|
+
resolve({ body: Buffer.concat(chunks), slot });
|
|
125
|
+
});
|
|
126
|
+
req.on("close", () => {
|
|
127
|
+
if (!done)
|
|
128
|
+
stop("gone");
|
|
129
|
+
});
|
|
130
|
+
req.on("error", (e) => {
|
|
131
|
+
if (done)
|
|
132
|
+
return;
|
|
133
|
+
done = true;
|
|
134
|
+
if (slot)
|
|
135
|
+
largeBodies.release();
|
|
136
|
+
reject(e);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
package/dist/requiredText.js
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
// dropped, a head naming Shodan without SHODAN_OWNERSHIP, or a tool registered with no entry here
|
|
6
6
|
// fails it. A tool added later adds its entry here.
|
|
7
7
|
//
|
|
8
|
-
// The cut. Claude Code
|
|
9
|
-
// cut at 2,048 characters, with "… [truncated]" appended:
|
|
8
|
+
// The cut. Claude Code from v2.1.280 sent each MCP tool description and each server's instructions
|
|
9
|
+
// to the model cut at 2,048 characters, with "… [truncated]" appended:
|
|
10
10
|
// - Claude Code docs, environment variables (code.claude.com/docs/en/env-vars, read 2026-10-05):
|
|
11
11
|
// CLAUDE_CODE_MAX_MCP_DESCRIPTION_LENGTH, "Maximum length in characters of each MCP tool
|
|
12
12
|
// description and each MCP server's instructions that Claude Code sends to the model (default:
|
|
@@ -14,10 +14,15 @@
|
|
|
14
14
|
// - anthropics/claude-code#87650 (closed, not planned): the minified constant Tpe=2048 gates the
|
|
15
15
|
// cut in v2.1.234; /mcp shows the whole text while the model receives the cut one, and only the
|
|
16
16
|
// instructions' cut is logged ("Server instructions truncated from X to 2048 chars").
|
|
17
|
+
// - Re-read 2026-10-18: the same page now gives default limits of 4,096, and 16,384 for a tool
|
|
18
|
+
// Claude Code loads through tool search, and v2.1.302's --debug log shows "Tool
|
|
19
|
+
// "exposure_radar" description truncated from 11807 to 4096 chars" (2.6.8's description). The
|
|
20
|
+
// budget stays 2,048: builds from v2.1.280 until that change cut there, and they are in use.
|
|
17
21
|
// The other clients looked at cut later or not at all: Cursor cuts tool descriptions at 6,500
|
|
18
22
|
// characters (Cursor staff, forum.cursor.com/t/147465); VS Code and Claude Desktop document no
|
|
19
23
|
// description cap (VS Code caps a tool's name at 64 characters and a request at 128 tools). So
|
|
20
|
-
//
|
|
24
|
+
// the shortest cut a client is known to make is the budget: text that fits it reaches every one of
|
|
25
|
+
// them whole. docs/tools.md ("The client cut") says the same for readers.
|
|
21
26
|
//
|
|
22
27
|
// Kept under src/ so marketing-site lib/mcpToolClaims.test.ts's resolver can fold the two Shodan
|
|
23
28
|
// sentences, which the descriptions and notes take from here.
|
|
@@ -127,7 +132,22 @@ export const REQUIRED_IN_HEAD = {
|
|
|
127
132
|
// none. The sentence says what that pair means and which field is the current reading; it names a
|
|
128
133
|
// field, not a tool (test/description-policy.test.mjs: no schema text names another tool).
|
|
129
134
|
export const PATCH_AVAILABLE_WITHDRAWN = "true beside an empty patch_evidence means evidence of a fix once held has since been withdrawn (patch_available is never set back to false), and fix_status (remediation.fix_status in the CVE's record) is the current reading, not patch_available.";
|
|
135
|
+
// #3016: cve_exposure's three KEV flags. kev_listed is core-backend's deprecated alias of
|
|
136
|
+
// kev_seen_in_observations (kevexposure/store.go CVEExposure.KEVListed): bool_or over the radar's
|
|
137
|
+
// observation rows, so it is false for a CISA-KEV CVE the radar holds no service for, whatever the
|
|
138
|
+
// catalog says. Through 3.0.2 the outputSchema gave all three as booleans with no text, and hosted
|
|
139
|
+
// cve_exposure CVE-2024-3400 (PAN-OS, CISA-KEV) served kev_listed false beside kev_catalog_listed
|
|
140
|
+
// true (2026-10-10), so a client reading data alone could report a KEV CVE as not on KEV. The
|
|
141
|
+
// field stays (dropping it would break a reader, a major version); its text says what it is.
|
|
142
|
+
export const KEV_LISTED_DEPRECATED = "Deprecated: the same value as kev_seen_in_observations, kept for compatibility.";
|
|
143
|
+
export const KEV_OBSERVATION_FLAG = "It is the radar's observation flag, not CISA-KEV catalog status";
|
|
144
|
+
export const KEV_CATALOG_FLAG = "The CISA-KEV catalog flag:";
|
|
130
145
|
export const REQUIRED_IN_OUTPUT_SCHEMA = {
|
|
131
146
|
get_cve: { "data.patch_available": [PATCH_AVAILABLE_WITHDRAWN] },
|
|
132
147
|
search_cves: { "data.cves[].patch_available": [PATCH_AVAILABLE_WITHDRAWN] },
|
|
148
|
+
cve_exposure: {
|
|
149
|
+
"data.kev_listed": [KEV_LISTED_DEPRECATED, KEV_OBSERVATION_FLAG],
|
|
150
|
+
"data.kev_seen_in_observations": [KEV_OBSERVATION_FLAG],
|
|
151
|
+
"data.kev_catalog_listed": [KEV_CATALOG_FLAG],
|
|
152
|
+
},
|
|
133
153
|
};
|
package/dist/resources.js
CHANGED
|
@@ -55,15 +55,15 @@ A count of 0 is a finding of none when the answer measured it. Under assessed: f
|
|
|
55
55
|
|
|
56
56
|
## How each tool measures
|
|
57
57
|
|
|
58
|
-
- cve_summary: measured; measured_at is summary.last_updated, the newest modification time among the active CVE records it counts. freshness is
|
|
58
|
+
- cve_summary: measured; measured_at is summary.last_updated, the newest modification time among the active CVE records it counts. freshness.nvd_poll is the fleet's last completed NVD poll: last_run_at is the answer's nvd_poll.last_completed_at, with scope fleet and the poll's interval. freshness is null where the answer carries no nvd_poll block (an older API, or one holding no fleet NVD poll it could read) or one without a real RFC 3339 time, and the note says which.
|
|
59
59
|
- search_cves: measured, measured_at null (each record carries its own times); coverage repeats total, total_counted, total_is_lower_bound, search_relaxed, limit and offset.
|
|
60
60
|
- get_cve: measured; measured_at is the record's updated_at, when EchelonGraph last wrote it. score_assessed false means not yet scored, not a score of 0.
|
|
61
61
|
- cve_exposure: not_assessed with measured_at null, every answer: its last_seen is when EchelonGraph last wrote or refreshed a service's row, not an observation time. exposure_state (exposed, measured_zero, not_assessed, tracking_unknown) says what the count is; exposed_hosts counts distinct ip:port services from Shodan data, not machines, and is not an internet-wide census.
|
|
62
62
|
- exposure_radar: not_assessed with measured_at null: no radar's answer gives one time at which what it counts was observed. freshness gives each radar's last_run_at where the API serves one.
|
|
63
63
|
- kev_recent: measured; measured_at is EchelonGraph's last successful fetch of CISA's KEV feed; freshness carries that fetch time, catalog_version and date_released.
|
|
64
64
|
- epss_history: measured_at is current.epss_updated_at, when EchelonGraph last wrote a changed EPSS value; the series is change-only, so a missing point is not recorded, not unchanged.
|
|
65
|
-
- check_affected: measured when the answer says assessed true; not_assessed when it says false or does not say. measured_at and freshness are null.
|
|
66
|
-
- check_sbom: measured when at least one component got a verdict, else not_assessed; measured_at null. not_affected is the one clean verdict; undetermined and not_assessed are not clean.
|
|
65
|
+
- check_affected: measured when the answer says assessed true; not_assessed when it says false or does not say. measured_at and freshness are null. A registry match carries fixed_in, the lowest version above the one asked about that no affected interval of its record holds, or null with fixed_in_reason, which is not a finding that no fix exists. A CPE match carries no fixed_in: the end of the NVD range it matched, cpe_version_end_excluding or cpe_version_end_including, is not a fix.
|
|
66
|
+
- check_sbom: measured when at least one component got a verdict, else not_assessed; measured_at null. not_affected is the one clean verdict; undetermined and not_assessed are not clean. Each match carries fixed_in, or null with fixed_in_reason, as check_affected's registry matches do. A deb, apk or alpine purl with an upstream qualifier (upstream=name, or upstream=name@version) is checked as the source package it names, which is how Debian and Alpine file advisories: its row says matched_via upstream, with that name as matched_package. Any other row the lookup ran for says matched_via purl_name; a refused row has neither. A binary package such as libssl3 sent without the qualifier is looked up by its own name, which the advisories do not use, and comes back not_assessed (package_not_in_advisory_corpus), never clean.
|
|
67
67
|
- scan_manifest: as check_sbom, on the purls this server reads from the lockfiles passed; not_assessed, with no API call, when no entry pins one registry version. Each entry not sent (a range, an unresolved version, a local path, a VCS checkout or a line not read) is listed in data.not_checked with its reason and is not clean; a refused file (package.json, go.sum and the other manifests of ranges) is named in the note, and nothing in it is checked.
|
|
68
68
|
- cve_intel: measured_at null: each row carries its own dates. An exploits row carries source_published_at, the source's own date for the reference, null when the source publishes none (EchelonGraph does not supply one), and eg_first_seen_at, when EchelonGraph recorded the reference; first_seen_at is deprecated and holds the same value as source_published_at. A timeline row carries enriched_at. An empty exploits list is not evidence that no public exploit exists.
|
|
69
69
|
- cve_remediation: measured with measured_at null, or not_assessed for a CVE record rejected by its numbering authority. Every text is its source's, verbatim and untested by EchelonGraph; each vendor item's kind is the vendor's own category. remediation_state none_in_source or not_parsed, and an empty list, are not a finding that no fix exists.
|