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.
@@ -7,13 +7,15 @@
7
7
  // What the answer holds:
8
8
  // cisa CISA KEV's required_action, due_date, short_description and notes_urls
9
9
  // for a KEV-listed CVE. CISA directs the action at US federal civilian
10
- // agencies (BOD 22-01); it is not EchelonGraph's advice.
10
+ // agencies (under BOD 26-04, which replaced BOD 22-01 on
11
+ // 10 June 2026); it is not EchelonGraph's advice.
11
12
  // fixed_branches each affected package with every affected range and its fix.
12
13
  // vendor_remediations each vendor advisory naming the CVE (at most 20, newest first), with its
13
14
  // remediation_state and the items it lists for the CVE, each with the
14
15
  // vendor's own category (kind): vendor_fix, workaround, mitigation,
15
16
  // no_fix_planned, none_available or other.
16
- // fix_references the record's references tagged Patch or Mitigation.
17
+ // fix_references the record's references tagged Patch or Mitigation, by NVD, or patch or
18
+ // mitigation, by the CNA (#2841: the API matches the tag in either case).
17
19
  // patches cve_patches rows.
18
20
  // coverage parsed_vendors, vendors_not_parsed and the caps.
19
21
  // failed_sections the sections the API could not read.
@@ -21,19 +23,40 @@
21
23
  // The rules the note keeps: not_parsed is not "no remediation"; none_in_source is what the
22
24
  // advisory lists, not a finding that no fix exists; an empty section is "none on record", never
23
25
  // "no fix"; a failed section is named, never relayed as empty. A REJECTED CVE is not_assessed.
26
+ //
27
+ // fix_status (#2841, #2518 item 3) is the API's fix-status rule (core-backend cve/fixstatus.go,
28
+ // the rule /pulse pages render), relayed: this file derives nothing from it.
29
+ //
30
+ // The installed version (#2841): ecosystem, package and version, all three or none. They travel
31
+ // in the X-EG-Ecosystem, X-EG-Package and X-EG-Version request headers, percent-encoded as
32
+ // check_affected sends them, and never in the URL (#1983: Cloud Run's request log records the URL
33
+ // and no request header). The API answers installed (what was asked and its verdict) and fixed_in,
34
+ // the first version above it that no affected range on record holds (the fixed bound of the range
35
+ // holding it, moved past any overlapping range, with fixed_in_detail naming it, #3074), or null
36
+ // with fixed_in_reason, from the registry matcher check_affected's registry path runs; that answer
37
+ // is private, no-store.
24
38
  import * as z from "zod";
25
39
  import { ANNOTATIONS, CVE_ID, SHOWN_BASE, api, badInput, checked, crashed, envelopeSchema, failed, failure, field, NOT_A_FINDING, okHead, opt, strAt, succeeded } from "../index.js";
26
- import { TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
40
+ import { answering, FULL_ARG, TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
41
+ import { fixStatusSchema } from "../fixStatus.js";
42
+ import { MATCH_HEADERS } from "./check_affected.js";
27
43
  const TOOL = "cve_remediation";
28
44
  const SECTIONS = ["cisa", "fixed_branches", "vendor_remediations", "fix_references", "patches"];
29
45
  const KINDS = ["vendor_fix", "workaround", "mitigation", "no_fix_planned", "none_available", "other"];
30
46
  const STATES = ["parsed", "none_in_source", "not_parsed", "withdrawn"];
47
+ // core-backend cve/pkgmatch_fixedin.go InstalledFix's verdicts.
48
+ const VERDICTS = ["affected", "not_affected", "undetermined", "not_assessed"];
49
+ // The installed version's arguments, in the order the headers are named.
50
+ const INSTALLED = ["ecosystem", "package", "version"];
31
51
  // Required in the description's head (src/requiredText.ts REQUIRED_IN_HEAD.cve_remediation).
32
52
  export const REMEDIATION_UNTESTED = "Every text is relayed as stated by its source; EchelonGraph has not tested it.";
33
53
  export const REMEDIATION_NOT_A_ZERO = "An empty list or a remediation_state of none_in_source or not_parsed is not a finding that no fix exists.";
34
- export const REMEDIATION_CISA = "CISA's required_action is directed at US federal civilian agencies (BOD 22-01) and is not EchelonGraph's advice.";
35
- const METHOD = "EchelonGraph's stored sources for one CVE (GET /api/v1/public/cves/:id/remediation): CISA's Known Exploited Vulnerabilities catalog text; the affected ranges and fixes of OSV advisories; the remediation each vendor advisory lists, categorised by the vendor's own category (Red Hat CSAF remediations, MSRC CVRF Workaround, Mitigation and Vendor Fix rows, Palo Alto's solution and work_around sections); the CVE record's references NVD tagged Patch or Mitigation; and cve_patches. Nothing is generated or tested.";
36
- export const CVE_REMEDIATION_DESCRIPTION = `How one CVE is fixed, as its sources state it, in one answer. ${REMEDIATION_UNTESTED} ${REMEDIATION_NOT_A_ZERO} Returns cisa (kev_listed, and for a KEV-listed CVE CISA's required_action, due_date, short_description and notes_urls, verbatim; ${REMEDIATION_CISA}), fixed_branches (each affected package with fixed_branches: every affected range, introduced and fixed or last_affected, and fixed_version, one range's fix), vendor_remediations (each vendor advisory naming the CVE, newest first, at most 20: vendor, vendor_advisory_id, remediation_state, remediation_kinds and items, each item with kind, source_category, text, url, fixed_build, product_ids and product_count), fix_references (the record's references NVD tagged Patch or Mitigation), patches, coverage (parsed_vendors, vendors_not_parsed and the caps) and failed_sections. kind is the vendor's own category, never read from the text: vendor_fix, workaround, mitigation, no_fix_planned, none_available, or other with the vendor's name for it in source_category. remediation_state is parsed, none_in_source (parsed; the advisory lists none for this CVE), not_parsed (EchelonGraph does not read that vendor's remediation) or withdrawn. A section the API could not read is named in failed_sections and coverage.sections_failed, never relayed as empty. A REJECTED CVE answers not_assessed. Pass a CVE ID like CVE-2021-44228. ${TEXT_BUDGET_DESCRIPTION} Cut, vendor texts are shortened first; kinds, states and URLs are kept.`;
54
+ export const REMEDIATION_CISA = "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.";
55
+ const METHOD = "EchelonGraph's stored sources for one CVE (GET /api/v1/public/cves/:id/remediation): CISA's Known Exploited Vulnerabilities catalog text; the affected ranges and fixes of OSV advisories; the remediation each vendor advisory lists, categorised by the vendor's own category (Red Hat CSAF remediations, MSRC CVRF Workaround, Mitigation and Vendor Fix rows, Palo Alto's solution and work_around sections); the CVE record's references tagged Patch or Mitigation by NVD, or patch or mitigation by the CNA; and cve_patches. Nothing is generated or tested.";
56
+ // #3084 (merged from 24dad5eb): REMEDIATION_CISA names BOD 26-04, which replaced BOD 22-01, 48 characters
57
+ // longer, which left this description 71 under the 2048-character cut (#2924's floor is 100); the
58
+ // remediation_state parentheses and the failed_sections sentence are said shorter, every claim kept.
59
+ export const CVE_REMEDIATION_DESCRIPTION = `How one CVE is fixed, as its sources state it, in one answer. ${REMEDIATION_UNTESTED} ${REMEDIATION_NOT_A_ZERO} Returns fix_status (the API's fix-status rule, as CVE Pulse shows it: state and basis), cisa (for a KEV-listed CVE CISA's required_action, due_date, short_description and notes_urls, verbatim; ${REMEDIATION_CISA}), fixed_branches (each affected package with fixed_branches: every affected range, introduced and fixed or last_affected, and fixed_version, one range's fix), vendor_remediations (each vendor advisory naming the CVE, newest first, at most 20, with remediation_state, remediation_kinds and items, each with kind, source_category, text and url), fix_references (references NVD or the CNA tagged Patch or Mitigation), patches, coverage and failed_sections. kind is the vendor's own category, never read from the text: vendor_fix, workaround, mitigation, no_fix_planned, none_available or other. remediation_state is parsed, none_in_source (none listed for this CVE), not_parsed (that vendor's remediation is not read) or withdrawn. ecosystem, package and version (all three or none, sent in request headers, never in the URL) add installed, the verdict at that version, and fixed_in, the lowest unaffected version above it, or null with fixed_in_reason. A section the API could not read is named in failed_sections, not relayed as empty. A REJECTED CVE answers not_assessed. ${TEXT_BUDGET_DESCRIPTION} Cut, vendor texts are shortened first; kinds, states and URLs are kept.`;
37
60
  // ── outputSchema ──
38
61
  // Built on first use: index.ts imports this file, so its exports are not initialised while this
39
62
  // module's top level runs.
@@ -67,6 +90,19 @@ function schemas() {
67
90
  cve_id: opt(z.string()),
68
91
  state: opt(z.string()).describe("assessed, or not_assessed for a CVE record rejected by its numbering authority."),
69
92
  not_assessed_reason: opt(z.string()),
93
+ fix_status: fixStatusSchema(),
94
+ installed: opt(z.looseObject({
95
+ ecosystem: opt(z.string()).describe("The ecosystem asked for, in its canonical OSV spelling."),
96
+ ecosystem_recognised: opt(z.boolean()),
97
+ package: opt(z.string()),
98
+ version: opt(z.string()),
99
+ verdict: opt(z.union([z.enum(VERDICTS), z.string()])).describe("affected (a range on record holds the version), not_affected (no range on record holds it), undetermined (a range could not be decided at it) or not_assessed (the record names no such package for this CVE, or the lookup did not run)."),
100
+ version_evidence: opt(z.string()),
101
+ interval: opt(z.looseObject({ introduced: opt(z.string()), fixed: opt(z.string()), last_affected: opt(z.string()) })).describe("The affected range holding the version (fixed is exclusive, last_affected inclusive); absent when none holds it."),
102
+ })).describe("The installed version asked for, as the API read it from the request headers, and its verdict against this CVE; absent when none was asked."),
103
+ fixed_in: opt(z.string()).describe("With an installed version: the first version above it that no affected range on record holds, always above it, as the advisory states it: the fixed bound of the range holding it, moved past any overlapping range that still holds that bound (fixed_in_detail then names it). null when no range on record holds it, no fix is on record past the ranges holding it, or the version was not assessed, with fixed_in_reason saying which. Absent when no installed version was asked."),
104
+ fixed_in_reason: opt(z.string()).describe("Why fixed_in is null; absent when fixed_in is a version."),
105
+ fixed_in_detail: opt(z.string()).describe("Set when fixed_in is not the fixed bound of the range holding the installed version: the overlapping ranges of the same record it moved past, which still hold that bound."),
70
106
  cisa: z
71
107
  .looseObject({
72
108
  kev_listed: opt(z.boolean()),
@@ -88,6 +124,10 @@ function schemas() {
88
124
  advisory_id: opt(z.string()),
89
125
  source: opt(z.string()),
90
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."),
91
131
  }))),
92
132
  vendor_remediations: opt(z.array(VendorAdvisory)),
93
133
  fix_references: opt(z.array(z.looseObject({ url: opt(z.string()), tags: opt(z.array(z.string())), source: opt(z.string()) }))),
@@ -103,6 +143,10 @@ function schemas() {
103
143
  vendor_advisory_limit: z.number().nullable(),
104
144
  item_text_cap: z.number().nullable(),
105
145
  items_per_advisory_cap: z.number().nullable(),
146
+ installed_asked: z
147
+ .strictObject({ ecosystem: z.string(), package: z.string(), version: z.string() })
148
+ .nullable()
149
+ .describe("The installed version this call sent (in request headers), as given; null when none."),
106
150
  });
107
151
  return { output: envelopeSchema({ data, coverage, freshness: null }) };
108
152
  }
@@ -115,8 +159,107 @@ const CUT = {
115
159
  { keep: ["vendor", "vendor_advisory_id", "withdrawn", "remediation_state", "remediation_kinds", "items_total", ["items", ["kind", "source_category", "text", "url", "fixed_build"]]], clip: 400, cap: 16 },
116
160
  { keep: ["vendor", "vendor_advisory_id", "remediation_state", "remediation_kinds", ["items", ["kind", "url"]]], clip: 2048, cap: 8 },
117
161
  ],
118
- whole: "get_vendor_advisory returns any one of these advisories by its vendor and vendor_advisory_id, whole in its structuredContent.data, with every remediation item it lists.",
162
+ whole: "get_vendor_advisory returns any one of these advisories by its vendor and vendor_advisory_id, whole in its structuredContent.data with full: true, with every remediation item it lists.",
163
+ };
164
+ // #3013: the structured result's cut. CVE-2021-44228's answer is 75,963 characters of structured
165
+ // result, 61,248 of them its 20 vendor_remediations, whose items carry the vendor's text (about 550
166
+ // characters an item, the payload) and its product_ids (about 550, the vendor's own identifiers).
167
+ // Before any advisory leaves, every advisory's items are thinned: product_ids first, keeping each
168
+ // text; then the texts, keeping each item's kind, source_category, url and fixed_build, so every
169
+ // advisory stays named with its state, kinds and links (get_vendor_advisory returns one whole).
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.
208
+ //
209
+ // #3013's review: the second level leaves every item's text out of data, and the text is the
210
+ // vendor's step itself (a workaround's or mitigation's wording), which the triage_cve and
211
+ // sbom_review prompts have the model quote. No string is shortened (the cut's rule), so the note
212
+ // says the text is gone and where each one is: get_vendor_advisory, by the advisory's vendor and
213
+ // vendor_advisory_id, returns that advisory's remediations with their texts, and the same call
214
+ // with full: true returns this data whole; the prompts send the model there when it is cut.
215
+ const ADVISORY_FIELDS = ["vendor", "vendor_display_name", "vendor_advisory_id", "vendor_published_at", "withdrawn", "remediation_state", "remediation_kinds", "items_total"];
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"]]];
218
+ const CVE_REMEDIATION_DATA = {
219
+ steps: [
220
+ {
221
+ lists: ["vendor_remediations"],
222
+ thin: [
223
+ [...ADVISORY_FIELDS, ["items", ["kind", "source_category", "source_subcategory", "text", "text_truncated", "text_chars", "url", "fixed_build", "product_count"]]],
224
+ [...ADVISORY_FIELDS, ["items", ["kind", "source_category", "url", "fixed_build"]]],
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,
245
+ },
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 },
250
+ ],
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) => {
254
+ const items = at.keeps.vendor_remediations?.find((x) => typeof x !== "string" && x[0] === "items");
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(" ");
257
+ },
119
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.`;
120
263
  const listed = (xs) => (xs.length <= 1 ? (xs[0] ?? "") : `${xs.slice(0, -1).join(", ")} and ${xs.at(-1)}`);
121
264
  const arr = (v) => (Array.isArray(v) ? v : null);
122
265
  const strs = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === "string") : null);
@@ -162,18 +305,70 @@ function branchSentence(rows) {
162
305
  fixed++;
163
306
  }
164
307
  }
165
- return `fixed_branches lists ${rows.length} affected package${rows.length === 1 ? "" : "s"} with ${ranges} affected range${ranges === 1 ? "" : "s"}, ${fixed} with a fix on record; an installed version's fix is the fixed of the range that holds it, compared in the ecosystem's version order.`;
308
+ return `fixed_branches lists ${rows.length} affected package${rows.length === 1 ? "" : "s"} with ${ranges} affected range${ranges === 1 ? "" : "s"}, ${fixed} with a fix on record; an installed version's fix is the first fixed above it that no range of its package holds (the fixed of the range that holds it, or, while another range still holds that version, that range's fixed), compared in the ecosystem's version order.`;
166
309
  }
167
- export async function cveRemediation(cve_id) {
168
- const id = cve_id.trim();
310
+ // fix_status's sentence: what the API's rule says, relayed. Nothing about the fix is inferred here.
311
+ function fixStatusSentence(cve, fs) {
312
+ if (fs === null || fs === undefined)
313
+ return null;
314
+ const state = strAt(fs, "state");
315
+ if (!state)
316
+ return null;
317
+ const basis = strAt(fs, "basis");
318
+ const unread = Array.isArray(field(fs, "unread_inputs")) ? field(fs, "unread_inputs").filter((x) => typeof x === "string") : [];
319
+ const why = state === "none_confirmed" ? ", which is not a finding that no fix exists" : "";
320
+ const notRead = unread.length ? `; it rests without ${listed(unread)}, which the API could not read (unread_inputs)` : "";
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}.`;
328
+ }
329
+ // The sentence on the installed version asked: what the API answered for it, relayed.
330
+ function installedSentence(asked, d) {
331
+ const who = `${asked.ecosystem} ${asked.package} ${asked.version}`;
332
+ if (!("fixed_in" in d)) {
333
+ return `The answer carries no fixed_in for the installed version asked (${who}): the API did not assess it, which is not a finding either way.`;
334
+ }
335
+ const inst = field(d, "installed");
336
+ const verdict = strAt(inst, "verdict");
337
+ const fixedIn = strAt(d, "fixed_in");
338
+ if (fixedIn) {
339
+ // #3074: fixed_in moved past an overlapping range, so it is not the holding range's bound.
340
+ const detail = strAt(d, "fixed_in_detail");
341
+ if (detail) {
342
+ return `For the installed version asked (${who}), installed.verdict is ${verdict ?? "not given"} and fixed_in is ${fixedIn}: the first version above it that no affected range on record holds (${detail}), as the advisory states it; EchelonGraph has not tested it.`;
343
+ }
344
+ const lo = strAt(field(inst, "interval"), "introduced");
345
+ const range = lo ? ` [${lo}, ${fixedIn})` : "";
346
+ return `For the installed version asked (${who}), installed.verdict is ${verdict ?? "not given"} and fixed_in is ${fixedIn}: the fixed bound of the affected range${range} that holds it, as the advisory states it; EchelonGraph has not tested it.`;
347
+ }
348
+ const reason = strAt(d, "fixed_in_reason") ?? "the answer gives no fixed_in_reason";
349
+ return `For the installed version asked (${who}), installed.verdict is ${verdict ?? "not given"} and fixed_in is null: ${reason}.`;
350
+ }
351
+ export async function cveRemediation(args) {
352
+ const id = (args.cve_id ?? "").trim();
169
353
  if (!id)
170
354
  return badInput(TOOL, "cve_id is required");
171
355
  if (!CVE_ID.test(id))
172
356
  return badInput(TOOL, `cve_id ${JSON.stringify(id.slice(0, 32))} is not a CVE id (expected the form CVE-2021-44228)`);
357
+ // The installed version: all three or none.
358
+ const given = { ecosystem: (args.ecosystem ?? "").trim(), package: (args.package ?? "").trim(), version: (args.version ?? "").trim() };
359
+ const present = INSTALLED.filter((k) => given[k] !== "");
360
+ const missing = INSTALLED.filter((k) => given[k] === "");
361
+ if (present.length > 0 && missing.length > 0) {
362
+ return badInput(TOOL, `ecosystem, package and version go together, all three or none: ${listed([...missing])} ${missing.length === 1 ? "is" : "are"} missing (given: ${listed([...present])})`);
363
+ }
364
+ const asked = present.length === INSTALLED.length ? given : null;
173
365
  const cve = id.toUpperCase();
174
366
  const path = `/api/v1/public/cves/${encodeURIComponent(cve)}/remediation`;
367
+ // #1983: percent-encoded, as the backend's matchHeaderValue decodes them; never in the URL.
368
+ const enc = encodeURIComponent;
369
+ const headers = asked ? { [MATCH_HEADERS.ecosystem]: enc(asked.ecosystem), [MATCH_HEADERS.package]: enc(asked.package), [MATCH_HEADERS.version]: enc(asked.version) } : undefined;
175
370
  try {
176
- const r = await api(path);
371
+ const r = await api(path, headers ? { headers } : undefined);
177
372
  if (!r.ok)
178
373
  return failed(TOOL, r);
179
374
  const d = r.data;
@@ -197,11 +392,13 @@ export async function cveRemediation(cve_id) {
197
392
  vendor_advisory_limit: typeof field(cov, "vendor_advisory_limit") === "number" ? field(cov, "vendor_advisory_limit") : null,
198
393
  item_text_cap: typeof field(cov, "item_text_cap") === "number" ? field(cov, "item_text_cap") : null,
199
394
  items_per_advisory_cap: typeof field(cov, "items_per_advisory_cap") === "number" ? field(cov, "items_per_advisory_cap") : null,
395
+ installed_asked: asked,
200
396
  };
201
397
  const head = okHead(TOOL, r.status);
202
398
  if (state === "not_assessed") {
203
399
  const reason = strAt(d, "not_assessed_reason") ?? "the API gives no reason";
204
- return succeeded(d, `${head} NOT ASSESSED: ${cve}'s remediation is not assessed (${reason}). A rejected CVE record is not an active vulnerability, so no fix is relayed for it.`, {
400
+ const inst = asked ? ` ${installedSentence(asked, d)}` : "";
401
+ return succeeded(d, `${head} NOT ASSESSED: ${cve}'s remediation is not assessed (${reason}). A rejected CVE record is not an active vulnerability, so no fix is relayed for it.${inst}`, {
205
402
  state: "not_assessed",
206
403
  measured_at: null,
207
404
  method: METHOD,
@@ -211,6 +408,11 @@ export async function cveRemediation(cve_id) {
211
408
  });
212
409
  }
213
410
  const said = [];
411
+ const fixSaid = fixStatusSentence(cve, field(d, "fix_status"));
412
+ if (fixSaid)
413
+ said.push(fixSaid);
414
+ if (asked)
415
+ said.push(installedSentence(asked, d));
214
416
  const cisa = field(d, "cisa");
215
417
  const action = strAt(cisa, "required_action");
216
418
  if (field(cisa, "kev_listed") === true) {
@@ -225,8 +427,12 @@ export async function cveRemediation(cve_id) {
225
427
  if (vendors && !(reported ?? []).includes("vendor_remediations"))
226
428
  said.push(...vendorSentences(cve, vendors));
227
429
  const refs = arr(field(d, "fix_references"));
228
- if (refs)
229
- said.push(refs.length ? `fix_references lists ${refs.length} reference${refs.length === 1 ? "" : "s"} the CVE record tags Patch or Mitigation.` : "No reference of the CVE record is tagged Patch or Mitigation.");
430
+ // #2841: the API keeps both spellings, NVD's (Patch, Mitigation) and a CNA's (patch, mitigation).
431
+ if (refs) {
432
+ said.push(refs.length
433
+ ? `fix_references lists ${refs.length} reference${refs.length === 1 ? "" : "s"} the CVE record tags as a patch or mitigation (NVD's Patch or Mitigation, or the CNA's patch or mitigation), each with the tags as written.`
434
+ : "No reference of the CVE record is tagged as a patch or mitigation, in NVD's spelling (Patch, Mitigation) or the CNA's (patch, mitigation).");
435
+ }
230
436
  if (reported === null)
231
437
  said.push("The answer carries no failed_sections, so an empty section may be one the API could not read, not a finding of none.");
232
438
  else if (reported.length)
@@ -250,8 +456,16 @@ export function registerCveRemediation(server) {
250
456
  server.registerTool(TOOL, {
251
457
  title: "How one CVE is fixed",
252
458
  description: CVE_REMEDIATION_DESCRIPTION,
253
- inputSchema: z.object({ cve_id: z.string().describe("a CVE ID, e.g. CVE-2021-44228") }),
459
+ inputSchema: CVE_REMEDIATION_INPUT,
254
460
  outputSchema: output,
255
461
  annotations: ANNOTATIONS,
256
- }, async ({ cve_id }) => checked(TOOL, output, await cveRemediation(cve_id)));
462
+ }, async (args) => checked(TOOL, output, await answering(args.full, CVE_REMEDIATION_DATA, () => cveRemediation(args))));
257
463
  }
464
+ // The installed version is optional and all-or-none (cveRemediation refuses a partial set).
465
+ export const CVE_REMEDIATION_INPUT = z.object({
466
+ cve_id: z.string().describe("a CVE ID, e.g. CVE-2021-44228"),
467
+ ecosystem: z.string().optional().describe("Installed version, with package and version: the package's ecosystem, such as npm, PyPI or Maven. Sent in a request header."),
468
+ package: z.string().optional().describe("Installed version, with ecosystem and version: the package name in that ecosystem, such as org.apache.logging.log4j:log4j-core. Sent in a request header."),
469
+ version: z.string().optional().describe("Installed version, with ecosystem and package: the version installed, such as 2.14.1. Sent in a request header."),
470
+ full: FULL_ARG,
471
+ });
@@ -1,5 +1,5 @@
1
1
  import * as z from "zod";
2
- import { TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
2
+ import { answering, FULL_ARG, NO_DATA_CUT, TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
3
3
  const TOOL = "epss_history";
4
4
  const CVE_ID = /^CVE-\d{4}-\d{4,}$/i;
5
5
  export const EPSS_HISTORY_METHOD = "EchelonGraph's record of FIRST's EPSS scores: the nightly EPSS fetcher overwrites each CVE's epss_score and epss_percentile from FIRST's published file and, for every CVE whose value changed, records the new values with the time it wrote them; this series is those records, oldest first, with repeated records of one change collapsed into the first.";
@@ -208,13 +208,19 @@ 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,
216
- inputSchema: z.object({ cve_id: kit.cveIdArg }),
219
+ inputSchema: z.object({ cve_id: kit.cveIdArg, full: FULL_ARG }),
217
220
  outputSchema: output,
218
221
  annotations: kit.annotations,
219
- }, async ({ cve_id }) => kit.checked(TOOL, output, await epssHistory(kit, cve_id)));
222
+ },
223
+ // #3013: a few points a CVE, the API's own cap on them, 2,508 characters for CVE-2021-44228:
224
+ // no list is named, so only a list at the top level of data would be cut past the budget.
225
+ async ({ cve_id, full }) => kit.checked(TOOL, output, await answering(full, NO_DATA_CUT, () => epssHistory(kit, cve_id))));
220
226
  }
@@ -13,7 +13,7 @@
13
13
  // the note says it can lag the feed.
14
14
  import * as z from "zod";
15
15
  import { ANNOTATIONS, api, badInput, checked, crashed, envelopeSchema, failed, field, lenAt, numAt, okHead, opt, rowsScoreNote, strAt, succeeded, } from "../index.js";
16
- import { GET_CVE_WHOLE, TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
16
+ import { answering, FULL_ARG, GET_CVE_WHOLE, TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
17
17
  const TOOL = "get_cwe";
18
18
  const CWE_ARG = /^(?:CWE-)?0*(\d{1,6})$/i;
19
19
  const MAX_PAGE = 200;
@@ -27,6 +27,10 @@ const GET_CWE_TEXT = {
27
27
  levels: [{ keep: ["cve_id", "severity", "cvss_v3_score", "echelongraph_score", "echelongraph_severity", "score_assessed", "score_unassessed_reason", "kev_listed", "published", "description"], clip: 120 }],
28
28
  whole: `${GET_CVE_WHOLE}.`,
29
29
  };
30
+ // #3013: the structured result's cut. CWE-79's page of 50 is 24,276 characters of structured result,
31
+ // within the budget; rows a few times longer leave data from the end. A page is always 50 rows, so
32
+ // full: true is the only way to read the rest of one.
33
+ const GET_CWE_DATA = { steps: [{ lists: ["cves"] }] };
30
34
  function schemas() {
31
35
  const row = z.looseObject({
32
36
  cve_id: opt(z.string()),
@@ -150,8 +154,9 @@ export function registerGetCwe(server) {
150
154
  inputSchema: z.object({
151
155
  cwe_id: z.string().describe("a CWE ID, e.g. CWE-79 (or 79)"),
152
156
  page: z.number().int().min(1).max(MAX_PAGE).optional().describe(`page of 50 CVEs (default 1, max ${MAX_PAGE})`),
157
+ full: FULL_ARG,
153
158
  }),
154
159
  outputSchema: output,
155
160
  annotations: ANNOTATIONS,
156
- }, async ({ cwe_id, page }) => checked(TOOL, output, await getCwe(cwe_id, page)));
161
+ }, async ({ cwe_id, page, full }) => checked(TOOL, output, await answering(full, GET_CWE_DATA, () => getCwe(cwe_id, page))));
157
162
  }
@@ -1,5 +1,5 @@
1
1
  import * as z from "zod";
2
- import { GET_CVE_WHOLE, TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
2
+ import { answering, FULL_ARG, GET_CVE_WHOLE, TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
3
3
  const TOOL = "kev_recent";
4
4
  const PATH = "/api/v1/public/kev/recent";
5
5
  export const KEV_RECENT_MAX_LIMIT = 200;
@@ -145,6 +145,21 @@ function kevText(a) {
145
145
  page: (shown) => `next_cursor continues after the last row of this page, not after the last row in this text: to read the rows left out in the text, call kev_recent again with the same filters${a.cursor?.trim() ? " and cursor" : ""} and a limit of ${shown}, whose rows are the ones in this text, then with that page's next_cursor and a limit of ${shown} or fewer.`,
146
146
  };
147
147
  }
148
+ // #3013: the structured result's cut. A production row is about 400 characters of compact JSON, so
149
+ // the default page of 50 fits (23,234 characters of structured result) and the largest, 200, was
150
+ // 84,380: rows leave data from the end, and, as for the text, the note says how to read them
151
+ // without skipping one, since next_cursor continues after the page, not after data's last row.
152
+ function kevData(a) {
153
+ return {
154
+ steps: [{ lists: ["kev"] }],
155
+ page: (t) => {
156
+ const k = t.kev?.returned;
157
+ if (!k)
158
+ return "";
159
+ return `next_cursor continues after the last row of this page, not after the last row in data: to read the rows data leaves out, call kev_recent again with the same filters${a.cursor?.trim() ? " and cursor" : ""} and a limit of ${k}, whose rows are the ones in data, then with that page's next_cursor and a limit of ${k} or fewer.`;
160
+ },
161
+ };
162
+ }
148
163
  async function kevRecent(d, a) {
149
164
  try {
150
165
  const h = headersFor(a);
@@ -208,9 +223,12 @@ async function kevRecent(d, a) {
208
223
  return d.crashed(TOOL, e);
209
224
  }
210
225
  }
226
+ // One output schema per process, not per request (3.0.2; index.ts envelopeSchema says why).
227
+ const OUTPUTS = new WeakMap();
211
228
  // Registers the tool on a server built by index.ts's createServer.
212
229
  export function registerKevRecent(server, d) {
213
- 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);
214
232
  server.registerTool("kev_recent", {
215
233
  title: "Recent CISA KEV additions",
216
234
  description: KEV_RECENT_DESCRIPTION,
@@ -221,8 +239,9 @@ export function registerKevRecent(server, d) {
221
239
  vendor: z.string().optional().describe("CISA vendorProject, an exact case-insensitive match, e.g. 'Microsoft'"),
222
240
  limit: z.number().int().min(1).max(KEV_RECENT_MAX_LIMIT).optional().describe("page size (default 50, max 200)"),
223
241
  cursor: z.string().optional().describe("next_cursor from the previous page"),
242
+ full: FULL_ARG,
224
243
  }),
225
244
  outputSchema: output,
226
245
  annotations: d.annotations,
227
- }, async (a) => d.checked(TOOL, output, await kevRecent(d, a)));
246
+ }, async (a) => d.checked(TOOL, output, await answering(a.full, kevData(a), () => kevRecent(d, a))));
228
247
  }
@@ -256,9 +256,17 @@ export async function sendBatches(deps, tool, purls) {
256
256
  // sent are always the input's distinct purls from position sent + 1 on
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
- // position, not only when the text cuts the list. `retry` is the tool and argument to send them
260
- // to ("check_sbom again with purls set to").
261
- export function sendingSentences(g, distinct, readOrder, retry) {
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). #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) {
262
270
  const out = [];
263
271
  if (g.batches > 1) {
264
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.`);
@@ -270,7 +278,7 @@ export function sendingSentences(g, distinct, readOrder, retry) {
270
278
  if (notSent > 0) {
271
279
  const one = notSent === 1;
272
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`;
273
- 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"}: call ${retry} ${one ? "it" : "them"}${g.not_sent_reason === "request_failed" ? "" : " after a minute"}.`);
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"}.`);
274
282
  }
275
283
  return out;
276
284
  }
@@ -312,7 +320,7 @@ export function verdictSentences(d, g) {
312
320
  out.push(`Affected: ${named.join("; ")}${hits.length > NOTE_AFFECTED_MAX ? `; and ${hits.length - NOTE_AFFECTED_MAX} more affected components in data.results` : ""}.`);
313
321
  // Said only when the answer carries fixed_in (an API before #2834 sends none).
314
322
  if (hits.some((r) => (Array.isArray(field(r, "matches")) ? field(r, "matches") : []).some((m) => isObj(m) && "fixed_in" in m))) {
315
- out.push("Each match's fixed_in is the fixed bound of the advisory interval that holds the component's version; null, with fixed_in_reason, where the advisory records no fix for that version.");
323
+ out.push("Each match's fixed_in is the first version above the component's that no affected interval of the advisory holds; null, with fixed_in_reason, where the advisory records no fix for that version.");
316
324
  }
317
325
  }
318
326
  // #2836: rows matched by the source package their purl's upstream qualifier names.
@@ -364,10 +372,11 @@ export const Row = z.looseObject({
364
372
  cve_id: opt(z.string()),
365
373
  kev_listed: opt(z.boolean()).describe("Whether the CVE is in CISA's Known Exploited Vulnerabilities catalog, as EchelonGraph's record holds it."),
366
374
  epss_score: opt(z.number()).describe("The CVE's EPSS score (FIRST), as EchelonGraph's record holds it; null when it has none."),
367
- match_reason: opt(z.string()).describe("Why the advisory applies, with the advisory interval the version falls inside: \"[A, B)\" names the fixed version B, \"[A, B]\" the last affected version B. On an upstream row it ends by naming the source package and the purl name."),
375
+ match_reason: opt(z.string()).describe("Why the advisory applies, with the advisory interval the version falls inside: \"[A, B)\" names that interval's fixed bound B (another interval of the advisory can still hold B; fixed_in is the version past them all), \"[A, B]\" the last affected version B. On an upstream row it ends by naming the source package and the purl name."),
368
376
  interval: opt(z.looseObject({ introduced: opt(z.string()), fixed: opt(z.string()), last_affected: opt(z.string()) })).describe("The advisory interval that holds this component's version (fixed is exclusive, last_affected inclusive); absent when no interval holds it."),
369
- fixed_in: opt(z.string()).describe("The fixed bound of the advisory interval that holds this component's version, always above it. null when that interval records no fix (it ends at last_affected, or has no end) or no interval holds the version, with fixed_in_reason saying which."),
377
+ fixed_in: opt(z.string()).describe("The lowest version above this component's that no affected interval of the CVE's record for the package holds, always above it: the fixed bound of the interval that holds the component's 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."),
370
378
  fixed_in_reason: opt(z.string()).describe("Why fixed_in is null; absent when fixed_in is a version."),
379
+ fixed_in_detail: opt(z.string()).describe("Set when fixed_in is not the fixed bound of the interval that holds the component's version: the overlapping intervals of the same record it moved past, which still hold that bound."),
371
380
  }))),
372
381
  count: opt(z.number()),
373
382
  advisories_considered: opt(z.number()),
@@ -376,6 +385,17 @@ export const Row = z.looseObject({
376
385
  undetermined: opt(z.array(z.looseObject({ cve_id: opt(z.string()) }))),
377
386
  capped: opt(z.boolean()),
378
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."),
379
399
  });
380
400
  // The batch route's answer, as data carries it: its fields, merged over the batches.
381
401
  export const batchDataShape = {