echelongraph-mcp 3.0.0 → 3.1.0
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 +695 -2
- package/README.md +85 -74
- package/dist/fixStatus.js +4 -2
- package/dist/http.js +73 -81
- package/dist/httpPolicy.js +213 -11
- package/dist/index.js +516 -52
- package/dist/logLines.js +86 -0
- package/dist/prompts.js +126 -21
- package/dist/readBody.js +140 -0
- package/dist/requiredText.js +38 -3
- package/dist/resources.js +3 -3
- package/dist/runtime.js +66 -1
- package/dist/schemaRefusal.js +138 -0
- package/dist/textBudget.js +1082 -78
- package/dist/tools/check_affected.js +23 -7
- package/dist/tools/check_sbom.js +287 -8
- package/dist/tools/cve_intel.js +163 -8
- package/dist/tools/cve_remediation.js +174 -18
- package/dist/tools/epss_history.js +45 -6
- package/dist/tools/kev_recent.js +4 -1
- package/dist/tools/match_batch.js +78 -15
- package/dist/tools/packageRecords.js +58 -0
- package/dist/tools/scan_manifest.js +26 -7
- package/dist/tools/vendor_advisories.js +40 -8
- package/docs/exposure-radar.md +4 -0
- package/docs/rate-limits.md +6 -3
- package/docs/structured-results.md +15 -3
- package/docs/tools.md +149 -12
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -27,10 +27,11 @@ const ApiCoverage = (instant) => z.looseObject({
|
|
|
27
27
|
first_day: opt(Day).describe("The earliest score date in the run log."),
|
|
28
28
|
last_day: opt(Day).describe("The latest score date in the run log."),
|
|
29
29
|
days_held: opt(z.number().int().min(0)).describe("Score dates from first_day to last_day that a refresh applied completely."),
|
|
30
|
-
days_partial: opt(z.array(Day)).describe("Score dates
|
|
30
|
+
days_partial: opt(z.array(Day)).describe("Score dates no refresh completed and one failed part-way: some CVEs hold that day, some do not. Also in days_in_progress while a retry of it has not finished."),
|
|
31
31
|
days_missing: opt(z.array(Day)).describe("Score dates from first_day to last_day that no refresh applied."),
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
days_in_progress: opt(z.array(Day)).describe("Score dates no refresh completed and one that has not finished is applying: some CVEs hold that day so far. Not counted held or missing; also in days_partial when an earlier refresh of it failed part-way."),
|
|
33
|
+
runs_undated: opt(z.number().int().min(0)).describe("Logged refreshes with no score date: the days they applied are not counted held, partial or in progress, and their points carry no score_date."),
|
|
34
|
+
refresh_runs_truncated: opt(z.boolean()).describe("True when only the newest refreshes of the run log were read, so the earliest score dates are not listed."),
|
|
34
35
|
});
|
|
35
36
|
const data = (instant) => z.looseObject({
|
|
36
37
|
cve_id: z.string(),
|
|
@@ -62,13 +63,28 @@ export function epssHistoryOutput(kit) {
|
|
|
62
63
|
first_day: z.string().nullable().describe("The earliest FIRST score date the run log lists; null when it lists none."),
|
|
63
64
|
last_day: z.string().nullable(),
|
|
64
65
|
days_held: z.number().int().min(0).nullable().describe("Score dates from first_day to last_day the record holds; null when none are listed."),
|
|
65
|
-
days_partial: z.array(z.string()).describe("Score dates held for some CVEs only."),
|
|
66
|
+
days_partial: z.array(z.string()).describe("Score dates held for some CVEs only, because their refreshes failed part-way."),
|
|
66
67
|
days_missing: z.array(z.string()).describe("Score dates from first_day to last_day the record does not hold."),
|
|
68
|
+
days_in_progress: z
|
|
69
|
+
.array(z.string())
|
|
70
|
+
.nullable()
|
|
71
|
+
.describe("Score dates a refresh that has not finished is applying (#3143): held for some CVEs so far. A date an earlier refresh failed part-way on is in days_partial too. Null when the API did not say."),
|
|
72
|
+
runs_undated: z
|
|
73
|
+
.number()
|
|
74
|
+
.int()
|
|
75
|
+
.min(0)
|
|
76
|
+
.nullable()
|
|
77
|
+
.describe("Logged refreshes with no score date (#3143): the days they applied are not counted, so a day in days_missing may be held for the CVEs they changed, and their points carry no score_date. Null when the API did not say."),
|
|
78
|
+
refresh_runs_truncated: z
|
|
79
|
+
.boolean()
|
|
80
|
+
.nullable()
|
|
81
|
+
.describe("True when only the newest refreshes of the run log were read, so the earliest score dates are not listed; null when the API did not say."),
|
|
67
82
|
}),
|
|
68
83
|
freshness: null,
|
|
69
84
|
});
|
|
70
85
|
}
|
|
71
86
|
const isNum = (v) => typeof v === "number" && Number.isFinite(v);
|
|
87
|
+
const isCount = (v) => Number.isInteger(v) && v >= 0;
|
|
72
88
|
const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
73
89
|
const realInstant = (v) => typeof v === "string" && Date.parse(v) > 0;
|
|
74
90
|
const isDay = (v) => typeof v === "string" && /^\d{4}-\d{2}-\d{2}$/.test(v);
|
|
@@ -96,7 +112,10 @@ function completeness(d, points) {
|
|
|
96
112
|
daysHeld: firstDay && isNum(cov.days_held) ? cov.days_held : undefined,
|
|
97
113
|
daysPartial: dayList(cov.days_partial),
|
|
98
114
|
daysMissing: dayList(cov.days_missing),
|
|
115
|
+
daysInProgress: Array.isArray(cov.days_in_progress) ? dayList(cov.days_in_progress) : undefined,
|
|
99
116
|
runLogSince: realInstant(cov.run_log_since) ? cov.run_log_since : undefined,
|
|
117
|
+
runsUndated: isCount(cov.runs_undated) ? cov.runs_undated : undefined,
|
|
118
|
+
runsTruncated: typeof cov.refresh_runs_truncated === "boolean" ? cov.refresh_runs_truncated : undefined,
|
|
100
119
|
hasCoverage: isObj(d.coverage),
|
|
101
120
|
};
|
|
102
121
|
}
|
|
@@ -146,12 +165,26 @@ function note(head, id, d, c) {
|
|
|
146
165
|
const held = c.daysHeld === undefined ? "" : `, ${c.daysHeld} held`;
|
|
147
166
|
const missing = c.daysMissing.length ? `; not held: ${sayDays(c.daysMissing, "days_missing")}` : "; none missing";
|
|
148
167
|
const partial = c.daysPartial.length ? `; held for some CVEs only (a refresh failed part-way): ${sayDays(c.daysPartial, "days_partial")}` : "";
|
|
168
|
+
// #3143: never "nothing failed". A retry of a date whose earlier refresh failed part-way is in
|
|
169
|
+
// days_partial too, so it is named in both clauses, each true of it.
|
|
170
|
+
const applying = c.daysInProgress?.length
|
|
171
|
+
? `; being applied by a refresh that has not finished, so held for some CVEs so far: ${sayDays(c.daysInProgress, "days_in_progress")}`
|
|
172
|
+
: "";
|
|
149
173
|
const since = c.runLogSince ? ` (run log since ${c.runLogSince}; earlier dates are not listed)` : "";
|
|
150
|
-
s.push(`FIRST score dates in EchelonGraph's run log${since}: ${c.firstDay} to ${c.lastDay}${held}${missing}${partial}.`);
|
|
174
|
+
s.push(`FIRST score dates in EchelonGraph's run log${since}: ${c.firstDay} to ${c.lastDay}${held}${missing}${partial}${applying}.`);
|
|
151
175
|
}
|
|
152
176
|
else if (c.hasCoverage) {
|
|
153
177
|
s.push("The run log lists no FIRST score date yet, so this answer does not say which days the record holds, and no point carries a score_date.");
|
|
154
178
|
}
|
|
179
|
+
// #3143: said only when non-zero; coverage carries the value either way. "Not counted": in no
|
|
180
|
+
// list, held, partial or in progress.
|
|
181
|
+
if (c.runsUndated) {
|
|
182
|
+
const one = c.runsUndated === 1;
|
|
183
|
+
s.push(`${c.runsUndated} logged refresh${one ? " has" : "es have"} no FIRST score date (coverage.runs_undated: ${one ? "its file named none, or it" : "their files named none, or they"} stopped before recording it). The day${one ? " it" : "s they"} applied ${one ? "is" : "are"} not counted, so a day listed missing, or not listed, may be held for the CVEs ${one ? "it" : "they"} changed, and the points ${one ? "it" : "they"} wrote carry no score_date.`);
|
|
184
|
+
}
|
|
185
|
+
if (c.runsTruncated) {
|
|
186
|
+
s.push("refresh_runs_truncated is true: only the newest refreshes of the run log were read, so first_day is not where the run log starts, and earlier score dates are not listed.");
|
|
187
|
+
}
|
|
155
188
|
if (d.points_truncated === true)
|
|
156
189
|
s.push("points_truncated is true: only the newest records were read, so the earliest changes are missing from this series.");
|
|
157
190
|
if (d.latest_point_matches_current === false) {
|
|
@@ -193,6 +226,9 @@ export async function epssHistory(kit, cve_id) {
|
|
|
193
226
|
days_held: c.daysHeld ?? null,
|
|
194
227
|
days_partial: c.daysPartial,
|
|
195
228
|
days_missing: c.daysMissing,
|
|
229
|
+
days_in_progress: c.daysInProgress ?? null,
|
|
230
|
+
runs_undated: c.runsUndated ?? null,
|
|
231
|
+
refresh_runs_truncated: c.runsTruncated ?? null,
|
|
196
232
|
},
|
|
197
233
|
freshness: null,
|
|
198
234
|
notes: [
|
|
@@ -208,8 +244,11 @@ export async function epssHistory(kit, cve_id) {
|
|
|
208
244
|
return kit.crashed(TOOL, e);
|
|
209
245
|
}
|
|
210
246
|
}
|
|
247
|
+
// One output schema per process, not per request (3.0.2; index.ts envelopeSchema says why).
|
|
248
|
+
const OUTPUTS = new WeakMap();
|
|
211
249
|
export function registerEPSSHistory(server, kit) {
|
|
212
|
-
const output = epssHistoryOutput(kit);
|
|
250
|
+
const output = OUTPUTS.get(kit) ?? epssHistoryOutput(kit);
|
|
251
|
+
OUTPUTS.set(kit, output);
|
|
213
252
|
server.registerTool(TOOL, {
|
|
214
253
|
title: "EPSS change history for one CVE",
|
|
215
254
|
description: EPSS_HISTORY_DESCRIPTION,
|
package/dist/tools/kev_recent.js
CHANGED
|
@@ -223,9 +223,12 @@ async function kevRecent(d, a) {
|
|
|
223
223
|
return d.crashed(TOOL, e);
|
|
224
224
|
}
|
|
225
225
|
}
|
|
226
|
+
// One output schema per process, not per request (3.0.2; index.ts envelopeSchema says why).
|
|
227
|
+
const OUTPUTS = new WeakMap();
|
|
226
228
|
// Registers the tool on a server built by index.ts's createServer.
|
|
227
229
|
export function registerKevRecent(server, d) {
|
|
228
|
-
const output = d.envelopeSchema({ data: Data, coverage: Coverage, freshness: Freshness });
|
|
230
|
+
const output = OUTPUTS.get(d.envelopeSchema) ?? d.envelopeSchema({ data: Data, coverage: Coverage, freshness: Freshness });
|
|
231
|
+
OUTPUTS.set(d.envelopeSchema, output);
|
|
229
232
|
server.registerTool("kev_recent", {
|
|
230
233
|
title: "Recent CISA KEV additions",
|
|
231
234
|
description: KEV_RECENT_DESCRIPTION,
|
|
@@ -257,11 +257,16 @@ export async function sendBatches(deps, tool, purls) {
|
|
|
257
257
|
// (purls.slice(answers.length * MAX_COMPONENTS)), which it can rebuild from its own input, given
|
|
258
258
|
// the order they are counted in (`readOrder`), which the sentence says whenever it gives the
|
|
259
259
|
// position, not only when the text cuts the list. #3013: the structured result's cut empties
|
|
260
|
-
// data.not_sent_purls before any row leaves (it is rebuilt from the position)
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
|
|
260
|
+
// data.not_sent_purls before any row leaves (it is rebuilt from the position). #3132's review:
|
|
261
|
+
// round 1 then gave it back the first entries the room the rows left held (3 of the rate-limited
|
|
262
|
+
// case's 800), a list a client would retry as though it were the whole; the cut now keeps it whole
|
|
263
|
+
// or not at all (check_sbom.ts sbomData), and the sentence gives the positions as coverage holds
|
|
264
|
+
// them. `retry` is the tool and argument to send them to ("check_sbom again with purls set to").
|
|
265
|
+
// #3132's second review: those positions index the purls this tool read, which a check_sbom
|
|
266
|
+
// client rebuilds from its own purl list (or SBOM), but a scan_manifest client cannot without
|
|
267
|
+
// reading the files as this tool does; with `positions` false (scan_manifest), the sentence sends
|
|
268
|
+
// it to the same call with full: true, which always returns the list, instead.
|
|
269
|
+
export function sendingSentences(g, distinct, readOrder, retry, positions = true) {
|
|
265
270
|
const out = [];
|
|
266
271
|
if (g.batches > 1) {
|
|
267
272
|
out.push(`The ${distinct} distinct purls make ${g.batches} batches of at most ${MAX_COMPONENTS}, sent one after another; the API answered ${g.batches_sent} of them, and data.summary sums those answers.`);
|
|
@@ -273,10 +278,64 @@ export function sendingSentences(g, distinct, readOrder, retry) {
|
|
|
273
278
|
if (notSent > 0) {
|
|
274
279
|
const one = notSent === 1;
|
|
275
280
|
const where = one ? `it is the input's distinct purl at position ${g.sent + 1}` : `they are the input's distinct purls from position ${g.sent + 1} on`;
|
|
276
|
-
out.push(`${plural(notSent, "purl was", "purls were")} NOT sent (not_sent_reason ${g.not_sent_reason}: ${g.not_sent_detail}), so ${one ? "it is" : "they are"} not checked and not clean; ${where}, ${readOrder}, and data.not_sent_purls lists ${one ? "it" : "them"}
|
|
281
|
+
out.push(`${plural(notSent, "purl was", "purls were")} NOT sent (not_sent_reason ${g.not_sent_reason}: ${g.not_sent_detail}), so ${one ? "it is" : "they are"} not checked and not clean; ${where}, ${readOrder}, and data.not_sent_purls lists ${one ? "it" : "them"} (past the structured result's budget it is left out whole by default, never cut short${positions ? `, and ${one ? "that position is" : "those positions are"} coverage.sent + 1 to coverage.distinct_purls; with full: true it is always there` : ": the same call with full: true always returns it"}): call ${retry} ${one ? "it" : "them"}${g.not_sent_reason === "request_failed" ? "" : " after a minute"}.`);
|
|
277
282
|
}
|
|
278
283
|
return out;
|
|
279
284
|
}
|
|
285
|
+
// One affected component as the note names it: its purl, and its first NOTE_CVES_MAX cve_ids.
|
|
286
|
+
const affectedNamed = (r) => {
|
|
287
|
+
const who = (typeof field(r, "purl") === "string" && field(r, "purl")) || `${field(r, "ecosystem") ?? ""} ${field(r, "package") ?? ""} ${field(r, "version") ?? ""}`.trim();
|
|
288
|
+
const ids = field(r, "cve_ids");
|
|
289
|
+
const list = Array.isArray(ids) ? ids.filter((i) => typeof i === "string") : [];
|
|
290
|
+
const shown = list.slice(0, NOTE_CVES_MAX).join(", ");
|
|
291
|
+
return `${who} (${shown}${list.length > NOTE_CVES_MAX ? `, and ${list.length - NOTE_CVES_MAX} more` : ""})`;
|
|
292
|
+
};
|
|
293
|
+
const affectedTail = (more) => (more > 0 ? `; and ${more} more affected components in data.results (whole with full: true)` : "");
|
|
294
|
+
// The note's sentence naming the affected components, the first NOTE_AFFECTED_MAX of `hits`.
|
|
295
|
+
function affectedSaid(hits) {
|
|
296
|
+
return `Affected: ${hits.slice(0, NOTE_AFFECTED_MAX).map(affectedNamed).join("; ")}${affectedTail(hits.length - NOTE_AFFECTED_MAX)}.`;
|
|
297
|
+
}
|
|
298
|
+
// #3269's third review: the characters, as a JSON string, of each row's name in that sentence,
|
|
299
|
+
// measured once per row object (the structured cut asks of the same rows many times).
|
|
300
|
+
const namedChars = new WeakMap();
|
|
301
|
+
const namedCharsOf = (r) => {
|
|
302
|
+
if (!isObj(r))
|
|
303
|
+
return JSON.stringify(affectedNamed(r)).length - 2;
|
|
304
|
+
let c = namedChars.get(r);
|
|
305
|
+
if (c === undefined)
|
|
306
|
+
namedChars.set(r, (c = JSON.stringify(affectedNamed(r)).length - 2));
|
|
307
|
+
return c;
|
|
308
|
+
};
|
|
309
|
+
const isAffected = (r) => field(r, "verdict") === "affected";
|
|
310
|
+
// The sentence's characters in the answer being cut, once per answer's rows.
|
|
311
|
+
const saidChars = new WeakMap();
|
|
312
|
+
/**
|
|
313
|
+
* #3269's third review: for a call of `m` of `rows` alone, the most characters (as JSON) the note's
|
|
314
|
+
* Affected sentence can take there, less what it takes in the answer of `all` (the call being cut;
|
|
315
|
+
* negative where a call of m of them names fewer or shorter components). A call of other purls
|
|
316
|
+
* names other components there, so a call that re-checks the rows a cut left out can carry a longer
|
|
317
|
+
* note than this one; it names at most NOTE_AFFECTED_MAX of its affected rows, so at most that many,
|
|
318
|
+
* or m, of these rows' longest names, and the tail counting the rest.
|
|
319
|
+
*/
|
|
320
|
+
export function affectedSaidOver(rows, all) {
|
|
321
|
+
const longest = rows.filter(isAffected).map(namedCharsOf).sort((a, b) => b - a);
|
|
322
|
+
const top = [0];
|
|
323
|
+
for (const c of longest.slice(0, NOTE_AFFECTED_MAX))
|
|
324
|
+
top.push(top[top.length - 1] + c);
|
|
325
|
+
let now = saidChars.get(all);
|
|
326
|
+
if (now === undefined) {
|
|
327
|
+
const own = all.filter(isAffected);
|
|
328
|
+
saidChars.set(all, (now = own.length ? JSON.stringify(affectedSaid(own)).length - 2 : 0));
|
|
329
|
+
}
|
|
330
|
+
const was = now;
|
|
331
|
+
return (m) => {
|
|
332
|
+
const hits = Math.min(m, longest.length);
|
|
333
|
+
if (hits === 0)
|
|
334
|
+
return -was;
|
|
335
|
+
const named = Math.min(hits, NOTE_AFFECTED_MAX);
|
|
336
|
+
return "Affected: ".length + top[named] + 2 * (named - 1) + affectedTail(hits - NOTE_AFFECTED_MAX).length + 1 - was;
|
|
337
|
+
};
|
|
338
|
+
}
|
|
280
339
|
// The verdict counts, the not-assessed reasons, the affected components by name, and the
|
|
281
340
|
// upstream-matched rows, from the (merged) answer.
|
|
282
341
|
export function verdictSentences(d, g) {
|
|
@@ -305,17 +364,10 @@ export function verdictSentences(d, g) {
|
|
|
305
364
|
if (Array.isArray(rows)) {
|
|
306
365
|
const hits = rows.filter((r) => field(r, "verdict") === "affected");
|
|
307
366
|
if (hits.length) {
|
|
308
|
-
|
|
309
|
-
const who = (typeof field(r, "purl") === "string" && field(r, "purl")) || `${field(r, "ecosystem") ?? ""} ${field(r, "package") ?? ""} ${field(r, "version") ?? ""}`.trim();
|
|
310
|
-
const ids = field(r, "cve_ids");
|
|
311
|
-
const list = Array.isArray(ids) ? ids.filter((i) => typeof i === "string") : [];
|
|
312
|
-
const shown = list.slice(0, NOTE_CVES_MAX).join(", ");
|
|
313
|
-
return `${who} (${shown}${list.length > NOTE_CVES_MAX ? `, and ${list.length - NOTE_CVES_MAX} more` : ""})`;
|
|
314
|
-
});
|
|
315
|
-
out.push(`Affected: ${named.join("; ")}${hits.length > NOTE_AFFECTED_MAX ? `; and ${hits.length - NOTE_AFFECTED_MAX} more affected components in data.results` : ""}.`);
|
|
367
|
+
out.push(affectedSaid(hits));
|
|
316
368
|
// Said only when the answer carries fixed_in (an API before #2834 sends none).
|
|
317
369
|
if (hits.some((r) => (Array.isArray(field(r, "matches")) ? field(r, "matches") : []).some((m) => isObj(m) && "fixed_in" in m))) {
|
|
318
|
-
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
|
|
370
|
+
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 no higher version is shown to be unaffected.");
|
|
319
371
|
}
|
|
320
372
|
}
|
|
321
373
|
// #2836: rows matched by the source package their purl's upstream qualifier names.
|
|
@@ -380,6 +432,17 @@ export const Row = z.looseObject({
|
|
|
380
432
|
undetermined: opt(z.array(z.looseObject({ cve_id: opt(z.string()) }))),
|
|
381
433
|
capped: opt(z.boolean()),
|
|
382
434
|
candidates_capped: opt(z.boolean()),
|
|
435
|
+
// #3132: set by this server's structured cut (check_sbom.ts SBOM_ROW_LEVELS), never by the API.
|
|
436
|
+
matches_truncated: z
|
|
437
|
+
.strictObject({
|
|
438
|
+
total: z.number().int().min(0),
|
|
439
|
+
returned: z.number().int().min(0),
|
|
440
|
+
kev_listed_cve_ids: z.array(z.string()).optional(),
|
|
441
|
+
ransomware_cve_ids: z.array(z.string()).optional(),
|
|
442
|
+
fixed_in_versions: z.array(z.string()).optional(),
|
|
443
|
+
})
|
|
444
|
+
.optional()
|
|
445
|
+
.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 (chosen used in ransomware first, then the other KEV-listed ones, then highest effective_score, and listed in the API's order); and, while they fit, of all its matches, the CVE IDs KEV-listed (kev_listed_cve_ids) or used in ransomware (ransomware_cve_ids), and the distinct fixed_in versions (fixed_in_versions). The same call with full: true returns every match."),
|
|
383
446
|
});
|
|
384
447
|
// The batch route's answer, as data carries it: its fields, merged over the batches.
|
|
385
448
|
export const batchDataShape = {
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// #3285: which rows of a CVE's affected packages are one package, as core-backend's registry
|
|
2
|
+
// matcher reads them. A module of its own, with no import, so cve_remediation (fixed_branches) and
|
|
3
|
+
// cve_intel (affected_packages) state the same reading.
|
|
4
|
+
//
|
|
5
|
+
// core-backend cve/pkgecosystem.go: baseTokenMatchableEcosystems names the base tokens whose ":"
|
|
6
|
+
// suffix is a repository locator, not a release (Packagist, Maven), and ecosystemBaseToken is the
|
|
7
|
+
// lowercased, trimmed token before the first ":". cve/pkgmatch_fixedin.go registryRecordKey folds
|
|
8
|
+
// a suffixed ecosystem onto its base only when that base is one of them, so a row of one
|
|
9
|
+
// package_name under "Packagist:https://packages.drupal.org/8" and one under "Packagist" are one
|
|
10
|
+
// record, whose ranges the matcher walks together; a distro's release suffix (Debian:12,
|
|
11
|
+
// Alpine:v3.20) stays in the key and is never folded. CVE-2025-48013 lists drupal/quick_node_block
|
|
12
|
+
// under both (fixed 2.0.1 and fixed 2.0.0, each from 0): read row by row, a Packagist 1.0.0 gets
|
|
13
|
+
// 2.0.0, which the Drupal advisory still holds affected; as one package it gets 2.0.1, the
|
|
14
|
+
// matcher's fixed_in. (registryRecordKey also spells a PyPI name the PEP 503 way; a PyPI row is
|
|
15
|
+
// never listed under two ecosystem strings, so that plays no part here.)
|
|
16
|
+
export const BASE_MATCHABLE_ECOSYSTEMS = ["packagist", "maven"];
|
|
17
|
+
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
18
|
+
const str = (v, k) => {
|
|
19
|
+
const x = isObject(v) ? v[k] : undefined;
|
|
20
|
+
return typeof x === "string" && x.trim() ? x : undefined;
|
|
21
|
+
};
|
|
22
|
+
/** core-backend ecosystemBaseToken: the lowercased, trimmed token before the first ":". */
|
|
23
|
+
export function ecosystemBaseToken(raw) {
|
|
24
|
+
const t = raw.trim().toLowerCase();
|
|
25
|
+
const i = t.indexOf(":");
|
|
26
|
+
return i > 0 ? t.slice(0, i) : t;
|
|
27
|
+
}
|
|
28
|
+
/** The ecosystem part of core-backend's registryRecordKey: lowercased, folded onto its base only where the base is base-matchable. */
|
|
29
|
+
export function recordEcosystem(raw) {
|
|
30
|
+
const eco = raw.trim().toLowerCase();
|
|
31
|
+
const base = ecosystemBaseToken(eco);
|
|
32
|
+
return base !== eco && BASE_MATCHABLE_ECOSYSTEMS.includes(base) ? base : eco;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The packages `rows` (each with ecosystem and package_name) list under more than one ecosystem
|
|
36
|
+
* string that the matcher folds into one record, in the order each was first listed; `folded` is
|
|
37
|
+
* how many rows fold into another so.
|
|
38
|
+
*/
|
|
39
|
+
export function packagesListedTwice(rows) {
|
|
40
|
+
const groups = new Map();
|
|
41
|
+
rows.forEach((r, i) => {
|
|
42
|
+
const eco = str(r, "ecosystem");
|
|
43
|
+
const name = str(r, "package_name");
|
|
44
|
+
if (!eco || !name || !BASE_MATCHABLE_ECOSYSTEMS.includes(ecosystemBaseToken(eco)))
|
|
45
|
+
return;
|
|
46
|
+
const key = `${recordEcosystem(eco)}\u0000${name.trim().toLowerCase()}`;
|
|
47
|
+
const g = groups.get(key) ?? { name, ecosystems: [], rows: [] };
|
|
48
|
+
if (!g.ecosystems.includes(eco))
|
|
49
|
+
g.ecosystems.push(eco);
|
|
50
|
+
g.rows.push(i);
|
|
51
|
+
groups.set(key, g);
|
|
52
|
+
});
|
|
53
|
+
const twice = [...groups.values()].filter((g) => g.ecosystems.length > 1);
|
|
54
|
+
return { twice, folded: twice.reduce((t, g) => t + g.rows.length - 1, 0) };
|
|
55
|
+
}
|
|
56
|
+
// What a note says of such rows, in cve_remediation's entries and cve_intel's rows alike: the rule
|
|
57
|
+
// the matcher applies, stated so that an agent walking the ranges reads them as it does.
|
|
58
|
+
export const ONE_PACKAGE_RULE = (what) => `${what} of one package_name under Packagist or Maven, bare or with a repository URL after ":", are one package, its ranges all of theirs together`;
|
|
@@ -15,31 +15,46 @@ const TEXT_NOT_CHECKED = 20;
|
|
|
15
15
|
// The order the distinct purls are counted in, for the position of those not sent (#2799).
|
|
16
16
|
const READ_ORDER = "counted in the order this tool read them (the files in the order given, each file's entries in the order the file lists them, each purl at its first place)";
|
|
17
17
|
const NOT_CHECKED_NOTE = "not_checked entries are not clean: each names a dependency this tool did not send to the API (its version is a range or unresolved, or it comes from a local path, a VCS checkout or a line this tool does not read), so no verdict was looked up for it.";
|
|
18
|
+
// 3.1.0's phase A review: fix C's wording tied "50 seconds a call" to the 429 wait alone, while
|
|
19
|
+
// match_batch.ts's TIME_BUDGET_MS bounds the whole call: a slow batch is cut off at its end
|
|
20
|
+
// (time_budget), and a 429 refused (rate_limited) or a batch failed (request_failed) after the first
|
|
21
|
+
// also answers partial, the rest in not_sent_purls. 3.0.2's "within 50 seconds" said so; the
|
|
22
|
+
// description says it again, the room from shorter wording elsewhere (every claim kept).
|
|
18
23
|
export const SCAN_MANIFEST_TITLE = "Check a lockfile against the advisory corpus";
|
|
19
|
-
export const SCAN_MANIFEST_DESCRIPTION = "Check a project's lockfile or pinned manifest against EchelonGraph's advisory corpus, one verdict per dependency. Pass files: 1 to 20 of filename and content, up to 5,000,000 characters in all; the filename
|
|
24
|
+
export const SCAN_MANIFEST_DESCRIPTION = "Check a project's lockfile or pinned manifest against EchelonGraph's advisory corpus, one verdict per dependency. Pass files: 1 to 20 of filename and content, up to 5,000,000 characters in all; the filename or format gives the format. Read: requirements.txt (== and === pins only), go.mod (require, after replace and exclude), package-lock.json and npm-shrinkwrap.json (v1-v3), Cargo.lock, Gemfile.lock, composer.lock, poetry.lock and gradle.lockfile. package.json, pyproject.toml, Pipfile, Gemfile, Cargo.toml, composer.json and build.gradle hold ranges and are refused, naming the lockfile; go.sum is refused: it lists versions the build does not select. The server reads the files into purls; only the purls are sent to the API, in POST bodies of at most 200, never in a URL; filenames and file contents are not sent on. Run from npm, this server is on your machine; over the hosted endpoint (mcp.echelongraph.io) it is EchelonGraph's, and the files are the request body, up to 6 MiB. data.results holds a row per distinct purl, with verdict (affected, not_affected, undetermined or not_assessed), not_assessed_reason, cve_ids and matched_via; each match carries kev_listed, epss_score and fixed_in or null with fixed_in_reason. not_affected is the only clean verdict. data.not_checked lists each entry not sent (file, line, reason); not_checked entries are not clean. At most 2,000 distinct purls and 50 seconds a call. The API allows 1,200 components a minute per caller; on a 429 it waits out Retry-After within that; a later batch cut off, refused or failed ends the call, the rest in data.not_sent_purls, unchecked, not clean." +
|
|
20
25
|
" " +
|
|
21
26
|
TEXT_BUDGET_DESCRIPTION +
|
|
22
|
-
" Cut, each row keeps index, purl, verdict, not_assessed_reason and cve_ids at least; not_checked
|
|
27
|
+
" Cut, each row keeps index, purl, verdict, not_assessed_reason and cve_ids at least; not_checked its first 20.";
|
|
23
28
|
// The text's cut: check_sbom's rows and levels, and data.not_checked beside them, cut to its
|
|
24
29
|
// first TEXT_NOT_CHECKED once the first level does not fit (structuredContent.data keeps it whole).
|
|
30
|
+
// #3132's second review: check_sbom's sentence on not_sent_purls says a second call can send them
|
|
31
|
+
// without reading the list, from their positions in the input; here those positions index purls
|
|
32
|
+
// read from the files, which a client cannot rebuild, so the sentence names full: true instead.
|
|
25
33
|
export function manifestText(sent) {
|
|
26
34
|
const sbom = sbomText(sent);
|
|
27
35
|
return {
|
|
28
36
|
...sbom,
|
|
29
37
|
sides: [
|
|
30
|
-
...(sbom.sides ?? [])
|
|
38
|
+
...(sbom.sides ?? []).map((side) => side.list === "not_sent_purls"
|
|
39
|
+
? {
|
|
40
|
+
...side,
|
|
41
|
+
said: (_inText, total) => `The ${total} purls not sent are the purls this tool read from position ${sent + 1} on, counted in the order the note gives for them; structuredContent.data.not_sent_purls lists every one with full: true.`,
|
|
42
|
+
}
|
|
43
|
+
: side),
|
|
31
44
|
{
|
|
32
45
|
list: "not_checked",
|
|
33
46
|
cap: TEXT_NOT_CHECKED,
|
|
34
47
|
said: (inText, total) => `coverage.not_checked_by_reason counts all ${total} entries not checked, ${inText} of them in the text.`,
|
|
35
48
|
},
|
|
36
49
|
],
|
|
37
|
-
|
|
50
|
+
// #3269's second review: no page sentence, as for check_sbom's text (sbomText says why).
|
|
38
51
|
};
|
|
39
52
|
}
|
|
40
53
|
// #3013: the structured result's cut, check_sbom's (sbomData: the lists beside the rows, then every
|
|
41
54
|
// row thinned, then rows leaving, clean ones first) with not_checked beside not_sent_purls.
|
|
42
|
-
|
|
55
|
+
// #3269's second review: the sentence promises only what holds (check_sbom.ts sbomPageSaid).
|
|
56
|
+
export const manifestPageSaid = (k) => `A call whose files hold the first ${k} of these purls, or fewer, keeps every row in data; a call of other files says in its own note what it leaves out.`;
|
|
57
|
+
const MANIFEST_DATA = sbomData(["not_sent_purls", "not_checked"], manifestPageSaid);
|
|
43
58
|
export function readFiles(a) {
|
|
44
59
|
if (!Array.isArray(a.files))
|
|
45
60
|
return "files must be a list of {filename, content}";
|
|
@@ -126,7 +141,7 @@ function noteFor(head, x, d, g) {
|
|
|
126
141
|
// counts above are coverage.not_checked_by_reason's, which no cut touches.
|
|
127
142
|
out.push(`${plural(nc, "entry was", "entries were")} NOT checked and ${nc === 1 ? "is" : "are"} not clean (${parts.join(", ")}): data.not_checked lists each with its file, line and reason, whole with full: true (past the structured result's budget, that list can be cut by default), and none was sent to the API.`);
|
|
128
143
|
}
|
|
129
|
-
out.push(...sendingSentences(g, x.purls.length, READ_ORDER, "check_sbom with purls set to"));
|
|
144
|
+
out.push(...sendingSentences(g, x.purls.length, READ_ORDER, "check_sbom with purls set to", false));
|
|
130
145
|
if (d === null)
|
|
131
146
|
return { note: out.join(" "), assessed: 0 };
|
|
132
147
|
const v = verdictSentences(d, g);
|
|
@@ -243,11 +258,15 @@ export function scanManifestOutput(envelopeSchema) {
|
|
|
243
258
|
freshness: null,
|
|
244
259
|
});
|
|
245
260
|
}
|
|
261
|
+
// One output schema per process, not per request (3.0.2; index.ts envelopeSchema says why).
|
|
262
|
+
const OUTPUTS = new WeakMap();
|
|
246
263
|
// A literal server.registerTool(name, { title, description, … }) call with constant strings:
|
|
247
264
|
// marketing-site lib/mcpToolClaims.test.ts reads it here (it follows index.ts's call to this
|
|
248
265
|
// function) and holds the /pulse/mcp row to this title and description.
|
|
249
266
|
export function registerScanManifest(server, deps) {
|
|
250
|
-
const
|
|
267
|
+
const envelope = deps.envelopeSchema;
|
|
268
|
+
const output = OUTPUTS.get(envelope) ?? scanManifestOutput(envelope);
|
|
269
|
+
OUTPUTS.set(envelope, output);
|
|
251
270
|
server.registerTool(SCAN_MANIFEST, {
|
|
252
271
|
title: SCAN_MANIFEST_TITLE,
|
|
253
272
|
description: SCAN_MANIFEST_DESCRIPTION,
|
|
@@ -77,6 +77,18 @@ const CVE_ID = /^CVE-\d{4}-\d{4,}$/i;
|
|
|
77
77
|
const VENDOR_SLUG = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
78
78
|
// No control characters (a header or log line could carry them), at most 200 characters.
|
|
79
79
|
const ADVISORY_ID = /^[^\x00-\x1f\x7f]{1,200}$/;
|
|
80
|
+
// #3011: a row's advisory_id is EchelonGraph's own ID for the advisory, a UUID (vendor_advisories,
|
|
81
|
+
// migration 063). The detail route is keyed on the vendor's ID, vendor_advisory_id (handler.go
|
|
82
|
+
// Detail, store.go GetAdvisoryByVendorAndID: WHERE v.slug = $1 AND va.vendor_advisory_id = $2), and
|
|
83
|
+
// the API has no route by the UUID, so get_vendor_advisory given a row's advisory_id answered a
|
|
84
|
+
// 404, worded as no record, for an advisory the API holds (CVE-2024-3400's Palo Alto row,
|
|
85
|
+
// a68d033b-5d1e-4537-8190-5e37897a27e8, 2026-10-10). A vendor's own ID could have that shape too, so
|
|
86
|
+
// the API is still asked; only its 404 for an ID of that shape is answered as the refusal it is.
|
|
87
|
+
const INTERNAL_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
88
|
+
// What a row's two IDs are, in the outputSchema of all three tools (the detail echoes advisory_id).
|
|
89
|
+
// Facts only, naming no other tool (test/description-policy.test.mjs: the MCP Directory policy).
|
|
90
|
+
const ROW_ADVISORY_ID_FIELD = "EchelonGraph's internal ID for this advisory, a UUID. It is not the vendor's advisory ID (vendor_advisory_id), and an advisory's detail is not looked up by it: the detail is keyed on vendor and vendor_advisory_id.";
|
|
91
|
+
const VENDOR_ADVISORY_ID_FIELD = "The vendor's own ID for the advisory (e.g. RHSA-2024:1234, GHSA-xxxx-xxxx-xxxx, or the CVE ID for Microsoft and Palo Alto Networks). With vendor, it is the key an advisory's detail is looked up by, as its advisory_id argument.";
|
|
80
92
|
const METHOD = "EchelonGraph's vendor-advisory feed: advisories as each vendor published them in its own feed (for example Microsoft MSRC, Red Hat, Cisco, Palo Alto Networks and GitHub GHSA), each feed polled on a schedule; advisories of a feed EchelonGraph has disabled are left out.";
|
|
81
93
|
const NO_FRESHNESS = "freshness is null: these answers carry no time at which the vendor-advisory pollers last completed a poll.";
|
|
82
94
|
const DATES = "vendor_published_at is the date the vendor gives for the advisory; our_first_seen_at is when EchelonGraph first recorded it, which is not the vendor's date.";
|
|
@@ -245,10 +257,10 @@ const REMEDIATION_KINDS_FIELD = "The kinds of remediation the advisory lists (fo
|
|
|
245
257
|
const REMEDIATION_KINDS = ["vendor_fix", "workaround", "mitigation", "no_fix_planned", "none_available", "other"];
|
|
246
258
|
const REMEDIATION_STATES = ["parsed", "none_in_source", "not_parsed", "withdrawn"];
|
|
247
259
|
const AdvisoryRow = z.looseObject({
|
|
248
|
-
advisory_id: opt(z.string()),
|
|
260
|
+
advisory_id: opt(z.string()).describe(ROW_ADVISORY_ID_FIELD),
|
|
249
261
|
vendor: opt(z.string()),
|
|
250
262
|
vendor_display_name: opt(z.string()),
|
|
251
|
-
vendor_advisory_id: opt(z.string()),
|
|
263
|
+
vendor_advisory_id: opt(z.string()).describe(VENDOR_ADVISORY_ID_FIELD),
|
|
252
264
|
cve_ids: opt(z.array(z.string())),
|
|
253
265
|
title: opt(z.string()),
|
|
254
266
|
severity: opt(z.string()),
|
|
@@ -352,8 +364,11 @@ const SEARCH_DATA = {
|
|
|
352
364
|
},
|
|
353
365
|
};
|
|
354
366
|
const ROW_CUT_DESCRIPTION = " Cut, each row keeps vendor, vendor_advisory_id, title, severity and cve_ids at least, its strings and lists shortened";
|
|
355
|
-
|
|
356
|
-
|
|
367
|
+
// The three tools' output schemas, one each per process, not per request (3.0.2; index.ts
|
|
368
|
+
// envelopeSchema says why).
|
|
369
|
+
const OUTPUTS = new WeakMap();
|
|
370
|
+
function outputsOf(envelopeSchema) {
|
|
371
|
+
const FOR_CVE_OUTPUT = envelopeSchema({
|
|
357
372
|
data: z.looseObject({
|
|
358
373
|
cve_id: opt(z.string()),
|
|
359
374
|
advisories: opt(z.array(AdvisoryRow)),
|
|
@@ -373,7 +388,7 @@ export function registerVendorAdvisoryTools(server, kit) {
|
|
|
373
388
|
}),
|
|
374
389
|
freshness: null,
|
|
375
390
|
});
|
|
376
|
-
const DETAIL_OUTPUT =
|
|
391
|
+
const DETAIL_OUTPUT = envelopeSchema({
|
|
377
392
|
data: AdvisoryRow.extend({
|
|
378
393
|
description: opt(z.string()),
|
|
379
394
|
known_cve_ids: opt(z.array(z.string())).describe("The CVE IDs of cve_ids that have a record in EchelonGraph's CVE feed."),
|
|
@@ -402,7 +417,7 @@ export function registerVendorAdvisoryTools(server, kit) {
|
|
|
402
417
|
}),
|
|
403
418
|
freshness: null,
|
|
404
419
|
});
|
|
405
|
-
const SEARCH_OUTPUT =
|
|
420
|
+
const SEARCH_OUTPUT = envelopeSchema({
|
|
406
421
|
data: z.looseObject({
|
|
407
422
|
advisories: opt(z.array(AdvisoryRow)),
|
|
408
423
|
total: opt(z.number()).describe("The matches; when total_capped is true, a lower bound: 1,000 or more."),
|
|
@@ -424,6 +439,12 @@ export function registerVendorAdvisoryTools(server, kit) {
|
|
|
424
439
|
}),
|
|
425
440
|
freshness: null,
|
|
426
441
|
});
|
|
442
|
+
return { FOR_CVE_OUTPUT, DETAIL_OUTPUT, SEARCH_OUTPUT };
|
|
443
|
+
}
|
|
444
|
+
export function registerVendorAdvisoryTools(server, kit) {
|
|
445
|
+
const outputs = OUTPUTS.get(kit.envelopeSchema) ?? outputsOf(kit.envelopeSchema);
|
|
446
|
+
OUTPUTS.set(kit.envelopeSchema, outputs);
|
|
447
|
+
const { FOR_CVE_OUTPUT, DETAIL_OUTPUT, SEARCH_OUTPUT } = outputs;
|
|
427
448
|
// The vendors' windows, read beside a tool's own request. Never a failure of the tool.
|
|
428
449
|
async function readWindows() {
|
|
429
450
|
const r = await kit.api(VENDOR_COVERAGE_PATH);
|
|
@@ -492,6 +513,13 @@ export function registerVendorAdvisoryTools(server, kit) {
|
|
|
492
513
|
}
|
|
493
514
|
}
|
|
494
515
|
// ── get_vendor_advisory ──
|
|
516
|
+
// #3011: advisory_id was a row's advisory_id (a UUID), not its vendor_advisory_id. The API was
|
|
517
|
+
// asked (a vendor's own ID could be shaped so) and holds no advisory of the vendor under it, which
|
|
518
|
+
// is the wrong ID passed, not a missing advisory: invalid_input, naming the field to pass.
|
|
519
|
+
function internalIdRefusal(vendor, id, path) {
|
|
520
|
+
const why = `advisory_id ${id} has the shape of a row's advisory_id, EchelonGraph's internal ID for an advisory (a UUID), which this tool does not take, and EchelonGraph holds no ${vendor} advisory whose vendor_advisory_id it is (HTTP 404)`;
|
|
521
|
+
return kit.failure("invalid_input", `get_vendor_advisory FAILED (state: invalid_input): ${why}. Call get_vendor_advisory again with the row's vendor and its vendor_advisory_id as advisory_id (e.g. CVE-2024-3400 for Palo Alto Networks, RHSA-2024:1234 for Red Hat). This says nothing about whether the advisory exists, and it is not a finding.`, { kind: "invalid_input", path, status: 404, message: `${why}; pass the row's vendor_advisory_id as advisory_id` });
|
|
522
|
+
}
|
|
495
523
|
async function detail(vendorArg, idArg) {
|
|
496
524
|
const tool = "get_vendor_advisory";
|
|
497
525
|
const vendor = vendorArg.trim();
|
|
@@ -507,6 +535,8 @@ export function registerVendorAdvisoryTools(server, kit) {
|
|
|
507
535
|
try {
|
|
508
536
|
const windowsRead = readWindows();
|
|
509
537
|
const r = await kit.api(`/api/v1/public/vendor-advisories/${encodeURIComponent(vendor)}/${encodeURIComponent(id)}`);
|
|
538
|
+
if (!r.ok && r.kind === "http" && r.status === 404 && INTERNAL_ID.test(id))
|
|
539
|
+
return internalIdRefusal(vendor, id, r.path);
|
|
510
540
|
if (!r.ok)
|
|
511
541
|
return kit.failed(tool, r);
|
|
512
542
|
const w = await windowsRead;
|
|
@@ -648,10 +678,12 @@ export function registerVendorAdvisoryTools(server, kit) {
|
|
|
648
678
|
}, async ({ cve_id, full }) => kit.checked("vendor_advisories_for_cve", FOR_CVE_OUTPUT, await answering(full, FOR_CVE_DATA, () => forCVE(cve_id))));
|
|
649
679
|
server.registerTool("get_vendor_advisory", {
|
|
650
680
|
title: "Vendor advisory detail",
|
|
651
|
-
description: `One vendor advisory in full, by vendor and the vendor's advisory ID (the vendor and vendor_advisory_id of a vendor advisory row): 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. remediations lists each remediation the vendor lists, with kind (the vendor's own category, never read from the text: vendor_fix, workaround, mitigation, no_fix_planned, none_available or other), source_category, text, url and the cve_ids and product_ids it covers; each text is the vendor's, verbatim, untested by EchelonGraph. remediation_state is parsed, none_in_source, not_parsed or withdrawn: none_in_source and not_parsed are not a finding that no fix exists. ${DATES_DESCRIPTION} ${REJECTED_CVE_DESCRIPTION} coverage.vendor_window is that vendor's window in what EchelonGraph holds: ${WINDOW_HEAD}; 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}`,
|
|
681
|
+
description: `One vendor advisory in full, by vendor and the vendor's advisory ID (the vendor and vendor_advisory_id of a vendor advisory row, never its advisory_id, EchelonGraph's internal UUID): 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. remediations lists each remediation the vendor lists, with kind (the vendor's own category, never read from the text: vendor_fix, workaround, mitigation, no_fix_planned, none_available or other), source_category, text, url and the cve_ids and product_ids it covers; each text is the vendor's, verbatim, untested by EchelonGraph. remediation_state is parsed, none_in_source, not_parsed or withdrawn: none_in_source and not_parsed are not a finding that no fix exists. ${DATES_DESCRIPTION} ${REJECTED_CVE_DESCRIPTION} coverage.vendor_window is that vendor's window in what EchelonGraph holds: ${WINDOW_HEAD}; 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}`,
|
|
652
682
|
inputSchema: z.object({
|
|
653
683
|
vendor: z.string().describe("the vendor slug, e.g. microsoft, redhat, github"),
|
|
654
|
-
advisory_id: z
|
|
684
|
+
advisory_id: z
|
|
685
|
+
.string()
|
|
686
|
+
.describe("the vendor's own advisory ID, a row's vendor_advisory_id, e.g. RHSA-2024:1234, GHSA-xxxx-xxxx-xxxx or CVE-2024-3400; not a row's advisory_id, EchelonGraph's internal UUID"),
|
|
655
687
|
full: FULL_ARG,
|
|
656
688
|
}),
|
|
657
689
|
outputSchema: DETAIL_OUTPUT,
|
package/docs/exposure-radar.md
CHANGED
|
@@ -43,6 +43,7 @@ maps to a CISA-KEV-listed CVE (see [How `cve_exposure` counts](tools.md#how-cve_
|
|
|
43
43
|
| `kev_exposure.newest_kev[].cvss_v3_score`, `kev_exposure.newest_kev[].epss_score` | Scores, not counts: the CVE record's CVSS v3 base score and EPSS probability (0 to 1), absent when the record has none. |
|
|
44
44
|
| `kev_exposure.last_run_at` | A timestamp, not a count: when the radar last completed a check, a cycle in which its Shodan search answered at least one query (others may have failed) and its list of services due for a re-check was read. A cycle that skipped the search for want of Shodan query credits, or whose reads failed, does not move it. |
|
|
45
45
|
| `kev_exposure.window.from`, `kev_exposure.window.to` | Timestamps, not counts: when Shodan collected the banners of the oldest and the newest of the service×CVE pairs counted, Shodan's own times, not when EchelonGraph stored or re-checked a pair; a span of observations made at different times. Relayed only when every counted pair has such a time; otherwise the note says how many do not. |
|
|
46
|
+
| `kev_exposure.dated_window.from`, `kev_exposure.dated_window.to`, `kev_exposure.dated_window.undated` | Relayed instead of the window when some counted pairs have no banner time on record (pairs stored before the banner time was captured stay so while the InternetDB re-check keeps them): from and to are the same Shodan banner times over the dated pairs only, and undated counts the pairs with none. The note gives both numbers; the window does not describe the undated pairs. |
|
|
46
47
|
|
|
47
48
|
A `newest_kev` row's `exposure_state` says whether its count is a measurement:
|
|
48
49
|
|
|
@@ -61,6 +62,7 @@ A `newest_kev` row's `exposure_state` says whether its count is a measurement:
|
|
|
61
62
|
| `exposed_databases.top_engines`, `exposed_databases.top_countries` | Up to 15 engines and 10 countries, ranked by those services. |
|
|
62
63
|
| `exposed_databases.pii_likely`, `exposed_databases.pci_likely` | Services whose schema names pass a high-confidence gate for personal data, or for payment-card data. The names are index, database, table or field names in the Shodan or LeakIX banner or in the check's response, never record values; the gate passes one unambiguous term (such as `ssn` or `cardholder`) or two distinct indicators. It is a precision-first schema gate, not a census: a service whose names do not pass it is not counted, whatever it holds, so the other services are not shown to hold no such data. |
|
|
63
64
|
| `exposed_databases.window.from`, `exposed_databases.window.to` | Timestamps, not counts: when EchelonGraph's check last confirmed the oldest and the newest of the services counted answering without authentication, a span of checks made at different times. Relayed only when every counted service has such a time; otherwise the note says how many do not. |
|
|
65
|
+
| `exposed_databases.dated_window.from`, `exposed_databases.dated_window.to`, `exposed_databases.dated_window.undated` | Relayed instead of the window when some counted services have no such time on record: from and to span the dated services only, and undated counts those with none. |
|
|
64
66
|
| `exposed_databases.last_run_at` | A timestamp, not a count: when the radar last completed a check, a cycle in which its Shodan search, or the LeakIX fallback, answered at least one query (others may have failed), its list of services due for a re-check was read, and the scan opt-out register could be consulted. A cycle that searched nothing, or whose reads failed, does not move it. |
|
|
65
67
|
|
|
66
68
|
## `leaked_credentials`
|
|
@@ -72,6 +74,7 @@ A `newest_kev` row's `exposure_state` says whether its count is a measurement:
|
|
|
72
74
|
| `leaked_credentials.distinct_repos` | Public GitHub repositories with at least one. |
|
|
73
75
|
| `leaked_credentials.top_providers`, `leaked_credentials.top_types` | Up to 15 providers and secret types, ranked by (repository, secret) pairs. |
|
|
74
76
|
| `leaked_credentials.window.from`, `leaked_credentials.window.to` | Timestamps, not counts: when EchelonGraph's detector last found the oldest and the newest of the (repository, secret) pairs counted in a public commit. |
|
|
77
|
+
| `leaked_credentials.dated_window.from`, `leaked_credentials.dated_window.to`, `leaked_credentials.dated_window.undated` | Relayed instead of the window when some counted pairs have no such time on record: from and to span the dated pairs only, and undated counts those with none. |
|
|
75
78
|
| `leaked_credentials.last_run_at` | A timestamp, not a count: when the radar last completed a check, a cycle that read the public GitHub event stream (fetches of some of the commits it lists may have failed). A cycle whose read of that stream failed does not move it. |
|
|
76
79
|
|
|
77
80
|
None of them is validated. Each is a credential-shaped string in a public commit that passed
|
|
@@ -90,6 +93,7 @@ labels each one. Only `shadow_ai.confirmed_exposed` counts exposed services.
|
|
|
90
93
|
| `confirmed_exposed.by_category` | The same services, by category. |
|
|
91
94
|
| `confirmed_exposed.last_24h` | Confirmed-exposed services first recorded in the last 24 hours. |
|
|
92
95
|
| `confirmed_exposed.window.from`, `confirmed_exposed.window.to` | Timestamps, not counts: when EchelonGraph's verifier ran the probe that last decided the oldest and the newest of the confirmed-exposed services, a span of probes made at different times. Relayed only when every one has such a time. |
|
|
96
|
+
| `confirmed_exposed.dated_window.from`, `confirmed_exposed.dated_window.to`, `confirmed_exposed.dated_window.undated` | Relayed instead of the window when some confirmed-exposed services have no such time on record: from and to span the dated ones only, and undated counts those with none. |
|
|
93
97
|
| `observed.total` | Every Certificate Transparency or Shodan observation on record, whatever its verification state: observed, not exposed. |
|
|
94
98
|
| `observed.by_category` | The same observations, by category. |
|
|
95
99
|
| `observed.last_24h` | Observations first recorded in the last 24 hours. |
|
package/docs/rate-limits.md
CHANGED
|
@@ -34,7 +34,9 @@ JSON-RPC error (code `-32029`) that names the limit and the seconds to wait. A r
|
|
|
34
34
|
64 KiB is refused (HTTP 413, code `-32600`), except a `tools/call` of `check_sbom` or
|
|
35
35
|
`scan_manifest`, or a `prompts/get` of `sbom_review`, which may be up to 6 MiB: enough for a
|
|
36
36
|
CycloneDX or SPDX document, or a call's lockfiles, at the tools' 5,000,000-character cap. Each server instance reads at most two such large bodies at
|
|
37
|
-
once; a third
|
|
37
|
+
once; a third waits up to 15 seconds for one to finish (two may wait at once), and is answered
|
|
38
|
+
HTTP 503 with `Retry-After` and a JSON-RPC error (code `-32030`) only when that wait runs out, or at
|
|
39
|
+
once when two are already waiting. Each
|
|
38
40
|
instance serves at most 64 requests at once; the next waits up to 10 seconds for one to finish,
|
|
39
41
|
and is then answered the same way. A `subscriptions/listen` stream, which stays open until its
|
|
40
42
|
client leaves, is not one of the 64.
|
|
@@ -46,5 +48,6 @@ server at all (<https://docs.cloud.google.com/run/docs/troubleshooting>). The ho
|
|
|
46
48
|
speaks HTTP/2 to Cloud Run, where the same page says a disconnect is passed on. Measured on
|
|
47
49
|
2026-10-05, Cloud Run reset the stream of a client that stopped reading its answer 15 s in, and the
|
|
48
50
|
call was logged as a client that closed. Whether a client that aborts mid-call is passed on at once
|
|
49
|
-
has not been observed yet
|
|
50
|
-
50-second budget and hold its
|
|
51
|
+
has not been observed yet: on 2026-10-18 two calls aborted 1.5 s in ran on to their answers. So an
|
|
52
|
+
abandoned `check_sbom` or `scan_manifest` call may run for up to its 50-second budget and hold its
|
|
53
|
+
slot that long, and a retry of it waits for that slot (above) instead of being refused at once.
|
|
@@ -10,7 +10,12 @@ Every field comes from what the API sends; where the API does not say, the field
|
|
|
10
10
|
a note says so. Per tool:
|
|
11
11
|
|
|
12
12
|
- `cve_summary` is `measured`, and its `measured_at` is `summary.last_updated`, the newest
|
|
13
|
-
modification time among the active CVE records it counts.
|
|
13
|
+
modification time among the active CVE records it counts. Its `freshness.nvd_poll` is the
|
|
14
|
+
fleet's last completed NVD poll, from the answer's `nvd_poll` block: `last_run_at` is
|
|
15
|
+
`nvd_poll.last_completed_at` as sent, with `scope` `fleet` and the poll's `interval`. It is
|
|
16
|
+
relayed only as a real RFC 3339 time at most 5 minutes after this server's clock. `freshness` is
|
|
17
|
+
`null` where the answer carries no `nvd_poll` (an older API, or the API could not read that time)
|
|
18
|
+
or one without such a time, and the note says which.
|
|
14
19
|
- `search_cves` is `measured`, with `measured_at` `null`: each record carries its own times. Its
|
|
15
20
|
`coverage` repeats the list's `total`, `total_counted`, `total_is_lower_bound`,
|
|
16
21
|
`search_relaxed`, `limit` and `offset`, and adds `returned`, the rows in the page. When
|
|
@@ -39,5 +44,12 @@ a note says so. Per tool:
|
|
|
39
44
|
then `lookup` (`cpe` or `registry`), `match_layer`, `count`, `capped`, `candidates_capped`,
|
|
40
45
|
`excluded_count`, `undetermined_count`, `not_affected_count` and `degraded`.
|
|
41
46
|
|
|
42
|
-
|
|
43
|
-
|
|
47
|
+
`search_cves`' and `get_cve`'s `freshness` is `null`: their answers carry no poll-completion
|
|
48
|
+
time. The fleet's last completed NVD poll is `cve_summary`'s `freshness`, as above.
|
|
49
|
+
|
|
50
|
+
Arguments that break a tool's `inputSchema` (a number outside its range, a value outside its
|
|
51
|
+
`enum`, the wrong JSON type, a required argument left out) are refused before any request is
|
|
52
|
+
made, with the same envelope as every other refusal: `state` `invalid_input`, `error.kind`
|
|
53
|
+
`invalid_input`, `isError` true, and notes that name each argument and the rule it broke (by its
|
|
54
|
+
JSON type, never by a value sent). Through 3.0.2 the MCP SDK answered those itself, with one
|
|
55
|
+
`Input validation error` text block and no `structuredContent` (#3015).
|