echelongraph-mcp 2.6.2 → 2.6.3

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.
@@ -1,4 +1,5 @@
1
1
  import * as z from "zod";
2
+ import { TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
2
3
  const TOOL = "kev_recent";
3
4
  const PATH = "/api/v1/public/kev/recent";
4
5
  export const KEV_RECENT_MAX_LIMIT = 200;
@@ -6,7 +7,10 @@ const MAX_VENDOR = 128;
6
7
  const MAX_CURSOR = 128;
7
8
  const KEV_RECENT_METHOD = "EchelonGraph's copy of the CISA KEV catalog: CISA's feed is polled every 5 minutes and each entry is written onto its CVE's record; rows are read from those records, newest kev_added_date first.";
8
9
  const KEV_RECENT_COVERAGE = "CISA KEV catalog";
9
- const KEV_RECENT_DESCRIPTION = "The CVEs CISA has added to its Known Exploited Vulnerabilities (KEV) catalog, newest first, from EchelonGraph's copy of that catalog, which polls CISA's feed every 5 minutes. Each row in kev gives cve_id, kev_added_date (CISA's dateAdded), kev_due_date, kev_vendor, kev_product, kev_vuln_name and kev_ransomware (known ransomware-campaign use), EchelonGraph's severity, cvss_v3_score, epss_score, epss_percentile and eg_kev_tier for the CVE, and our_first_seen_kev, when EchelonGraph's poller first recorded the CVE entering the catalog (null where no such record exists). Filter by since and until (YYYY-MM-DD, inclusive, on kev_added_date; an RFC 3339 timestamp, such as the kev_added_date 2024-04-12T00:00:00Z that CVE records carry, is read as the date written in it, with the time and offset dropped, and anything else is refused), ransomware, and vendor (an exact, case-insensitive match on kev_vendor); page with limit (1 to 200, default 50) and cursor, passing back the previous page's next_cursor with the same filters. Rows within one date are ordered by cve_id. total counts the CVEs matching the filters; kev_listed_total counts every CVE EchelonGraph holds as KEV-listed, and catalog.catalog_count is CISA's own count in the catalog last fetched, so a gap between the two is entries not yet in EchelonGraph's CVE table, which no page returns. CISA's requiredAction and shortDescription are not returned. Its structured result carries state (measured), measured_at (our last successful fetch of CISA's feed, null when the API does not give it), method, coverage (the CISA KEV catalog: total, returned, kev_listed_total, catalog_count, limit, has_more), freshness (last_successful_fetch_at, catalog_version, date_released) and notes, with data equal to the API's JSON; the result's last text block repeats it without data (the first text block) and without the note's sentences (the text block before it), with which notes ends.";
10
+ const KEV_RECENT_DESCRIPTION = "The CVEs CISA has added to its Known Exploited Vulnerabilities (KEV) catalog, newest first, from EchelonGraph's copy of that catalog, which polls CISA's feed every 5 minutes. Each row in kev gives cve_id, kev_added_date (CISA's dateAdded), kev_due_date, kev_vendor, kev_product, kev_vuln_name and kev_ransomware (known ransomware-campaign use), EchelonGraph's severity, cvss_v3_score, epss_score, epss_percentile and eg_kev_tier for the CVE, and our_first_seen_kev, when EchelonGraph's poller first recorded the CVE entering the catalog (null where no such record exists). Filter by since and until (YYYY-MM-DD, inclusive, on kev_added_date; an RFC 3339 timestamp, such as the kev_added_date 2024-04-12T00:00:00Z that CVE records carry, is read as the date written in it, with the time and offset dropped, and anything else is refused), ransomware, and vendor (an exact, case-insensitive match on kev_vendor); page with limit (1 to 200, default 50) and cursor, passing back the previous page's next_cursor with the same filters. Rows within one date are ordered by cve_id. total counts the CVEs matching the filters; kev_listed_total counts every CVE EchelonGraph holds as KEV-listed, and catalog.catalog_count is CISA's own count in the catalog last fetched, so a gap between the two is entries not yet in EchelonGraph's CVE table, which no page returns. CISA's requiredAction and shortDescription are not returned. Its structured result carries state (measured), measured_at (our last successful fetch of CISA's feed, null when the API does not give it), method, coverage (the CISA KEV catalog: total, returned, kev_listed_total, catalog_count, limit, has_more), freshness (last_successful_fetch_at, catalog_version, date_released) and notes, with data equal to the API's JSON; the result's last text block repeats it without data (the first text block) and without the note's sentences (the text block before it), with which notes ends." +
11
+ " " +
12
+ TEXT_BUDGET_DESCRIPTION +
13
+ " Cut, each row keeps fewer fields, cve_id, kev_added_date, kev_vendor and kev_ransomware at least, or rows are left out, and next_cursor still continues after the last row of the page, not of the text.";
10
14
  const opt = (t) => t.nullable().optional();
11
15
  const KEVRow = z.looseObject({
12
16
  cve_id: opt(z.string()),
@@ -121,6 +125,25 @@ const str = (o, ...k) => {
121
125
  return typeof v === "string" && v ? v : null;
122
126
  };
123
127
  const realInstant = (s) => s !== null && Date.parse(s) > 0;
128
+ // #2783: a production row is about 520 characters as pretty JSON, so a page of 200 was 105,925
129
+ // characters of text. Past DATA_TEXT_BUDGET each row in the first text block keeps CISA's fields
130
+ // and EchelonGraph's severity, cvss_v3_score and epss_score; then, if the page is still too long,
131
+ // only cve_id, kev_added_date, kev_vendor, kev_product and kev_ransomware, names cut to 40
132
+ // characters; then without kev_product, which fits a page of 200.
133
+ // next_cursor continues after the page's last row, so the note says how to read any row the text
134
+ // leaves out without skipping it.
135
+ function kevText(a) {
136
+ return {
137
+ rows: "kev",
138
+ levels: [
139
+ { keep: ["cve_id", "kev_added_date", "kev_due_date", "kev_vendor", "kev_product", "kev_vuln_name", "kev_ransomware", "severity", "cvss_v3_score", "epss_score"], clip: 200 },
140
+ { keep: ["cve_id", "kev_added_date", "kev_vendor", "kev_product", "kev_ransomware"], clip: 40 },
141
+ { keep: ["cve_id", "kev_added_date", "kev_vendor", "kev_ransomware"], clip: 40 },
142
+ ],
143
+ whole: "get_cve returns any one of these CVEs' records whole.",
144
+ 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.`,
145
+ };
146
+ }
124
147
  async function kevRecent(d, a) {
125
148
  try {
126
149
  const h = headersFor(a);
@@ -178,7 +201,7 @@ async function kevRecent(d, a) {
178
201
  ? "measured_at is EchelonGraph's last successful fetch of CISA's feed, the time this copy of the catalog was last confirmed."
179
202
  : "measured_at is null: the answer does not give EchelonGraph's last successful fetch of CISA's feed.",
180
203
  ],
181
- });
204
+ }, kevText(a));
182
205
  }
183
206
  catch (e) {
184
207
  return d.crashed(TOOL, e);
@@ -23,9 +23,31 @@
23
23
  // the routes are keyed on, refused locally unless they have an identifier's shape, so no free
24
24
  // text reaches a URL through them.
25
25
  //
26
+ // How a search matched (#2728). A term of one or two characters cannot use the API's trigram
27
+ // index, so the API matches it as a WHOLE WORD (a run of letters and digits) and says so with
28
+ // search_match "word"; a longer term is a substring, search_match "substring". And a search
29
+ // counts at most 1,000 matches: past that the API answers total 1000 with total_capped true. The
30
+ // tools relay both in coverage, the description states the whole-word rule, and a capped total is
31
+ // written "1,000+" in the note, never as an exact count.
32
+ //
33
+ // What is held of each vendor (#2729). What EchelonGraph holds of a vendor is not the vendor's
34
+ // whole history: Palo Alto's RSS feed carries only its ~25 newest updates, and MSRC's poller moved
35
+ // past documents it never read, so vendor_advisories_for_cve answered 0 for CVE-2024-3400 and
36
+ // CVE-2024-21412, which both vendors did publish advisories for. core-backend now reads those
37
+ // histories back (vendoradv backfill.go) and serves each vendor's window at
38
+ // /api/v1/public/vendor-advisories/coverage (coverage.go): advisories held, the earliest and latest
39
+ // vendor_published_at among them, and history_backfill. All three tools read it beside their own
40
+ // request and relay it in coverage, and the note says when a CVE's ID year begins before a
41
+ // vendor's earliest held advisory, or a vendor's history is still being read, so an empty answer
42
+ // is never read as "the vendor published none". The window is context, not the answer: when it
43
+ // cannot be read (an API older than the route, a failure), the answer is relayed as before, with
44
+ // the windows null and a note saying they are unknown, never failed. The coverage request carries
45
+ // nothing typed: a fixed path, no query, no search header.
46
+ //
26
47
  // Logic only: the envelope, the failure contract and the API call are index.ts's, handed in as
27
48
  // a VendorAdvisoryKit so this file cannot drift from them.
28
49
  import * as z from "zod";
50
+ import { TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
29
51
  // core-backend vendoradv.SearchHeader.
30
52
  export const VENDOR_ADVISORY_SEARCH_HEADER = "X-EG-Advisory-Search";
31
53
  // handler.go List caps the search at 100 bytes; a longer term would be cut, possibly inside a
@@ -33,6 +55,11 @@ export const VENDOR_ADVISORY_SEARCH_HEADER = "X-EG-Advisory-Search";
33
55
  const SEARCH_MAX_BYTES = 100;
34
56
  // store.go ListAdvisoriesByCVE: LIMIT 20, newest first.
35
57
  const BY_CVE_CAP = 20;
58
+ // #2729: every enabled vendor's window (handler.go CoverageHandler).
59
+ export const VENDOR_COVERAGE_PATH = "/api/v1/public/vendor-advisories/coverage";
60
+ // history_backfill values that mean the vendor's older advisories are not all held yet
61
+ // (backfill.go backfillStatus).
62
+ const BACKFILLING = new Set(["in_progress", "not_started"]);
36
63
  const CVE_ID = /^CVE-\d{4}-\d{4,}$/i;
37
64
  // vendors.slug's CHECK constraint (migration 063).
38
65
  const VENDOR_SLUG = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
@@ -65,6 +92,67 @@ function withdrawnNote(rows) {
65
92
  return "";
66
93
  return ` WITHDRAWN: ${w.length} of these ${rows.length} ${plural(rows.length, "advisory", "advisories")} ${plural(w.length, "was", "were")} withdrawn (rescinded) by ${plural(w.length, "its", "their")} vendor (withdrawn true): ${w.join(", ")}. Report ${plural(w.length, "it", "them")} as withdrawn, not as a current advisory.`;
67
94
  }
95
+ const instantOrNull = (o, k) => {
96
+ const v = field(o, k);
97
+ return realInstant(v) ? v : null;
98
+ };
99
+ // The /coverage answer's rows; undefined when it carries no vendors list. A row without a vendor
100
+ // slug cannot be named, so it is left out.
101
+ function windowsOf(d) {
102
+ const rows = field(d, "vendors");
103
+ if (!Array.isArray(rows))
104
+ return undefined;
105
+ return rows.flatMap((r) => {
106
+ const vendor = str(r, "vendor");
107
+ if (!vendor)
108
+ return [];
109
+ return [
110
+ {
111
+ vendor,
112
+ vendor_display_name: str(r, "vendor_display_name") ?? null,
113
+ advisories: num(r, "advisories") ?? null,
114
+ earliest_vendor_published_at: instantOrNull(r, "earliest_vendor_published_at"),
115
+ latest_vendor_published_at: instantOrNull(r, "latest_vendor_published_at"),
116
+ history_backfill: str(r, "history_backfill") ?? null,
117
+ },
118
+ ];
119
+ });
120
+ }
121
+ const day = (instant) => instant.slice(0, 10);
122
+ const backfilling = (w) => w.history_backfill !== null && BACKFILLING.has(w.history_backfill);
123
+ // What a note says of one window: what is held, and the history read when it is not done.
124
+ const windowItem = (w, heldOnly) => {
125
+ const held = w.earliest_vendor_published_at === null
126
+ ? "none held"
127
+ : `held ${heldOnly ? "only " : ""}from ${day(w.earliest_vendor_published_at)}${w.latest_vendor_published_at ? ` to ${day(w.latest_vendor_published_at)}` : ""}`;
128
+ return `${w.vendor} (${held}${backfilling(w) ? `; history_backfill ${w.history_backfill}` : ""})`;
129
+ };
130
+ // The sentence for windows that could not be read: no "none here" is a finding about a vendor.
131
+ const windowsUnknown = (why) => ` The vendors' coverage windows could not be read (${why}), so this answer cannot say from what date each vendor's advisories are held: no advisory here from a vendor is not a finding that it published none.`;
132
+ // vendor_advisories_for_cve: the vendors with no advisory in the answer that may have published
133
+ // one EchelonGraph does not hold: none held at all, the earliest held dated after 1 January of the
134
+ // year in the CVE ID, or a history still being read.
135
+ function notFullyHeld(windows, rows, year) {
136
+ const answered = new Set(rows.map((r) => str(r, "vendor")).filter((v) => v !== undefined));
137
+ const yearStart = Date.UTC(year, 0, 1);
138
+ return windows.filter((w) => !answered.has(w.vendor) && (w.earliest_vendor_published_at === null || Date.parse(w.earliest_vendor_published_at) > yearStart || backfilling(w)));
139
+ }
140
+ function notFullyHeldNote(cve, year, missing) {
141
+ if (!missing.length)
142
+ return "";
143
+ const k = missing.length;
144
+ const yearStart = Date.UTC(year, 0, 1);
145
+ const items = missing.map((w) => windowItem(w, w.earliest_vendor_published_at !== null && Date.parse(w.earliest_vendor_published_at) > yearStart));
146
+ return ` ${cve} is a ${year} CVE ID, and EchelonGraph may not hold every advisory ${plural(k, "1 vendor", `${k} vendors`)} published for it (vendors_not_fully_held): ${items.join("; ")}. An advisory a vendor published before the earliest one EchelonGraph holds is not held, and while history_backfill is in_progress or not_started the vendor's older advisories are still being read, so no advisory here from ${plural(k, "that vendor", "those vendors")} is not a finding that ${plural(k, "it", "they")} published none.`;
147
+ }
148
+ // search_vendor_advisories and get_vendor_advisory: the vendors whose history is still being read.
149
+ function backfillingNote(windows) {
150
+ const b = windows.filter(backfilling);
151
+ if (!b.length)
152
+ return "";
153
+ const k = b.length;
154
+ return ` The older advisories of ${plural(k, "1 vendor", `${k} vendors`)} are still being read (history_backfill in_progress or not_started): ${b.map((w) => windowItem(w, false)).join("; ")}. EchelonGraph does not yet hold all of ${plural(k, "its", "their")} older advisories, so none found from ${plural(k, "it", "them")} is not a finding that ${plural(k, "it", "they")} published none.`;
155
+ }
68
156
  // ── Output schemas ──
69
157
  const opt = (t) => t.nullable().optional();
70
158
  const DATE_FIELDS = {
@@ -89,6 +177,48 @@ const AdvisoryRow = z.looseObject({
89
177
  // (marketing-site/lib/mcpToolClaims.test.ts) can fold the descriptions to their text.
90
178
  const DATES_DESCRIPTION = "Each advisory carries vendor_published_at (the vendor's date), our_first_seen_at (when EchelonGraph first recorded it) and withdrawn (true: the vendor rescinded it; the note names each such advisory).";
91
179
  const ENVELOPE_DESCRIPTION = "Its structured result carries state (measured), measured_at, method, coverage, freshness (null: the answer carries no poll-completion time) and notes, with data equal to the API's JSON; the result's last text block repeats it without data (the first text block) and without the note's sentences (the text block before it), with which notes ends.";
180
+ // #2729: what a vendor's window is.
181
+ const HISTORY_BACKFILL_DESCRIPTION = "history_backfill: complete (the vendor's published history has been read back as far as its source goes), in_progress or not_started (it is still being read, so the vendor's older advisories are not all held yet), or not_supported (EchelonGraph has no history read for that vendor, so what it holds is what the vendor's feed has carried)";
182
+ const WINDOW_DESCRIPTION = `vendor, advisories (how many EchelonGraph holds from that vendor), earliest_vendor_published_at and latest_vendor_published_at (the earliest and latest vendor_published_at among them, null when none is held) and ${HISTORY_BACKFILL_DESCRIPTION}`;
183
+ const NOT_HELD_DESCRIPTION = "An advisory a vendor published before its earliest_vendor_published_at is not held, so no advisory from a vendor is not a finding that it published none.";
184
+ // #2728: how a search matches, and its capped total.
185
+ const SEARCH_MATCH_DESCRIPTION = "A query of 3 or more characters is matched case-insensitively as a substring of each advisory's title, description, vendor name, vendor_advisory_id, affected_products and cve_ids (search_match substring). A query of 1 or 2 characters matches whole words only (search_match word): it must equal, ignoring case, a whole word (a run of letters and digits) of one of those, so xz finds xz-utils but not xzibit, and a 1- or 2-character query with any other character, such as c#, matches nothing.";
186
+ const TOTAL_CAPPED_DESCRIPTION = "A search counts at most 1,000 matches: past that, total is 1000 and total_capped is true, which means 1,000 or more (the note writes 1,000+), never exactly 1,000; paging past it still works. Without a query, total is the exact count and total_capped is false.";
187
+ const VendorWindowSchema = z.strictObject({
188
+ vendor: z.string().describe("The vendor slug."),
189
+ vendor_display_name: z.string().nullable(),
190
+ advisories: z.number().nullable().describe("How many advisories EchelonGraph holds from this vendor, withdrawn ones not counted."),
191
+ earliest_vendor_published_at: z
192
+ .string()
193
+ .nullable()
194
+ .describe("The earliest vendor_published_at among them; null when none is held. An advisory this vendor published before it is not held."),
195
+ latest_vendor_published_at: z.string().nullable().describe("The latest vendor_published_at among them; null when none is held."),
196
+ // The four values the API sends today, and any later one as a string: a new state must not turn
197
+ // the answer into a failure.
198
+ history_backfill: z
199
+ .union([z.enum(["complete", "in_progress", "not_started", "not_supported"]), z.string()])
200
+ .nullable()
201
+ .describe(`${HISTORY_BACKFILL_DESCRIPTION}.`),
202
+ });
203
+ const WINDOWS = z.array(VendorWindowSchema).nullable().describe(`Each vendor's window in what EchelonGraph holds; null when the windows could not be read. ${NOT_HELD_DESCRIPTION}`);
204
+ // #2783: a production row is up to about 2,000 characters as pretty JSON (its summary, its
205
+ // affected_products and, for a library like log4j, dozens of cve_ids), so search_vendor_advisories
206
+ // "log4j" at limit 50 was 103,147 characters of text. Past DATA_TEXT_BUDGET each row in the first
207
+ // text block keeps what identifies and rates the advisory, every string cut to 200 characters and
208
+ // every list to 20 entries; then fewer fields, strings to 100 and lists to 10.
209
+ const ROW_CUT_LEVELS = [
210
+ { keep: ["vendor", "vendor_display_name", "vendor_advisory_id", "title", "severity", "cvss_v3_score", "cve_ids", "summary", "affected_products", "vendor_published_at", "withdrawn"], clip: 200, cap: 20 },
211
+ { keep: ["vendor", "vendor_advisory_id", "title", "severity", "cve_ids", "vendor_published_at", "withdrawn"], clip: 100, cap: 10 },
212
+ ];
213
+ const WHOLE = "get_vendor_advisory returns any one of these advisories whole, by its vendor and vendor_advisory_id.";
214
+ const FOR_CVE_TEXT = { rows: "advisories", levels: ROW_CUT_LEVELS, whole: WHOLE };
215
+ const searchText = (a) => ({
216
+ rows: "advisories",
217
+ levels: ROW_CUT_LEVELS,
218
+ whole: WHOLE,
219
+ page: (shown, _rows, d) => `To read the rows left out in the text, call search_vendor_advisories again with the same arguments and offset ${(num(d, "offset") ?? a.offset ?? 0) + shown}; a page of ${shown} rows or fewer like these fits the text without leaving rows out.`,
220
+ });
221
+ const ROW_CUT_DESCRIPTION = " Cut, each row keeps vendor, vendor_advisory_id, title, severity and cve_ids at least, its strings and lists shortened";
92
222
  export function registerVendorAdvisoryTools(server, kit) {
93
223
  const FOR_CVE_OUTPUT = kit.envelopeSchema({
94
224
  data: z.looseObject({ cve_id: opt(z.string()), advisories: opt(z.array(AdvisoryRow)), total: opt(z.number()) }),
@@ -96,6 +226,12 @@ export function registerVendorAdvisoryTools(server, kit) {
96
226
  returned: z.number().nullable().describe("The advisories in this answer."),
97
227
  cap: z.number().describe("The most the API returns for one CVE, newest first."),
98
228
  at_cap: z.boolean().nullable().describe("true: the answer is full, so the vendor feeds may hold more advisories for this CVE than it lists."),
229
+ cve_year: z.number().describe("The year in the CVE ID."),
230
+ vendor_windows: WINDOWS,
231
+ vendors_not_fully_held: z
232
+ .array(z.string())
233
+ .nullable()
234
+ .describe("The vendors with no advisory in this answer that may have published one EchelonGraph does not hold: none held, the earliest held dated after 1 January of cve_year, or history_backfill in_progress or not_started. No advisory from them is not a finding that they published none. null when the windows could not be read."),
99
235
  }),
100
236
  freshness: null,
101
237
  });
@@ -109,26 +245,41 @@ export function registerVendorAdvisoryTools(server, kit) {
109
245
  withdrawn_at: opt(z.string()),
110
246
  withdrawn_reason: opt(z.string()),
111
247
  }),
112
- coverage: null,
248
+ coverage: z.strictObject({
249
+ vendor_window: VendorWindowSchema.nullable().describe("This advisory's vendor's window in what EchelonGraph holds; null when the windows could not be read or carry none for this vendor."),
250
+ }),
113
251
  freshness: null,
114
252
  });
115
253
  const SEARCH_OUTPUT = kit.envelopeSchema({
116
254
  data: z.looseObject({
117
255
  advisories: opt(z.array(AdvisoryRow)),
118
- total: opt(z.number()),
256
+ total: opt(z.number()).describe("The matches; when total_capped is true, a lower bound: 1,000 or more."),
119
257
  limit: opt(z.number()),
120
258
  offset: opt(z.number()),
121
259
  search_applied: opt(z.boolean()).describe("Whether a free-text search filtered this answer."),
260
+ search_match: opt(z.string()).describe("How the query was matched: word (a query of 1 or 2 characters, whole words only) or substring; null without a query."),
261
+ total_capped: opt(z.boolean()).describe("true: the search stopped counting at total, so total is a lower bound (1,000+), not the count."),
122
262
  }),
123
263
  coverage: z.strictObject({
124
- total: z.number().nullable(),
264
+ total: z.number().nullable().describe("The API's total; when total_capped is true, a lower bound, not the count."),
265
+ total_capped: z.boolean().nullable().describe("true: total is the API's cap on a search's count, so 1,000 or more match."),
125
266
  returned: z.number().nullable(),
126
267
  limit: z.number().nullable(),
127
268
  offset: z.number().nullable(),
128
269
  search_applied: z.boolean().nullable(),
270
+ search_match: z.enum(["word", "substring"]).nullable().describe("word: a query of 1 or 2 characters, matched as a whole word only; substring: a longer query; null without a query."),
271
+ vendor_windows: WINDOWS,
129
272
  }),
130
273
  freshness: null,
131
274
  });
275
+ // The vendors' windows, read beside a tool's own request. Never a failure of the tool.
276
+ async function readWindows() {
277
+ const r = await kit.api(VENDOR_COVERAGE_PATH);
278
+ if (!r.ok)
279
+ return { ok: false, why: kit.describeFailure(r).replace(/\.$/, "") };
280
+ const windows = windowsOf(r.data);
281
+ return windows ? { ok: true, windows } : { ok: false, why: `the answer of GET ${VENDOR_COVERAGE_PATH} carries no vendors list` };
282
+ }
132
283
  // ── vendor_advisories_for_cve ──
133
284
  async function forCVE(cveArg) {
134
285
  const tool = "vendor_advisories_for_cve";
@@ -138,29 +289,41 @@ export function registerVendorAdvisoryTools(server, kit) {
138
289
  if (!CVE_ID.test(raw))
139
290
  return kit.badInput(tool, "cve_id must be a CVE ID such as CVE-2024-21412");
140
291
  const cve = raw.toUpperCase();
292
+ const year = Number(cve.slice(4, 8));
141
293
  try {
294
+ const windowsRead = readWindows();
142
295
  const r = await kit.api(`/api/v1/public/vendor-advisories/by-cve/${encodeURIComponent(cve)}`);
143
296
  if (!r.ok)
144
297
  return kit.failed(tool, r);
298
+ const w = await windowsRead;
145
299
  const rows = rowsOf(r.data);
146
300
  const returned = rows?.length ?? null;
147
301
  const atCap = returned === null ? null : returned >= BY_CVE_CAP;
302
+ const missing = w.ok ? notFullyHeld(w.windows, rows ?? [], year) : null;
148
303
  const env = {
149
304
  state: "measured",
150
305
  measured_at: null,
151
306
  method: METHOD,
152
- coverage: { returned, cap: BY_CVE_CAP, at_cap: atCap },
307
+ coverage: {
308
+ returned,
309
+ cap: BY_CVE_CAP,
310
+ at_cap: atCap,
311
+ cve_year: year,
312
+ vendor_windows: w.ok ? w.windows : null,
313
+ vendors_not_fully_held: missing ? missing.map((m) => m.vendor) : null,
314
+ },
153
315
  freshness: null,
154
316
  notes: [LIST_MEASURED_AT, NO_FRESHNESS, DATES],
155
317
  };
156
318
  const head = kit.okHead(tool, r.status);
319
+ const held = w.ok ? notFullyHeldNote(cve, year, missing ?? []) : windowsUnknown(w.why);
157
320
  if (returned === 0) {
158
- return kit.succeeded(r.data, `${head} No vendor advisory on record names ${cve}: a measured empty result, EchelonGraph was queried successfully and found nothing (we looked and found nothing). This is not a lookup failure. ${ONLY_POLLED}`, env);
321
+ return kit.succeeded(r.data, `${head} No vendor advisory on record names ${cve}: a measured empty result, EchelonGraph was queried successfully and found nothing (we looked and found nothing). This is not a lookup failure. ${ONLY_POLLED}${held}`, env, FOR_CVE_TEXT);
159
322
  }
160
323
  if (rows === undefined)
161
- return kit.succeeded(r.data, head, env);
324
+ return kit.succeeded(r.data, `${head}${held}`, env, FOR_CVE_TEXT);
162
325
  const cap = atCap ? ` The API returns at most ${BY_CVE_CAP} advisories for one CVE, newest first, and this answer is full (at_cap true), so there may be more.` : "";
163
- return kit.succeeded(r.data, `${head} ${rows.length} vendor ${plural(rows.length, "advisory names", "advisories name")} ${cve}.${cap}${withdrawnNote(rows)} ${ONLY_POLLED}`, env);
326
+ return kit.succeeded(r.data, `${head} ${rows.length} vendor ${plural(rows.length, "advisory names", "advisories name")} ${cve}.${cap}${withdrawnNote(rows)} ${ONLY_POLLED}${held}`, env, FOR_CVE_TEXT);
164
327
  }
165
328
  catch (e) {
166
329
  return kit.crashed(tool, e);
@@ -180,17 +343,21 @@ export function registerVendorAdvisoryTools(server, kit) {
180
343
  if (!ADVISORY_ID.test(id))
181
344
  return kit.badInput(tool, "advisory_id must be the vendor's advisory ID (vendor_advisory_id), at most 200 characters, with no control characters");
182
345
  try {
346
+ const windowsRead = readWindows();
183
347
  const r = await kit.api(`/api/v1/public/vendor-advisories/${encodeURIComponent(vendor)}/${encodeURIComponent(id)}`);
184
348
  if (!r.ok)
185
349
  return kit.failed(tool, r);
350
+ const w = await windowsRead;
186
351
  const firstSeen = field(r.data, "our_first_seen_at");
187
352
  const cveIDs = field(r.data, "cve_ids");
188
353
  const known = field(r.data, "known_cve_ids");
354
+ const slug = str(r.data, "vendor") ?? vendor;
355
+ const window = w.ok ? (w.windows.find((x) => x.vendor === slug) ?? null) : null;
189
356
  const env = {
190
357
  state: "measured",
191
358
  measured_at: realInstant(firstSeen) ? firstSeen : null,
192
359
  method: METHOD,
193
- coverage: null,
360
+ coverage: { vendor_window: window },
194
361
  freshness: null,
195
362
  notes: [
196
363
  realInstant(firstSeen)
@@ -200,14 +367,15 @@ export function registerVendorAdvisoryTools(server, kit) {
200
367
  DATES,
201
368
  ],
202
369
  };
203
- const shown = `${str(r.data, "vendor") ?? vendor}/${str(r.data, "vendor_advisory_id") ?? id}`;
370
+ const shown = `${slug}/${str(r.data, "vendor_advisory_id") ?? id}`;
204
371
  const withdrawn = field(r.data, "withdrawn") === true
205
372
  ? ` WITHDRAWN: the vendor withdrew (rescinded) ${shown} (withdrawn true${realInstant(field(r.data, "withdrawn_at")) ? `, withdrawn_at ${field(r.data, "withdrawn_at")}` : ""}). Report it as withdrawn, not as a current advisory.`
206
373
  : "";
207
374
  const cves = Array.isArray(cveIDs) && Array.isArray(known) && cveIDs.length > known.length
208
375
  ? ` Of the ${cveIDs.length} CVE IDs it lists (cve_ids), ${known.length} ${plural(known.length, "has", "have")} a record in EchelonGraph's CVE feed (known_cve_ids); the others have no record there yet, which does not mean they are not CVEs.`
209
376
  : "";
210
- return kit.succeeded(r.data, `${kit.okHead(tool, r.status)} Returned the advisory ${shown}.${withdrawn}${cves}`, env);
377
+ const held = !w.ok ? windowsUnknown(w.why) : window ? backfillingNote([window]) : "";
378
+ return kit.succeeded(r.data, `${kit.okHead(tool, r.status)} Returned the advisory ${shown}.${withdrawn}${cves}${held}`, env);
211
379
  }
212
380
  catch (e) {
213
381
  return kit.crashed(tool, e);
@@ -234,6 +402,7 @@ export function registerVendorAdvisoryTools(server, kit) {
234
402
  // The term in the header only, never the URL (#1983).
235
403
  const headers = query ? { [VENDOR_ADVISORY_SEARCH_HEADER]: encodeURIComponent(query) } : {};
236
404
  const path = `/api/v1/public/vendor-advisories?${q.toString()}`;
405
+ const windowsRead = readWindows();
237
406
  const r = await kit.api(path, { headers });
238
407
  if (!r.ok)
239
408
  return kit.failed(tool, r);
@@ -247,18 +416,27 @@ export function registerVendorAdvisoryTools(server, kit) {
247
416
  detail: `the answer does not say the search was applied (search_applied is ${applied === undefined ? "absent" : JSON.stringify(applied)}: an API that does not read the ${VENDOR_ADVISORY_SEARCH_HEADER} header answers the unsearched list), so it is not relayed as the search's matches`,
248
417
  });
249
418
  }
419
+ const w = await windowsRead;
420
+ // With a vendor filter, only that vendor's window.
421
+ const windows = w.ok ? w.windows.filter((x) => !vendor || x.vendor === vendor) : null;
250
422
  const rows = rowsOf(r.data);
251
423
  const total = num(r.data, "total");
424
+ const capped = field(r.data, "total_capped");
425
+ const isCapped = capped === true && total !== undefined && total > 0;
426
+ const match = field(r.data, "search_match");
252
427
  const env = {
253
428
  state: "measured",
254
429
  measured_at: null,
255
430
  method: METHOD,
256
431
  coverage: {
257
432
  total: total ?? null,
433
+ total_capped: typeof capped === "boolean" ? capped : null,
258
434
  returned: rows?.length ?? null,
259
435
  limit: num(r.data, "limit") ?? null,
260
436
  offset: num(r.data, "offset") ?? null,
261
437
  search_applied: typeof applied === "boolean" ? applied : null,
438
+ search_match: match === "word" || match === "substring" ? match : null,
439
+ vendor_windows: windows,
262
440
  },
263
441
  freshness: null,
264
442
  notes: [
@@ -270,13 +448,22 @@ export function registerVendorAdvisoryTools(server, kit) {
270
448
  };
271
449
  const head = kit.okHead(tool, r.status);
272
450
  const what = query ? "The search matched" : "The list holds";
451
+ // How the term was matched: a short term is whole words only. The term itself is never written.
452
+ const wordOnly = query && match === "word"
453
+ ? " The query is 1 or 2 characters, so it matched whole words only (search_match word): an advisory matches when the query equals, ignoring case, a whole word (a run of letters and digits) of its title, description, vendor_advisory_id, an affected product, a CVE ID or its vendor's name, never part of a longer word."
454
+ : "";
455
+ const held = !w.ok ? windowsUnknown(w.why) : backfillingNote(windows ?? []);
273
456
  if (total === 0) {
274
- return kit.succeeded(r.data, `${head} ${what} 0 vendor advisories: a measured empty result, EchelonGraph was queried successfully and nothing matched these filters (we looked and found nothing). This is not a lookup failure. ${ONLY_POLLED}`, env);
457
+ return kit.succeeded(r.data, `${head} ${what} 0 vendor advisories: a measured empty result, EchelonGraph was queried successfully and nothing matched these filters (we looked and found nothing). This is not a lookup failure. ${ONLY_POLLED}${wordOnly}${held}`, env, searchText(a));
275
458
  }
276
459
  if (total === undefined)
277
- return kit.succeeded(r.data, head, env);
460
+ return kit.succeeded(r.data, `${head}${wordOnly}${held}`, env, searchText(a));
278
461
  const page = rows === undefined ? "" : `; ${rows.length} returned in this page (offset ${num(r.data, "offset") ?? a.offset ?? 0})`;
279
- return kit.succeeded(r.data, `${head} ${what} ${total} vendor ${plural(total, "advisory", "advisories")}${page}.`, env);
462
+ // A capped total is a lower bound: written "1,000+", never as the count.
463
+ const counted = isCapped
464
+ ? `${total.toLocaleString("en-US")}+ vendor advisories (total_capped true: the API stops counting a search's matches at ${total.toLocaleString("en-US")}, so total is a lower bound, not the count; paging past it still works)`
465
+ : `${total} vendor ${plural(total, "advisory", "advisories")}`;
466
+ return kit.succeeded(r.data, `${head} ${what} ${counted}${page}.${wordOnly}${held}`, env, searchText(a));
280
467
  }
281
468
  catch (e) {
282
469
  return kit.crashed(tool, e);
@@ -284,14 +471,14 @@ export function registerVendorAdvisoryTools(server, kit) {
284
471
  }
285
472
  server.registerTool("vendor_advisories_for_cve", {
286
473
  title: "Vendor advisories for one CVE",
287
- description: `The vendor-published advisories that name one CVE, newest first, at most 20: for each, vendor, vendor_display_name, vendor_advisory_id, title, severity and cvss_v3_score where the vendor gives them. Covers the vendor feeds EchelonGraph polls, for example Microsoft MSRC, Red Hat, Cisco, Palo Alto Networks and GitHub GHSA; an empty answer means none of those names the CVE. ${DATES_DESCRIPTION} Pass a CVE ID like CVE-2024-21412. coverage gives returned, cap and at_cap (true: the answer is full, so there may be more). measured_at is null. ${ENVELOPE_DESCRIPTION}`,
474
+ description: `The vendor-published advisories that name one CVE, newest first, at most 20: for each, vendor, vendor_display_name, vendor_advisory_id, title, severity and cvss_v3_score where the vendor gives them. Covers the vendor feeds EchelonGraph polls, for example Microsoft MSRC, Red Hat, Cisco, Palo Alto Networks and GitHub GHSA; an empty answer means that none of the advisories EchelonGraph holds from those feeds names the CVE, not that no vendor published one (see vendor_windows and vendors_not_fully_held). ${DATES_DESCRIPTION} Pass a CVE ID like CVE-2024-21412. coverage gives returned, cap and at_cap (true: the answer is full, so there may be more); cve_year, the year in the CVE ID; vendor_windows, each vendor's window in what EchelonGraph holds: ${WINDOW_DESCRIPTION}; and vendors_not_fully_held, the vendors with no advisory in the answer of which EchelonGraph holds none, whose earliest held advisory is dated after 1 January of cve_year, or whose history is still being read, which the note names. ${NOT_HELD_DESCRIPTION} vendor_windows and vendors_not_fully_held are null when the windows could not be read, and the note says so. measured_at is null. ${ENVELOPE_DESCRIPTION} ${TEXT_BUDGET_DESCRIPTION}${ROW_CUT_DESCRIPTION}.`,
288
475
  inputSchema: z.object({ cve_id: z.string().describe("a CVE ID, e.g. CVE-2024-21412") }),
289
476
  outputSchema: FOR_CVE_OUTPUT,
290
477
  annotations: kit.annotations,
291
478
  }, async ({ cve_id }) => kit.checked("vendor_advisories_for_cve", FOR_CVE_OUTPUT, await forCVE(cve_id)));
292
479
  server.registerTool("get_vendor_advisory", {
293
480
  title: "Vendor advisory detail",
294
- description: `One vendor advisory in full, by vendor and the vendor's advisory ID (the vendor and vendor_advisory_id fields of a row from search_vendor_advisories or vendor_advisories_for_cve): title, description, severity, cvss_v3_score, cve_ids and known_cve_ids (those with a record in EchelonGraph's CVE feed), affected_products, remediation, references, vendor_modified_at, and withdrawn_at and withdrawn_reason when the vendor withdrew it. ${DATES_DESCRIPTION} measured_at is our_first_seen_at. ${ENVELOPE_DESCRIPTION}`,
481
+ description: `One vendor advisory in full, by vendor and the vendor's advisory ID (the vendor and vendor_advisory_id fields of a row from search_vendor_advisories or vendor_advisories_for_cve): title, description, severity, cvss_v3_score, cve_ids and known_cve_ids (those with a record in EchelonGraph's CVE feed), affected_products, remediation, references, vendor_modified_at, and withdrawn_at and withdrawn_reason when the vendor withdrew it. ${DATES_DESCRIPTION} coverage.vendor_window is that vendor's window in what EchelonGraph holds: ${WINDOW_DESCRIPTION}; null when the windows could not be read or carry none for that vendor. measured_at is our_first_seen_at. ${ENVELOPE_DESCRIPTION} ${TEXT_BUDGET_DESCRIPTION}`,
295
482
  inputSchema: z.object({
296
483
  vendor: z.string().describe("the vendor slug, e.g. microsoft, redhat, github"),
297
484
  advisory_id: z.string().describe("the vendor's advisory ID, e.g. RHSA-2024:1234 or GHSA-xxxx-xxxx-xxxx"),
@@ -301,9 +488,9 @@ export function registerVendorAdvisoryTools(server, kit) {
301
488
  }, async ({ vendor, advisory_id }) => kit.checked("get_vendor_advisory", DETAIL_OUTPUT, await detail(vendor, advisory_id)));
302
489
  server.registerTool("search_vendor_advisories", {
303
490
  title: "Search vendor advisories",
304
- description: `Search or list vendor-published advisories, newest first. query is matched case-insensitively as a substring of each advisory's title, description, vendor name, vendor_advisory_id, affected_products and cve_ids; filter by vendor (slug), by severity band, and by whether the advisory names any CVE ID. Advisories their vendor withdrew are left out. Returns advisories, each with vendor, vendor_advisory_id, title, severity, cvss_v3_score, cve_ids, summary and affected_products where present, and the answer's total, limit, offset and search_applied. ${DATES_DESCRIPTION} The query is sent in a request header, never in the URL. coverage repeats total, limit, offset and search_applied, and gives returned, the rows in this page. measured_at is null. ${ENVELOPE_DESCRIPTION}`,
491
+ description: `Search or list vendor-published advisories, newest first. ${SEARCH_MATCH_DESCRIPTION} Filter by vendor (slug), by severity band, and by whether the advisory names any CVE ID. Advisories their vendor withdrew are left out. Returns advisories, each with vendor, vendor_advisory_id, title, severity, cvss_v3_score, cve_ids, summary and affected_products where present, and the answer's total, total_capped, limit, offset, search_applied and search_match. ${TOTAL_CAPPED_DESCRIPTION} ${DATES_DESCRIPTION} The query is sent in a request header, never in the URL. coverage repeats total, total_capped, limit, offset, search_applied and search_match, and gives returned, the rows in this page, and vendor_windows, each vendor's window in what EchelonGraph holds (only that vendor's when vendor is given): ${WINDOW_DESCRIPTION}. ${NOT_HELD_DESCRIPTION} measured_at is null. ${ENVELOPE_DESCRIPTION} ${TEXT_BUDGET_DESCRIPTION}${ROW_CUT_DESCRIPTION}, or rows are left out and the note gives the offset to call next.`,
305
492
  inputSchema: z.object({
306
- query: z.string().optional().describe("free text, at most 100 bytes: a product, an advisory ID, a CVE ID or a keyword, e.g. 'exchange server'"),
493
+ query: z.string().optional().describe("free text, at most 100 bytes: a product, an advisory ID, a CVE ID or a keyword, e.g. 'exchange server'; 1 or 2 characters match whole words only"),
307
494
  vendor: z.string().optional().describe("a vendor slug, e.g. microsoft, redhat, github"),
308
495
  severity: z.enum(["Critical", "High", "Medium", "Low"]).optional().describe("one severity band"),
309
496
  has_cve: z.boolean().optional().describe("true: only advisories naming a CVE; false: only those naming none"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "echelongraph-mcp",
3
- "version": "2.6.2",
3
+ "version": "2.6.3",
4
4
  "mcpName": "io.echelongraph/echelongraph-mcp",
5
5
  "description": "MCP server for CVE and vulnerability lookups: CVE records with EchelonGraph multi-source scores, CISA KEV, EPSS history, CWE weaknesses, public exploits, version and SBOM checks against the vulnerability advisory corpus, vendor security advisories, and a per-CVE internet-exposure footprint derived from Shodan data. Shodan data is owned by Shodan, which holds its copyright (© Shodan). Free and keyless, for Claude, Cursor and any MCP client.",
6
6
  "type": "module",
package/server.json CHANGED
@@ -17,7 +17,7 @@
17
17
  ]
18
18
  }
19
19
  ],
20
- "version": "2.6.2",
20
+ "version": "2.6.3",
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": "2.6.2",
31
+ "version": "2.6.3",
32
32
  "transport": {
33
33
  "type": "stdio"
34
34
  },