echelongraph-mcp 2.7.2 → 3.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +219 -0
- package/README.md +163 -433
- package/dist/fixStatus.js +30 -0
- package/dist/httpPolicy.js +16 -1
- package/dist/index.js +287 -34
- package/dist/prompts.js +63 -33
- package/dist/requiredText.js +16 -1
- package/dist/resources.js +32 -6
- package/dist/textBudget.js +588 -22
- package/dist/tools/check_affected.js +45 -12
- package/dist/tools/check_sbom.js +158 -9
- package/dist/tools/cve_intel.js +63 -9
- package/dist/tools/cve_remediation.js +230 -16
- package/dist/tools/epss_history.js +10 -4
- package/dist/tools/get_cwe.js +7 -2
- package/dist/tools/kev_recent.js +22 -3
- package/dist/tools/match_batch.js +27 -7
- package/dist/tools/scan_manifest.js +35 -10
- package/dist/tools/vendor_advisories.js +47 -11
- package/docs/exposure-radar.md +181 -0
- package/docs/rate-limits.md +50 -0
- package/docs/structured-results.md +43 -0
- package/docs/tools.md +167 -0
- package/package.json +2 -1
- package/server.json +2 -2
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
// The JSON is relayed verbatim as data (#1874): every match keeps kev_listed, ransomware,
|
|
28
28
|
// epss_score, effective_score, effective_severity and score_assessed as the API sent them.
|
|
29
29
|
import * as z from "zod";
|
|
30
|
-
import { GET_CVE_WHOLE, TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
|
|
30
|
+
import { answering, FULL_ARG, GET_CVE_WHOLE, TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
|
|
31
31
|
const TOOL = "check_affected";
|
|
32
32
|
export const MATCH_PATH = "/api/v1/public/cves/match";
|
|
33
33
|
// handler.go, the four request headers the endpoint reads before the query string.
|
|
@@ -74,10 +74,11 @@ const Match = z.looseObject({
|
|
|
74
74
|
vendor_unknown: opt(z.boolean()).describe("true: the vendor was not verified, so the match is real but its attribution to your vendor is not."),
|
|
75
75
|
match_reason: opt(z.string()),
|
|
76
76
|
version_evidence: opt(z.string()),
|
|
77
|
-
// #2834: registry matches only (core-backend cve/pkgmatch_fixedin.go
|
|
77
|
+
// #2834, #3074: registry matches only (core-backend cve/pkgmatch_fixedin.go registryFixFor).
|
|
78
78
|
interval: opt(z.looseObject({ introduced: opt(z.string()), fixed: opt(z.string()), last_affected: opt(z.string()) })).describe("Registry match: the advisory interval that holds this version (fixed is exclusive, last_affected inclusive); absent when no interval holds it."),
|
|
79
|
-
fixed_in: opt(z.string()).describe("Registry match: the fixed bound of the
|
|
79
|
+
fixed_in: opt(z.string()).describe("Registry match: the lowest version above this one that no affected interval of the CVE's record for the package holds, always above it: the fixed bound of the interval that holds this version, moved past any overlapping interval that still holds that bound (fixed_in_detail then names it). null when no such version is on record (an interval on the way records no fix: it ends at last_affected, or has no end) or no interval holds the version, with fixed_in_reason saying which. Absent on a CPE match."),
|
|
80
80
|
fixed_in_reason: opt(z.string()).describe("Why fixed_in is null; absent when fixed_in is a version."),
|
|
81
|
+
fixed_in_detail: opt(z.string()).describe("Set when fixed_in is not the fixed bound of the interval that holds this version: the overlapping intervals of the same record it moved past, which still hold that bound."),
|
|
81
82
|
});
|
|
82
83
|
const Undetermined = z.looseObject({
|
|
83
84
|
cve_id: opt(z.string()),
|
|
@@ -130,7 +131,7 @@ const COVERAGE = z.strictObject({
|
|
|
130
131
|
// Constant strings, so marketing-site lib/mcpToolClaims.test.ts can fold them should it read
|
|
131
132
|
// this file.
|
|
132
133
|
export const CHECK_AFFECTED_TITLE = "Am I affected? (product or package at a version)";
|
|
133
|
-
export const CHECK_AFFECTED_DESCRIPTION = "Whether a product or package at a given version is affected by known CVEs, from the same matcher as echelongraph.io/am-i-affected. The CPE path takes product (an NVD CPE product token, such as
|
|
134
|
+
export const CHECK_AFFECTED_DESCRIPTION = "Whether a product or package at a given version is affected by known CVEs, from the same matcher as echelongraph.io/am-i-affected. The CPE path takes product (an NVD CPE product token, such as nginx) and version, and returns the CVEs whose NVD CPE match criteria include that product at that version; each match names the vendor NVD asserts (cpe_vendor) and whether it was verified (vendor_unknown). The registry path takes ecosystem (npm, PyPI, Maven and other OSV ecosystem names), package and version, and decides each OSV advisory EchelonGraph holds for that package as affected, not affected or undetermined. count depends on assessed: assessed false means the lookup did not evaluate this component, not_assessed_reason says why, and a count of 0 there is not a finding of not affected. An advisory undecidable at this version is reported as undetermined (undetermined_count, and up to 50 of them in undetermined), never as safe. Each match carries cve_id, kev_listed, ransomware, epss_score, effective_score, effective_severity and score_assessed (false: not yet scored, so echelongraph_score is withheld). A registry match also carries interval, the advisory interval holding it, and fixed_in, the lowest unaffected version above it, or null with fixed_in_reason; a CPE match carries no fixed_in. All four inputs travel in request headers, never in the URL." +
|
|
134
135
|
" " +
|
|
135
136
|
TEXT_BUDGET_DESCRIPTION +
|
|
136
137
|
" Cut, the excluded and undetermined samples keep their first 10 entries, each its cve_id and reason, cve_ids keeps its first 10, and each match keeps cve_id, kev_listed, ransomware, epss_score, effective_score, score_assessed and fixed_in at least. Every CPE match stays in the text; near the cap, a registry list's last matches can leave it, and the note says how many.";
|
|
@@ -139,6 +140,7 @@ export const CHECK_AFFECTED_INPUT = z.object({
|
|
|
139
140
|
ecosystem: z.string().optional().describe("Registry path: the package's ecosystem, such as npm, PyPI or Maven. Give with package, not with product."),
|
|
140
141
|
package: z.string().optional().describe("Registry path: the package name in that ecosystem, such as lodash."),
|
|
141
142
|
version: z.string().describe("The version to check, such as 3.0.0."),
|
|
143
|
+
full: FULL_ARG,
|
|
142
144
|
});
|
|
143
145
|
const field = (o, k) => (o !== null && typeof o === "object" ? o[k] : undefined);
|
|
144
146
|
const num = (o, k) => {
|
|
@@ -172,22 +174,24 @@ function matchesNote(matches) {
|
|
|
172
174
|
}
|
|
173
175
|
return out;
|
|
174
176
|
}
|
|
175
|
-
// #2834: what the registry matches say about a fix. fixed_in is the
|
|
176
|
-
// that
|
|
177
|
-
//
|
|
177
|
+
// #2834: what the registry matches say about a fix. fixed_in is the first version above this one
|
|
178
|
+
// that no affected interval of the match's record holds (core-backend cve/pkgmatch_fixedin.go):
|
|
179
|
+
// the fixed bound of the interval that holds this version, moved past any overlapping interval
|
|
180
|
+
// that still holds it (#3074), so it is per match: an advisory with several branches (Log4Shell:
|
|
181
|
+
// 2.3.1, 2.12.2, 2.15.0) names the one for this version.
|
|
178
182
|
function fixedInNote(matches) {
|
|
179
183
|
const registry = matches.filter((m) => m !== null && typeof m === "object" && "fixed_in" in m);
|
|
180
184
|
if (registry.length === 0)
|
|
181
185
|
return "";
|
|
182
186
|
const fixed = registry.filter((m) => typeof field(m, "fixed_in") === "string").length;
|
|
183
187
|
const none = registry.length - fixed;
|
|
184
|
-
return ` fixed_in, the
|
|
188
|
+
return ` fixed_in, the first version above this one that no affected interval of the advisory holds: ${fixed === registry.length ? `set on every match` : `set on ${fixed} of ${registry.length} matches`}${none > 0 ? `; null on ${none}, where the advisory records no fix for this version (fixed_in_reason says why)` : ""}.`;
|
|
185
189
|
}
|
|
186
190
|
// #2783: a production match is about 2,100 characters as pretty JSON, most of it the CVE's
|
|
187
191
|
// description and match_reason, so openssl 3.0.0 (71 matches) was 156,058 characters of text and
|
|
188
192
|
// linux_kernel 5.10.0 (200, the API's cap) 364,408. Past DATA_TEXT_BUDGET each match in the first
|
|
189
193
|
// text block keeps its scores, flags, vendor attribution and match_reason (for a registry match,
|
|
190
|
-
// the advisory interval its version falls inside: "[A, B)" names
|
|
194
|
+
// the advisory interval its version falls inside: "[A, B)" names that interval's fixed bound B, "[A, B]" the
|
|
191
195
|
// last affected one, #2830) with the
|
|
192
196
|
// description cut to 200 characters; then the same without the description; then only the fields
|
|
193
197
|
// the description promises; then, for a list at the cap, without cpe_vendor and vendor_unknown;
|
|
@@ -221,7 +225,7 @@ const CHECK_AFFECTED_TEXT = {
|
|
|
221
225
|
keep: [
|
|
222
226
|
"cve_id", "severity", "cvss_v3_score", "description", "kev_listed", "ransomware", "epss_score", "echelongraph_score", "echelongraph_severity", "effective_score", "effective_severity",
|
|
223
227
|
"score_assessed", "score_unassessed_reason", "matched_criteria", "cpe_vendor", "vendor_unknown", "match_confidence", "match_reason", "version_evidence", "fixed_in",
|
|
224
|
-
"fixed_in_reason",
|
|
228
|
+
"fixed_in_reason", "fixed_in_detail",
|
|
225
229
|
],
|
|
226
230
|
clip: 200,
|
|
227
231
|
},
|
|
@@ -250,6 +254,32 @@ const CHECK_AFFECTED_TEXT = {
|
|
|
250
254
|
],
|
|
251
255
|
whole: `${GET_CVE_WHOLE}, and cve_intel a CVE's affected packages and fixed versions.`,
|
|
252
256
|
};
|
|
257
|
+
// #3013: the structured result's cut. A production match is about 1,650 characters of compact JSON
|
|
258
|
+
// (its description, matched criteria and match reason), so openssl 3.0.0's 71 matches were 147,564
|
|
259
|
+
// characters of structured result and linux_kernel 5.10.0's 200 (the API's cap) 331,682. As in the
|
|
260
|
+
// text, the samples beside the matches go first (excluded and undetermined, each to at most the same
|
|
261
|
+
// number of its first entries; excluded_count and undetermined_count count every entry), then the
|
|
262
|
+
// matches, and cve_ids, each match's cve_id in order, last: it names every match in a few thousand
|
|
263
|
+
// characters. Before any match leaves, every match is thinned (most of a match is its description,
|
|
264
|
+
// about 1,400 characters for openssl, which get_cve returns): first to its scores, flags, the
|
|
265
|
+
// criteria it matched and fixed_in, then to the fields this tool's description says a match keeps
|
|
266
|
+
// at least, with fixed_in_reason. Only then do matches leave from the end (the lowest ranked).
|
|
267
|
+
const CHECK_AFFECTED_DATA = {
|
|
268
|
+
steps: [
|
|
269
|
+
{ lists: ["excluded", "undetermined"] },
|
|
270
|
+
{
|
|
271
|
+
lists: ["matches"],
|
|
272
|
+
thin: [
|
|
273
|
+
[
|
|
274
|
+
"cve_id", "severity", "cvss_v3_score", "kev_listed", "ransomware", "epss_score", "echelongraph_score", "echelongraph_severity", "effective_score", "effective_severity", "score_assessed",
|
|
275
|
+
"score_unassessed_reason", "matched_criteria", "cpe_vendor", "vendor_unknown", "match_confidence", "version_evidence", "fixed_in", "fixed_in_reason",
|
|
276
|
+
],
|
|
277
|
+
["cve_id", "kev_listed", "ransomware", "epss_score", "effective_score", "score_assessed", "fixed_in", "fixed_in_reason"],
|
|
278
|
+
],
|
|
279
|
+
},
|
|
280
|
+
{ lists: ["cve_ids"] },
|
|
281
|
+
],
|
|
282
|
+
};
|
|
253
283
|
async function checkAffected(kit, a) {
|
|
254
284
|
const product = (a.product ?? "").trim();
|
|
255
285
|
const ecosystem = (a.ecosystem ?? "").trim();
|
|
@@ -343,13 +373,16 @@ async function checkAffected(kit, a) {
|
|
|
343
373
|
return kit.crashed(TOOL, e);
|
|
344
374
|
}
|
|
345
375
|
}
|
|
376
|
+
// One output schema per process, not per request (3.0.2; index.ts envelopeSchema says why).
|
|
377
|
+
const OUTPUTS = new WeakMap();
|
|
346
378
|
export function registerCheckAffected(server, kit) {
|
|
347
|
-
const output = kit.envelopeSchema({ data: DATA, coverage: COVERAGE, freshness: null });
|
|
379
|
+
const output = OUTPUTS.get(kit.envelopeSchema) ?? kit.envelopeSchema({ data: DATA, coverage: COVERAGE, freshness: null });
|
|
380
|
+
OUTPUTS.set(kit.envelopeSchema, output);
|
|
348
381
|
server.registerTool(TOOL, {
|
|
349
382
|
title: CHECK_AFFECTED_TITLE,
|
|
350
383
|
description: CHECK_AFFECTED_DESCRIPTION,
|
|
351
384
|
inputSchema: CHECK_AFFECTED_INPUT,
|
|
352
385
|
outputSchema: output,
|
|
353
386
|
annotations: kit.annotations,
|
|
354
|
-
}, async (a) => kit.checked(TOOL, output, await checkAffected(kit, a)));
|
|
387
|
+
}, async (a) => kit.checked(TOOL, output, await answering(a.full, CHECK_AFFECTED_DATA, () => checkAffected(kit, a))));
|
|
355
388
|
}
|
package/dist/tools/check_sbom.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as z from "zod";
|
|
2
|
-
import { TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
|
|
2
|
+
import { answering, FULL_ARG, TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
|
|
3
3
|
import { CLEAN_NOTE, DISTRO_NOTE, FRESHNESS_NOTE, MAX_PURLS, MEASURED_AT_NOTE, METHOD, batchDataShape, dedupe, field, isObj, plural, purlsRefusal, sendBatches, sendingCoverage, sendingCoverageShape, sendingSentences, verdictSentences, } from "./match_batch.js";
|
|
4
4
|
// #2835: the batch loop, its constants and the row schema moved to match_batch.ts, which
|
|
5
5
|
// scan_manifest shares; re-exported here, where the tests and prompts.ts import them.
|
|
@@ -25,7 +25,7 @@ export const CHECK_SBOM_TITLE = "Check an SBOM against the advisory corpus";
|
|
|
25
25
|
export const CHECK_SBOM_ROUTING = "For container images and Kubernetes pods, the input is an SBOM of each image, or its purls.";
|
|
26
26
|
export const CHECK_SBOM_DESCRIPTION = "Check a dependency list against EchelonGraph's advisory corpus, one verdict per component. " +
|
|
27
27
|
CHECK_SBOM_ROUTING +
|
|
28
|
-
" Pass purls (package URLs, up to 2,000 distinct) or sbom (a CycloneDX or SPDX JSON document, up to 5,000,000 characters). This server reads the purls from the document; only they are sent to the API, in POST bodies of at most 200, never in a URL; the document itself is not sent on. Run from npm, this server is on your machine; over the hosted endpoint (mcp.echelongraph.io) it is EchelonGraph's, and the document is the request body, accepted up to 6 MiB. data.results holds one row per component sent, in order, with verdict (affected, not_affected, undetermined or not_assessed), not_assessed_reason, cve_ids, matched_package and matched_via (a deb or apk purl's upstream qualifier matches as its source package); each match carries fixed_in (
|
|
28
|
+
" Pass purls (package URLs, up to 2,000 distinct) or sbom (a CycloneDX or SPDX JSON document, up to 5,000,000 characters). This server reads the purls from the document; only they are sent to the API, in POST bodies of at most 200, never in a URL; the document itself is not sent on. Run from npm, this server is on your machine; over the hosted endpoint (mcp.echelongraph.io) it is EchelonGraph's, and the document is the request body, accepted up to 6 MiB. data.results holds one row per component sent, in order, with verdict (affected, not_affected, undetermined or not_assessed), not_assessed_reason, cve_ids, matched_package and matched_via (a deb or apk purl's upstream qualifier matches as its source package); each match carries fixed_in (the next unaffected version) or null with fixed_in_reason. data.summary counts the verdicts. not_affected is the only clean verdict: not_assessed (no verdict, as for a deb, apk or rpm purl without a distro qualifier naming its release, which EchelonGraph does not guess) and undetermined are not, and the note counts both. The API allows 1,200 components a minute per caller; on a 429 the tool waits out Retry-After, up to 50 seconds a call, then answers what it has, the rest in data.not_sent_purls, unchecked and not clean. Input with more than 2,000 distinct purls is refused, not truncated." +
|
|
29
29
|
" " +
|
|
30
30
|
TEXT_BUDGET_DESCRIPTION +
|
|
31
31
|
" Cut, each row keeps index, purl, verdict, not_assessed_reason and cve_ids at least, each match its fixed_in while kept; not_affected rows leave the text first, then not_assessed ones; not_sent_purls keeps its first 10.";
|
|
@@ -34,7 +34,7 @@ export const CHECK_SBOM_DESCRIPTION = "Check a dependency list against EchelonGr
|
|
|
34
34
|
// text, and 2,000 purls about 1,200,000. Past DATA_TEXT_BUDGET each row in the first text block
|
|
35
35
|
// keeps its verdict, counts, cve_ids, matched_package and matched_via (#2836), and each match its
|
|
36
36
|
// scores, flags, fixed_in (#2834) and match_reason (the
|
|
37
|
-
// advisory interval the version falls inside: "[A, B)" names
|
|
37
|
+
// advisory interval the version falls inside: "[A, B)" names that interval's fixed bound B, "[A, B]" the last
|
|
38
38
|
// affected one, #2830) without the
|
|
39
39
|
// description; then the same without match_reason; then only index, purl, verdict,
|
|
40
40
|
// not_assessed_reason and cve_ids. When rows must still go, the clean ones (not_affected) leave
|
|
@@ -49,7 +49,7 @@ export const CHECK_SBOM_DESCRIPTION = "Check a dependency list against EchelonGr
|
|
|
49
49
|
// reading the list. noteFor gives that order (READ_ORDER) beside the position, so this sentence
|
|
50
50
|
// points to it.
|
|
51
51
|
export function sbomText(sent) {
|
|
52
|
-
const matches = ["cve_id", "severity", "effective_severity", "effective_score", "kev_listed", "ransomware", "epss_score", "score_assessed", "fixed_in"];
|
|
52
|
+
const matches = ["cve_id", "severity", "effective_severity", "effective_score", "kev_listed", "ransomware", "epss_score", "score_assessed", "fixed_in", "fixed_in_reason"];
|
|
53
53
|
const row = [
|
|
54
54
|
"index", "purl", "ecosystem", "package", "version", "verdict", "assessed", "not_assessed_reason", "count", "not_affected_count", "undetermined_count", "cve_ids", "matched_package", "matched_via",
|
|
55
55
|
];
|
|
@@ -69,11 +69,155 @@ export function sbomText(sent) {
|
|
|
69
69
|
],
|
|
70
70
|
leaveOutFirst: { field: "verdict", values: ["not_affected", "not_assessed"], why: "data.summary counts every verdict" },
|
|
71
71
|
// check_affected cuts its text too past the budget (#2802): only its structuredContent.data is
|
|
72
|
-
// a component's matches whole.
|
|
73
|
-
whole: "check_affected, given a row's ecosystem, package and version, returns that component's matches, whole in its structuredContent.data, and cve_intel a CVE's affected packages and fixed versions.",
|
|
72
|
+
// a component's matches whole, with full: true (#3013).
|
|
73
|
+
whole: "check_affected, given a row's ecosystem, package and version, returns that component's matches, whole in its structuredContent.data with full: true, and cve_intel a CVE's affected packages and fixed versions.",
|
|
74
74
|
page: (shown) => `To read every row in the text, check ${shown} or fewer purls per call.`,
|
|
75
75
|
};
|
|
76
76
|
}
|
|
77
|
+
// #3013: the structured result's cut. A production row is about 1,900 characters of compact JSON
|
|
78
|
+
// (its matches, each a CVE with its description), so the 50-component Juice Shop document was 98,682
|
|
79
|
+
// characters of structured result and 2,000 purls 3,790,686. The lists beside the rows go first
|
|
80
|
+
// (not_sent_purls: the note says from which position of the input they run, and in what order; and,
|
|
81
|
+
// for scan_manifest, not_checked, which coverage.not_checked_by_reason counts). Then, before any
|
|
82
|
+
// row leaves, every row is thinned to SBOM_ROW_THIN: what a row says of the component and its
|
|
83
|
+
// verdict, and per match what a triage reads (a match's description, about 535 characters, is
|
|
84
|
+
// get_cve's); the first #3013 cut, before release, kept 3 of Juice Shop's 50 rows whole, all affected, and named the
|
|
85
|
+
// other 7 affected components nowhere a client reading structuredContent sees. Only then do rows
|
|
86
|
+
// leave, as in the text: the not_affected rows first, then the not_assessed ones (data.summary
|
|
87
|
+
// counts every verdict), then the last of the rest, so the affected and undetermined rows are the
|
|
88
|
+
// last to go.
|
|
89
|
+
// #3013's review: a thinned match kept cve_id, kev_listed, epss_score, fixed_in and
|
|
90
|
+
// fixed_in_reason, while the workload_triage prompt reads kev_listed, ransomware, epss_score,
|
|
91
|
+
// effective_score, score_assessed and fixed_in from a row's matches (its step 5 and ordering keys),
|
|
92
|
+
// so a client reading structuredContent lost ransomware and the score it ranks by. Each field a
|
|
93
|
+
// prompt reads from check_sbom's or scan_manifest's matches is kept (test/prompt-fields.test.mjs
|
|
94
|
+
// holds every prompt's to this list).
|
|
95
|
+
export const SBOM_ROW_THIN = [
|
|
96
|
+
"index",
|
|
97
|
+
"purl",
|
|
98
|
+
"verdict",
|
|
99
|
+
"not_assessed_reason",
|
|
100
|
+
"cve_ids",
|
|
101
|
+
"matched_package",
|
|
102
|
+
"matched_via",
|
|
103
|
+
"matched_version_from",
|
|
104
|
+
"purl_name",
|
|
105
|
+
["matches", ["cve_id", "kev_listed", "ransomware", "epss_score", "effective_score", "score_assessed", "fixed_in", "fixed_in_reason"]],
|
|
106
|
+
];
|
|
107
|
+
// #3132: past SBOM_ROW_THIN, each row is cut on its own terms before any row leaves. Through 3.0.0
|
|
108
|
+
// SBOM_ROW_THIN was the only level, and it keeps every match object, so one row still large once
|
|
109
|
+
// thinned left data, and every row after it with it (rows leave from the end): measured on hosted
|
|
110
|
+
// 3.0.0 (2026-10-11), five Debian 12 purls, all affected, kept 1 row (libssl3; linux-libc-dev
|
|
111
|
+
// 6.1.55-1's 200 matches are 42,417 characters thinned); Juice Shop 11.1.2 with
|
|
112
|
+
// magento/community-edition 2.3.0 inserted at index 2 kept 2 of 51 rows (19 affected); that purl
|
|
113
|
+
// alone kept none, and the note said "check 0 or fewer purls per call". Now, in order:
|
|
114
|
+
// 1. SBOM_ROW_THIN, every row, every match (as before);
|
|
115
|
+
// 2. the same, with every row's matches capped at one number m, the most that fit: a row with
|
|
116
|
+
// more than m keeps its first m (the API's order: highest effective_score first) and carries
|
|
117
|
+
// matches_truncated, its own total and returned, and the CVE IDs among all its matches that
|
|
118
|
+
// are KEV-listed (kev_listed_cve_ids) or used in ransomware (ransomware_cve_ids), and the
|
|
119
|
+
// distinct fixed_in versions they name (fixed_in_versions), so what a triage ranks by first
|
|
120
|
+
// is still said of the matches cut; rows with m or fewer matches are untouched;
|
|
121
|
+
// 3. SBOM_ROW_COMPACT, each match cut to cve_id, kev_listed, ransomware, fixed_in and
|
|
122
|
+
// effective_score, the row keeping matched_package, matched_via and an upstream row's
|
|
123
|
+
// matched_version_from and purl_name, matches capped at one number m as in 2 (any m, 0
|
|
124
|
+
// included), with the same matches_truncated;
|
|
125
|
+
// 4. SBOM_ROW_MIN, the text's leanest level (index, purl, verdict, not_assessed_reason and
|
|
126
|
+
// cve_ids), with that matches_truncated on each row that had matches;
|
|
127
|
+
// 5. SBOM_ROW_MIN with matches_truncated cut to total and returned;
|
|
128
|
+
// and only then do rows leave (clean, then not assessed, then the last of the rest).
|
|
129
|
+
// #3132's review: round 1 had no level 3, and level 2 was taken at m = 0. The production
|
|
130
|
+
// synthetic's 201-purl probe (synthetic/probes.mjs SBOM_PROBE_PURLS, recorded on hosted 3.0.0 with
|
|
131
|
+
// full: true, 2026-10-11: 201 affected rows, 415 matches, 710,724 characters) then came back at
|
|
132
|
+
// level 5 with 132 rows, every match, KEV flag and fixed_in gone, and the probe's EXPECT (log4j-core
|
|
133
|
+
// 2.14.1's CVE-2021-44228 match, fixed_in 2.15.0; the upstream row's matched_via) failed, where
|
|
134
|
+
// 3.0.0 kept 43 rows thinned and passed. So now:
|
|
135
|
+
// - level 2 is taken only at m >= 1 (least): at m = 0 it holds less than level 3;
|
|
136
|
+
// - when not even level 5 keeps every row, rows leave at level 3 with matches capped at
|
|
137
|
+
// SBOM_LEAVING_CAP (leaveAt), not at level 5: a page of the size the note names then comes back
|
|
138
|
+
// with each row's KEV flags and fixed_in, which at level 5 no page size did;
|
|
139
|
+
// - once the cut is settled, the rows kept take back detail in input order while the result fits
|
|
140
|
+
// (giveBack): each the richest level above its own that fits, its matches at the compact level
|
|
141
|
+
// at the most that fit, stopping at the first row that takes back none.
|
|
142
|
+
// Measured on that recording (test/check_sbom-large-rows.test.mjs): 54 of 201 rows, each compact
|
|
143
|
+
// with every match (none has more than 7), the log4j-core row with its CVE-2021-44228 match and the
|
|
144
|
+
// upstream row with matched_via, at 29,959 characters.
|
|
145
|
+
// cve_ids is what the API caps at 200 a row, about 3,400 characters, so every row survives level 5
|
|
146
|
+
// unless the rows are many (the probe's 201, Juice Shop's full 840 purls).
|
|
147
|
+
export const SBOM_ROW_COMPACT = [
|
|
148
|
+
"index",
|
|
149
|
+
"purl",
|
|
150
|
+
"verdict",
|
|
151
|
+
"not_assessed_reason",
|
|
152
|
+
"cve_ids",
|
|
153
|
+
"matched_package",
|
|
154
|
+
"matched_via",
|
|
155
|
+
"matched_version_from",
|
|
156
|
+
"purl_name",
|
|
157
|
+
["matches", ["cve_id", "kev_listed", "ransomware", "fixed_in", "effective_score"]],
|
|
158
|
+
];
|
|
159
|
+
export const SBOM_ROW_MIN = ["index", "purl", "verdict", "not_assessed_reason", "cve_ids"];
|
|
160
|
+
// The most matches a row keeps, compact, when rows leave (the API's order, highest effective_score
|
|
161
|
+
// first); the rest are in its cve_ids and matches_truncated.
|
|
162
|
+
export const SBOM_LEAVING_CAP = 10;
|
|
163
|
+
// matches_truncated for a row whose matches data keeps `kept` of (none at levels 3 and 4); with
|
|
164
|
+
// `summary`, the three lists drawn from all of the row's matches. Absent on a row with no match
|
|
165
|
+
// cut, and on one with no matches.
|
|
166
|
+
function matchesTruncated(row, kept, summary) {
|
|
167
|
+
const ms = Array.isArray(row.matches) ? row.matches.filter(isObj) : [];
|
|
168
|
+
if (ms.length <= kept)
|
|
169
|
+
return {};
|
|
170
|
+
const ids = (f) => [...new Set(ms.filter((m) => m[f] === true && typeof m.cve_id === "string").map((m) => m.cve_id))];
|
|
171
|
+
const fixed = [...new Set(ms.map((m) => m.fixed_in).filter((v) => typeof v === "string"))];
|
|
172
|
+
return {
|
|
173
|
+
matches_truncated: {
|
|
174
|
+
total: ms.length,
|
|
175
|
+
returned: Math.min(kept, ms.length),
|
|
176
|
+
...(summary ? { kev_listed_cve_ids: ids("kev_listed"), ransomware_cve_ids: ids("ransomware"), fixed_in_versions: fixed } : {}),
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
const MATCHES_TRUNCATED_SAID = "carrying matches_truncated, which gives its own total and returned, and, of all its matches, the CVE IDs KEV-listed (kev_listed_cve_ids) or used in ransomware (ransomware_cve_ids) and the distinct fixed_in versions (fixed_in_versions)";
|
|
181
|
+
const cappedSaid = (cap) => `each row's matches keeping at most its first ${cap} (the API's order, highest effective_score first), a row with more than that ${MATCHES_TRUNCATED_SAID}`;
|
|
182
|
+
export const SBOM_ROW_LEVELS = [
|
|
183
|
+
SBOM_ROW_THIN,
|
|
184
|
+
{ keep: SBOM_ROW_THIN, capped: "matches", least: 1, mark: (row, cap) => matchesTruncated(row, cap, true), said: cappedSaid },
|
|
185
|
+
{ keep: SBOM_ROW_COMPACT, capped: "matches", mark: (row, cap) => matchesTruncated(row, cap, true), said: cappedSaid },
|
|
186
|
+
{ keep: SBOM_ROW_MIN, mark: (row) => matchesTruncated(row, 0, true), said: () => `a row that had matches ${MATCHES_TRUNCATED_SAID}` },
|
|
187
|
+
{ keep: SBOM_ROW_MIN, mark: (row) => matchesTruncated(row, 0, false), said: () => "a row that had matches carrying matches_truncated with its total and returned 0" },
|
|
188
|
+
];
|
|
189
|
+
const SBOM_LEAVE_AT = { level: 2, cap: SBOM_LEAVING_CAP };
|
|
190
|
+
// The page sentence (#3132): never "0 or fewer". Rows leave only once no level keeps them all, so a
|
|
191
|
+
// call of as many purls as data kept rows keeps each of its rows; when not one row fits, no page
|
|
192
|
+
// size does, and the note says where a row is read instead.
|
|
193
|
+
// #3132's review: the lists beside the rows go first, scan_manifest's not_checked before
|
|
194
|
+
// not_sent_purls, which keeps every purl or none (whole): those purls are the list a client
|
|
195
|
+
// retries, and round 1 kept 3 of the rate-limited case's 800. With none kept, the note gives them
|
|
196
|
+
// exactly as positions in the input (sendingSentences: coverage.sent + 1 to
|
|
197
|
+
// coverage.distinct_purls), and the room the rows leave goes back to not_sent_purls before
|
|
198
|
+
// not_checked.
|
|
199
|
+
export function sbomData(sides, page) {
|
|
200
|
+
const others = sides.filter((x) => x !== "not_sent_purls");
|
|
201
|
+
return {
|
|
202
|
+
steps: [
|
|
203
|
+
...(others.length ? [{ lists: others }] : []),
|
|
204
|
+
...(sides.includes("not_sent_purls") ? [{ lists: ["not_sent_purls"], whole: true }] : []),
|
|
205
|
+
{
|
|
206
|
+
lists: ["results"],
|
|
207
|
+
thin: SBOM_ROW_LEVELS,
|
|
208
|
+
leaveOutFirst: { field: "verdict", values: ["not_affected", "not_assessed"], why: "data.summary counts every verdict" },
|
|
209
|
+
leaveAt: SBOM_LEAVE_AT,
|
|
210
|
+
giveBack: true,
|
|
211
|
+
},
|
|
212
|
+
],
|
|
213
|
+
page: (t) => t.results && t.results.returned < t.results.total
|
|
214
|
+
? t.results.returned > 0
|
|
215
|
+
? page(t.results.returned)
|
|
216
|
+
: `Not one row fits data even cut to ${SBOM_ROW_COMPACT.map((x) => (typeof x === "string" ? x : x[0])).join(", ")}, so no number of purls per call does: the same call with full: true returns every row whole.`
|
|
217
|
+
: "",
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
const SBOM_DATA = sbomData(["not_sent_purls"], (k) => `To read every row in data, check ${k} or fewer purls per call.`);
|
|
77
221
|
// The purls of a CycloneDX JSON document: every components[] entry, nested ones included.
|
|
78
222
|
// metadata.component (the subject of the SBOM, not a dependency of it) is not checked.
|
|
79
223
|
function fromCycloneDX(doc) {
|
|
@@ -250,11 +394,15 @@ export function checkSbomOutput(envelopeSchema) {
|
|
|
250
394
|
freshness: null,
|
|
251
395
|
});
|
|
252
396
|
}
|
|
397
|
+
// One output schema per process, not per request (3.0.2; index.ts envelopeSchema says why).
|
|
398
|
+
const OUTPUTS = new WeakMap();
|
|
253
399
|
// A literal server.registerTool(name, { title, description, … }) call with constant strings:
|
|
254
400
|
// marketing-site lib/mcpToolClaims.test.ts reads it here (it follows index.ts's call to this
|
|
255
401
|
// function) and holds the /pulse/mcp row to this title and description.
|
|
256
402
|
export function registerCheckSbom(server, deps) {
|
|
257
|
-
const
|
|
403
|
+
const envelope = deps.envelopeSchema;
|
|
404
|
+
const output = OUTPUTS.get(envelope) ?? checkSbomOutput(envelope);
|
|
405
|
+
OUTPUTS.set(envelope, output);
|
|
258
406
|
server.registerTool(CHECK_SBOM, {
|
|
259
407
|
title: CHECK_SBOM_TITLE,
|
|
260
408
|
description: CHECK_SBOM_DESCRIPTION,
|
|
@@ -264,11 +412,12 @@ export function registerCheckSbom(server, deps) {
|
|
|
264
412
|
.union([z.string(), z.record(z.string(), z.unknown())])
|
|
265
413
|
.optional()
|
|
266
414
|
.describe("a CycloneDX JSON or SPDX JSON document, as JSON text or as an object; its purls are read by this MCP server and only they are sent to the API; over the hosted endpoint (mcp.echelongraph.io) the document is the request body"),
|
|
415
|
+
full: FULL_ARG,
|
|
267
416
|
}),
|
|
268
417
|
outputSchema: output,
|
|
269
418
|
annotations: deps.annotations,
|
|
270
|
-
}, async (a, ctx) => {
|
|
419
|
+
}, async (a, ctx) => answering(a.full, SBOM_DATA, async () => {
|
|
271
420
|
const run = checkSbom({ ...deps, signal: ctx.mcpReq.signal }, a);
|
|
272
421
|
return deps.checked(CHECK_SBOM, output, await (deps.hold ? deps.hold(run) : run));
|
|
273
|
-
});
|
|
422
|
+
}));
|
|
274
423
|
}
|
package/dist/tools/cve_intel.js
CHANGED
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
// the rows where fixed_version is not the whole answer.
|
|
42
42
|
import * as z from "zod";
|
|
43
43
|
import { ANNOTATIONS, CVE_ID, NOT_A_FINDING, SHOWN_BASE, api, badInput, boolAt, checked, crashed, envelopeSchema, failed, failure, field, numAt, okHead, opt, strAt, succeeded, } from "../index.js";
|
|
44
|
-
import { TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
|
|
44
|
+
import { answering, FULL_ARG, TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
|
|
45
45
|
const TOOL = "cve_intel";
|
|
46
46
|
// The sections relayed, in the answer's order, and the keys each failed_sections name covers.
|
|
47
47
|
const LISTS = ["cwes", "exploits", "affected_packages", "fixed_versions", "timeline"];
|
|
@@ -73,13 +73,22 @@ const LEFT_OUT = ["vendor_advisories", "patches", "ai", "trending", "historical_
|
|
|
73
73
|
// Per-CVE row limits of the API (core-backend cve/enrichment_store.go): a list this long may be cut.
|
|
74
74
|
const API_ROW_LIMIT = { exploits: 10, affected_packages: 50, fixed_versions: 200, timeline: 100 };
|
|
75
75
|
const TIMELINE_RELAYED = 20;
|
|
76
|
-
const CVE_INTEL_METHOD = "EchelonGraph's per-CVE enrichment (GET /api/v1/public/cves/:id/enrichment). cwes: CWE classifications stored from NVD, GitHub (GHSA) and CVE.org records, each with the source that gave it, named from the MITRE CWE catalog EchelonGraph embeds. exploits:
|
|
76
|
+
const CVE_INTEL_METHOD = "EchelonGraph's per-CVE enrichment (GET /api/v1/public/cves/:id/enrichment). cwes: CWE classifications stored from NVD, GitHub (GHSA) and CVE.org records, each with the source that gave it, named from the MITRE CWE catalog EchelonGraph embeds. exploits: EchelonGraph's cve_exploits records, whose data model holds public exploit code, namely Metasploit modules (metasploit), Exploit-DB entries (exploit_db), GitHub proof-of-concept repositories (github_poc) and vendor proofs of concept (vendor_poc), and Nuclei detection templates (nuclei_template), which check whether a system is affected and are not necessarily exploit code, filled by a curated seed and by one poller per automated source, each run only where it is enabled, so which sources are current depends on the deployment. affected_packages and fixed_versions: package-ecosystem advisory data, each row naming its source. timeline: EchelonGraph's enrichment history for the CVE, newest first.";
|
|
77
77
|
const VERIFIED_STATUS_DESCRIPTION = "verified_status is the label stored with each reference: verified for a Metasploit module, for an Exploit-DB entry Exploit-DB marks verified, and for curated seed rows marked so; reported for a public artefact nothing has confirmed works (nuclei templates, GitHub proofs of concept, unverified Exploit-DB entries); unconfirmed where a curated row says so. It is a label from the source, not a guarantee that the exploit works against a given system.";
|
|
78
|
-
const FIXED_BRANCHES_DESCRIPTION = 'fixed_branches lists each affected range of the package on record: introduced, and fixed (the range\'s fix) or last_affected (its last affected version, where no fix is on record), each null when absent, with advisory_id (the OSV record that published the range) and source (the loader\'s label for that record), both null on a row loaded before they were stored. Ranges with an advisory_id are in the order their record lists them (records in id order); ranges without one are oldest first. To pick the range for an installed version, compare in the ecosystem\'s version order (release numbers part by part, as numbers: 2.4 is below 2.13.0): the version is in a range when it is at or above introduced ("0" is the first version) and below fixed, or at or below last_affected; that range\'s fixed
|
|
78
|
+
const FIXED_BRANCHES_DESCRIPTION = 'fixed_branches lists each affected range of the package on record: introduced, and fixed (the range\'s fix) or last_affected (its last affected version, where no fix is on record), each null when absent, with advisory_id (the OSV record that published the range) and source (the loader\'s label for that record), both null on a row loaded before they were stored. Ranges with an advisory_id are in the order their record lists them (records in id order); ranges without one are oldest first. To pick the range for an installed version, compare in the ecosystem\'s version order (release numbers part by part, as numbers: 2.4 is below 2.13.0): the version is in a range when it is at or above introduced ("0" is the first version) and below fixed, or at or below last_affected; its fix is that range\'s fixed, or, while another range still holds that, the other range\'s fixed (none past a range without one); a version in no range is outside every range on record. fixed_branches is null where the package\'s ranges were never loaded, and [] where no version range is on record (the advisory gives none, only commit ranges, or more than are stored), which is not a finding that no fix exists: fixed_version can still name one. fixed_version is one range\'s fix, kept for compatibility (one per package: the range the loader kept, the advisory\'s last as a rule), not the fix for every affected range: for CVE-2021-44228, its advisory gives log4j-core three ranges, and fixed_version 2.12.2 is the fix for the one from 2.4, while the range holding 2.14.1 (from 2.13.0) is fixed in 2.15.0.';
|
|
79
|
+
// #3039: a Nuclei template is a detection check, not necessarily exploit code (core-backend
|
|
80
|
+
// egkev.ExploitCodeKinds, #3038; /pulse words it so since #3022). The head says "exploit code,
|
|
81
|
+
// detection templates" where it said "public exploit code"; the method and the note say what a
|
|
82
|
+
// template is, and count templates apart. The room: "the newest" and "per-CVE" (14 characters).
|
|
79
83
|
// #2876: the exploit dates are named for what they are. Within the 100 characters of headroom
|
|
80
84
|
// under the 2048-character client cut (prompts-resources.test.mjs), the room came from tighter
|
|
81
85
|
// glosses and from the input hint, which inputSchema's cve_id description carries.
|
|
82
|
-
|
|
86
|
+
// #3013's review: in #3074's first cut the fixed_branches sentence still said a range's fixed is
|
|
87
|
+
// the fix for a version it holds, the rule that gave lodash 4.17.15 under CVE-2026-4800 the
|
|
88
|
+
// still-affected 4.17.21 (ranges [0, 4.17.21) and [4.0.0, 4.18.0); the fix is 4.18.0). It now
|
|
89
|
+
// states the walk FIXED_BRANCHES_DESCRIPTION and branchSentences state, in fewer characters than
|
|
90
|
+
// the old rule (a range's bounds folded into the list of its fields), so the headroom holds.
|
|
91
|
+
export const CVE_INTEL_DESCRIPTION = `Weakness, exploit code, detection templates, affected packages and fixed versions for one CVE, from EchelonGraph's per-CVE enrichment. Returns cwes (each cwe_id with name and source), exploits (each with kind, source_name, source_url, source_published_at (the source's date, null if none), eg_first_seen_at (when EchelonGraph recorded it) and verified_status; at most 10, verified first) with exploits_total, exploits_capped, exploits_by_kind and exploits_by_status, affected_packages (ecosystem, package_name, version_range, fixed_version, fixed_branches), fixed_versions (each fixed version with the vulnerable_range it fixes) and timeline (newest enrichment-history rows, with timeline_total). fixed_branches lists each affected range on record (from introduced ("0" is the first version) to below fixed, or through last_affected, with advisory_id (its OSV record) and source); in the ecosystem's version order, an affected version's fix is the first fixed above it that no range of its row holds (none past a range with no fixed). fixed_version is one range's fix, kept for compatibility, not the fix for every affected range. fixed_branches [] is not a finding that no fix exists. verified_status is the source's label, not a guarantee the exploit works on a given system. An empty exploits list is not evidence that no public exploit exists: it covers only the sources EchelonGraph ingests, and which are polled depends on the deployment. A section the API could not read is named in coverage.sections_failed and left out of data, never relayed as an empty list. Its structured result carries notes, with data, which the first text block holds whole up to 30,000 characters; its coverage names the sections relayed, failed and left out. ${TEXT_BUDGET_DESCRIPTION}`;
|
|
83
92
|
// ── outputSchema ──
|
|
84
93
|
const Rows = (shape) => z.array(z.looseObject(shape));
|
|
85
94
|
const CountMap = z.record(z.string(), z.number());
|
|
@@ -173,6 +182,24 @@ function cweSentence(cve, cwes) {
|
|
|
173
182
|
});
|
|
174
183
|
return `${cve} is classified under ${listed(named)}.`;
|
|
175
184
|
}
|
|
185
|
+
// #3039: exploit code and detection templates are counted apart. A Nuclei template
|
|
186
|
+
// (nuclei_template) checks whether a system is affected; it is not necessarily exploit code
|
|
187
|
+
// (core-backend egkev.ExploitCodeKinds, #3038), and until #3039 the note called a Nuclei-only CVE's
|
|
188
|
+
// one template "1 public exploit reference" (CVE-2026-42018 on the hosted endpoint, 2026-10-10;
|
|
189
|
+
// 2,800 production CVEs hold only Nuclei rows). The kind counts are exploits_by_kind's, and where
|
|
190
|
+
// the answer carries none, the listed rows' when they are every reference on record.
|
|
191
|
+
const DETECTION_KIND = "nuclei_template";
|
|
192
|
+
const DETECTION_NOTE = "a detection template checks whether a system is affected; it is not necessarily exploit code";
|
|
193
|
+
const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
|
194
|
+
// The number of Nuclei detection templates among the references: exploits_by_kind's count where
|
|
195
|
+
// the answer carries the map, the listed rows' where they are all of them, undefined otherwise.
|
|
196
|
+
function templateCount(d, rows, total) {
|
|
197
|
+
const byKind = field(d, "exploits_by_kind");
|
|
198
|
+
if (byKind !== null && typeof byKind === "object" && !Array.isArray(byKind))
|
|
199
|
+
return numAt(d, "exploits_by_kind", DETECTION_KIND) ?? 0;
|
|
200
|
+
const listed = rows.filter((r) => strAt(r, "kind") === DETECTION_KIND).length;
|
|
201
|
+
return total === undefined || rows.length === total ? listed : undefined;
|
|
202
|
+
}
|
|
176
203
|
function exploitSentences(cve, d, rows) {
|
|
177
204
|
const total = numAt(d, "exploits_total");
|
|
178
205
|
const verified = numAt(d, "exploits_by_status", "verified");
|
|
@@ -186,7 +213,14 @@ function exploitSentences(cve, d, rows) {
|
|
|
186
213
|
`exploits is empty and the answer carries no exploits_total, so it does not count the references on record for ${cve}; an empty list is not evidence that no public exploit exists.`,
|
|
187
214
|
];
|
|
188
215
|
}
|
|
189
|
-
|
|
216
|
+
const listedTemplates = rows.filter((r) => strAt(r, "kind") === DETECTION_KIND).length;
|
|
217
|
+
const code = rows.length - listedTemplates;
|
|
218
|
+
const what = listedTemplates === 0
|
|
219
|
+
? `${plural(rows.length, "public exploit reference", "public exploit references")}`
|
|
220
|
+
: code === 0
|
|
221
|
+
? `${plural(rows.length, "Nuclei detection template", "Nuclei detection templates")} and nothing else (${DETECTION_NOTE})`
|
|
222
|
+
: `${plural(rows.length, "reference", "references")}: ${plural(code, "public exploit reference", "public exploit references")} and ${plural(listedTemplates, "Nuclei detection template", "Nuclei detection templates")} (${DETECTION_NOTE})`;
|
|
223
|
+
return [`exploits lists ${what} for ${cve}, ${verifiedRows} with verified_status verified; the answer carries no exploits_total, so it does not say how many are on record.${cut}`];
|
|
190
224
|
}
|
|
191
225
|
if (total === 0) {
|
|
192
226
|
return [
|
|
@@ -195,7 +229,23 @@ function exploitSentences(cve, d, rows) {
|
|
|
195
229
|
}
|
|
196
230
|
const v = verified === undefined ? "" : `, ${verified} of them with verified_status verified`;
|
|
197
231
|
const cut = capped === true ? ` exploits_capped is true: exploits lists ${rows.length} of the ${total}, verified first.` : "";
|
|
198
|
-
|
|
232
|
+
const templates = templateCount(d, rows, total);
|
|
233
|
+
if (templates === undefined) {
|
|
234
|
+
return [
|
|
235
|
+
`EchelonGraph holds ${plural(total, "reference", "references")} for ${cve} (exploits_total)${v}; the answer carries no exploits_by_kind, so it does not say how many are exploit code and how many Nuclei detection templates (${DETECTION_NOTE}).${cut}`,
|
|
236
|
+
];
|
|
237
|
+
}
|
|
238
|
+
if (templates === 0) {
|
|
239
|
+
return [`EchelonGraph holds ${plural(total, "public exploit reference", "public exploit references")} for ${cve} (exploits_total)${v}.${cut}`];
|
|
240
|
+
}
|
|
241
|
+
if (templates >= total) {
|
|
242
|
+
return [
|
|
243
|
+
`EchelonGraph holds ${plural(total, "Nuclei detection template", "Nuclei detection templates")} for ${cve} and nothing else in exploits (exploits_total ${total}): ${DETECTION_NOTE}. It holds no Metasploit module, Exploit-DB entry or proof of concept for ${cve}, which is not evidence that none exists.${cut}`,
|
|
244
|
+
];
|
|
245
|
+
}
|
|
246
|
+
return [
|
|
247
|
+
`EchelonGraph holds ${plural(total, "reference", "references")} for ${cve} (exploits_total)${v}: ${plural(total - templates, "public exploit reference", "public exploit references")} and ${plural(templates, "Nuclei detection template", "Nuclei detection templates")}; ${DETECTION_NOTE}.${cut}`,
|
|
248
|
+
];
|
|
199
249
|
}
|
|
200
250
|
function listSentence(name, rows) {
|
|
201
251
|
const limit = API_ROW_LIMIT[name] ?? 0;
|
|
@@ -238,7 +288,7 @@ function branchSentences(rows) {
|
|
|
238
288
|
});
|
|
239
289
|
const few = (xs) => (xs.length <= 3 ? listed(xs) : `${xs.slice(0, 3).join(", ")} and ${xs.length - 3} more`);
|
|
240
290
|
if (multi.length) {
|
|
241
|
-
out.push(`${multi.length} affected_packages row${multi.length === 1 ? " lists" : "s list"} more than one affected range in fixed_branches (${few(multi)}): there, fixed_version is one range's fix, and an installed version's fix is the fixed of the range that holds it.`);
|
|
291
|
+
out.push(`${multi.length} affected_packages row${multi.length === 1 ? " lists" : "s list"} more than one affected range in fixed_branches (${few(multi)}): there, fixed_version is one range's fix, and an installed version's fix is the first fixed above it that no range of the row holds: the fixed of the range that holds it, or, while another range still holds that version, that range's fixed (none where a range on the way has no fixed).`);
|
|
242
292
|
}
|
|
243
293
|
if (unknown.length) {
|
|
244
294
|
out.push(`${unknown.length} affected_packages row${unknown.length === 1 ? " carries" : "s carry"} fixed_branches null (${few(unknown)}): ${unknown.length === 1 ? "its" : "their"} ranges are not on record, so fixed_version, one range's fix, is all the answer holds, and it need not be the fix for a given installed version.`);
|
|
@@ -345,13 +395,17 @@ export async function cveIntel(cve_id) {
|
|
|
345
395
|
return crashed(TOOL, e);
|
|
346
396
|
}
|
|
347
397
|
}
|
|
398
|
+
// #3013: the structured result's cut. CVE-2021-44228's is 12,877 characters, within the budget; past
|
|
399
|
+
// it the newest enrichment-history rows go first, then the affected packages and fixed versions,
|
|
400
|
+
// each to at most the same number of its first entries, then any other list at the top of data.
|
|
401
|
+
const CVE_INTEL_DATA = { steps: [{ lists: ["timeline"] }, { lists: ["affected_packages", "fixed_versions"] }] };
|
|
348
402
|
export function registerCveIntel(server) {
|
|
349
403
|
const { output } = schemasOnce();
|
|
350
404
|
server.registerTool("cve_intel", {
|
|
351
405
|
title: "CVE weakness, exploits and packages",
|
|
352
406
|
description: CVE_INTEL_DESCRIPTION,
|
|
353
|
-
inputSchema: z.object({ cve_id: z.string().describe("a CVE ID, e.g. CVE-2021-44228") }),
|
|
407
|
+
inputSchema: z.object({ cve_id: z.string().describe("a CVE ID, e.g. CVE-2021-44228"), full: FULL_ARG }),
|
|
354
408
|
outputSchema: output,
|
|
355
409
|
annotations: ANNOTATIONS,
|
|
356
|
-
}, async ({ cve_id }) => checked(TOOL, output, await cveIntel(cve_id)));
|
|
410
|
+
}, async ({ cve_id, full }) => checked(TOOL, output, await answering(full, CVE_INTEL_DATA, () => cveIntel(cve_id))));
|
|
357
411
|
}
|