echelongraph-mcp 2.6.2 → 2.6.5
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 +254 -0
- package/README.md +169 -35
- package/dist/http.js +111 -33
- package/dist/httpPolicy.js +160 -4
- package/dist/index.js +240 -66
- package/dist/prompts.js +60 -10
- package/dist/resources.js +62 -11
- package/dist/runtime.js +26 -4
- package/dist/textBudget.js +254 -0
- package/dist/tools/check_affected.js +64 -2
- package/dist/tools/check_sbom.js +105 -9
- package/dist/tools/cve_intel.js +2 -1
- package/dist/tools/epss_history.js +2 -1
- package/dist/tools/get_cwe.js +11 -2
- package/dist/tools/kev_recent.js +26 -2
- package/dist/tools/vendor_advisories.js +206 -17
- package/package.json +2 -2
- package/server.json +2 -2
|
@@ -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,50 @@ 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
|
+
// get_vendor_advisory's own first text block is cut too past the budget (#2802): only its
|
|
214
|
+
// structuredContent.data is one advisory whole.
|
|
215
|
+
const WHOLE = "get_vendor_advisory returns any one of these advisories by its vendor and vendor_advisory_id, whole in its structuredContent.data, and in its first text block with every field (past 30,000 characters, each list cut to its first entries).";
|
|
216
|
+
const FOR_CVE_TEXT = { rows: "advisories", levels: ROW_CUT_LEVELS, whole: WHOLE };
|
|
217
|
+
const searchText = (a) => ({
|
|
218
|
+
rows: "advisories",
|
|
219
|
+
levels: ROW_CUT_LEVELS,
|
|
220
|
+
whole: WHOLE,
|
|
221
|
+
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.`,
|
|
222
|
+
});
|
|
223
|
+
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
224
|
export function registerVendorAdvisoryTools(server, kit) {
|
|
93
225
|
const FOR_CVE_OUTPUT = kit.envelopeSchema({
|
|
94
226
|
data: z.looseObject({ cve_id: opt(z.string()), advisories: opt(z.array(AdvisoryRow)), total: opt(z.number()) }),
|
|
@@ -96,6 +228,12 @@ export function registerVendorAdvisoryTools(server, kit) {
|
|
|
96
228
|
returned: z.number().nullable().describe("The advisories in this answer."),
|
|
97
229
|
cap: z.number().describe("The most the API returns for one CVE, newest first."),
|
|
98
230
|
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."),
|
|
231
|
+
cve_year: z.number().describe("The year in the CVE ID."),
|
|
232
|
+
vendor_windows: WINDOWS,
|
|
233
|
+
vendors_not_fully_held: z
|
|
234
|
+
.array(z.string())
|
|
235
|
+
.nullable()
|
|
236
|
+
.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
237
|
}),
|
|
100
238
|
freshness: null,
|
|
101
239
|
});
|
|
@@ -109,26 +247,41 @@ export function registerVendorAdvisoryTools(server, kit) {
|
|
|
109
247
|
withdrawn_at: opt(z.string()),
|
|
110
248
|
withdrawn_reason: opt(z.string()),
|
|
111
249
|
}),
|
|
112
|
-
coverage:
|
|
250
|
+
coverage: z.strictObject({
|
|
251
|
+
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."),
|
|
252
|
+
}),
|
|
113
253
|
freshness: null,
|
|
114
254
|
});
|
|
115
255
|
const SEARCH_OUTPUT = kit.envelopeSchema({
|
|
116
256
|
data: z.looseObject({
|
|
117
257
|
advisories: opt(z.array(AdvisoryRow)),
|
|
118
|
-
total: opt(z.number()),
|
|
258
|
+
total: opt(z.number()).describe("The matches; when total_capped is true, a lower bound: 1,000 or more."),
|
|
119
259
|
limit: opt(z.number()),
|
|
120
260
|
offset: opt(z.number()),
|
|
121
261
|
search_applied: opt(z.boolean()).describe("Whether a free-text search filtered this answer."),
|
|
262
|
+
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."),
|
|
263
|
+
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
264
|
}),
|
|
123
265
|
coverage: z.strictObject({
|
|
124
|
-
total: z.number().nullable(),
|
|
266
|
+
total: z.number().nullable().describe("The API's total; when total_capped is true, a lower bound, not the count."),
|
|
267
|
+
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
268
|
returned: z.number().nullable(),
|
|
126
269
|
limit: z.number().nullable(),
|
|
127
270
|
offset: z.number().nullable(),
|
|
128
271
|
search_applied: z.boolean().nullable(),
|
|
272
|
+
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."),
|
|
273
|
+
vendor_windows: WINDOWS,
|
|
129
274
|
}),
|
|
130
275
|
freshness: null,
|
|
131
276
|
});
|
|
277
|
+
// The vendors' windows, read beside a tool's own request. Never a failure of the tool.
|
|
278
|
+
async function readWindows() {
|
|
279
|
+
const r = await kit.api(VENDOR_COVERAGE_PATH);
|
|
280
|
+
if (!r.ok)
|
|
281
|
+
return { ok: false, why: kit.describeFailure(r).replace(/\.$/, "") };
|
|
282
|
+
const windows = windowsOf(r.data);
|
|
283
|
+
return windows ? { ok: true, windows } : { ok: false, why: `the answer of GET ${VENDOR_COVERAGE_PATH} carries no vendors list` };
|
|
284
|
+
}
|
|
132
285
|
// ── vendor_advisories_for_cve ──
|
|
133
286
|
async function forCVE(cveArg) {
|
|
134
287
|
const tool = "vendor_advisories_for_cve";
|
|
@@ -138,29 +291,41 @@ export function registerVendorAdvisoryTools(server, kit) {
|
|
|
138
291
|
if (!CVE_ID.test(raw))
|
|
139
292
|
return kit.badInput(tool, "cve_id must be a CVE ID such as CVE-2024-21412");
|
|
140
293
|
const cve = raw.toUpperCase();
|
|
294
|
+
const year = Number(cve.slice(4, 8));
|
|
141
295
|
try {
|
|
296
|
+
const windowsRead = readWindows();
|
|
142
297
|
const r = await kit.api(`/api/v1/public/vendor-advisories/by-cve/${encodeURIComponent(cve)}`);
|
|
143
298
|
if (!r.ok)
|
|
144
299
|
return kit.failed(tool, r);
|
|
300
|
+
const w = await windowsRead;
|
|
145
301
|
const rows = rowsOf(r.data);
|
|
146
302
|
const returned = rows?.length ?? null;
|
|
147
303
|
const atCap = returned === null ? null : returned >= BY_CVE_CAP;
|
|
304
|
+
const missing = w.ok ? notFullyHeld(w.windows, rows ?? [], year) : null;
|
|
148
305
|
const env = {
|
|
149
306
|
state: "measured",
|
|
150
307
|
measured_at: null,
|
|
151
308
|
method: METHOD,
|
|
152
|
-
coverage: {
|
|
309
|
+
coverage: {
|
|
310
|
+
returned,
|
|
311
|
+
cap: BY_CVE_CAP,
|
|
312
|
+
at_cap: atCap,
|
|
313
|
+
cve_year: year,
|
|
314
|
+
vendor_windows: w.ok ? w.windows : null,
|
|
315
|
+
vendors_not_fully_held: missing ? missing.map((m) => m.vendor) : null,
|
|
316
|
+
},
|
|
153
317
|
freshness: null,
|
|
154
318
|
notes: [LIST_MEASURED_AT, NO_FRESHNESS, DATES],
|
|
155
319
|
};
|
|
156
320
|
const head = kit.okHead(tool, r.status);
|
|
321
|
+
const held = w.ok ? notFullyHeldNote(cve, year, missing ?? []) : windowsUnknown(w.why);
|
|
157
322
|
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);
|
|
323
|
+
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
324
|
}
|
|
160
325
|
if (rows === undefined)
|
|
161
|
-
return kit.succeeded(r.data, head,
|
|
326
|
+
return kit.succeeded(r.data, `${head}${held}`, env, FOR_CVE_TEXT);
|
|
162
327
|
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);
|
|
328
|
+
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
329
|
}
|
|
165
330
|
catch (e) {
|
|
166
331
|
return kit.crashed(tool, e);
|
|
@@ -180,17 +345,21 @@ export function registerVendorAdvisoryTools(server, kit) {
|
|
|
180
345
|
if (!ADVISORY_ID.test(id))
|
|
181
346
|
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
347
|
try {
|
|
348
|
+
const windowsRead = readWindows();
|
|
183
349
|
const r = await kit.api(`/api/v1/public/vendor-advisories/${encodeURIComponent(vendor)}/${encodeURIComponent(id)}`);
|
|
184
350
|
if (!r.ok)
|
|
185
351
|
return kit.failed(tool, r);
|
|
352
|
+
const w = await windowsRead;
|
|
186
353
|
const firstSeen = field(r.data, "our_first_seen_at");
|
|
187
354
|
const cveIDs = field(r.data, "cve_ids");
|
|
188
355
|
const known = field(r.data, "known_cve_ids");
|
|
356
|
+
const slug = str(r.data, "vendor") ?? vendor;
|
|
357
|
+
const window = w.ok ? (w.windows.find((x) => x.vendor === slug) ?? null) : null;
|
|
189
358
|
const env = {
|
|
190
359
|
state: "measured",
|
|
191
360
|
measured_at: realInstant(firstSeen) ? firstSeen : null,
|
|
192
361
|
method: METHOD,
|
|
193
|
-
coverage:
|
|
362
|
+
coverage: { vendor_window: window },
|
|
194
363
|
freshness: null,
|
|
195
364
|
notes: [
|
|
196
365
|
realInstant(firstSeen)
|
|
@@ -200,14 +369,15 @@ export function registerVendorAdvisoryTools(server, kit) {
|
|
|
200
369
|
DATES,
|
|
201
370
|
],
|
|
202
371
|
};
|
|
203
|
-
const shown = `${
|
|
372
|
+
const shown = `${slug}/${str(r.data, "vendor_advisory_id") ?? id}`;
|
|
204
373
|
const withdrawn = field(r.data, "withdrawn") === true
|
|
205
374
|
? ` 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
375
|
: "";
|
|
207
376
|
const cves = Array.isArray(cveIDs) && Array.isArray(known) && cveIDs.length > known.length
|
|
208
377
|
? ` 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
378
|
: "";
|
|
210
|
-
|
|
379
|
+
const held = !w.ok ? windowsUnknown(w.why) : window ? backfillingNote([window]) : "";
|
|
380
|
+
return kit.succeeded(r.data, `${kit.okHead(tool, r.status)} Returned the advisory ${shown}.${withdrawn}${cves}${held}`, env);
|
|
211
381
|
}
|
|
212
382
|
catch (e) {
|
|
213
383
|
return kit.crashed(tool, e);
|
|
@@ -234,6 +404,7 @@ export function registerVendorAdvisoryTools(server, kit) {
|
|
|
234
404
|
// The term in the header only, never the URL (#1983).
|
|
235
405
|
const headers = query ? { [VENDOR_ADVISORY_SEARCH_HEADER]: encodeURIComponent(query) } : {};
|
|
236
406
|
const path = `/api/v1/public/vendor-advisories?${q.toString()}`;
|
|
407
|
+
const windowsRead = readWindows();
|
|
237
408
|
const r = await kit.api(path, { headers });
|
|
238
409
|
if (!r.ok)
|
|
239
410
|
return kit.failed(tool, r);
|
|
@@ -247,18 +418,27 @@ export function registerVendorAdvisoryTools(server, kit) {
|
|
|
247
418
|
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
419
|
});
|
|
249
420
|
}
|
|
421
|
+
const w = await windowsRead;
|
|
422
|
+
// With a vendor filter, only that vendor's window.
|
|
423
|
+
const windows = w.ok ? w.windows.filter((x) => !vendor || x.vendor === vendor) : null;
|
|
250
424
|
const rows = rowsOf(r.data);
|
|
251
425
|
const total = num(r.data, "total");
|
|
426
|
+
const capped = field(r.data, "total_capped");
|
|
427
|
+
const isCapped = capped === true && total !== undefined && total > 0;
|
|
428
|
+
const match = field(r.data, "search_match");
|
|
252
429
|
const env = {
|
|
253
430
|
state: "measured",
|
|
254
431
|
measured_at: null,
|
|
255
432
|
method: METHOD,
|
|
256
433
|
coverage: {
|
|
257
434
|
total: total ?? null,
|
|
435
|
+
total_capped: typeof capped === "boolean" ? capped : null,
|
|
258
436
|
returned: rows?.length ?? null,
|
|
259
437
|
limit: num(r.data, "limit") ?? null,
|
|
260
438
|
offset: num(r.data, "offset") ?? null,
|
|
261
439
|
search_applied: typeof applied === "boolean" ? applied : null,
|
|
440
|
+
search_match: match === "word" || match === "substring" ? match : null,
|
|
441
|
+
vendor_windows: windows,
|
|
262
442
|
},
|
|
263
443
|
freshness: null,
|
|
264
444
|
notes: [
|
|
@@ -270,13 +450,22 @@ export function registerVendorAdvisoryTools(server, kit) {
|
|
|
270
450
|
};
|
|
271
451
|
const head = kit.okHead(tool, r.status);
|
|
272
452
|
const what = query ? "The search matched" : "The list holds";
|
|
453
|
+
// How the term was matched: a short term is whole words only. The term itself is never written.
|
|
454
|
+
const wordOnly = query && match === "word"
|
|
455
|
+
? " 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."
|
|
456
|
+
: "";
|
|
457
|
+
const held = !w.ok ? windowsUnknown(w.why) : backfillingNote(windows ?? []);
|
|
273
458
|
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);
|
|
459
|
+
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
460
|
}
|
|
276
461
|
if (total === undefined)
|
|
277
|
-
return kit.succeeded(r.data, head,
|
|
462
|
+
return kit.succeeded(r.data, `${head}${wordOnly}${held}`, env, searchText(a));
|
|
278
463
|
const page = rows === undefined ? "" : `; ${rows.length} returned in this page (offset ${num(r.data, "offset") ?? a.offset ?? 0})`;
|
|
279
|
-
|
|
464
|
+
// A capped total is a lower bound: written "1,000+", never as the count.
|
|
465
|
+
const counted = isCapped
|
|
466
|
+
? `${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)`
|
|
467
|
+
: `${total} vendor ${plural(total, "advisory", "advisories")}`;
|
|
468
|
+
return kit.succeeded(r.data, `${head} ${what} ${counted}${page}.${wordOnly}${held}`, env, searchText(a));
|
|
280
469
|
}
|
|
281
470
|
catch (e) {
|
|
282
471
|
return kit.crashed(tool, e);
|
|
@@ -284,14 +473,14 @@ export function registerVendorAdvisoryTools(server, kit) {
|
|
|
284
473
|
}
|
|
285
474
|
server.registerTool("vendor_advisories_for_cve", {
|
|
286
475
|
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}
|
|
476
|
+
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
477
|
inputSchema: z.object({ cve_id: z.string().describe("a CVE ID, e.g. CVE-2024-21412") }),
|
|
289
478
|
outputSchema: FOR_CVE_OUTPUT,
|
|
290
479
|
annotations: kit.annotations,
|
|
291
480
|
}, async ({ cve_id }) => kit.checked("vendor_advisories_for_cve", FOR_CVE_OUTPUT, await forCVE(cve_id)));
|
|
292
481
|
server.registerTool("get_vendor_advisory", {
|
|
293
482
|
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}`,
|
|
483
|
+
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
484
|
inputSchema: z.object({
|
|
296
485
|
vendor: z.string().describe("the vendor slug, e.g. microsoft, redhat, github"),
|
|
297
486
|
advisory_id: z.string().describe("the vendor's advisory ID, e.g. RHSA-2024:1234 or GHSA-xxxx-xxxx-xxxx"),
|
|
@@ -301,9 +490,9 @@ export function registerVendorAdvisoryTools(server, kit) {
|
|
|
301
490
|
}, async ({ vendor, advisory_id }) => kit.checked("get_vendor_advisory", DETAIL_OUTPUT, await detail(vendor, advisory_id)));
|
|
302
491
|
server.registerTool("search_vendor_advisories", {
|
|
303
492
|
title: "Search vendor advisories",
|
|
304
|
-
description: `Search or list vendor-published advisories, newest first.
|
|
493
|
+
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
494
|
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'"),
|
|
495
|
+
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
496
|
vendor: z.string().optional().describe("a vendor slug, e.g. microsoft, redhat, github"),
|
|
308
497
|
severity: z.enum(["Critical", "High", "Medium", "Low"]).optional().describe("one severity band"),
|
|
309
498
|
has_cve: z.boolean().optional().describe("true: only advisories naming a CVE; false: only those naming none"),
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "echelongraph-mcp",
|
|
3
|
-
"version": "2.6.
|
|
3
|
+
"version": "2.6.5",
|
|
4
4
|
"mcpName": "io.echelongraph/echelongraph-mcp",
|
|
5
|
-
"description": "MCP server for CVE
|
|
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",
|
|
7
7
|
"bin": {
|
|
8
8
|
"echelongraph-mcp": "dist/index.js"
|
package/server.json
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
]
|
|
18
18
|
}
|
|
19
19
|
],
|
|
20
|
-
"version": "2.6.
|
|
20
|
+
"version": "2.6.5",
|
|
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.
|
|
31
|
+
"version": "2.6.5",
|
|
32
32
|
"transport": {
|
|
33
33
|
"type": "stdio"
|
|
34
34
|
},
|