echelongraph-mcp 2.7.2 → 3.0.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 +124 -0
- package/README.md +146 -432
- package/dist/fixStatus.js +28 -0
- package/dist/index.js +225 -29
- package/dist/prompts.js +55 -31
- package/dist/requiredText.js +1 -1
- package/dist/resources.js +32 -6
- package/dist/textBudget.js +328 -16
- package/dist/tools/check_affected.js +41 -11
- package/dist/tools/check_sbom.js +47 -8
- package/dist/tools/cve_intel.js +15 -6
- package/dist/tools/cve_remediation.js +156 -16
- package/dist/tools/epss_history.js +6 -3
- package/dist/tools/get_cwe.js +7 -2
- package/dist/tools/kev_recent.js +18 -2
- package/dist/tools/match_batch.js +10 -6
- package/dist/tools/scan_manifest.js +20 -7
- package/dist/tools/vendor_advisories.js +34 -7
- package/docs/exposure-radar.md +181 -0
- package/docs/rate-limits.md +50 -0
- package/docs/structured-results.md +43 -0
- package/docs/tools.md +163 -0
- package/package.json +2 -1
- package/server.json +2 -2
package/dist/textBudget.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
-
// The text budget (#2783)
|
|
2
|
-
// than that is cut. index.ts's
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
1
|
+
// The text budget (#2783), and the structured result's (#3013): what a success may cost in each
|
|
2
|
+
// channel a client hands a model, and how an answer larger than that is cut. index.ts's
|
|
3
|
+
// `succeeded` builds every success's first text block with dataText (a tool that returns a list of
|
|
4
|
+
// rows names it with a TextCut), and its structured result with cutData (each tool names its lists
|
|
5
|
+
// in the order they are cut with a DataCut, and answering() hands the call's `full` and DataCut to
|
|
6
|
+
// `succeeded`). A module of its own, so a tool module can import its types,
|
|
7
|
+
// TEXT_BUDGET_DESCRIPTION and FULL_ARG without importing index.ts, which starts the stdio server
|
|
8
|
+
// when loaded.
|
|
9
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
10
|
+
import * as z from "zod";
|
|
7
11
|
// What a success costs in the channel a model reads is mostly its first text block, and through
|
|
8
12
|
// 2.6.2 that block was the API's JSON pretty-printed, whatever its size. Measured on production
|
|
9
13
|
// through https://mcp.echelongraph.io/mcp (2.6.2, 2026-10-04): search_cves {search: "openssl"} at
|
|
@@ -18,8 +22,8 @@
|
|
|
18
22
|
// serialized in a text block too, as a SHOULD, for backwards compatibility, and SEP-1624 proposes
|
|
19
23
|
// content as the model-facing, token-efficient form and structuredContent as the machine-facing one.
|
|
20
24
|
//
|
|
21
|
-
// So
|
|
22
|
-
// when it
|
|
25
|
+
// So the first text block is the API's JSON when it fits DATA_TEXT_BUDGET, half the ceiling, and
|
|
26
|
+
// cut when it does not (structuredContent.data, below, is cut on its own terms, never from this):
|
|
23
27
|
// 1. pretty-printed, as since 1.x, when that fits;
|
|
24
28
|
// 2. else the same JSON with each element of a list on a line of its own, which parses to the
|
|
25
29
|
// same value, when that fits;
|
|
@@ -33,19 +37,67 @@
|
|
|
33
37
|
// level keeps, and before any row leaves the text;
|
|
34
38
|
// 4. else every list keeps its first n entries, n halving, and then strings are cut too.
|
|
35
39
|
// Whenever the text is not the JSON whole, the note ends with one sentence, starting TEXT CUT,
|
|
36
|
-
// that says what the first text block leaves out,
|
|
40
|
+
// that says what the first text block leaves out, where structuredContent.data carries all of it,
|
|
37
41
|
// and how to read the rest in the text: the tool that returns one row, and what of it, and the page
|
|
38
42
|
// size or offset at which a page fits. The cut is deterministic: the same answer is cut the same way.
|
|
43
|
+
//
|
|
44
|
+
// The structured result, per client (#3013). Through 2.7.x structuredContent.data was the API's
|
|
45
|
+
// JSON whole, on the reading above that structuredContent is the machine-facing form. It is not, in
|
|
46
|
+
// the client the README gives first-class install steps: Claude Code hands the model
|
|
47
|
+
// structuredContent, not the text blocks (reproduced 2026-10-11 over raw JSON-RPC: get_cve
|
|
48
|
+
// CVE-2021-44228 was 65,540 characters of structuredContent beside a first text block of 23,759,
|
|
49
|
+
// and Claude Code refused it, "exceeds maximum allowed tokens", against its default
|
|
50
|
+
// MAX_MCP_OUTPUT_TOKENS of 25,000, and saved it to a file, so the model got a path, not an answer).
|
|
51
|
+
// The spec (MCP 2025-06-18, Tools, Structured Content) does not say which of the two a client gives
|
|
52
|
+
// the model. So the text cut above reached every client but that one. Since 3.0.0 the structured
|
|
53
|
+
// result is held to the same budget, STRUCTURED_BUDGET: the whole of it, envelope and notes with
|
|
54
|
+
// data, as compact JSON, since that is what such a client sizes. Past it, data is cut (cutData):
|
|
55
|
+
// 1. the lists the tool's DataCut names, step by step in its order. Where a step names thinning
|
|
56
|
+
// levels (a list whose single entries are large: check_sbom's and scan_manifest's results,
|
|
57
|
+
// check_affected's matches, cve_remediation's vendor_remediations, search_cves' and the
|
|
58
|
+
// vendor-advisory tools' rows), every entry is first cut to the fields each level keeps, level
|
|
59
|
+
// by level, all entries kept, so that no row leaves while thinning its detail would do; then
|
|
60
|
+
// each step keeps the most entries that fit, every list in the step at most the same number,
|
|
61
|
+
// its first ones, or for a list named with leaveOutFirst (check_sbom's results) those entries
|
|
62
|
+
// the tool names leave first (the clean rows, counted in data.summary), then the last of the
|
|
63
|
+
// rest, or for a step of series named keepNewest (exposure_radar's daily and weekly counts,
|
|
64
|
+
// which the API sends oldest first) its last ones, the newest, the oldest leaving first; a
|
|
65
|
+
// step that cannot fit even empty is emptied, and the next one is cut;
|
|
66
|
+
// 2. then every other list at the top level of data, the same way. A list inside an object of
|
|
67
|
+
// data (get_cve's remediation summary, with fix_status, #2841) is cut only where a DataCut
|
|
68
|
+
// names it, and none names get_cve's. Once a step fits, the room it leaves goes back to the
|
|
69
|
+
// steps emptied before it, the last first, each the same way (scan_manifest's not_checked,
|
|
70
|
+
// emptied while its rows were whole, keeps its entries once the thinned rows fit);
|
|
71
|
+
// 3. no string is shortened and no value changes: a thinned entry keeps some of its fields as
|
|
72
|
+
// they are (every field a thinning level drops is optional in the outputSchema, so data still
|
|
73
|
+
// validates against it), and no field outside the lists cut changes; the envelope says
|
|
74
|
+
// data_truncated true, truncated gives each list cut by its path in data with its total, the
|
|
75
|
+
// number returned and, where its entries are thinned, thinned true, and the note ends with
|
|
76
|
+
// sentences starting DATA CUT that say what was cut and to which fields, how to read the rest
|
|
77
|
+
// (the offset or page size, where the tool pages: a page size the note says fits data whole
|
|
78
|
+
// is counted on the entries whole, never on the thinned ones, #3013's review) and that the
|
|
79
|
+
// same call with full: true returns data whole.
|
|
80
|
+
// The first text block is cut from the whole answer as before, not from the cut data: what it
|
|
81
|
+
// holds is unchanged for the clients that read it. full: true is for programs, which read data
|
|
82
|
+
// whole whatever its size; a client that sizes structured results, Claude Code among them, may
|
|
83
|
+
// still refuse such an answer past its own cap (MAX_MCP_OUTPUT_TOKENS raises Claude Code's).
|
|
84
|
+
// Rejected (#3013's decision of 2026-10-11): saying only that Claude Code gets files; cutting only
|
|
85
|
+
// for a client whose clientInfo.name is Claude Code (the same call would answer differently by
|
|
86
|
+
// client, and the next client to pass structuredContent on would get the 65,540 again); and dropping
|
|
87
|
+
// structuredContent (it loses the outputSchema check the spec recommends and the suite relies on).
|
|
39
88
|
export const DATA_TEXT_BUDGET = 30_000;
|
|
40
89
|
// What a note may say of reading one CVE's record through get_cve (#2802): get_cve's own first text
|
|
41
|
-
// block is cut too past the budget (each list to its first entries, every other field kept),
|
|
42
|
-
//
|
|
43
|
-
|
|
90
|
+
// block is cut too past the budget (each list to its first entries, every other field kept), and
|
|
91
|
+
// since #3013 so is its structured result's data, unless the call says full: true. A row tool's
|
|
92
|
+
// `whole` sentence starts with it.
|
|
93
|
+
export const GET_CVE_WHOLE = "get_cve returns any one of these CVEs' records, whole in its structuredContent.data with full: true, and in its first text block with every field (past 30,000 characters, each list cut to its first entries)";
|
|
44
94
|
// What every tool description says about it, one constant string (marketing-site
|
|
45
95
|
// lib/mcpToolClaims.test.ts folds only constant strings); the suite holds its number to
|
|
46
96
|
// DATA_TEXT_BUDGET's. It uses none of the words that check reviews as the page's own prose (how,
|
|
47
|
-
// when, does, have, on and the rest of its PROSE list), so no reviewed entry goes stale.
|
|
48
|
-
|
|
97
|
+
// when, does, have, on and the rest of its PROSE list), so no reviewed entry goes stale. #3013: the
|
|
98
|
+
// same length as 2.7.x's (203 characters), since several descriptions sit within ten characters of
|
|
99
|
+
// the floor the suite keeps under the client cut (#2924); FULL_ARG says the rest.
|
|
100
|
+
export const TEXT_BUDGET_DESCRIPTION = "Past 30,000 characters of JSON, the first text block holds data cut to fit, and the note says what the cut leaves out and where to read it (TEXT CUT); so is structured data (DATA CUT), unless full: true.";
|
|
49
101
|
const isPlainObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
50
102
|
const field = (o, k) => (isPlainObject(o) ? o[k] : undefined);
|
|
51
103
|
const listed = (xs) => (xs.length < 2 ? xs.join("") : `${xs.slice(0, -1).join(", ")} and ${xs[xs.length - 1]}`);
|
|
@@ -165,6 +217,12 @@ function cutSides(data, sides) {
|
|
|
165
217
|
export const FIRST_TEXT_BLOCK = {
|
|
166
218
|
opens: (chars) => `TEXT CUT: the API's answer is ${chars} characters of JSON, more than the ${DATA_TEXT_BUDGET} the first text block holds, so that block is cut, and structuredContent.data carries the answer whole.`,
|
|
167
219
|
};
|
|
220
|
+
// #3013: the first text block beside a structuredContent.data that is cut too (DATA CUT): the same
|
|
221
|
+
// text, cut the same way, and sentences that say data is whole only with full: true.
|
|
222
|
+
export const FIRST_TEXT_BLOCK_BESIDE_A_CUT = {
|
|
223
|
+
opens: (chars) => `TEXT CUT: the API's answer is ${chars} characters of JSON, more than the ${DATA_TEXT_BUDGET} the first text block holds, so that block is cut, and structuredContent.data carries the answer whole with full: true (DATA CUT, below).`,
|
|
224
|
+
leftOut: (n, which) => `the other ${n} are left out of it${which}, and structuredContent.data holds them with full: true`,
|
|
225
|
+
};
|
|
168
226
|
/** The first text block for `data` within DATA_TEXT_BUDGET, and the TEXT CUT sentence when it is cut. */
|
|
169
227
|
export function dataText(data, cut, place = FIRST_TEXT_BLOCK) {
|
|
170
228
|
const pretty = JSON.stringify(data, null, 2);
|
|
@@ -232,7 +290,8 @@ export function dataText(data, cut, place = FIRST_TEXT_BLOCK) {
|
|
|
232
290
|
const which = outFirst > 0
|
|
233
291
|
? `: the ${outFirst} whose ${firstOut?.field} is ${(firstOut?.values ?? []).join(" or ")} (${firstOut?.why})${out.size > outFirst ? `, and the last ${out.size - outFirst} of the rest` : ""}`
|
|
234
292
|
: `, the last ${out.size} in order`;
|
|
235
|
-
const
|
|
293
|
+
const elsewhere = place.leftOut ? place.leftOut(out.size, which) : `the other ${out.size} are in structuredContent.data only${which}`;
|
|
294
|
+
const left = `Only ${kept.length} of the ${n} rows of ${cut.rows} are in it; ${elsewhere}.`;
|
|
236
295
|
return { text: shown, said: say(rowsSaid, ...sidesSaid, left, cut.whole, cut.page?.(kept.length, n, data)) };
|
|
237
296
|
}
|
|
238
297
|
base = { ...beside, [cut.rows]: cutRows };
|
|
@@ -250,5 +309,258 @@ export function dataText(data, cut, place = FIRST_TEXT_BLOCK) {
|
|
|
250
309
|
const more = last.caps.size > 8 ? `, and ${last.caps.size - 8} more` : "";
|
|
251
310
|
const lists = last.caps.size ? `Every list in it keeps at most its first ${last.n} ${last.n === 1 ? "entry" : "entries"}: ${listed(named)}${more}, each named with its full length.` : "";
|
|
252
311
|
const strings = last.s.clipped ? `Every string in it longer than ${last.clip} characters keeps its first ${last.clip}, ending in "…".` : "";
|
|
253
|
-
|
|
312
|
+
const said = say(rowsSaid, ...sidesSaid, lists, strings, cut?.whole);
|
|
313
|
+
if (last.caps.size === 0)
|
|
314
|
+
return { text: last.text, said };
|
|
315
|
+
return { text: last.text, said, capped: Object.fromEntries([...last.caps].map(([p, len]) => [p || "the answer", { total: len, returned: Math.min(last.n, len) }])) };
|
|
316
|
+
}
|
|
317
|
+
// ── The structured result's budget (#3013) ──
|
|
318
|
+
//
|
|
319
|
+
// What a success's structured result may cost, all of it (the envelope, its notes and data), as
|
|
320
|
+
// compact JSON, which is how a client that hands structuredContent to a model reads it: the same
|
|
321
|
+
// number as the first text block's. See "The structured result, per client" above.
|
|
322
|
+
export const STRUCTURED_BUDGET = DATA_TEXT_BUDGET;
|
|
323
|
+
// The clause of a paging tool's DATA CUT sentence that names the page size whose rows fit data
|
|
324
|
+
// whole, given PageAt.wholeFit for its rows (search_cves, search_vendor_advisories).
|
|
325
|
+
export const wholePage = (n) => n > 0
|
|
326
|
+
? `a page of ${n} ${n === 1 ? "row" : "rows"} or fewer like these fits data whole.`
|
|
327
|
+
: `even a page of one row like these is past ${STRUCTURED_BUDGET} characters with that row whole, so its data is cut too.`;
|
|
328
|
+
// A tool that names no list: only step 2 (every list at the top level of data) cuts its data.
|
|
329
|
+
export const NO_DATA_CUT = { steps: [] };
|
|
330
|
+
const calls = new AsyncLocalStorage();
|
|
331
|
+
export function answering(full, cut, fn) {
|
|
332
|
+
return calls.run({ full: full === true, cut }, fn);
|
|
333
|
+
}
|
|
334
|
+
export const answeringNow = () => calls.getStore();
|
|
335
|
+
// The argument every tool takes (#3013), and what it says, one constant string. It names no other
|
|
336
|
+
// tool and directs no model (test/description-policy.test.mjs reads inputSchema descriptions too).
|
|
337
|
+
export const FULL_ARG_DESCRIPTION = "true: data in the structured result is the answer whole, however long. Without it, a structured result past 30,000 characters of JSON has its data cut (DATA CUT): in an order the note states, the entries of its longest lists keep only some of their fields, and then the lists only some of their entries; data_truncated is true, and truncated gives each list cut with its total, the number returned and whether its entries are thinned. Whole data is for a program; a client that sizes structured results, as Claude Code does against MAX_MCP_OUTPUT_TOKENS, can refuse an answer past its cap.";
|
|
338
|
+
export const FULL_ARG = z.boolean().optional().describe(FULL_ARG_DESCRIPTION);
|
|
339
|
+
const thinned = (e, keep) => isPlainObject(e) ? cutRow(e, keep, { keep, clip: Number.POSITIVE_INFINITY }, { left: new Set(), clipped: 0, capped: 0 }) : e;
|
|
340
|
+
// An entry JSON cannot write (undefined) is written null in a list.
|
|
341
|
+
const entryChars = (e) => JSON.stringify(e)?.length ?? 4;
|
|
342
|
+
const listOf = (data, at) => at.reduce((o, k) => field(o, k), data);
|
|
343
|
+
function listAt(data, path, firstOut, thin, newest) {
|
|
344
|
+
const at = path.split(".");
|
|
345
|
+
const v = listOf(data, at);
|
|
346
|
+
if (!Array.isArray(v) || v.length === 0)
|
|
347
|
+
return undefined;
|
|
348
|
+
const n = v.length;
|
|
349
|
+
const entries = thin ? v.map((e) => thinned(e, thin)) : v;
|
|
350
|
+
const lens = entries.map(entryChars);
|
|
351
|
+
// The indices in the order they leave: from the end, or, for a series kept newest, from the start.
|
|
352
|
+
const leavingOrder = (i) => (newest ? i : n - 1 - i);
|
|
353
|
+
const order = [];
|
|
354
|
+
for (const value of firstOut?.values ?? [])
|
|
355
|
+
for (let j = 0; j < n; j++)
|
|
356
|
+
if (field(v[leavingOrder(j)], firstOut?.field ?? "") === value)
|
|
357
|
+
order.push(leavingOrder(j));
|
|
358
|
+
const named = order.length;
|
|
359
|
+
const queued = new Set(order);
|
|
360
|
+
for (let j = 0; j < n; j++)
|
|
361
|
+
if (!queued.has(leavingOrder(j)))
|
|
362
|
+
order.push(leavingOrder(j));
|
|
363
|
+
const leaving = [0];
|
|
364
|
+
for (const i of order)
|
|
365
|
+
leaving.push(leaving[leaving.length - 1] + lens[i]);
|
|
366
|
+
const all = leaving[n];
|
|
367
|
+
const kept = (k) => Math.max(0, Math.min(n, k));
|
|
368
|
+
return {
|
|
369
|
+
path,
|
|
370
|
+
at,
|
|
371
|
+
n,
|
|
372
|
+
firstOut,
|
|
373
|
+
thin,
|
|
374
|
+
...(newest ? { newest } : {}),
|
|
375
|
+
named,
|
|
376
|
+
chars: (k) => all - leaving[n - kept(k)] + Math.max(0, kept(k) - 1),
|
|
377
|
+
keep: (k) => {
|
|
378
|
+
const out = new Set(order.slice(0, n - kept(k)));
|
|
379
|
+
return entries.filter((_, i) => !out.has(i));
|
|
380
|
+
},
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
// `o` with the list at `at` replaced by `v`, each object on the way copied, its keys in their order.
|
|
384
|
+
function withList(o, at, v) {
|
|
385
|
+
const [k, ...rest] = at;
|
|
386
|
+
return { ...o, [k]: rest.length ? withList(o[k], rest, v) : v };
|
|
387
|
+
}
|
|
388
|
+
const orListed = (xs) => (xs.length < 2 ? xs.join("") : `${xs.slice(0, -1).join(", ")} or ${xs[xs.length - 1]}`);
|
|
389
|
+
// The fields a thinning level keeps, as a sentence says them.
|
|
390
|
+
const keepSaid = (keep) => listed(keep.map((x) => (typeof x === "string" ? x : `${x[0]} (each with ${listed([...x[1]])})`)));
|
|
391
|
+
// What a cut keeps of each list it has cut so far, as truncated and as the DATA CUT sentences.
|
|
392
|
+
function cutSaid(cut, kept, wholeChars, data, over, wholeFit) {
|
|
393
|
+
const isThinned = ({ list, k }) => list.thin !== undefined && k > 0;
|
|
394
|
+
const cutOnes = kept.filter((x) => x.k < x.list.n || isThinned(x));
|
|
395
|
+
if (cutOnes.length === 0)
|
|
396
|
+
return { said: "" };
|
|
397
|
+
const truncated = Object.fromEntries(cutOnes.map((x) => [x.list.path, { total: x.list.n, returned: x.k, ...(isThinned(x) ? { thinned: true } : {}) }]));
|
|
398
|
+
const parts = [];
|
|
399
|
+
const byKept = new Map();
|
|
400
|
+
for (const x of cutOnes) {
|
|
401
|
+
const { list, k } = x;
|
|
402
|
+
const out = list.n - k;
|
|
403
|
+
const first = Math.min(out, list.named);
|
|
404
|
+
const end = list.newest ? "oldest" : "last";
|
|
405
|
+
const which = list.firstOut ? `whose ${list.firstOut.field} is ${orListed(list.firstOut.values)} (${list.firstOut.why})` : "";
|
|
406
|
+
const leftOut = out === 0 ? "" : first === 0 ? `, the ${end} ${out} left out` : first === out ? `, the ${out} left out being entries ${which}` : `, the ${out} left out being the ${first} ${which} and the ${end} ${out - first} of the rest`;
|
|
407
|
+
if (isThinned(x)) {
|
|
408
|
+
parts.push(`${list.path} keeps ${k === list.n ? `all ${k}` : `${k} of its ${list.n}`} entries, each cut to ${keepSaid(list.thin)}${leftOut}`);
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
if (!list.firstOut || first === 0 || k === 0) {
|
|
412
|
+
const newest = list.newest === true && k > 0;
|
|
413
|
+
const key = `${k}:${newest}`;
|
|
414
|
+
const group = byKept.get(key);
|
|
415
|
+
if (group)
|
|
416
|
+
group.lists.push(list);
|
|
417
|
+
else {
|
|
418
|
+
const g = { k, newest, lists: [list] };
|
|
419
|
+
byKept.set(key, g);
|
|
420
|
+
parts.push(g);
|
|
421
|
+
}
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
parts.push(`${list.path} keeps ${k} of its ${list.n} entries${leftOut}`);
|
|
425
|
+
}
|
|
426
|
+
const cuts = parts.map((p) => {
|
|
427
|
+
if (typeof p === "string")
|
|
428
|
+
return p;
|
|
429
|
+
const paths = listed(p.lists.map((l) => l.path));
|
|
430
|
+
const which = p.newest ? "newest" : "first";
|
|
431
|
+
if (p.lists.length === 1)
|
|
432
|
+
return p.k === 0 ? `${paths} keeps none of its ${p.lists[0].n} entries` : `${paths} keeps its ${which} ${p.k} of ${p.lists[0].n} entries`;
|
|
433
|
+
return p.k === 0 ? `${paths} keep none of their entries` : `${paths} keep their ${which} ${p.k} entries each`;
|
|
434
|
+
});
|
|
435
|
+
const keeps = Object.fromEntries(cutOnes.filter(isThinned).map((x) => [x.list.path, x.list.thin]));
|
|
436
|
+
const page = cut.page?.(truncated, data, { keeps, wholeFit }) ?? "";
|
|
437
|
+
const said = [
|
|
438
|
+
`DATA CUT: with data whole, this structured result is ${wholeChars} characters of JSON, more than the ${STRUCTURED_BUDGET} it holds unless the call says full: true, so its data is cut: ${cuts.join("; ")}.`,
|
|
439
|
+
"truncated gives the total and the number returned of each list cut, and thinned where its entries are cut to some of their fields.",
|
|
440
|
+
page,
|
|
441
|
+
over ? `Cut so, the structured result is still past ${STRUCTURED_BUDGET} characters: data holds no other list to cut.` : "",
|
|
442
|
+
"The same call with full: true returns data whole.",
|
|
443
|
+
];
|
|
444
|
+
return { truncated, said: said.filter(Boolean).join(" ") };
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* The structured result for `data` cut to STRUCTURED_BUDGET, as `cut` says (see "The structured
|
|
448
|
+
* result, per client" above): `structured` builds the result for a data, its truncated and the DATA
|
|
449
|
+
* CUT sentences, and is called with data null to size the rest of it. `wholeChars` is the result's
|
|
450
|
+
* size with data whole. undefined when data holds no list to cut.
|
|
451
|
+
*/
|
|
452
|
+
export function cutData(data, cut, wholeChars, structured) {
|
|
453
|
+
if (!isPlainObject(data))
|
|
454
|
+
return undefined;
|
|
455
|
+
const named = new Set(cut.steps.flatMap((s) => s.lists));
|
|
456
|
+
const steps = [...cut.steps, { lists: Object.keys(data).filter((k) => !named.has(k)) }];
|
|
457
|
+
// Each list cut so far, in the order cut, at the level it was thinned to, how many entries it
|
|
458
|
+
// keeps, and the step that cut it.
|
|
459
|
+
const kept = new Map();
|
|
460
|
+
const apply = (also = []) => [...kept.values(), ...also].reduce((d, { list, k }) => withList(d, list.at, list.keep(k)), data);
|
|
461
|
+
// PageAt.wholeFit: of the list at `path`, the most first entries, each whole, that the result
|
|
462
|
+
// holds within the budget with the rest of data whole and no DATA CUT sentence (structured with
|
|
463
|
+
// truncated undefined): the shape of the same call at that page size. Counted once per path.
|
|
464
|
+
const wholeFits = new Map();
|
|
465
|
+
const wholeFit = (path) => {
|
|
466
|
+
const known = wholeFits.get(path);
|
|
467
|
+
if (known !== undefined)
|
|
468
|
+
return known;
|
|
469
|
+
const at = path.split(".");
|
|
470
|
+
const v = listOf(data, at);
|
|
471
|
+
let k = 0;
|
|
472
|
+
if (Array.isArray(v)) {
|
|
473
|
+
let chars = JSON.stringify(structured(withList(data, at, []), undefined, "")).length;
|
|
474
|
+
for (const e of v) {
|
|
475
|
+
chars += entryChars(e) + (k > 0 ? 1 : 0);
|
|
476
|
+
if (chars > STRUCTURED_BUDGET)
|
|
477
|
+
break;
|
|
478
|
+
k++;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
wholeFits.set(path, k);
|
|
482
|
+
return k;
|
|
483
|
+
};
|
|
484
|
+
// The result's size with this step's lists at k entries each: the rest of the result (data
|
|
485
|
+
// null), data with this step's lists empty (`base`), and their entries.
|
|
486
|
+
const size = (lists, k, base) => {
|
|
487
|
+
const all = [...kept.values(), ...lists.map((list) => ({ list, k: Math.min(k, list.n) }))];
|
|
488
|
+
const { truncated, said } = cutSaid(cut, all, wholeChars, data, false, wholeFit);
|
|
489
|
+
return JSON.stringify(structured(null, truncated, said)).length - "null".length + base + lists.reduce((t, l) => t + l.chars(k), 0);
|
|
490
|
+
};
|
|
491
|
+
let fits = false;
|
|
492
|
+
for (const [s, step] of steps.entries()) {
|
|
493
|
+
// Every entry whole, then each thinning level in turn, every entry kept; at the leanest level
|
|
494
|
+
// the entries leave until the result fits.
|
|
495
|
+
const levels = [undefined, ...(step.thin ?? [])];
|
|
496
|
+
let lists = [];
|
|
497
|
+
let k = 0;
|
|
498
|
+
for (const [i, thin] of levels.entries()) {
|
|
499
|
+
lists = step.lists.map((p) => listAt(data, p, step.leaveOutFirst, thin, step.keepNewest)).filter((l) => l !== undefined && !kept.has(l.path));
|
|
500
|
+
if (lists.length === 0)
|
|
501
|
+
break;
|
|
502
|
+
const base = JSON.stringify(apply(lists.map((list) => ({ list, k: 0 })))).length;
|
|
503
|
+
const top = Math.max(...lists.map((l) => l.n));
|
|
504
|
+
if (i < levels.length - 1) {
|
|
505
|
+
if (size(lists, top, base) <= STRUCTURED_BUDGET) {
|
|
506
|
+
k = top;
|
|
507
|
+
fits = true;
|
|
508
|
+
break;
|
|
509
|
+
}
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
if (size(lists, 0, base) <= STRUCTURED_BUDGET) {
|
|
513
|
+
// The most entries each list of the step keeps with the result inside the budget.
|
|
514
|
+
let hi = top;
|
|
515
|
+
while (k < hi) {
|
|
516
|
+
const mid = Math.ceil((k + hi) / 2);
|
|
517
|
+
if (size(lists, mid, base) <= STRUCTURED_BUDGET)
|
|
518
|
+
k = mid;
|
|
519
|
+
else
|
|
520
|
+
hi = mid - 1;
|
|
521
|
+
}
|
|
522
|
+
fits = true;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
for (const list of lists)
|
|
526
|
+
kept.set(list.path, { list, k: Math.min(k, list.n), step: s });
|
|
527
|
+
if (fits)
|
|
528
|
+
break;
|
|
529
|
+
}
|
|
530
|
+
// The room the step that fits leaves goes back to the steps emptied before it, the last first,
|
|
531
|
+
// each step's lists keeping at most the same number of their entries, as many as fit, at the
|
|
532
|
+
// level they were thinned to (scan_manifest's not_checked, emptied while its rows were whole,
|
|
533
|
+
// keeps its entries once the thinned rows fit).
|
|
534
|
+
if (fits) {
|
|
535
|
+
const exact = () => {
|
|
536
|
+
const { truncated, said } = cutSaid(cut, [...kept.values()], wholeChars, data, false, wholeFit);
|
|
537
|
+
return JSON.stringify(structured(apply(), truncated, said)).length;
|
|
538
|
+
};
|
|
539
|
+
const last = Math.max(...[...kept.values()].map((e) => e.step));
|
|
540
|
+
for (let s = last - 1; s >= 0; s--) {
|
|
541
|
+
const entries = [...kept.values()].filter((e) => e.step === s);
|
|
542
|
+
if (entries.length === 0)
|
|
543
|
+
continue;
|
|
544
|
+
const at = (k) => {
|
|
545
|
+
for (const e of entries)
|
|
546
|
+
e.k = Math.min(k, e.list.n);
|
|
547
|
+
};
|
|
548
|
+
let k = Math.min(...entries.map((e) => e.k));
|
|
549
|
+
let hi = Math.max(...entries.map((e) => e.list.n));
|
|
550
|
+
while (k < hi) {
|
|
551
|
+
const mid = Math.ceil((k + hi) / 2);
|
|
552
|
+
at(mid);
|
|
553
|
+
if (exact() <= STRUCTURED_BUDGET)
|
|
554
|
+
k = mid;
|
|
555
|
+
else
|
|
556
|
+
hi = mid - 1;
|
|
557
|
+
}
|
|
558
|
+
at(k);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
const { truncated, said } = cutSaid(cut, [...kept.values()], wholeChars, data, !fits, wholeFit);
|
|
562
|
+
if (truncated === undefined)
|
|
563
|
+
return undefined;
|
|
564
|
+
const d = apply();
|
|
565
|
+
return { data: d, truncated, said, structured: structured(d, truncated, said) };
|
|
254
566
|
}
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
// The JSON is relayed verbatim as data (#1874): every match keeps kev_listed, ransomware,
|
|
28
28
|
// epss_score, effective_score, effective_severity and score_assessed as the API sent them.
|
|
29
29
|
import * as z from "zod";
|
|
30
|
-
import { GET_CVE_WHOLE, TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
|
|
30
|
+
import { answering, FULL_ARG, GET_CVE_WHOLE, TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
|
|
31
31
|
const TOOL = "check_affected";
|
|
32
32
|
export const MATCH_PATH = "/api/v1/public/cves/match";
|
|
33
33
|
// handler.go, the four request headers the endpoint reads before the query string.
|
|
@@ -74,10 +74,11 @@ const Match = z.looseObject({
|
|
|
74
74
|
vendor_unknown: opt(z.boolean()).describe("true: the vendor was not verified, so the match is real but its attribution to your vendor is not."),
|
|
75
75
|
match_reason: opt(z.string()),
|
|
76
76
|
version_evidence: opt(z.string()),
|
|
77
|
-
// #2834: registry matches only (core-backend cve/pkgmatch_fixedin.go
|
|
77
|
+
// #2834, #3074: registry matches only (core-backend cve/pkgmatch_fixedin.go registryFixFor).
|
|
78
78
|
interval: opt(z.looseObject({ introduced: opt(z.string()), fixed: opt(z.string()), last_affected: opt(z.string()) })).describe("Registry match: the advisory interval that holds this version (fixed is exclusive, last_affected inclusive); absent when no interval holds it."),
|
|
79
|
-
fixed_in: opt(z.string()).describe("Registry match: the fixed bound of the
|
|
79
|
+
fixed_in: opt(z.string()).describe("Registry match: the lowest version above this one that no affected interval of the CVE's record for the package holds, always above it: the fixed bound of the interval that holds this version, moved past any overlapping interval that still holds that bound (fixed_in_detail then names it). null when no such version is on record (an interval on the way records no fix: it ends at last_affected, or has no end) or no interval holds the version, with fixed_in_reason saying which. Absent on a CPE match."),
|
|
80
80
|
fixed_in_reason: opt(z.string()).describe("Why fixed_in is null; absent when fixed_in is a version."),
|
|
81
|
+
fixed_in_detail: opt(z.string()).describe("Set when fixed_in is not the fixed bound of the interval that holds this version: the overlapping intervals of the same record it moved past, which still hold that bound."),
|
|
81
82
|
});
|
|
82
83
|
const Undetermined = z.looseObject({
|
|
83
84
|
cve_id: opt(z.string()),
|
|
@@ -130,7 +131,7 @@ const COVERAGE = z.strictObject({
|
|
|
130
131
|
// Constant strings, so marketing-site lib/mcpToolClaims.test.ts can fold them should it read
|
|
131
132
|
// this file.
|
|
132
133
|
export const CHECK_AFFECTED_TITLE = "Am I affected? (product or package at a version)";
|
|
133
|
-
export const CHECK_AFFECTED_DESCRIPTION = "Whether a product or package at a given version is affected by known CVEs, from the same matcher as echelongraph.io/am-i-affected. The CPE path takes product (an NVD CPE product token, such as
|
|
134
|
+
export const CHECK_AFFECTED_DESCRIPTION = "Whether a product or package at a given version is affected by known CVEs, from the same matcher as echelongraph.io/am-i-affected. The CPE path takes product (an NVD CPE product token, such as nginx) and version, and returns the CVEs whose NVD CPE match criteria include that product at that version; each match names the vendor NVD asserts (cpe_vendor) and whether it was verified (vendor_unknown). The registry path takes ecosystem (npm, PyPI, Maven and other OSV ecosystem names), package and version, and decides each OSV advisory EchelonGraph holds for that package as affected, not affected or undetermined. count depends on assessed: assessed false means the lookup did not evaluate this component, not_assessed_reason says why, and a count of 0 there is not a finding of not affected. An advisory undecidable at this version is reported as undetermined (undetermined_count, and up to 50 of them in undetermined), never as safe. Each match carries cve_id, kev_listed, ransomware, epss_score, effective_score, effective_severity and score_assessed (false: not yet scored, so echelongraph_score is withheld). A registry match also carries interval, the advisory interval holding it, and fixed_in, the lowest unaffected version above it, or null with fixed_in_reason; a CPE match carries no fixed_in. All four inputs travel in request headers, never in the URL." +
|
|
134
135
|
" " +
|
|
135
136
|
TEXT_BUDGET_DESCRIPTION +
|
|
136
137
|
" Cut, the excluded and undetermined samples keep their first 10 entries, each its cve_id and reason, cve_ids keeps its first 10, and each match keeps cve_id, kev_listed, ransomware, epss_score, effective_score, score_assessed and fixed_in at least. Every CPE match stays in the text; near the cap, a registry list's last matches can leave it, and the note says how many.";
|
|
@@ -139,6 +140,7 @@ export const CHECK_AFFECTED_INPUT = z.object({
|
|
|
139
140
|
ecosystem: z.string().optional().describe("Registry path: the package's ecosystem, such as npm, PyPI or Maven. Give with package, not with product."),
|
|
140
141
|
package: z.string().optional().describe("Registry path: the package name in that ecosystem, such as lodash."),
|
|
141
142
|
version: z.string().describe("The version to check, such as 3.0.0."),
|
|
143
|
+
full: FULL_ARG,
|
|
142
144
|
});
|
|
143
145
|
const field = (o, k) => (o !== null && typeof o === "object" ? o[k] : undefined);
|
|
144
146
|
const num = (o, k) => {
|
|
@@ -172,22 +174,24 @@ function matchesNote(matches) {
|
|
|
172
174
|
}
|
|
173
175
|
return out;
|
|
174
176
|
}
|
|
175
|
-
// #2834: what the registry matches say about a fix. fixed_in is the
|
|
176
|
-
// that
|
|
177
|
-
//
|
|
177
|
+
// #2834: what the registry matches say about a fix. fixed_in is the first version above this one
|
|
178
|
+
// that no affected interval of the match's record holds (core-backend cve/pkgmatch_fixedin.go):
|
|
179
|
+
// the fixed bound of the interval that holds this version, moved past any overlapping interval
|
|
180
|
+
// that still holds it (#3074), so it is per match: an advisory with several branches (Log4Shell:
|
|
181
|
+
// 2.3.1, 2.12.2, 2.15.0) names the one for this version.
|
|
178
182
|
function fixedInNote(matches) {
|
|
179
183
|
const registry = matches.filter((m) => m !== null && typeof m === "object" && "fixed_in" in m);
|
|
180
184
|
if (registry.length === 0)
|
|
181
185
|
return "";
|
|
182
186
|
const fixed = registry.filter((m) => typeof field(m, "fixed_in") === "string").length;
|
|
183
187
|
const none = registry.length - fixed;
|
|
184
|
-
return ` fixed_in, the
|
|
188
|
+
return ` fixed_in, the first version above this one that no affected interval of the advisory holds: ${fixed === registry.length ? `set on every match` : `set on ${fixed} of ${registry.length} matches`}${none > 0 ? `; null on ${none}, where the advisory records no fix for this version (fixed_in_reason says why)` : ""}.`;
|
|
185
189
|
}
|
|
186
190
|
// #2783: a production match is about 2,100 characters as pretty JSON, most of it the CVE's
|
|
187
191
|
// description and match_reason, so openssl 3.0.0 (71 matches) was 156,058 characters of text and
|
|
188
192
|
// linux_kernel 5.10.0 (200, the API's cap) 364,408. Past DATA_TEXT_BUDGET each match in the first
|
|
189
193
|
// text block keeps its scores, flags, vendor attribution and match_reason (for a registry match,
|
|
190
|
-
// the advisory interval its version falls inside: "[A, B)" names
|
|
194
|
+
// the advisory interval its version falls inside: "[A, B)" names that interval's fixed bound B, "[A, B]" the
|
|
191
195
|
// last affected one, #2830) with the
|
|
192
196
|
// description cut to 200 characters; then the same without the description; then only the fields
|
|
193
197
|
// the description promises; then, for a list at the cap, without cpe_vendor and vendor_unknown;
|
|
@@ -221,7 +225,7 @@ const CHECK_AFFECTED_TEXT = {
|
|
|
221
225
|
keep: [
|
|
222
226
|
"cve_id", "severity", "cvss_v3_score", "description", "kev_listed", "ransomware", "epss_score", "echelongraph_score", "echelongraph_severity", "effective_score", "effective_severity",
|
|
223
227
|
"score_assessed", "score_unassessed_reason", "matched_criteria", "cpe_vendor", "vendor_unknown", "match_confidence", "match_reason", "version_evidence", "fixed_in",
|
|
224
|
-
"fixed_in_reason",
|
|
228
|
+
"fixed_in_reason", "fixed_in_detail",
|
|
225
229
|
],
|
|
226
230
|
clip: 200,
|
|
227
231
|
},
|
|
@@ -250,6 +254,32 @@ const CHECK_AFFECTED_TEXT = {
|
|
|
250
254
|
],
|
|
251
255
|
whole: `${GET_CVE_WHOLE}, and cve_intel a CVE's affected packages and fixed versions.`,
|
|
252
256
|
};
|
|
257
|
+
// #3013: the structured result's cut. A production match is about 1,650 characters of compact JSON
|
|
258
|
+
// (its description, matched criteria and match reason), so openssl 3.0.0's 71 matches were 147,564
|
|
259
|
+
// characters of structured result and linux_kernel 5.10.0's 200 (the API's cap) 331,682. As in the
|
|
260
|
+
// text, the samples beside the matches go first (excluded and undetermined, each to at most the same
|
|
261
|
+
// number of its first entries; excluded_count and undetermined_count count every entry), then the
|
|
262
|
+
// matches, and cve_ids, each match's cve_id in order, last: it names every match in a few thousand
|
|
263
|
+
// characters. Before any match leaves, every match is thinned (most of a match is its description,
|
|
264
|
+
// about 1,400 characters for openssl, which get_cve returns): first to its scores, flags, the
|
|
265
|
+
// criteria it matched and fixed_in, then to the fields this tool's description says a match keeps
|
|
266
|
+
// at least, with fixed_in_reason. Only then do matches leave from the end (the lowest ranked).
|
|
267
|
+
const CHECK_AFFECTED_DATA = {
|
|
268
|
+
steps: [
|
|
269
|
+
{ lists: ["excluded", "undetermined"] },
|
|
270
|
+
{
|
|
271
|
+
lists: ["matches"],
|
|
272
|
+
thin: [
|
|
273
|
+
[
|
|
274
|
+
"cve_id", "severity", "cvss_v3_score", "kev_listed", "ransomware", "epss_score", "echelongraph_score", "echelongraph_severity", "effective_score", "effective_severity", "score_assessed",
|
|
275
|
+
"score_unassessed_reason", "matched_criteria", "cpe_vendor", "vendor_unknown", "match_confidence", "version_evidence", "fixed_in", "fixed_in_reason",
|
|
276
|
+
],
|
|
277
|
+
["cve_id", "kev_listed", "ransomware", "epss_score", "effective_score", "score_assessed", "fixed_in", "fixed_in_reason"],
|
|
278
|
+
],
|
|
279
|
+
},
|
|
280
|
+
{ lists: ["cve_ids"] },
|
|
281
|
+
],
|
|
282
|
+
};
|
|
253
283
|
async function checkAffected(kit, a) {
|
|
254
284
|
const product = (a.product ?? "").trim();
|
|
255
285
|
const ecosystem = (a.ecosystem ?? "").trim();
|
|
@@ -351,5 +381,5 @@ export function registerCheckAffected(server, kit) {
|
|
|
351
381
|
inputSchema: CHECK_AFFECTED_INPUT,
|
|
352
382
|
outputSchema: output,
|
|
353
383
|
annotations: kit.annotations,
|
|
354
|
-
}, async (a) => kit.checked(TOOL, output, await checkAffected(kit, a)));
|
|
384
|
+
}, async (a) => kit.checked(TOOL, output, await answering(a.full, CHECK_AFFECTED_DATA, () => checkAffected(kit, a))));
|
|
355
385
|
}
|
package/dist/tools/check_sbom.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as z from "zod";
|
|
2
|
-
import { TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
|
|
2
|
+
import { answering, FULL_ARG, TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
|
|
3
3
|
import { CLEAN_NOTE, DISTRO_NOTE, FRESHNESS_NOTE, MAX_PURLS, MEASURED_AT_NOTE, METHOD, batchDataShape, dedupe, field, isObj, plural, purlsRefusal, sendBatches, sendingCoverage, sendingCoverageShape, sendingSentences, verdictSentences, } from "./match_batch.js";
|
|
4
4
|
// #2835: the batch loop, its constants and the row schema moved to match_batch.ts, which
|
|
5
5
|
// scan_manifest shares; re-exported here, where the tests and prompts.ts import them.
|
|
@@ -25,7 +25,7 @@ export const CHECK_SBOM_TITLE = "Check an SBOM against the advisory corpus";
|
|
|
25
25
|
export const CHECK_SBOM_ROUTING = "For container images and Kubernetes pods, the input is an SBOM of each image, or its purls.";
|
|
26
26
|
export const CHECK_SBOM_DESCRIPTION = "Check a dependency list against EchelonGraph's advisory corpus, one verdict per component. " +
|
|
27
27
|
CHECK_SBOM_ROUTING +
|
|
28
|
-
" Pass purls (package URLs, up to 2,000 distinct) or sbom (a CycloneDX or SPDX JSON document, up to 5,000,000 characters). This server reads the purls from the document; only they are sent to the API, in POST bodies of at most 200, never in a URL; the document itself is 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 document is the request body, accepted up to 6 MiB. data.results holds one row per component sent, in order, with verdict (affected, not_affected, undetermined or not_assessed), not_assessed_reason, cve_ids, matched_package and matched_via (a deb or apk purl's upstream qualifier matches as its source package); each match carries fixed_in (
|
|
28
|
+
" Pass purls (package URLs, up to 2,000 distinct) or sbom (a CycloneDX or SPDX JSON document, up to 5,000,000 characters). This server reads the purls from the document; only they are sent to the API, in POST bodies of at most 200, never in a URL; the document itself is 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 document is the request body, accepted up to 6 MiB. data.results holds one row per component sent, in order, with verdict (affected, not_affected, undetermined or not_assessed), not_assessed_reason, cve_ids, matched_package and matched_via (a deb or apk purl's upstream qualifier matches as its source package); each match carries fixed_in (the next unaffected version) or null with fixed_in_reason. data.summary counts the verdicts. not_affected is the only clean verdict: not_assessed (no verdict, as for a deb, apk or rpm purl without a distro qualifier naming its release, which EchelonGraph does not guess) and undetermined are not, and the note counts both. The API allows 1,200 components a minute per caller; on a 429 the tool waits out Retry-After, up to 50 seconds a call, then answers what it has, the rest in data.not_sent_purls, unchecked and not clean. Input with more than 2,000 distinct purls is refused, not truncated." +
|
|
29
29
|
" " +
|
|
30
30
|
TEXT_BUDGET_DESCRIPTION +
|
|
31
31
|
" Cut, each row keeps index, purl, verdict, not_assessed_reason and cve_ids at least, each match its fixed_in while kept; not_affected rows leave the text first, then not_assessed ones; not_sent_purls keeps its first 10.";
|
|
@@ -34,7 +34,7 @@ export const CHECK_SBOM_DESCRIPTION = "Check a dependency list against EchelonGr
|
|
|
34
34
|
// text, and 2,000 purls about 1,200,000. Past DATA_TEXT_BUDGET each row in the first text block
|
|
35
35
|
// keeps its verdict, counts, cve_ids, matched_package and matched_via (#2836), and each match its
|
|
36
36
|
// scores, flags, fixed_in (#2834) and match_reason (the
|
|
37
|
-
// advisory interval the version falls inside: "[A, B)" names
|
|
37
|
+
// advisory interval the version falls inside: "[A, B)" names that interval's fixed bound B, "[A, B]" the last
|
|
38
38
|
// affected one, #2830) without the
|
|
39
39
|
// description; then the same without match_reason; then only index, purl, verdict,
|
|
40
40
|
// not_assessed_reason and cve_ids. When rows must still go, the clean ones (not_affected) leave
|
|
@@ -49,7 +49,7 @@ export const CHECK_SBOM_DESCRIPTION = "Check a dependency list against EchelonGr
|
|
|
49
49
|
// reading the list. noteFor gives that order (READ_ORDER) beside the position, so this sentence
|
|
50
50
|
// points to it.
|
|
51
51
|
export function sbomText(sent) {
|
|
52
|
-
const matches = ["cve_id", "severity", "effective_severity", "effective_score", "kev_listed", "ransomware", "epss_score", "score_assessed", "fixed_in"];
|
|
52
|
+
const matches = ["cve_id", "severity", "effective_severity", "effective_score", "kev_listed", "ransomware", "epss_score", "score_assessed", "fixed_in", "fixed_in_reason"];
|
|
53
53
|
const row = [
|
|
54
54
|
"index", "purl", "ecosystem", "package", "version", "verdict", "assessed", "not_assessed_reason", "count", "not_affected_count", "undetermined_count", "cve_ids", "matched_package", "matched_via",
|
|
55
55
|
];
|
|
@@ -69,11 +69,49 @@ export function sbomText(sent) {
|
|
|
69
69
|
],
|
|
70
70
|
leaveOutFirst: { field: "verdict", values: ["not_affected", "not_assessed"], why: "data.summary counts every verdict" },
|
|
71
71
|
// check_affected cuts its text too past the budget (#2802): only its structuredContent.data is
|
|
72
|
-
// a component's matches whole.
|
|
73
|
-
whole: "check_affected, given a row's ecosystem, package and version, returns that component's matches, whole in its structuredContent.data, and cve_intel a CVE's affected packages and fixed versions.",
|
|
72
|
+
// a component's matches whole, with full: true (#3013).
|
|
73
|
+
whole: "check_affected, given a row's ecosystem, package and version, returns that component's matches, whole in its structuredContent.data with full: true, and cve_intel a CVE's affected packages and fixed versions.",
|
|
74
74
|
page: (shown) => `To read every row in the text, check ${shown} or fewer purls per call.`,
|
|
75
75
|
};
|
|
76
76
|
}
|
|
77
|
+
// #3013: the structured result's cut. A production row is about 1,900 characters of compact JSON
|
|
78
|
+
// (its matches, each a CVE with its description), so the 50-component Juice Shop document was 98,682
|
|
79
|
+
// characters of structured result and 2,000 purls 3,790,686. The lists beside the rows go first
|
|
80
|
+
// (not_sent_purls: the note says from which position of the input they run, and in what order; and,
|
|
81
|
+
// for scan_manifest, not_checked, which coverage.not_checked_by_reason counts). Then, before any
|
|
82
|
+
// row leaves, every row is thinned to SBOM_ROW_THIN: what a row says of the component and its
|
|
83
|
+
// verdict, and per match what a triage reads (a match's description, about 535 characters, is
|
|
84
|
+
// get_cve's); the first #3013 cut, before release, kept 3 of Juice Shop's 50 rows whole, all affected, and named the
|
|
85
|
+
// other 7 affected components nowhere a client reading structuredContent sees. Only then do rows
|
|
86
|
+
// leave, as in the text: the not_affected rows first, then the not_assessed ones (data.summary
|
|
87
|
+
// counts every verdict), then the last of the rest, so the affected and undetermined rows are the
|
|
88
|
+
// last to go.
|
|
89
|
+
// #3013's review: a thinned match kept cve_id, kev_listed, epss_score, fixed_in and
|
|
90
|
+
// fixed_in_reason, while the workload_triage prompt reads kev_listed, ransomware, epss_score,
|
|
91
|
+
// effective_score, score_assessed and fixed_in from a row's matches (its step 5 and ordering keys),
|
|
92
|
+
// so a client reading structuredContent lost ransomware and the score it ranks by. Each field a
|
|
93
|
+
// prompt reads from check_sbom's or scan_manifest's matches is kept (test/prompt-fields.test.mjs
|
|
94
|
+
// holds every prompt's to this list).
|
|
95
|
+
export const SBOM_ROW_THIN = [
|
|
96
|
+
"index",
|
|
97
|
+
"purl",
|
|
98
|
+
"verdict",
|
|
99
|
+
"not_assessed_reason",
|
|
100
|
+
"cve_ids",
|
|
101
|
+
"matched_package",
|
|
102
|
+
"matched_via",
|
|
103
|
+
["matches", ["cve_id", "kev_listed", "ransomware", "epss_score", "effective_score", "score_assessed", "fixed_in", "fixed_in_reason"]],
|
|
104
|
+
];
|
|
105
|
+
export function sbomData(sides, page) {
|
|
106
|
+
return {
|
|
107
|
+
steps: [
|
|
108
|
+
{ lists: sides },
|
|
109
|
+
{ lists: ["results"], thin: [SBOM_ROW_THIN], leaveOutFirst: { field: "verdict", values: ["not_affected", "not_assessed"], why: "data.summary counts every verdict" } },
|
|
110
|
+
],
|
|
111
|
+
page: (t) => (t.results && t.results.returned < t.results.total ? page(t.results.returned) : ""),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
const SBOM_DATA = sbomData(["not_sent_purls"], (k) => `To read every row in data, check ${k} or fewer purls per call.`);
|
|
77
115
|
// The purls of a CycloneDX JSON document: every components[] entry, nested ones included.
|
|
78
116
|
// metadata.component (the subject of the SBOM, not a dependency of it) is not checked.
|
|
79
117
|
function fromCycloneDX(doc) {
|
|
@@ -264,11 +302,12 @@ export function registerCheckSbom(server, deps) {
|
|
|
264
302
|
.union([z.string(), z.record(z.string(), z.unknown())])
|
|
265
303
|
.optional()
|
|
266
304
|
.describe("a CycloneDX JSON or SPDX JSON document, as JSON text or as an object; its purls are read by this MCP server and only they are sent to the API; over the hosted endpoint (mcp.echelongraph.io) the document is the request body"),
|
|
305
|
+
full: FULL_ARG,
|
|
267
306
|
}),
|
|
268
307
|
outputSchema: output,
|
|
269
308
|
annotations: deps.annotations,
|
|
270
|
-
}, async (a, ctx) => {
|
|
309
|
+
}, async (a, ctx) => answering(a.full, SBOM_DATA, async () => {
|
|
271
310
|
const run = checkSbom({ ...deps, signal: ctx.mcpReq.signal }, a);
|
|
272
311
|
return deps.checked(CHECK_SBOM, output, await (deps.hold ? deps.hold(run) : run));
|
|
273
|
-
});
|
|
312
|
+
}));
|
|
274
313
|
}
|