echelongraph-mcp 3.0.0 → 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 +95 -0
- package/README.md +23 -7
- package/dist/fixStatus.js +4 -2
- package/dist/httpPolicy.js +16 -1
- package/dist/index.js +66 -9
- package/dist/prompts.js +8 -2
- package/dist/requiredText.js +15 -0
- package/dist/textBudget.js +281 -27
- package/dist/tools/check_affected.js +4 -1
- package/dist/tools/check_sbom.js +114 -4
- package/dist/tools/cve_intel.js +49 -4
- package/dist/tools/cve_remediation.js +82 -8
- package/dist/tools/epss_history.js +4 -1
- package/dist/tools/kev_recent.js +4 -1
- package/dist/tools/match_batch.js +22 -6
- package/dist/tools/scan_manifest.js +15 -3
- package/dist/tools/vendor_advisories.js +13 -4
- package/docs/tools.md +10 -6
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/tools/cve_intel.js
CHANGED
|
@@ -73,9 +73,13 @@ 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
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.
|
|
@@ -84,7 +88,7 @@ const FIXED_BRANCHES_DESCRIPTION = 'fixed_branches lists each affected range of
|
|
|
84
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
|
|
85
89
|
// states the walk FIXED_BRANCHES_DESCRIPTION and branchSentences state, in fewer characters than
|
|
86
90
|
// the old rule (a range's bounds folded into the list of its fields), so the headroom holds.
|
|
87
|
-
export const CVE_INTEL_DESCRIPTION = `Weakness,
|
|
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}`;
|
|
88
92
|
// ── outputSchema ──
|
|
89
93
|
const Rows = (shape) => z.array(z.looseObject(shape));
|
|
90
94
|
const CountMap = z.record(z.string(), z.number());
|
|
@@ -178,6 +182,24 @@ function cweSentence(cve, cwes) {
|
|
|
178
182
|
});
|
|
179
183
|
return `${cve} is classified under ${listed(named)}.`;
|
|
180
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
|
+
}
|
|
181
203
|
function exploitSentences(cve, d, rows) {
|
|
182
204
|
const total = numAt(d, "exploits_total");
|
|
183
205
|
const verified = numAt(d, "exploits_by_status", "verified");
|
|
@@ -191,7 +213,14 @@ function exploitSentences(cve, d, rows) {
|
|
|
191
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.`,
|
|
192
214
|
];
|
|
193
215
|
}
|
|
194
|
-
|
|
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}`];
|
|
195
224
|
}
|
|
196
225
|
if (total === 0) {
|
|
197
226
|
return [
|
|
@@ -200,7 +229,23 @@ function exploitSentences(cve, d, rows) {
|
|
|
200
229
|
}
|
|
201
230
|
const v = verified === undefined ? "" : `, ${verified} of them with verified_status verified`;
|
|
202
231
|
const cut = capped === true ? ` exploits_capped is true: exploits lists ${rows.length} of the ${total}, verified first.` : "";
|
|
203
|
-
|
|
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
|
+
];
|
|
204
249
|
}
|
|
205
250
|
function listSentence(name, rows) {
|
|
206
251
|
const limit = API_ROW_LIMIT[name] ?? 0;
|
|
@@ -124,6 +124,10 @@ function schemas() {
|
|
|
124
124
|
advisory_id: opt(z.string()),
|
|
125
125
|
source: opt(z.string()),
|
|
126
126
|
}))),
|
|
127
|
+
fixed_branches_truncated: z
|
|
128
|
+
.object({ total: z.number().int(), returned: z.number().int() })
|
|
129
|
+
.optional()
|
|
130
|
+
.describe("Present only where the structured result's cut (DATA CUT) kept this package's first ranges, not all: total, its ranges in the answer; returned, the ones data keeps. The same call with full: true returns every range."),
|
|
127
131
|
}))),
|
|
128
132
|
vendor_remediations: opt(z.array(VendorAdvisory)),
|
|
129
133
|
fix_references: opt(z.array(z.looseObject({ url: opt(z.string()), tags: opt(z.array(z.string())), source: opt(z.string()) }))),
|
|
@@ -163,9 +167,44 @@ const CUT = {
|
|
|
163
167
|
// Before any advisory leaves, every advisory's items are thinned: product_ids first, keeping each
|
|
164
168
|
// text; then the texts, keeping each item's kind, source_category, url and fixed_build, so every
|
|
165
169
|
// advisory stays named with its state, kinds and links (get_vendor_advisory returns one whole).
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
170
|
+
// fix_status and fixed_in (#2841) are not lists, and installed is an object, so none of them is
|
|
171
|
+
// ever cut.
|
|
172
|
+
//
|
|
173
|
+
// #3139: through 3.0.0 advisories then left from the end, before patches, and fixed_branches had no
|
|
174
|
+
// step, so CVE-2023-44487 (KEV-listed) kept 2 of its 20 advisories at 29,704 characters while
|
|
175
|
+
// fixed_branches (13,581 characters, 50 rows) and patches (7,471, 30) stayed whole, and the note
|
|
176
|
+
// named no tool that lists the other 18 (measured on hosted 3.0.0, 2026-10-11; also CVE-2018-12126
|
|
177
|
+
// 6/20, CVE-2019-11135 2/3, CVE-2018-3639 16/20). Now vendor_remediations is a keepAll step: every
|
|
178
|
+
// advisory stays, thinned, while patches (each to its vendor, patch_url and published_at: the
|
|
179
|
+
// erratum, which vendor_remediations names again for Red Hat) and then fixed_branches (each to its
|
|
180
|
+
// package, range, fixed_version and its ranges' introduced, fixed and last_affected, what the
|
|
181
|
+
// sbom_review prompt walks; the per-range source, advisory_id and dependents_count go) are thinned
|
|
182
|
+
// and then lose rows from the end, and then any other list at the top of data (fix_references).
|
|
183
|
+
// Only when all of that is cut and the result is still past the budget do advisories leave, from
|
|
184
|
+
// the end; whenever one does, the note names vendor_advisories_for_cve, which lists every vendor
|
|
185
|
+
// advisory naming the CVE, as where the rest are.
|
|
186
|
+
// #3139's review: that order dropped 2 of CVE-2023-44487's 30 patches while its 50 fixed_branches
|
|
187
|
+
// rows stayed whole (13,581 characters). Decided (2026-10-11): fixed_branches is thinned before any
|
|
188
|
+
// patch row leaves. The order is now:
|
|
189
|
+
// 1. vendor_remediations thinned, as above, every advisory kept (keepAll);
|
|
190
|
+
// 2. fixed_branches thinned (each package to its ecosystem, package_name, version_range,
|
|
191
|
+
// fixed_version and its ranges' introduced, fixed and last_affected: each range's source and
|
|
192
|
+
// advisory_id go, and the package's dependents_count and source), then each package's ranges
|
|
193
|
+
// capped at one number, the most that fit (at least 1), a package with more carrying
|
|
194
|
+
// fixed_branches_truncated with its total and returned; every package kept (keepAll);
|
|
195
|
+
// 3. patches thinned (vendor, patch_url and published_at), every patch kept (keepAll);
|
|
196
|
+
// 4. any other list at the top of data (fix_references), cut from the end;
|
|
197
|
+
// 5. then patch rows leave from the end, then fixed_branches rows, and only then advisories
|
|
198
|
+
// (textBudget.ts: the last keepAll step's entries leave first);
|
|
199
|
+
// 6. the room left goes back to each package's ranges, in the order of fixed_branches (giveBack):
|
|
200
|
+
// step 2 caps them before the patches are cut, and a cap that held while every patch was
|
|
201
|
+
// whole would otherwise stay after the patches are thinned.
|
|
202
|
+
// Round 2 replayed CVE-2023-44487 at 28 of 30 patches: fix_references (2,191 characters) were cut
|
|
203
|
+
// after patch rows left, and the 30 thinned were about 240 characters past the budget beside them.
|
|
204
|
+
// Round 3 (#3139's second review, 2026-10-11): patches is a keepAll step, so fix_references are cut
|
|
205
|
+
// first and CVE-2023-44487 keeps all 20 advisories, all 50 fixed_branches and all 30 patches, each
|
|
206
|
+
// thinned (test/cve_remediation.test.mjs); round 1 (patches cut before fixed_branches was thinned)
|
|
207
|
+
// kept 11 patches without fix_references.
|
|
169
208
|
//
|
|
170
209
|
// #3013's review: the second level leaves every item's text out of data, and the text is the
|
|
171
210
|
// vendor's step itself (a workaround's or mitigation's wording), which the triage_cve and
|
|
@@ -175,6 +214,7 @@ const CUT = {
|
|
|
175
214
|
// with full: true returns this data whole; the prompts send the model there when it is cut.
|
|
176
215
|
const ADVISORY_FIELDS = ["vendor", "vendor_display_name", "vendor_advisory_id", "vendor_published_at", "withdrawn", "remediation_state", "remediation_kinds", "items_total"];
|
|
177
216
|
const ITEM_TEXT_ELSEWHERE = "No item in data keeps its text, the vendor's own wording of the step (a workaround or mitigation among them): get_vendor_advisory, given an advisory's vendor and its vendor_advisory_id as advisory_id, returns it among that advisory's remediations, whole in its structuredContent.data with full: true.";
|
|
217
|
+
const FIXED_BRANCHES_THIN = ["ecosystem", "package_name", "version_range", "fixed_version", ["fixed_branches", ["introduced", "fixed", "last_affected"]]];
|
|
178
218
|
const CVE_REMEDIATION_DATA = {
|
|
179
219
|
steps: [
|
|
180
220
|
{
|
|
@@ -183,15 +223,43 @@ const CVE_REMEDIATION_DATA = {
|
|
|
183
223
|
[...ADVISORY_FIELDS, ["items", ["kind", "source_category", "source_subcategory", "text", "text_truncated", "text_chars", "url", "fixed_build", "product_count"]]],
|
|
184
224
|
[...ADVISORY_FIELDS, ["items", ["kind", "source_category", "url", "fixed_build"]]],
|
|
185
225
|
],
|
|
226
|
+
keepAll: true,
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
lists: ["fixed_branches"],
|
|
230
|
+
thin: [
|
|
231
|
+
FIXED_BRANCHES_THIN,
|
|
232
|
+
{
|
|
233
|
+
keep: FIXED_BRANCHES_THIN,
|
|
234
|
+
capped: "fixed_branches",
|
|
235
|
+
least: 1,
|
|
236
|
+
mark: (row, cap) => {
|
|
237
|
+
const n = Array.isArray(row.fixed_branches) ? row.fixed_branches.length : 0;
|
|
238
|
+
return n > cap ? { fixed_branches_truncated: { total: n, returned: cap } } : {};
|
|
239
|
+
},
|
|
240
|
+
said: (cap) => `each package's fixed_branches keeping at most its first ${cap} ranges, a package with more carrying fixed_branches_truncated with its total and returned`,
|
|
241
|
+
},
|
|
242
|
+
],
|
|
243
|
+
keepAll: true,
|
|
244
|
+
giveBack: true,
|
|
186
245
|
},
|
|
187
|
-
|
|
246
|
+
// #3139 round 3: every patch row stays, thinned (keepAll), while the step after it (any other
|
|
247
|
+
// list at the top of data: fix_references) is cut; patch rows leave only after that, and
|
|
248
|
+
// before any fixed_branches row or advisory (the last keepAll step's entries leave first).
|
|
249
|
+
{ lists: ["patches"], thin: [["vendor", "patch_url", "published_at"]], keepAll: true },
|
|
188
250
|
],
|
|
189
|
-
// Said where the advisories kept are thinned to a level whose items keep no text
|
|
190
|
-
|
|
251
|
+
// Said where the advisories kept are thinned to a level whose items keep no text, and (#3139)
|
|
252
|
+
// wherever an advisory is left out.
|
|
253
|
+
page: (t, _d, at) => {
|
|
191
254
|
const items = at.keeps.vendor_remediations?.find((x) => typeof x !== "string" && x[0] === "items");
|
|
192
|
-
|
|
255
|
+
const v = t.vendor_remediations;
|
|
256
|
+
return [items && !items[1].includes("text") ? ITEM_TEXT_ELSEWHERE : "", v && v.returned < v.total ? advisoriesElsewhere(v.total - v.returned) : ""].filter(Boolean).join(" ");
|
|
193
257
|
},
|
|
194
258
|
};
|
|
259
|
+
// #3139: where the advisories a cut leaves out are read. vendor_advisories_for_cve lists every one
|
|
260
|
+
// naming the CVE, with its vendor and vendor_advisory_id (CVE-2023-44487's 20 at 15,505
|
|
261
|
+
// characters on hosted 3.0.0), and get_vendor_advisory then returns any one whole.
|
|
262
|
+
const advisoriesElsewhere = (n) => `vendor_advisories_for_cve, given this CVE, lists every vendor advisory naming it, the ${n} left out of data among them, each with its vendor and vendor_advisory_id.`;
|
|
195
263
|
const listed = (xs) => (xs.length <= 1 ? (xs[0] ?? "") : `${xs.slice(0, -1).join(", ")} and ${xs.at(-1)}`);
|
|
196
264
|
const arr = (v) => (Array.isArray(v) ? v : null);
|
|
197
265
|
const strs = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === "string") : null);
|
|
@@ -250,7 +318,13 @@ function fixStatusSentence(cve, fs) {
|
|
|
250
318
|
const unread = Array.isArray(field(fs, "unread_inputs")) ? field(fs, "unread_inputs").filter((x) => typeof x === "string") : [];
|
|
251
319
|
const why = state === "none_confirmed" ? ", which is not a finding that no fix exists" : "";
|
|
252
320
|
const notRead = unread.length ? `; it rests without ${listed(unread)}, which the API could not read (unread_inputs)` : "";
|
|
253
|
-
|
|
321
|
+
// #3076, third pass: what vendor_fix_released evidence is, so "available" is not read as more
|
|
322
|
+
// than it says. Relayed from the field the API set, nothing inferred.
|
|
323
|
+
const released = field(fs, "vendor_released");
|
|
324
|
+
const qualifier = released !== null && released !== undefined && typeof released === "object"
|
|
325
|
+
? `; the vendor states a fix is released, but the CVE record names no fixed version (vendor_released), so the fixed release is the vendor advisory's to name`
|
|
326
|
+
: "";
|
|
327
|
+
return `fix_status for ${cve} is ${state}${basis ? ` (basis ${basis})` : ""}, the API's fix-status rule${why}${qualifier}${notRead}.`;
|
|
254
328
|
}
|
|
255
329
|
// The sentence on the installed version asked: what the API answered for it, relayed.
|
|
256
330
|
function installedSentence(asked, d) {
|
|
@@ -208,8 +208,11 @@ export async function epssHistory(kit, cve_id) {
|
|
|
208
208
|
return kit.crashed(TOOL, e);
|
|
209
209
|
}
|
|
210
210
|
}
|
|
211
|
+
// One output schema per process, not per request (3.0.2; index.ts envelopeSchema says why).
|
|
212
|
+
const OUTPUTS = new WeakMap();
|
|
211
213
|
export function registerEPSSHistory(server, kit) {
|
|
212
|
-
const output = epssHistoryOutput(kit);
|
|
214
|
+
const output = OUTPUTS.get(kit) ?? epssHistoryOutput(kit);
|
|
215
|
+
OUTPUTS.set(kit, output);
|
|
213
216
|
server.registerTool(TOOL, {
|
|
214
217
|
title: "EPSS change history for one CVE",
|
|
215
218
|
description: EPSS_HISTORY_DESCRIPTION,
|
package/dist/tools/kev_recent.js
CHANGED
|
@@ -223,9 +223,12 @@ async function kevRecent(d, a) {
|
|
|
223
223
|
return d.crashed(TOOL, e);
|
|
224
224
|
}
|
|
225
225
|
}
|
|
226
|
+
// One output schema per process, not per request (3.0.2; index.ts envelopeSchema says why).
|
|
227
|
+
const OUTPUTS = new WeakMap();
|
|
226
228
|
// Registers the tool on a server built by index.ts's createServer.
|
|
227
229
|
export function registerKevRecent(server, d) {
|
|
228
|
-
const output = d.envelopeSchema({ data: Data, coverage: Coverage, freshness: Freshness });
|
|
230
|
+
const output = OUTPUTS.get(d.envelopeSchema) ?? d.envelopeSchema({ data: Data, coverage: Coverage, freshness: Freshness });
|
|
231
|
+
OUTPUTS.set(d.envelopeSchema, output);
|
|
229
232
|
server.registerTool("kev_recent", {
|
|
230
233
|
title: "Recent CISA KEV additions",
|
|
231
234
|
description: KEV_RECENT_DESCRIPTION,
|
|
@@ -257,11 +257,16 @@ export async function sendBatches(deps, tool, purls) {
|
|
|
257
257
|
// (purls.slice(answers.length * MAX_COMPONENTS)), which it can rebuild from its own input, given
|
|
258
258
|
// the order they are counted in (`readOrder`), which the sentence says whenever it gives the
|
|
259
259
|
// position, not only when the text cuts the list. #3013: the structured result's cut empties
|
|
260
|
-
// data.not_sent_purls before any row leaves (it is rebuilt from the position)
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
|
|
260
|
+
// data.not_sent_purls before any row leaves (it is rebuilt from the position). #3132's review:
|
|
261
|
+
// round 1 then gave it back the first entries the room the rows left held (3 of the rate-limited
|
|
262
|
+
// case's 800), a list a client would retry as though it were the whole; the cut now keeps it whole
|
|
263
|
+
// or not at all (check_sbom.ts sbomData), and the sentence gives the positions as coverage holds
|
|
264
|
+
// them. `retry` is the tool and argument to send them to ("check_sbom again with purls set to").
|
|
265
|
+
// #3132's second review: those positions index the purls this tool read, which a check_sbom
|
|
266
|
+
// client rebuilds from its own purl list (or SBOM), but a scan_manifest client cannot without
|
|
267
|
+
// reading the files as this tool does; with `positions` false (scan_manifest), the sentence sends
|
|
268
|
+
// it to the same call with full: true, which always returns the list, instead.
|
|
269
|
+
export function sendingSentences(g, distinct, readOrder, retry, positions = true) {
|
|
265
270
|
const out = [];
|
|
266
271
|
if (g.batches > 1) {
|
|
267
272
|
out.push(`The ${distinct} distinct purls make ${g.batches} batches of at most ${MAX_COMPONENTS}, sent one after another; the API answered ${g.batches_sent} of them, and data.summary sums those answers.`);
|
|
@@ -273,7 +278,7 @@ export function sendingSentences(g, distinct, readOrder, retry) {
|
|
|
273
278
|
if (notSent > 0) {
|
|
274
279
|
const one = notSent === 1;
|
|
275
280
|
const where = one ? `it is the input's distinct purl at position ${g.sent + 1}` : `they are the input's distinct purls from position ${g.sent + 1} on`;
|
|
276
|
-
out.push(`${plural(notSent, "purl was", "purls were")} NOT sent (not_sent_reason ${g.not_sent_reason}: ${g.not_sent_detail}), so ${one ? "it is" : "they are"} not checked and not clean; ${where}, ${readOrder}, and data.not_sent_purls lists ${one ? "it" : "them"}
|
|
281
|
+
out.push(`${plural(notSent, "purl was", "purls were")} NOT sent (not_sent_reason ${g.not_sent_reason}: ${g.not_sent_detail}), so ${one ? "it is" : "they are"} not checked and not clean; ${where}, ${readOrder}, and data.not_sent_purls lists ${one ? "it" : "them"} (past the structured result's budget it is left out whole by default, never cut short${positions ? `, and ${one ? "that position is" : "those positions are"} coverage.sent + 1 to coverage.distinct_purls; with full: true it is always there` : ": the same call with full: true always returns it"}): call ${retry} ${one ? "it" : "them"}${g.not_sent_reason === "request_failed" ? "" : " after a minute"}.`);
|
|
277
282
|
}
|
|
278
283
|
return out;
|
|
279
284
|
}
|
|
@@ -380,6 +385,17 @@ export const Row = z.looseObject({
|
|
|
380
385
|
undetermined: opt(z.array(z.looseObject({ cve_id: opt(z.string()) }))),
|
|
381
386
|
capped: opt(z.boolean()),
|
|
382
387
|
candidates_capped: opt(z.boolean()),
|
|
388
|
+
// #3132: set by this server's structured cut (check_sbom.ts SBOM_ROW_LEVELS), never by the API.
|
|
389
|
+
matches_truncated: z
|
|
390
|
+
.strictObject({
|
|
391
|
+
total: z.number().int().min(0),
|
|
392
|
+
returned: z.number().int().min(0),
|
|
393
|
+
kev_listed_cve_ids: z.array(z.string()).optional(),
|
|
394
|
+
ransomware_cve_ids: z.array(z.string()).optional(),
|
|
395
|
+
fixed_in_versions: z.array(z.string()).optional(),
|
|
396
|
+
})
|
|
397
|
+
.optional()
|
|
398
|
+
.describe("Present only on a row whose matches the structured result's cut (DATA CUT) shortened or left out: total, the row's matches in the answer; returned, the ones data keeps (its first, highest effective_score first); and, while they fit, 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). The same call with full: true returns every match."),
|
|
383
399
|
});
|
|
384
400
|
// The batch route's answer, as data carries it: its fields, merged over the batches.
|
|
385
401
|
export const batchDataShape = {
|
|
@@ -22,12 +22,20 @@ export const SCAN_MANIFEST_DESCRIPTION = "Check a project's lockfile or pinned m
|
|
|
22
22
|
" Cut, each row keeps index, purl, verdict, not_assessed_reason and cve_ids at least; not_checked keeps its first 20.";
|
|
23
23
|
// The text's cut: check_sbom's rows and levels, and data.not_checked beside them, cut to its
|
|
24
24
|
// first TEXT_NOT_CHECKED once the first level does not fit (structuredContent.data keeps it whole).
|
|
25
|
+
// #3132's second review: check_sbom's sentence on not_sent_purls says a second call can send them
|
|
26
|
+
// without reading the list, from their positions in the input; here those positions index purls
|
|
27
|
+
// read from the files, which a client cannot rebuild, so the sentence names full: true instead.
|
|
25
28
|
export function manifestText(sent) {
|
|
26
29
|
const sbom = sbomText(sent);
|
|
27
30
|
return {
|
|
28
31
|
...sbom,
|
|
29
32
|
sides: [
|
|
30
|
-
...(sbom.sides ?? [])
|
|
33
|
+
...(sbom.sides ?? []).map((side) => side.list === "not_sent_purls"
|
|
34
|
+
? {
|
|
35
|
+
...side,
|
|
36
|
+
said: (_inText, total) => `The ${total} purls not sent are the purls this tool read from position ${sent + 1} on, counted in the order the note gives for them; structuredContent.data.not_sent_purls lists every one with full: true.`,
|
|
37
|
+
}
|
|
38
|
+
: side),
|
|
31
39
|
{
|
|
32
40
|
list: "not_checked",
|
|
33
41
|
cap: TEXT_NOT_CHECKED,
|
|
@@ -126,7 +134,7 @@ function noteFor(head, x, d, g) {
|
|
|
126
134
|
// counts above are coverage.not_checked_by_reason's, which no cut touches.
|
|
127
135
|
out.push(`${plural(nc, "entry was", "entries were")} NOT checked and ${nc === 1 ? "is" : "are"} not clean (${parts.join(", ")}): data.not_checked lists each with its file, line and reason, whole with full: true (past the structured result's budget, that list can be cut by default), and none was sent to the API.`);
|
|
128
136
|
}
|
|
129
|
-
out.push(...sendingSentences(g, x.purls.length, READ_ORDER, "check_sbom with purls set to"));
|
|
137
|
+
out.push(...sendingSentences(g, x.purls.length, READ_ORDER, "check_sbom with purls set to", false));
|
|
130
138
|
if (d === null)
|
|
131
139
|
return { note: out.join(" "), assessed: 0 };
|
|
132
140
|
const v = verdictSentences(d, g);
|
|
@@ -243,11 +251,15 @@ export function scanManifestOutput(envelopeSchema) {
|
|
|
243
251
|
freshness: null,
|
|
244
252
|
});
|
|
245
253
|
}
|
|
254
|
+
// One output schema per process, not per request (3.0.2; index.ts envelopeSchema says why).
|
|
255
|
+
const OUTPUTS = new WeakMap();
|
|
246
256
|
// A literal server.registerTool(name, { title, description, … }) call with constant strings:
|
|
247
257
|
// marketing-site lib/mcpToolClaims.test.ts reads it here (it follows index.ts's call to this
|
|
248
258
|
// function) and holds the /pulse/mcp row to this title and description.
|
|
249
259
|
export function registerScanManifest(server, deps) {
|
|
250
|
-
const
|
|
260
|
+
const envelope = deps.envelopeSchema;
|
|
261
|
+
const output = OUTPUTS.get(envelope) ?? scanManifestOutput(envelope);
|
|
262
|
+
OUTPUTS.set(envelope, output);
|
|
251
263
|
server.registerTool(SCAN_MANIFEST, {
|
|
252
264
|
title: SCAN_MANIFEST_TITLE,
|
|
253
265
|
description: SCAN_MANIFEST_DESCRIPTION,
|
|
@@ -352,8 +352,11 @@ const SEARCH_DATA = {
|
|
|
352
352
|
},
|
|
353
353
|
};
|
|
354
354
|
const ROW_CUT_DESCRIPTION = " Cut, each row keeps vendor, vendor_advisory_id, title, severity and cve_ids at least, its strings and lists shortened";
|
|
355
|
-
|
|
356
|
-
|
|
355
|
+
// The three tools' output schemas, one each per process, not per request (3.0.2; index.ts
|
|
356
|
+
// envelopeSchema says why).
|
|
357
|
+
const OUTPUTS = new WeakMap();
|
|
358
|
+
function outputsOf(envelopeSchema) {
|
|
359
|
+
const FOR_CVE_OUTPUT = envelopeSchema({
|
|
357
360
|
data: z.looseObject({
|
|
358
361
|
cve_id: opt(z.string()),
|
|
359
362
|
advisories: opt(z.array(AdvisoryRow)),
|
|
@@ -373,7 +376,7 @@ export function registerVendorAdvisoryTools(server, kit) {
|
|
|
373
376
|
}),
|
|
374
377
|
freshness: null,
|
|
375
378
|
});
|
|
376
|
-
const DETAIL_OUTPUT =
|
|
379
|
+
const DETAIL_OUTPUT = envelopeSchema({
|
|
377
380
|
data: AdvisoryRow.extend({
|
|
378
381
|
description: opt(z.string()),
|
|
379
382
|
known_cve_ids: opt(z.array(z.string())).describe("The CVE IDs of cve_ids that have a record in EchelonGraph's CVE feed."),
|
|
@@ -402,7 +405,7 @@ export function registerVendorAdvisoryTools(server, kit) {
|
|
|
402
405
|
}),
|
|
403
406
|
freshness: null,
|
|
404
407
|
});
|
|
405
|
-
const SEARCH_OUTPUT =
|
|
408
|
+
const SEARCH_OUTPUT = envelopeSchema({
|
|
406
409
|
data: z.looseObject({
|
|
407
410
|
advisories: opt(z.array(AdvisoryRow)),
|
|
408
411
|
total: opt(z.number()).describe("The matches; when total_capped is true, a lower bound: 1,000 or more."),
|
|
@@ -424,6 +427,12 @@ export function registerVendorAdvisoryTools(server, kit) {
|
|
|
424
427
|
}),
|
|
425
428
|
freshness: null,
|
|
426
429
|
});
|
|
430
|
+
return { FOR_CVE_OUTPUT, DETAIL_OUTPUT, SEARCH_OUTPUT };
|
|
431
|
+
}
|
|
432
|
+
export function registerVendorAdvisoryTools(server, kit) {
|
|
433
|
+
const outputs = OUTPUTS.get(kit.envelopeSchema) ?? outputsOf(kit.envelopeSchema);
|
|
434
|
+
OUTPUTS.set(kit.envelopeSchema, outputs);
|
|
435
|
+
const { FOR_CVE_OUTPUT, DETAIL_OUTPUT, SEARCH_OUTPUT } = outputs;
|
|
427
436
|
// The vendors' windows, read beside a tool's own request. Never a failure of the tool.
|
|
428
437
|
async function readWindows() {
|
|
429
438
|
const r = await kit.api(VENDOR_COVERAGE_PATH);
|
package/docs/tools.md
CHANGED
|
@@ -62,8 +62,8 @@ Both tools relay the API's JSON as it was sent, and the note says what the score
|
|
|
62
62
|
|
|
63
63
|
## What `patch_available` says
|
|
64
64
|
|
|
65
|
-
`patch_available` is `true` when EchelonGraph holds evidence of a fix for the CVE,
|
|
66
|
-
`patch_evidence` lists the sources
|
|
65
|
+
`patch_available` is `true` when EchelonGraph holds, or once held, evidence of a fix for the CVE,
|
|
66
|
+
and `patch_evidence` lists the sources it holds now, strongest first: `vendor_patch` (a vendor
|
|
67
67
|
patch on record, from Ubuntu or Red Hat), `fixed_version` (a fixed version recorded for an
|
|
68
68
|
affected package, or the fixed bound of an affected version range), `release_reference` (a
|
|
69
69
|
reference that EchelonGraph confirmed through the GitHub API is a published release containing
|
|
@@ -74,11 +74,15 @@ CSAF `vendor_fix`, Microsoft's Vendor Fix), `nvd_patch_reference` (an NVD refere
|
|
|
74
74
|
(a sentence of the CVE record's solutions, written by its CNA, that names a fixed version) and
|
|
75
75
|
`description_fix` (a sentence of the CVE description that names a fixed version). A reference page
|
|
76
76
|
that only mentions a patch, and a CVE's presence in CISA's KEV catalog, are not evidence.
|
|
77
|
-
`get_cve` relays the CNA's own sentence as `cna_solution`,
|
|
78
|
-
|
|
79
|
-
|
|
77
|
+
`get_cve` relays the CNA's own sentence as `cna_solution`, which section of the record it is as
|
|
78
|
+
`cna_solution_section` (`solution` or `workaround`: a workaround's sentence is the record's
|
|
79
|
+
workaround, never its solution), its date for when the vulnerability was made public as
|
|
80
|
+
`date_public` (with `date_public_source` `cna`), and the CNA as `cna`, each only where the CVE
|
|
81
|
+
record has it. `false` means no fix evidence is on record, which is **not** a
|
|
80
82
|
finding that no fix exists. Once `true`, `patch_available` is not set back to `false`, so it can be
|
|
81
|
-
`true` beside an empty `patch_evidence
|
|
83
|
+
`true` beside an empty `patch_evidence`: evidence of a fix once held has since been withdrawn, and
|
|
84
|
+
`remediation.fix_status` in the CVE's record is the current reading, not `patch_available` (since
|
|
85
|
+
3.0.2 the field's outputSchema text says so). An answer with no `patch_evidence` (an API older than that
|
|
82
86
|
field) does not name the evidence, and a `false` there is not a finding that no fix exists either.
|
|
83
87
|
Both tools relay the two fields as the API sends them.
|
|
84
88
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "echelongraph-mcp",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.2",
|
|
4
4
|
"mcpName": "io.echelongraph/echelongraph-mcp",
|
|
5
5
|
"description": "MCP server for CVE lookups: EchelonGraph scores, CISA KEV, EPSS, CWE, exploits, version and SBOM checks, vendor advisories, and per-CVE exposure derived from Shodan data. Shodan data is owned by Shodan, which holds its copyright (© Shodan). Keyless.",
|
|
6
6
|
"type": "module",
|
package/server.json
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
]
|
|
18
18
|
}
|
|
19
19
|
],
|
|
20
|
-
"version": "3.0.
|
|
20
|
+
"version": "3.0.2",
|
|
21
21
|
"remotes": [
|
|
22
22
|
{
|
|
23
23
|
"type": "streamable-http",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
{
|
|
29
29
|
"registryType": "npm",
|
|
30
30
|
"identifier": "echelongraph-mcp",
|
|
31
|
-
"version": "3.0.
|
|
31
|
+
"version": "3.0.2",
|
|
32
32
|
"transport": {
|
|
33
33
|
"type": "stdio"
|
|
34
34
|
},
|