echelongraph-mcp 3.0.2 → 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 +603 -5
- package/README.md +77 -82
- package/dist/http.js +73 -81
- package/dist/httpPolicy.js +197 -10
- package/dist/index.js +451 -44
- package/dist/logLines.js +86 -0
- package/dist/prompts.js +121 -22
- package/dist/readBody.js +140 -0
- package/dist/requiredText.js +23 -3
- package/dist/resources.js +3 -3
- package/dist/runtime.js +66 -1
- package/dist/schemaRefusal.js +138 -0
- package/dist/textBudget.js +826 -76
- package/dist/tools/check_affected.js +19 -6
- package/dist/tools/check_sbom.js +191 -22
- package/dist/tools/cve_intel.js +145 -35
- package/dist/tools/cve_remediation.js +101 -19
- package/dist/tools/epss_history.js +41 -5
- package/dist/tools/match_batch.js +57 -10
- package/dist/tools/packageRecords.js +58 -0
- package/dist/tools/scan_manifest.js +11 -4
- package/dist/tools/vendor_advisories.js +27 -4
- 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 +139 -6
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -282,6 +282,60 @@ export function sendingSentences(g, distinct, readOrder, retry, positions = true
|
|
|
282
282
|
}
|
|
283
283
|
return out;
|
|
284
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
|
+
}
|
|
285
339
|
// The verdict counts, the not-assessed reasons, the affected components by name, and the
|
|
286
340
|
// upstream-matched rows, from the (merged) answer.
|
|
287
341
|
export function verdictSentences(d, g) {
|
|
@@ -310,17 +364,10 @@ export function verdictSentences(d, g) {
|
|
|
310
364
|
if (Array.isArray(rows)) {
|
|
311
365
|
const hits = rows.filter((r) => field(r, "verdict") === "affected");
|
|
312
366
|
if (hits.length) {
|
|
313
|
-
|
|
314
|
-
const who = (typeof field(r, "purl") === "string" && field(r, "purl")) || `${field(r, "ecosystem") ?? ""} ${field(r, "package") ?? ""} ${field(r, "version") ?? ""}`.trim();
|
|
315
|
-
const ids = field(r, "cve_ids");
|
|
316
|
-
const list = Array.isArray(ids) ? ids.filter((i) => typeof i === "string") : [];
|
|
317
|
-
const shown = list.slice(0, NOTE_CVES_MAX).join(", ");
|
|
318
|
-
return `${who} (${shown}${list.length > NOTE_CVES_MAX ? `, and ${list.length - NOTE_CVES_MAX} more` : ""})`;
|
|
319
|
-
});
|
|
320
|
-
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));
|
|
321
368
|
// Said only when the answer carries fixed_in (an API before #2834 sends none).
|
|
322
369
|
if (hits.some((r) => (Array.isArray(field(r, "matches")) ? field(r, "matches") : []).some((m) => isObj(m) && "fixed_in" in m))) {
|
|
323
|
-
out.push("Each match's fixed_in is the first version above the component's that no affected interval of the advisory holds; null, with fixed_in_reason, where
|
|
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.");
|
|
324
371
|
}
|
|
325
372
|
}
|
|
326
373
|
// #2836: rows matched by the source package their purl's upstream qualifier names.
|
|
@@ -395,7 +442,7 @@ export const Row = z.looseObject({
|
|
|
395
442
|
fixed_in_versions: z.array(z.string()).optional(),
|
|
396
443
|
})
|
|
397
444
|
.optional()
|
|
398
|
-
.describe("Present only on a row whose matches the structured result's cut (DATA CUT) shortened or left out: total, the row's matches in the answer; returned, the ones data keeps (
|
|
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."),
|
|
399
446
|
});
|
|
400
447
|
// The batch route's answer, as data carries it: its fields, merged over the batches.
|
|
401
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,11 +15,16 @@ 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).
|
|
25
30
|
// #3132's second review: check_sbom's sentence on not_sent_purls says a second call can send them
|
|
@@ -42,12 +47,14 @@ export function manifestText(sent) {
|
|
|
42
47
|
said: (inText, total) => `coverage.not_checked_by_reason counts all ${total} entries not checked, ${inText} of them in the text.`,
|
|
43
48
|
},
|
|
44
49
|
],
|
|
45
|
-
|
|
50
|
+
// #3269's second review: no page sentence, as for check_sbom's text (sbomText says why).
|
|
46
51
|
};
|
|
47
52
|
}
|
|
48
53
|
// #3013: the structured result's cut, check_sbom's (sbomData: the lists beside the rows, then every
|
|
49
54
|
// row thinned, then rows leaving, clean ones first) with not_checked beside not_sent_purls.
|
|
50
|
-
|
|
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);
|
|
51
58
|
export function readFiles(a) {
|
|
52
59
|
if (!Array.isArray(a.files))
|
|
53
60
|
return "files must be a list of {filename, content}";
|
|
@@ -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()),
|
|
@@ -501,6 +513,13 @@ export function registerVendorAdvisoryTools(server, kit) {
|
|
|
501
513
|
}
|
|
502
514
|
}
|
|
503
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
|
+
}
|
|
504
523
|
async function detail(vendorArg, idArg) {
|
|
505
524
|
const tool = "get_vendor_advisory";
|
|
506
525
|
const vendor = vendorArg.trim();
|
|
@@ -516,6 +535,8 @@ export function registerVendorAdvisoryTools(server, kit) {
|
|
|
516
535
|
try {
|
|
517
536
|
const windowsRead = readWindows();
|
|
518
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);
|
|
519
540
|
if (!r.ok)
|
|
520
541
|
return kit.failed(tool, r);
|
|
521
542
|
const w = await windowsRead;
|
|
@@ -657,10 +678,12 @@ export function registerVendorAdvisoryTools(server, kit) {
|
|
|
657
678
|
}, async ({ cve_id, full }) => kit.checked("vendor_advisories_for_cve", FOR_CVE_OUTPUT, await answering(full, FOR_CVE_DATA, () => forCVE(cve_id))));
|
|
658
679
|
server.registerTool("get_vendor_advisory", {
|
|
659
680
|
title: "Vendor advisory detail",
|
|
660
|
-
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}`,
|
|
661
682
|
inputSchema: z.object({
|
|
662
683
|
vendor: z.string().describe("the vendor slug, e.g. microsoft, redhat, github"),
|
|
663
|
-
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"),
|
|
664
687
|
full: FULL_ARG,
|
|
665
688
|
}),
|
|
666
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).
|
package/docs/tools.md
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Part of the [README](../README.md), whose Tools table says what each tool answers. This page says,
|
|
4
4
|
field by field, how `cve_summary`, `get_cve`, `search_cves`, `cve_exposure` and `check_affected`
|
|
5
|
-
read the API's answer
|
|
5
|
+
read the API's answer, and how `check_sbom` and `scan_manifest` cut their rows past the
|
|
6
|
+
structured result's budget; `exposure_radar` has [a page of its own](exposure-radar.md). It is kept out
|
|
6
7
|
of the README because npm keeps only the first 65,536 characters of a package's README, and the
|
|
7
8
|
package ships it in `docs/`. Exposure counts are derived from Shodan data. Shodan data is owned by
|
|
8
9
|
Shodan, which holds its copyright (© Shodan).
|
|
@@ -34,6 +35,15 @@ outputSchema describes is left out and named in the note, and a `poller` that is
|
|
|
34
35
|
object nor null is left out whole; `summary` is relayed either way, so a change to that diagnostic
|
|
35
36
|
block never withholds the counts.
|
|
36
37
|
|
|
38
|
+
`nvd_poll` is when the fleet of API instances last completed an NVD poll: `last_completed_at` (the
|
|
39
|
+
end of the last window fetched and stored without an error), with `scope` `fleet` and the poll's
|
|
40
|
+
`interval`. The API leaves it out when that time is unknown, and never puts the answering
|
|
41
|
+
instance's counters in its place. It is the result's `freshness` (`freshness.nvd_poll`, its time as
|
|
42
|
+
`last_run_at`) where `last_completed_at` is a real RFC 3339 time at most 5 minutes after this
|
|
43
|
+
server's clock and `scope` is `fleet`. Otherwise `freshness` is `null`, and the note says whether
|
|
44
|
+
the answer carries no `nvd_poll` or one without such a time. `poller.last_poll_at` is never the
|
|
45
|
+
feed's freshness.
|
|
46
|
+
|
|
37
47
|
The tool relays the API's JSON as it was sent, less a `poller` field left out as above. When
|
|
38
48
|
`summary.none`, `summary.nvd_none` or `summary.rejected` is above zero, the note says what it
|
|
39
49
|
counts, with the number from the answer.
|
|
@@ -133,9 +143,14 @@ affected, by one of two lookup paths:
|
|
|
133
143
|
criteria name that product with a version range that includes the version. It matches the
|
|
134
144
|
token across vendors, so each match carries `cpe_vendor` and `vendor_unknown`: a match with
|
|
135
145
|
`vendor_unknown` true is real, but its vendor is not verified to be yours.
|
|
136
|
-
- **Registry path** — `ecosystem` (`npm`, `PyPI`, `Maven`, …), `package` and `version`. Each
|
|
137
|
-
advisory
|
|
138
|
-
affected (
|
|
146
|
+
- **Registry path** — `ecosystem` (`npm`, `PyPI`, `Maven`, …), `package` and `version`. Each CVE
|
|
147
|
+
named by the OSV advisory records EchelonGraph holds for the package is decided against the
|
|
148
|
+
version once, as affected (one match), not affected (`not_affected_count`) or undetermined
|
|
149
|
+
(`undetermined_count`). When OSV publishes one CVE for the package in several records (a GHSA
|
|
150
|
+
and a package repository's own advisory, say), EchelonGraph stores a row for each and decides
|
|
151
|
+
them together, so the two counts count CVEs, while `advisories_considered` counts the stored
|
|
152
|
+
rows: a CVE stored as two rows and cleared counts 2 in `advisories_considered` and 1 in
|
|
153
|
+
`not_affected_count`.
|
|
139
154
|
|
|
140
155
|
Read `assessed` before `count`. `assessed` false means the lookup did not evaluate the
|
|
141
156
|
component, and `not_assessed_reason` says why: `product_not_in_cpe_corpus`,
|
|
@@ -156,8 +171,19 @@ holding interval's fixed bound, moved past any overlapping interval that still h
|
|
|
156
171
|
and `fixed_in_detail` then names the intervals it moved past (#3074). `fixed_in` is always above
|
|
157
172
|
the version asked about. It is null where no such version is recorded (the walk reaches an
|
|
158
173
|
interval that ends at `last_affected`, or has no end, with none beyond it) or no interval holds
|
|
159
|
-
the version, and `fixed_in_reason` then says which: a null is not a finding that no fix exists.
|
|
160
|
-
|
|
174
|
+
the version, and `fixed_in_reason` then says which: a null is not a finding that no fix exists. When
|
|
175
|
+
a CVE is stored as several records (#3137), `fixed_in` is walked over the records that store
|
|
176
|
+
intervals. A record of the same CVE that stores none withholds it only when that record's versions
|
|
177
|
+
or recorded fix may hold it affected. A record that cannot decide it (no version data, a git commit
|
|
178
|
+
range, or one recorded fix that `fixed_in` is at or past) does not, so the same lookup at
|
|
179
|
+
`fixed_in` itself can come back undetermined rather than not affected. A CPE
|
|
180
|
+
match carries no `fixed_in`, since the end of a CPE version range is not a fix. Once
|
|
181
|
+
EchelonGraph's API serves it, a CPE match carries that end in its own field, as NVD states it,
|
|
182
|
+
`cpe_version_end_excluding` (the first version the matched range no longer holds) or
|
|
183
|
+
`cpe_version_end_including` (the last one it holds), absent where the range sets no end. Only the
|
|
184
|
+
first cut level of the text and the first thinned level of `data` keep them, so a CPE answer cut
|
|
185
|
+
past those levels, as typical ones are, carries them only with `full: true`. Another range of the
|
|
186
|
+
same CVE, or a vendor backport, can sit past it, so it is never given as a fixed version. `check_sbom` and
|
|
161
187
|
`scan_manifest` relay the same fields (`interval`, `fixed_in`, `fixed_in_reason` and
|
|
162
188
|
`fixed_in_detail`) on each match.
|
|
163
189
|
|
|
@@ -165,3 +191,110 @@ match carries no `fixed_in`, since the end of a CPE version range is not a fix.
|
|
|
165
191
|
`X-EG-Ecosystem` and `X-EG-Package` request headers, never in the URL, so request logs and
|
|
166
192
|
trace spans that record URLs do not hold what was looked up. The endpoint reads a CPE vendor
|
|
167
193
|
from the URL only, so this tool takes none.
|
|
194
|
+
|
|
195
|
+
## The client cut
|
|
196
|
+
|
|
197
|
+
The server instructions and every tool description are held to 2,048 characters
|
|
198
|
+
(`CLIENT_DESCRIPTION_CUT`, `src/requiredText.ts`), and the sentences a client must not lose sit in
|
|
199
|
+
the first 2,048 characters of each (`REQUIRED_IN_HEAD`). 2,048 is the shortest cut a client is
|
|
200
|
+
known to make:
|
|
201
|
+
|
|
202
|
+
- Claude Code from v2.1.280 sends the model at most `CLAUDE_CODE_MAX_MCP_DESCRIPTION_LENGTH`
|
|
203
|
+
characters of each description and of the instructions. Its documented default was 2,048 when
|
|
204
|
+
this budget was set (read 2026-10-05). On 2026-10-18 its documentation gives 4,096, and 16,384
|
|
205
|
+
for a tool it loads through tool search, and v2.1.302's debug log shows a 4,096 cut ("Tool
|
|
206
|
+
"exposure_radar" description truncated from 11807 to 4096 chars", from 2.6.8's 11,807-character
|
|
207
|
+
description). Builds between v2.1.280 and that change still cut at 2,048.
|
|
208
|
+
- Cursor cuts tool descriptions at 6,500 characters. VS Code and Claude Desktop document no cap.
|
|
209
|
+
|
|
210
|
+
Text that fits 2,048 therefore reaches each of them whole. The tests hold every served text to it
|
|
211
|
+
over stdio (`test/tools.test.mjs`) and over the hosted endpoint (`test/http.test.mjs`), and the
|
|
212
|
+
synthetic writes an `mcp text cut` line per transport and era that an alert reads.
|
|
213
|
+
|
|
214
|
+
## How `check_sbom` and `scan_manifest` cut their rows
|
|
215
|
+
|
|
216
|
+
Past 30,000 characters of structured result, and without `full: true`, `check_sbom` and
|
|
217
|
+
`scan_manifest` cut `data` in this order (the note's `DATA CUT` sentences say where each answer
|
|
218
|
+
stopped):
|
|
219
|
+
|
|
220
|
+
1. `scan_manifest`'s `not_checked`, then `not_sent_purls`, which is kept whole or not at all (the
|
|
221
|
+
note gives the positions of the purls not sent, so a second call can send them).
|
|
222
|
+
2. Every row is thinned, every row kept: each keeps `index`, `purl`, `verdict`,
|
|
223
|
+
`not_assessed_reason`, `cve_ids`, `matched_package` and `matched_via` (and an upstream row's
|
|
224
|
+
`matched_version_from` and `purl_name`), and each match `cve_id`, `kev_listed`, `ransomware`,
|
|
225
|
+
`epss_score`, `effective_score`, `score_assessed`, `fixed_in` and `fixed_in_reason`: every field
|
|
226
|
+
a prompt reads from a match.
|
|
227
|
+
3. Before any other row's matches are cut, the `not_affected` and `not_assessed` rows are cut to
|
|
228
|
+
`index`, `purl`, `verdict`, `not_assessed_reason` and `cve_ids`, and leave, the `not_affected`
|
|
229
|
+
ones first, as many as must for every other row to keep all its matches. `data.summary` counts
|
|
230
|
+
every verdict, and the note says how many left (#3269).
|
|
231
|
+
4. Where the other rows' matches must be capped even with all of those rows gone, those rows stay
|
|
232
|
+
instead, cut so, while every other row fits at its floor: its matches used in ransomware or
|
|
233
|
+
KEV-listed, and 10 more (#3269's review). Each row's matches are capped at one number, the most
|
|
234
|
+
that fit, never under 10, and a row whose floor is above that number keeps its floor. A row
|
|
235
|
+
with more keeps the matches chosen used in ransomware first, then its other KEV-listed ones,
|
|
236
|
+
then those of highest `effective_score`, listed in the API's order, and carries
|
|
237
|
+
`matches_truncated`: its total, the number returned, and the KEV-listed and ransomware CVE IDs
|
|
238
|
+
and `fixed_in` versions of all its matches (#3270). Only where even the floor does not fit with
|
|
239
|
+
every one of those rows do they leave, as many as must for it to.
|
|
240
|
+
5. Each match is cut to the fields of step 2 less `fixed_in_reason`, still capped as step 4 says:
|
|
241
|
+
every level that keeps matches keeps every field `workload_triage` ranks by. A null `fixed_in`
|
|
242
|
+
there without its `fixed_in_reason` is a reason cut for size, and `sbom_review` never calls it
|
|
243
|
+
no fix: it checks the purl again with `full: true`, or reads the fix from `cve_remediation` or
|
|
244
|
+
`get_cve`. Kept here, the reason cost the 201-purl probe a row (43 to 42).
|
|
245
|
+
6. Where even that does not fit with all of those rows gone, no row's matches are cut below its
|
|
246
|
+
floor (#3269's second review): the other rows leave instead, whole, at step 5's level and
|
|
247
|
+
their floor. The rows kept are chosen those with a KEV-listed or ransomware match first, then
|
|
248
|
+
the others, each in input order, each one that fits beside those chosen before it, so a row
|
|
249
|
+
too large to fit beside them leaves and the rows after it can stay. The note names the
|
|
250
|
+
`affected` and `undetermined` rows that left by `index`, those with a KEV-listed or ransomware
|
|
251
|
+
match first and apart from the others (the first 100 indices or runs of them in all), and
|
|
252
|
+
`check_sbom`'s note says how to check them again (#3269's third review): "Re-check these K
|
|
253
|
+
purls in this order, in calls of 7, 5, 8, 8 and 16, then of at most 17: each such call keeps
|
|
254
|
+
every row in data." Each of the first five sizes is the most of the rows left, in that order,
|
|
255
|
+
that the cut's own measure counts as fitting at their floor with `FIRST_FIT_SLACK` (600
|
|
256
|
+
characters) held back, so a lower bound: a real call of them often keeps more. The last is the
|
|
257
|
+
most such that any that many of the rest in a row count so. Each is counted for those rows, as
|
|
258
|
+
the cut would cut such a call, with this call's note longer by what such a call's note names of
|
|
259
|
+
them (its affected components). A call of fewer of them in a row keeps every row too. A row whose floor alone is
|
|
260
|
+
past the budget goes in a call of its own. Where not even one row fits at its floor (one row
|
|
261
|
+
whose KEV-listed matches alone are past the budget), the row chosen first stays alone with the
|
|
262
|
+
most of its matches that fit, chosen as in step 4, and the note says so; where not one match
|
|
263
|
+
fits beside it, with none.
|
|
264
|
+
7. Only where no row but the `not_affected` and `not_assessed` ones has a match is each row cut
|
|
265
|
+
further, to `index`, `purl`, `verdict`, `not_assessed_reason` and `cve_ids`, before rows leave.
|
|
266
|
+
|
|
267
|
+
Once the cut is settled, the rows kept take back detail in input order while room is left, and
|
|
268
|
+
then the rows steps 3 and 4 sent out come back, cut as step 3 says, as many as the room left
|
|
269
|
+
holds, where every other row stayed.
|
|
270
|
+
|
|
271
|
+
When rows leave `data`, the note names a page size N: "A call of the first N of these purls, or
|
|
272
|
+
of fewer, keeps every row in data; a call of other purls says in its own note what it leaves
|
|
273
|
+
out." (`scan_manifest`: a call whose files hold the first N of these purls.) N is counted for this
|
|
274
|
+
answer's own rows, as the cut above would cut a call of them, so it can be larger or smaller than
|
|
275
|
+
the number of rows this answer keeps; it is not a page size for the rest of the purls, whose rows
|
|
276
|
+
can be larger: round 2's "To read every row in data, check N or fewer purls per call" read as
|
|
277
|
+
one, and calls of the purls in chunks of N lost rows in 55% of such mixes (#3269's second review).
|
|
278
|
+
Nor is it a size for checking the rows left out again, which are the heavy ones: calls of N of
|
|
279
|
+
them dropped 504 of 2,540 rows on realistic Debian images (#3269's third review); the re-check
|
|
280
|
+
sentence of step 6 gives those sizes. `workload_triage` (its step 6) and `sbom_review` (its step
|
|
281
|
+
1) check again the `affected` and `undetermined` rows the note names, those with a KEV-listed or
|
|
282
|
+
ransomware match first, in calls of the sizes that sentence gives, at most 5 calls, and report the
|
|
283
|
+
rest as unchecked; `workload_triage` takes its counts from `data.summary`, which counts every row
|
|
284
|
+
whatever the cut. `scan_manifest` names its rows left out the same way and gives no re-check sizes:
|
|
285
|
+
its rows are checked again through `check_sbom`, by purls its note does not give.
|
|
286
|
+
|
|
287
|
+
The first text block, past its own 30,000 characters, keeps the same priority: before any row is
|
|
288
|
+
cut to `index`, `purl`, `verdict`, `not_assessed_reason` and `cve_ids`, the `not_affected` and
|
|
289
|
+
`not_assessed` rows are cut to that and leave the text, as many as must, so an affected row keeps
|
|
290
|
+
its matches while room allows; only where that cannot fit is every row cut so, and then rows
|
|
291
|
+
leave, those two kinds first, and the rows with a KEV-listed or ransomware match last, as `data`
|
|
292
|
+
keeps them (#3284: through 3.1.0's review the text left the rest from the end in input order, so a
|
|
293
|
+
KEV-bearing row `data` kept could leave the text unnamed; on 36 realistic Debian 12 images a client
|
|
294
|
+
reading the text alone missed 95 of 413 such rows). The note names by `index` each of those that
|
|
295
|
+
still leaves ("Of the rows left out of it, 30 have a KEV-listed or ransomware match: at index 511
|
|
296
|
+
to 513, …"). The note says what those rows leave out, and that each string in
|
|
297
|
+
them keeps its first 100 characters where one is longer. The text names no page size (through
|
|
298
|
+
#3269's review it said "check N or fewer purls per call", N the rows it kept, and a call of the
|
|
299
|
+
first N purls kept fewer in 34 of 89 mixes); its note says how many rows it leaves out, of which
|
|
300
|
+
kinds, and that `structuredContent.data` holds them.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "echelongraph-mcp",
|
|
3
|
-
"version": "3.0
|
|
3
|
+
"version": "3.1.0",
|
|
4
4
|
"mcpName": "io.echelongraph/echelongraph-mcp",
|
|
5
5
|
"description": "MCP server for CVE lookups: EchelonGraph scores, CISA KEV, EPSS, CWE, exploits, version and SBOM checks, vendor advisories, and per-CVE exposure derived from Shodan data. Shodan data is owned by Shodan, which holds its copyright (© Shodan). Keyless.",
|
|
6
6
|
"type": "module",
|
package/server.json
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
]
|
|
18
18
|
}
|
|
19
19
|
],
|
|
20
|
-
"version": "3.0
|
|
20
|
+
"version": "3.1.0",
|
|
21
21
|
"remotes": [
|
|
22
22
|
{
|
|
23
23
|
"type": "streamable-http",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
{
|
|
29
29
|
"registryType": "npm",
|
|
30
30
|
"identifier": "echelongraph-mcp",
|
|
31
|
-
"version": "3.0
|
|
31
|
+
"version": "3.1.0",
|
|
32
32
|
"transport": {
|
|
33
33
|
"type": "stdio"
|
|
34
34
|
},
|