echelongraph-mcp 2.7.2 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +124 -0
- package/README.md +146 -432
- package/dist/fixStatus.js +28 -0
- package/dist/index.js +225 -29
- package/dist/prompts.js +55 -31
- package/dist/requiredText.js +1 -1
- package/dist/resources.js +32 -6
- package/dist/textBudget.js +328 -16
- package/dist/tools/check_affected.js +41 -11
- package/dist/tools/check_sbom.js +47 -8
- package/dist/tools/cve_intel.js +15 -6
- package/dist/tools/cve_remediation.js +156 -16
- package/dist/tools/epss_history.js +6 -3
- package/dist/tools/get_cwe.js +7 -2
- package/dist/tools/kev_recent.js +18 -2
- package/dist/tools/match_batch.js +10 -6
- package/dist/tools/scan_manifest.js +20 -7
- package/dist/tools/vendor_advisories.js +34 -7
- package/docs/exposure-radar.md +181 -0
- package/docs/rate-limits.md +50 -0
- package/docs/structured-results.md +43 -0
- package/docs/tools.md +163 -0
- package/package.json +2 -1
- package/server.json +2 -2
package/dist/prompts.js
CHANGED
|
@@ -49,6 +49,11 @@ const text = (t) => ({ messages: [{ role: "user", content: { type: "text", text:
|
|
|
49
49
|
// gives them as the vendor's, untested, and a not_parsed or none_in_source state as no finding that
|
|
50
50
|
// the vendor lists no fix. vendor_advisories_for_cve rows carry remediation_kinds too, so step 4
|
|
51
51
|
// sends the model to get_vendor_advisory where a row lists a workaround or a mitigation.
|
|
52
|
+
// #3013's review: the Patch line has the model state a vendor's workaround or mitigation step,
|
|
53
|
+
// whose wording is each item's text, and cve_remediation's cut can leave that text out of data
|
|
54
|
+
// (its second thinning level) or shorten it in the text (its TEXT CUT); no string is shortened in
|
|
55
|
+
// data, so the line sends the model to get_vendor_advisory for the step where the note says so.
|
|
56
|
+
const VENDOR_STEP_ELSEWHERE = "Where cve_remediation's note says its data or its text leaves out or shortens an item's text (DATA CUT, TEXT CUT), read that step in full from get_vendor_advisory for that advisory (its vendor and vendor_advisory_id).";
|
|
52
57
|
export function triageCveText(cve) {
|
|
53
58
|
return [
|
|
54
59
|
`Triage ${cve} using the EchelonGraph MCP tools. Call them in this order, each with cve_id ${cve}:`,
|
|
@@ -63,7 +68,7 @@ export function triageCveText(cve) {
|
|
|
63
68
|
"- Exploited? CISA-KEV listing and its date, known ransomware use, and public exploit code on record.",
|
|
64
69
|
"- Likelihood: EPSS score and percentile, and how it has moved.",
|
|
65
70
|
"- Reachable? exposure_state and the count on record, or not assessed.",
|
|
66
|
-
`- 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
|
|
71
|
+
`- 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}`,
|
|
67
72
|
"- 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.",
|
|
68
73
|
"- Decision: patch now, schedule, or monitor, with a one-line reason drawn from the lines above.",
|
|
69
74
|
].join("\n");
|
|
@@ -81,10 +86,12 @@ export function kevWeeklyBriefText(days) {
|
|
|
81
86
|
"- If total is 0, write that no CVE was added in that range in the catalog copy fetched at measured_at. If kev_recent failed, write that the catalog could not be read: never that nothing was added.",
|
|
82
87
|
].join("\n");
|
|
83
88
|
}
|
|
84
|
-
// am_i_affected (#2834): a registry match carries fixed_in, the
|
|
85
|
-
// that
|
|
86
|
-
//
|
|
87
|
-
//
|
|
89
|
+
// am_i_affected (#2834): a registry match carries fixed_in, the first version above the one asked
|
|
90
|
+
// about that no affected interval of the advisory holds (core-backend cve/pkgmatch_fixedin.go: the
|
|
91
|
+
// fixed bound of the interval that holds it, moved past any overlapping interval, #3074;
|
|
92
|
+
// check_affected keeps it at every cut level), so the registry text gives it, and a null or absent
|
|
93
|
+
// one as no fixed version on record for that range. A CPE match carries none, so the product text
|
|
94
|
+
// names none.
|
|
88
95
|
export function amIAffectedText(a) {
|
|
89
96
|
const subject = a.product ? `the product ${JSON.stringify(a.product)} (an NVD CPE product token)` : `the package ${JSON.stringify(a.package)} in the ${JSON.stringify(a.ecosystem)} ecosystem`;
|
|
90
97
|
const args = a.product ? { product: a.product, version: a.version } : { ecosystem: a.ecosystem, package: a.package, version: a.version };
|
|
@@ -97,7 +104,7 @@ export function amIAffectedText(a) {
|
|
|
97
104
|
...(a.product
|
|
98
105
|
? []
|
|
99
106
|
: [
|
|
100
|
-
" For each match, give its fixed_in where it is a version: the
|
|
107
|
+
" For each match, give its fixed_in where it is a version: the first version above this one that no affected interval of the advisory holds. Where fixed_in is null or absent, write that the result gives no fixed version for this version's range, which is not a finding that no fix exists.",
|
|
101
108
|
]),
|
|
102
109
|
"- assessed true and count 0: no matching CVE in EchelonGraph's data at this version. Give undetermined_count too: an advisory that could not be decided at this version is not safe.",
|
|
103
110
|
"- assessed false: NOT ASSESSED. Write that the lookup did not evaluate this version, give not_assessed_reason, and never report it as not affected or clean.",
|
|
@@ -117,18 +124,15 @@ export function amIAffectedText(a) {
|
|
|
117
124
|
// check_sbom's matches for 2.14.1 say it "is listed verbatim in the advisory's affected versions",
|
|
118
125
|
// which names no fix. So a fixed version is given only where it is strictly greater than the
|
|
119
126
|
// installed version, and otherwise the line says the results give none for the component's branch
|
|
120
|
-
// and points to the advisory.
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
// fixed_branches, every range with its fix, so after the match_reason interval the rule reads the
|
|
130
|
-
// range that holds the installed version (2.14.1: [2.13.0, 2.15.0), so 2.15.0), and falls back to
|
|
131
|
-
// fixed_version only for a row without fixed_branches.
|
|
127
|
+
// and points to the advisory. Since #2817 cve_intel's affected_packages rows carry fixed_branches,
|
|
128
|
+
// every range with its fix, so the rule reads the range that holds the installed version (2.14.1:
|
|
129
|
+
// [2.13.0, 2.15.0), so 2.15.0), and falls back to fixed_version only for a row without
|
|
130
|
+
// fixed_branches. #3074: a range's fixed can lie inside another range of the same row (lodash under
|
|
131
|
+
// CVE-2026-4800 held [0, 4.17.21) and [4.0.0, 4.18.0), and 4.17.21 is still affected), so the rule
|
|
132
|
+
// walks, as core-backend's fixed_in does: while another range holds the fixed version, it moves to
|
|
133
|
+
// that range's fixed, and a range on the way with no fixed ends it with none. So the version given
|
|
134
|
+
// is the first one above the installed version that no range of the row holds, never simply the
|
|
135
|
+
// fixed of the range that holds it.
|
|
132
136
|
// A cut text keeps index, purl, verdict, not_assessed_reason and cve_ids on a row, not its
|
|
133
137
|
// ecosystem, package or version, so the model reads those from the purl, as core-backend's
|
|
134
138
|
// matcher does (cve/matchbatch.go purlNameVersion, shared/pkg/purlosv): step 2 lists every type
|
|
@@ -137,27 +141,47 @@ export function amIAffectedText(a) {
|
|
|
137
141
|
// purlNameVersion names with their namespace, and says any other is unknown, with no fixed version.
|
|
138
142
|
// test/prompt-fields.test.mjs fromPurl holds the same tables.
|
|
139
143
|
//
|
|
140
|
-
// fixed_in (#2834): each check_sbom match carries fixed_in, the
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
144
|
+
// fixed_in (#2834, #3074): each check_sbom match carries fixed_in, the first version above the
|
|
145
|
+
// component's that no affected interval of the advisory holds (core-backend cve/pkgmatch_fixedin.go:
|
|
146
|
+
// the fixed bound of the interval that holds it, moved past any overlapping interval), which
|
|
147
|
+
// core-backend asserts is above it, or null where no such version is on record (an interval on the
|
|
148
|
+
// way ends at last_affected, or has no end) or no interval holds the version. check_sbom's text
|
|
149
|
+
// keeps fixed_in on every match it keeps, so where the text keeps a match the rule reads fixed_in
|
|
150
|
+
// alone: a version is the fix, null is "no fix on record". Through 2.7.2 the rule fell back to the
|
|
151
|
+
// advisory interval in the match's match_reason (core-backend cve/pkgmatch.go renderInterval,
|
|
152
|
+
// "[A, B)" a fixed bound, "[A, B]" a last_affected one, #2830): prose, read by its brackets, which
|
|
153
|
+
// #2834 replaced with fixed_in, so the rule no longer reads it (and since #3074 a "[A, B)" names one
|
|
154
|
+
// interval's B, which another can still hold). The cve_intel rule is for a component whose matches
|
|
155
|
+
// the cut text leaves out (its leanest level keeps cve_ids, not matches), and for a match without
|
|
156
|
+
// fixed_in (an API before #2834). fixed_in_reason, which says why fixed_in is null, and
|
|
157
|
+
// fixed_in_detail (#3074), which names the overlapping intervals fixed_in moved past, are not
|
|
158
|
+
// named yet: through 2.7.x check_sbom's cut levels kept fixed_in and neither of them; since #3013
|
|
159
|
+
// they keep fixed_in_reason too (check_sbom.ts sbomText, as the structured result's thinned rows
|
|
160
|
+
// do), so the rule can name it, and still not fixed_in_detail. test/prompt-fields.test.mjs holds
|
|
161
|
+
// every field a prompt names to the text its tools return.
|
|
162
|
+
//
|
|
163
|
+
// Upstream rows (#2836): a deb, apk or alpine purl with an upstream qualifier is matched by the
|
|
164
|
+
// source package it names, which is how Debian and Alpine file advisories (core-backend
|
|
165
|
+
// cve/matchbatch.go purlUpstream), at the qualifier's version where it gives one; the row says so
|
|
166
|
+
// with matched_via upstream and matched_package. Step 2 reads the package and version that way, so
|
|
167
|
+
// the cve_intel lookup is by the source package, and the fix list names matched_package beside the
|
|
168
|
+
// purl. The leanest cut leaves matched_package and matched_via out of a row, and step 2 says how to
|
|
169
|
+
// read both from the purl.
|
|
147
170
|
//
|
|
148
171
|
// Remediation (#2841): for the first CVEs of the list, cve_remediation gives each vendor's own
|
|
149
|
-
// remediation, as the vendor categorises it; the list gives it as the vendor's, untested
|
|
172
|
+
// remediation, as the vendor categorises it; the list gives it as the vendor's, untested, and reads
|
|
173
|
+
// a step whose text a cut leaves out from get_vendor_advisory (VENDOR_STEP_ELSEWHERE, #3013's review).
|
|
150
174
|
export function sbomReviewText(sbom) {
|
|
151
175
|
return [
|
|
152
176
|
"Review the SBOM below for known vulnerabilities, using the EchelonGraph MCP tools.",
|
|
153
|
-
`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; where the note says the first text block cuts not_sent_purls to its first entries, 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.`,
|
|
154
|
-
"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).",
|
|
177
|
+
`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.`,
|
|
178
|
+
"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.",
|
|
155
179
|
"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 and list the rest as not looked up, unranked.",
|
|
156
180
|
ENVELOPE_RULES,
|
|
157
181
|
"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).",
|
|
158
182
|
"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.",
|
|
159
|
-
"For each line give the component (purl), its cve_ids, the key values used, and for each CVE a fixed version where the results support one, by this rule.
|
|
160
|
-
|
|
183
|
+
"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 and write \"no fix on record\", which is not a finding that no fix exists. Where the text keeps no match for the component (a cut text can keep its cve_ids alone), 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, 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.",
|
|
184
|
+
`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}`,
|
|
161
185
|
"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.",
|
|
162
186
|
"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's counts, by not_assessed_reason, as not checked, never as clean.",
|
|
163
187
|
"",
|
|
@@ -211,7 +235,7 @@ export function workloadTriageText(source) {
|
|
|
211
235
|
`1. List the workloads and the digest of each image they run, with your own tools. ${each(WORKLOAD_LIST)} An image you cannot resolve to a digest is listed with its tag as not pinned: a tag can later point at another image.`,
|
|
212
236
|
`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.`,
|
|
213
237
|
"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.",
|
|
214
|
-
`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, the distinct purls you sent from the position the note gives on, in the order you sent them.`,
|
|
238
|
+
`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.`,
|
|
215
239
|
`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 text keeps a row's cve_ids without its matches, call get_cve for each of those CVEs, 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.`,
|
|
216
240
|
ENVELOPE_RULES,
|
|
217
241
|
"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.",
|
package/dist/requiredText.js
CHANGED
|
@@ -100,7 +100,7 @@ export const REQUIRED_IN_HEAD = {
|
|
|
100
100
|
cve_remediation: [
|
|
101
101
|
"Every text is relayed as stated by its source; EchelonGraph has not tested it.",
|
|
102
102
|
"An empty list or a remediation_state of none_in_source or not_parsed is not a finding that no fix exists.",
|
|
103
|
-
"CISA's required_action is directed at US federal civilian agencies (BOD 22-01) and is not EchelonGraph's advice.",
|
|
103
|
+
"CISA's required_action is directed at US federal civilian agencies (under BOD 26-04, which replaced BOD 22-01 on 10 June 2026) and is not EchelonGraph's advice.",
|
|
104
104
|
],
|
|
105
105
|
cve_intel: [
|
|
106
106
|
"An empty exploits list is not evidence that no public exploit exists",
|
package/dist/resources.js
CHANGED
|
@@ -25,7 +25,7 @@ export const METHODOLOGY = `# How EchelonGraph MCP results are measured
|
|
|
25
25
|
|
|
26
26
|
Every tool result carries an evidence envelope in its structuredContent, and repeats it in its last text block (less what an earlier text block already carries verbatim), so a client that passes text blocks alone still sees it.
|
|
27
27
|
|
|
28
|
-
A success's first text block is the API's JSON, cut to fit 30,000 characters when it is longer: the note then says what the cut leaves out and how to read it (TEXT CUT)
|
|
28
|
+
A success's first text block is the API's JSON, cut to fit 30,000 characters when it is longer: the note then says what the cut leaves out and how to read it (TEXT CUT). structuredContent's data holds the answer whole unless the whole structured result would pass 30,000 characters of JSON: then, unless the call passes full: true, it is cut in an order the note states (the entries of its longest lists keep only some of their fields, then the lists only some of their entries), data_truncated is true, truncated gives each list cut, and a note starting DATA CUT says what was cut and how to read the rest. Claude Code hands the model structuredContent, not the text blocks, and sizes it against MAX_MCP_OUTPUT_TOKENS; full: true is for a program that reads data whole. The resource ${CVE_TEMPLATE} cuts its data as get_cve's first text block is cut, and then says data_truncated true, truncated naming each list it cut, and a note starting DATA CUT.
|
|
29
29
|
|
|
30
30
|
## Envelope fields
|
|
31
31
|
|
|
@@ -37,7 +37,9 @@ A success's first text block is the API's JSON, cut to fit 30,000 characters whe
|
|
|
37
37
|
| coverage | What the answer covers, where the tool can say: for example in_scope for cve_exposure, the list's account of its own count for search_cves, assessed and not_assessed_reason first for check_affected. |
|
|
38
38
|
| freshness | The producing radar's or fetcher's last completed check, where the API serves one; null where it serves none. |
|
|
39
39
|
| notes | The caveats, one sentence each. |
|
|
40
|
-
|
|
|
40
|
+
| data_truncated | On a success: true when data is cut to keep the structured result within 30,000 characters (DATA CUT); false when data is the answer whole. full: true returns it whole. |
|
|
41
|
+
| truncated | When data_truncated is true: each list cut, by its path in data, with total, returned and, where its entries keep only some of their fields, thinned. |
|
|
42
|
+
| data | On a success: the API's JSON (exposure_radar and cve_intel relay a labelled selection, and cve_summary leaves out a poller field of another JSON type, named in the note), its lists cut when data_truncated is true. |
|
|
41
43
|
| error | On a failure: kind, path, status and message. |
|
|
42
44
|
|
|
43
45
|
## state
|
|
@@ -157,9 +159,20 @@ const json = (uri, v) => ({ contents: [{ uri, mimeType: "application/json", text
|
|
|
157
159
|
// pretty-printed as before; its notes leave out get_cve's sentences about its first text block and,
|
|
158
160
|
// when data is cut, end with the resource's own, starting DATA CUT, which names get_cve's
|
|
159
161
|
// structuredContent.data as the record whole. A record that fits is the structured result
|
|
160
|
-
// pretty-printed, character for character as before.
|
|
162
|
+
// pretty-printed, character for character as before. #3013: get_cve's structured data is cut too
|
|
163
|
+
// past its budget unless the call says full: true, so the resource reads it with full: true
|
|
164
|
+
// (index.ts) and cuts it here, as before, from the record whole.
|
|
165
|
+
//
|
|
166
|
+
// #3013's review: the envelope read with full: true says data_truncated false, and through the
|
|
167
|
+
// review the resource copied it unchanged beside the data it had cut (cpe_match 64 of 396), so the
|
|
168
|
+
// same JSON said its data was whole and cut. The envelope now says what this resource cut, as a
|
|
169
|
+
// tool's DATA CUT does: data_truncated true and truncated, each list dataText cut to its first
|
|
170
|
+
// entries by its path in data, with total and returned (Capped: [] marks a list inside each entry
|
|
171
|
+
// of a list, whose total is the longest); a cut of strings alone names no list, so truncated is
|
|
172
|
+
// then {}, and the note says how strings are cut. Where the resource cuts nothing, the envelope is
|
|
173
|
+
// get_cve's: data_truncated false, no truncated.
|
|
161
174
|
const CVE_RESOURCE_CUT = {
|
|
162
|
-
opens: (chars) => `DATA CUT: the API's answer is ${chars} characters of JSON, more than the ${DATA_TEXT_BUDGET} this resource's data holds, so data here is cut, and get_cve's structuredContent.data carries the answer whole.`,
|
|
175
|
+
opens: (chars) => `DATA CUT: the API's answer is ${chars} characters of JSON, more than the ${DATA_TEXT_BUDGET} this resource's data holds, so data here is cut, and get_cve's structuredContent.data carries the answer whole with full: true.`,
|
|
163
176
|
};
|
|
164
177
|
export function cveResourceText(structured, sentences) {
|
|
165
178
|
const { data, ...envelope } = structured;
|
|
@@ -168,7 +181,20 @@ export function cveResourceText(structured, sentences) {
|
|
|
168
181
|
const firstBlock = new Set(sentences(dataText(data).said));
|
|
169
182
|
const own = dataText(data, undefined, CVE_RESOURCE_CUT);
|
|
170
183
|
const notes = Array.isArray(envelope.notes) ? [...envelope.notes.filter((n) => !firstBlock.has(n)), ...sentences(own.said)] : envelope.notes;
|
|
171
|
-
|
|
184
|
+
// own.said is empty exactly when the text is data whole (pretty, or one row a line).
|
|
185
|
+
const cut = own.said !== "";
|
|
186
|
+
// The envelope's keys in their order, notes and data_truncated replaced, truncated after it.
|
|
187
|
+
const env = {};
|
|
188
|
+
for (const [k, v] of Object.entries(envelope)) {
|
|
189
|
+
if (k === "truncated")
|
|
190
|
+
continue;
|
|
191
|
+
env[k] = k === "notes" ? notes : k === "data_truncated" ? cut : v;
|
|
192
|
+
if (k === "data_truncated" && cut)
|
|
193
|
+
env.truncated = own.capped ?? {};
|
|
194
|
+
}
|
|
195
|
+
if (cut && !Object.hasOwn(env, "data_truncated"))
|
|
196
|
+
Object.assign(env, { data_truncated: true, truncated: own.capped ?? {} });
|
|
197
|
+
const head = JSON.stringify(env, null, 2);
|
|
172
198
|
const body = own.text.replace(/\n/g, "\n ");
|
|
173
199
|
return head === "{}" ? `{\n "data": ${body}\n}` : `${head.slice(0, -2)},\n "data": ${body}\n}`;
|
|
174
200
|
}
|
|
@@ -185,7 +211,7 @@ export function registerResources(server, kit) {
|
|
|
185
211
|
}, async (uri) => json(uri.href, await readSources(kit)));
|
|
186
212
|
server.registerResource("cve", new ResourceTemplate(CVE_TEMPLATE, { list: undefined }), {
|
|
187
213
|
title: "CVE record",
|
|
188
|
-
description: "One CVE's record as get_cve returns it, as JSON: the evidence envelope (state, measured_at, method, notes) with data, the API's record. Past 30,000 characters of JSON, data is cut as get_cve's first text block is, and a note starting DATA CUT says what the cut leaves out; get_cve's structured result holds the record whole. cve_id is a CVE ID such as CVE-2024-3400.",
|
|
214
|
+
description: "One CVE's record as get_cve returns it, as JSON: the evidence envelope (state, measured_at, method, notes) with data, the API's record. Past 30,000 characters of JSON, data is cut as get_cve's first text block is, data_truncated is true, truncated names each list cut, and a note starting DATA CUT says what the cut leaves out; get_cve's structured result holds the record whole with full: true. cve_id is a CVE ID such as CVE-2024-3400.",
|
|
189
215
|
mimeType: "application/json",
|
|
190
216
|
}, async (uri, vars) => {
|
|
191
217
|
const raw = Array.isArray(vars.cve_id) ? vars.cve_id[0] : vars.cve_id;
|