echelongraph-mcp 3.0.2 → 3.1.1

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.
@@ -79,6 +79,10 @@ const Match = z.looseObject({
79
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
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."),
82
+ // #2834: CPE matches only (core-backend cve/cpematch.go ProductMatch): the matched NVD range's
83
+ // own end, as NVD states it, in its own field and never as fixed_in.
84
+ cpe_version_end_excluding: opt(z.string()).describe("CPE match: NVD's versionEndExcluding for the range that matched, the first version that range no longer holds. Not a fix, and never fixed_in: another range of the same CVE, or a vendor backport, can sit past it. Absent on a registry match, and where the range sets no such end."),
85
+ cpe_version_end_including: opt(z.string()).describe("CPE match: NVD's versionEndIncluding for the range that matched, the last version that range holds. Not a fix, and never fixed_in. Absent on a registry match, and where the range sets no such end."),
82
86
  });
83
87
  const Undetermined = z.looseObject({
84
88
  cve_id: opt(z.string()),
@@ -108,7 +112,7 @@ const DATA = z.looseObject({
108
112
  excluded: z.unknown().optional(),
109
113
  undetermined_count: opt(z.number()),
110
114
  undetermined: opt(z.array(Undetermined)),
111
- not_affected_count: opt(z.number()).describe("Advisories decided as not affecting this version."),
115
+ not_affected_count: opt(z.number()).describe("CVEs decided as not affecting this version: on the registry path one per CVE, however many of its advisory records are stored (advisories_considered counts those)."),
112
116
  degraded: opt(z.boolean()).describe("true: the lookup ran out of time."),
113
117
  undecidable_excluded_count: opt(z.number()),
114
118
  undecided_candidate_count: opt(z.number()),
@@ -178,14 +182,16 @@ function matchesNote(matches) {
178
182
  // that no affected interval of the match's record holds (core-backend cve/pkgmatch_fixedin.go):
179
183
  // the fixed bound of the interval that holds this version, moved past any overlapping interval
180
184
  // 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.
185
+ // 2.3.1, 2.12.2, 2.15.0) names the one for this version. The record is every advisory row stored for
186
+ // the CVE and the package (#3137), so a null can come from another row of the CVE: no fix recorded,
187
+ // or one that row may still hold affected, or versions that could not be ordered.
182
188
  function fixedInNote(matches) {
183
189
  const registry = matches.filter((m) => m !== null && typeof m === "object" && "fixed_in" in m);
184
190
  if (registry.length === 0)
185
191
  return "";
186
192
  const fixed = registry.filter((m) => typeof field(m, "fixed_in") === "string").length;
187
193
  const none = registry.length - fixed;
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)` : ""}.`;
194
+ 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 CVE's advisory records show no version above this one to be unaffected (fixed_in_reason says why; it is not a finding that no fix exists)` : ""}.`;
189
195
  }
190
196
  // #2783: a production match is about 2,100 characters as pretty JSON, most of it the CVE's
191
197
  // description and match_reason, so openssl 3.0.0 (71 matches) was 156,058 characters of text and
@@ -225,7 +231,7 @@ const CHECK_AFFECTED_TEXT = {
225
231
  keep: [
226
232
  "cve_id", "severity", "cvss_v3_score", "description", "kev_listed", "ransomware", "epss_score", "echelongraph_score", "echelongraph_severity", "effective_score", "effective_severity",
227
233
  "score_assessed", "score_unassessed_reason", "matched_criteria", "cpe_vendor", "vendor_unknown", "match_confidence", "match_reason", "version_evidence", "fixed_in",
228
- "fixed_in_reason", "fixed_in_detail",
234
+ "fixed_in_reason", "fixed_in_detail", "cpe_version_end_excluding", "cpe_version_end_including",
229
235
  ],
230
236
  clip: 200,
231
237
  },
@@ -252,6 +258,12 @@ const CHECK_AFFECTED_TEXT = {
252
258
  { list: "excluded", keep: ["cve_id", "reason"], clip: 100, cap: 10 },
253
259
  { list: "undetermined", keep: ["cve_id", "reason"], clip: 100, cap: 10 },
254
260
  ],
261
+ // #2899: the matches that leave the text near the cap are the last in rank order (effective_score),
262
+ // so through 3.0.2 a KEV-listed or ransomware match ranked low was in structuredContent.data only,
263
+ // and the note said how many left, not that one of them is exploited. Now every match whose
264
+ // kev_listed or ransomware is true stays while any other match is in the text, and should one
265
+ // still have to leave (every other already out), the note names each by its cve_id.
266
+ leaveOutLast: { fields: ["kev_listed", "ransomware"], name: "cve_id", why: "known exploited, per CISA-KEV" },
255
267
  whole: `${GET_CVE_WHOLE}, and cve_intel a CVE's affected packages and fixed versions.`,
256
268
  };
257
269
  // #3013: the structured result's cut. A production match is about 1,650 characters of compact JSON
@@ -273,6 +285,7 @@ const CHECK_AFFECTED_DATA = {
273
285
  [
274
286
  "cve_id", "severity", "cvss_v3_score", "kev_listed", "ransomware", "epss_score", "echelongraph_score", "echelongraph_severity", "effective_score", "effective_severity", "score_assessed",
275
287
  "score_unassessed_reason", "matched_criteria", "cpe_vendor", "vendor_unknown", "match_confidence", "version_evidence", "fixed_in", "fixed_in_reason",
288
+ "cpe_version_end_excluding", "cpe_version_end_including",
276
289
  ],
277
290
  ["cve_id", "kev_listed", "ransomware", "epss_score", "effective_score", "score_assessed", "fixed_in", "fixed_in_reason"],
278
291
  ],
@@ -337,10 +350,10 @@ async function checkAffected(kit, a) {
337
350
  else if (count === 0 && assessed === true) {
338
351
  note += ` 0 CVEs match this version, a measured result (we looked and found nothing that matches), not a lookup failure.`;
339
352
  if (lookup === "registry" && notAffected !== null)
340
- note += ` ${plural(notAffected, "advisory was", "advisories were")} decided not to affect this version (not_affected_count), and only those are cleared.`;
353
+ note += ` ${plural(notAffected, "CVE was", "CVEs were")} decided not to affect this version (not_affected_count, one per CVE however many of its advisory records are stored), and only those are cleared.`;
341
354
  }
342
355
  if (undetermined !== null && undetermined > 0) {
343
- note += ` UNDETERMINED: ${plural(undetermined, "advisory names", "advisories name")} this package with a version range that could not be decided at this version (undetermined_count, listed in undetermined): report ${undetermined === 1 ? "it" : "them"} as undetermined, never as safe.`;
356
+ note += ` UNDETERMINED: ${plural(undetermined, "CVE", "CVEs")} with advisories naming this package could not be decided at this version (undetermined_count, one per CVE, listed in undetermined): report ${undetermined === 1 ? "it" : "them"} as undetermined, never as safe.`;
344
357
  }
345
358
  if (candidatesCapped === true && reason !== "candidate_window_truncated")
346
359
  note += " candidates_capped is true: not every candidate CVE was loaded, so more CVEs may match than the answer lists.";
@@ -1,6 +1,7 @@
1
1
  import * as z from "zod";
2
2
  import { answering, FULL_ARG, TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
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";
3
+ import { boundedRecord } from "../inputBounds.js";
4
+ import { CLEAN_NOTE, DISTRO_NOTE, FRESHNESS_NOTE, MAX_PURLS, MEASURED_AT_NOTE, METHOD, affectedSaidOver, batchDataShape, dedupe, field, isObj, plural, purlsRefusal, sendBatches, sendingCoverage, sendingCoverageShape, sendingSentences, verdictSentences, } from "./match_batch.js";
4
5
  // #2835: the batch loop, its constants and the row schema moved to match_batch.ts, which
5
6
  // scan_manifest shares; re-exported here, where the tests and prompts.ts import them.
6
7
  export { API_COMPONENTS_PER_MINUTE, BATCH_PATH, MAX_COMPONENTS, MAX_PURLS, TIME_BUDGET_MS, mergeAnswers } from "./match_batch.js";
@@ -9,6 +10,27 @@ export const CHECK_SBOM = "check_sbom";
9
10
  // components) is 0.74 MB; this leaves room for a much larger one while bounding what this process
10
11
  // parses.
11
12
  export const MAX_SBOM_CHARS = 5_000_000;
13
+ // #3345: the most top-level members an sbom passed as an object may have, checked before zod
14
+ // reads any of them (inputBounds.ts boundedRecord). CycloneDX 1.6 defines 18 ($schema, bomFormat,
15
+ // specVersion, serialNumber, version, metadata, components, services, externalReferences,
16
+ // dependencies, compositions, properties, vulnerabilities, annotations, formulation, declarations,
17
+ // definitions, signature), SPDX 2.3 16 and SPDX 3.0's JSON-LD 2; 64 leaves room for a later
18
+ // version's and a generator's own. What a member holds is bounded by the document's character cap
19
+ // above and by the arguments' element limit (index.ts MAX_TOOL_INPUT_ELEMENTS).
20
+ export const MAX_SBOM_MEMBERS = 64;
21
+ // #3345: the most entries purls may hold, repeats included, checked before zod reads any
22
+ // (inputBounds.ts). Not MAX_PURLS, the most DISTINCT purls a call checks, counted after trimming
23
+ // and dedupe (purlsRefusal): a list may repeat a purl, as a flattened SBOM does once per install
24
+ // path, and through 3.1.0 such a list was checked with each purl sent once. Five times MAX_PURLS,
25
+ // room for each of 2,000 distinct purls five times over; only a list past it, which 3.1.0 had no
26
+ // bound for, is newly refused. It is reached over the hosted endpoint as on stdio (no body cap
27
+ // there): a check_sbom call may be a large body (httpPolicy.ts, up to 6 MiB), and 10,000 entries
28
+ // of realistic purls (22 to 120 characters, npm to rpm with qualifiers) are a 0.25 to 1.2 MB
29
+ // tools/call body, of the longest the backend takes (MAX_PURL_LEN, 512) 5.15 MB
30
+ // (test/input-bounds.test.mjs holds that under the cap). Its 10,002 elements are far below the
31
+ // arguments' element limit (index.ts MAX_TOOL_INPUT_ELEMENTS), and the answer grows with the
32
+ // distinct purls sent, not with this.
33
+ export const MAX_PURL_ENTRIES = 10_000;
12
34
  // CycloneDX nests components; this bounds the walk.
13
35
  const MAX_DEPTH = 32;
14
36
  // The order in which extract() reads and counts the distinct purls (#2799 review): purls as listed;
@@ -25,7 +47,7 @@ export const CHECK_SBOM_TITLE = "Check an SBOM against the advisory corpus";
25
47
  export const CHECK_SBOM_ROUTING = "For container images and Kubernetes pods, the input is an SBOM of each image, or its purls.";
26
48
  export const CHECK_SBOM_DESCRIPTION = "Check a dependency list against EchelonGraph's advisory corpus, one verdict per component. " +
27
49
  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 (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." +
50
+ " Pass purls (package URLs, up to 2,000 distinct, 10,000 with repeats) or sbom (a CycloneDX or SPDX JSON document, up to 5,000,000 characters). This server reads the document's purls; 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, not clean. Input with more than 2,000 distinct purls is refused, not truncated." +
29
51
  " " +
30
52
  TEXT_BUDGET_DESCRIPTION +
31
53
  " 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.";
@@ -48,6 +70,12 @@ export const CHECK_SBOM_DESCRIPTION = "Check a dependency list against EchelonGr
48
70
  // them (x.purls.slice(answers.length * MAX_COMPONENTS)), so a second call can send them without
49
71
  // reading the list. noteFor gives that order (READ_ORDER) beside the position, so this sentence
50
72
  // points to it.
73
+ // #3269 (the structured cut's rule, kept in the text too): the clean and not_assessed rows left
74
+ // the text only once every row was cut to the last level, so an image SBOM's text kept every clean
75
+ // row and no affected row's matches: replayed on the production synthetic's rows, 150 purls with 40
76
+ // affected kept 150 rows and the matches of none of the 40, on 3.0.0 and 3.0.2 alike. Now, before
77
+ // any row is cut to the last level, those rows are cut to it and leave the text, as many as must;
78
+ // where even that does not fit, the cut goes on as before.
51
79
  export function sbomText(sent) {
52
80
  const matches = ["cve_id", "severity", "effective_severity", "effective_score", "kev_listed", "ransomware", "epss_score", "score_assessed", "fixed_in", "fixed_in_reason"];
53
81
  const row = [
@@ -68,10 +96,22 @@ export function sbomText(sent) {
68
96
  },
69
97
  ],
70
98
  leaveOutFirst: { field: "verdict", values: ["not_affected", "not_assessed"], why: "data.summary counts every verdict" },
99
+ outFirstBefore: 2,
100
+ // #3284: data keeps the rows with a KEV-listed or ransomware match first (SBOM_LEAVE_LAST),
101
+ // while the text left rows from the end in input order, so a KEV-bearing row data kept could
102
+ // leave the text, named in no text block and in no note (the DATA CUT sentence names only rows
103
+ // left out of data): on the reviewer's 36 realistic Debian-12 images a client reading the text
104
+ // alone missed 95 of 413 KEV-bearing rows (linux-libc-dev at index 282, 5 KEV CVEs, among
105
+ // them). Now such rows leave the text last, as #2899's check_affected matches do, and the note
106
+ // names each one that still leaves by its index.
107
+ leaveOutLast: { fields: [], name: "index", why: "", has: hasKev, said: "a KEV-listed or ransomware match", runs: true },
71
108
  // check_affected cuts its text too past the budget (#2802): only its structuredContent.data is
72
109
  // a component's matches whole, with full: true (#3013).
73
110
  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
- page: (shown) => `To read every row in the text, check ${shown} or fewer purls per call.`,
111
+ // #3269's second review: no page sentence. "To read every row in the text, check N or fewer purls
112
+ // per call", N the rows the text kept, was false for a call of the first N purls in 34 of 89
113
+ // mixes (on 3.0.2 too): the clean rows that leave first make N no page size. The sentence on
114
+ // the rows left out says how many left and that structuredContent.data holds them.
75
115
  };
76
116
  }
77
117
  // #3013: the structured result's cut. A production row is about 1,900 characters of compact JSON
@@ -110,16 +150,18 @@ export const SBOM_ROW_THIN = [
110
150
  // 3.0.0 (2026-10-11), five Debian 12 purls, all affected, kept 1 row (libssl3; linux-libc-dev
111
151
  // 6.1.55-1's 200 matches are 42,417 characters thinned); Juice Shop 11.1.2 with
112
152
  // magento/community-edition 2.3.0 inserted at index 2 kept 2 of 51 rows (19 affected); that purl
113
- // alone kept none, and the note said "check 0 or fewer purls per call". Now, in order:
153
+ // alone kept none, and the note said "check 0 or fewer purls per call". Now, in order (since
154
+ // #3269, the clean and not-assessed rows leave between 1 and 2: see SBOM_ROW_COMPACT's comment):
114
155
  // 1. SBOM_ROW_THIN, every row, every match (as before);
115
156
  // 2. the same, with every row's matches capped at one number m, the most that fit: a row with
116
- // more than m keeps its first m (the API's order: highest effective_score first) and carries
157
+ // more than m keeps m (its first in the API's order through 3.0.2; pickMatches's since #3270) and carries
117
158
  // matches_truncated, its own total and returned, and the CVE IDs among all its matches that
118
159
  // are KEV-listed (kev_listed_cve_ids) or used in ransomware (ransomware_cve_ids), and the
119
160
  // distinct fixed_in versions they name (fixed_in_versions), so what a triage ranks by first
120
161
  // is still said of the matches cut; rows with m or fewer matches are untouched;
121
162
  // 3. SBOM_ROW_COMPACT, each match cut to cve_id, kev_listed, ransomware, fixed_in and
122
- // effective_score, the row keeping matched_package, matched_via and an upstream row's
163
+ // effective_score (and, since #3270, epss_score and score_assessed), the row keeping
164
+ // matched_package, matched_via and an upstream row's
123
165
  // matched_version_from and purl_name, matches capped at one number m as in 2 (any m, 0
124
166
  // included), with the same matches_truncated;
125
167
  // 4. SBOM_ROW_MIN, the text's leanest level (index, purl, verdict, not_assessed_reason and
@@ -144,6 +186,49 @@ export const SBOM_ROW_THIN = [
144
186
  // upstream row with matched_via, at 29,959 characters.
145
187
  // cve_ids is what the API caps at 200 a row, about 3,400 characters, so every row survives level 5
146
188
  // unless the rows are many (the probe's 201, Juice Shop's full 840 purls).
189
+ // #3269's second review (DO-NOT-SHIP on its first): levels 2 and 3 went on below a row's floor,
190
+ // to a cap of 1 and then 0, and then to levels 4 and 5, every row kept with one match or none:
191
+ // five Debian 12 purls beside an image's 150 (40 affected) kept 45 affected rows, 1 with any match
192
+ // (3.0.2: 26, 22 with every match). Now levels 2 and 3 hold each row's floor (matchFloor) whatever
193
+ // their cap, taken from a cap of SBOM_LEAVING_CAP; levels 4 and 5 are not taken while a row other
194
+ // than a not_affected or not_assessed one has matches; and rows leave whole at level 3 at their
195
+ // floor (SBOM_LEAVE_AT), the rows kept chosen those with a KEV-listed or ransomware match first,
196
+ // then the others, each in input order, each one that fits (SBOM_LEAVE_LAST, textBudget.ts
197
+ // keptByFit), the rest named by index in the note. Where not even one row fits at its floor, the
198
+ // row chosen first stays alone below it, its matches the most that fit (belowSaid). Measured on
199
+ // the 40 Debian-image mixes of that review: 819 affected rows keep every match or their floor
200
+ // (round 1 of it: 462, 744 more under the floor; 3.0.2: 929, 116 more under it, its compact
201
+ // matches without epss_score and score_assessed, which #3270 keeps: with those two left out, this
202
+ // ladder keeps 1,025). #3269's third review: 820 (1,030 with those two left out), the note naming
203
+ // the rows left out with a KEV-listed match apart and how to check them again.
204
+ //
205
+ // #3269 and #3270, the post-close review of #3132 (3.0.2):
206
+ // - Every row went down these levels together, the clean ones with the affected, and rows left
207
+ // only at the last, so an ordinary image SBOM kept its clean rows by cutting the affected rows'
208
+ // matches: 150 purls, 40 of them affected (test/check_sbom-large-rows.test.mjs's image-shaped
209
+ // mix), kept all 150 rows and the matches of 2 of the 40 affected, where 3.0.0 kept 56 rows and
210
+ // every match of all 40. An affected row's matches are what an SBOM check is for, and the clean
211
+ // rows are counted in data.summary. So now (sbomData's outFirst) the not_affected and
212
+ // not_assessed rows are cut to SBOM_ROW_MIN (a not_assessed row keeps not_assessed_reason),
213
+ // then leave, not_affected first, before any other row is cut below SBOM_ROW_THIN; once the
214
+ // cut is settled, those that left come back, cut so, as many as the room left holds. #3269's
215
+ // review: they leave so only where that keeps every other row's matches whole; where the
216
+ // others must be capped anyway, they stay while the others fit at matchFloor (below).
217
+ // - A capped row kept its first m matches, the API's order (highest effective_score first), so a
218
+ // KEV-listed CVE ranked lower was cut and only matches_truncated named it: magento/community-
219
+ // edition 2.3.0's CVE-2025-54236, at position 21 of its 200, with Juice Shop beside it. A capped
220
+ // row now keeps its KEV-listed and ransomware matches first, then the highest effective_score
221
+ // (pickMatches), in the API's order.
222
+ // - SBOM_ROW_COMPACT dropped epss_score and score_assessed, which workload_triage ranks by: the
223
+ // 201-purl probe kept 54 rows and not one match with either. Every level that keeps matches now
224
+ // keeps every field a prompt reads from a match (test/prompt-fields.test.mjs holds each level of
225
+ // SBOM_ROW_LEVELS to them); compact is SBOM_ROW_THIN without fixed_in_reason. 3.1.0's
226
+ // integration (#2834 beside #3269): sbom_review relays a null fixed_in's fixed_in_reason, and
227
+ // keeping it here was measured: it cost the 201-purl probe a row (43 to 42; the DATA CUT
228
+ // sentence naming it is 17 characters longer) and the second review's image mixes affected rows
229
+ // at their floor (16 to 12, 32 to 31, 31 to 30, 23 to 22). So it stays out, and sbom_review
230
+ // reads a null fixed_in without it as a reason the cut left out, never as no fix
231
+ // (test/check_sbom-large-rows.test.mjs pins the probe; prompt-fields the prompt).
147
232
  export const SBOM_ROW_COMPACT = [
148
233
  "index",
149
234
  "purl",
@@ -154,12 +239,28 @@ export const SBOM_ROW_COMPACT = [
154
239
  "matched_via",
155
240
  "matched_version_from",
156
241
  "purl_name",
157
- ["matches", ["cve_id", "kev_listed", "ransomware", "fixed_in", "effective_score"]],
242
+ ["matches", ["cve_id", "kev_listed", "ransomware", "epss_score", "effective_score", "score_assessed", "fixed_in"]],
158
243
  ];
159
244
  export const SBOM_ROW_MIN = ["index", "purl", "verdict", "not_assessed_reason", "cve_ids"];
160
- // The most matches a row keeps, compact, when rows leave (the API's order, highest effective_score
161
- // first); the rest are in its cve_ids and matches_truncated.
245
+ // The most matches a row keeps, compact, when rows leave (pickMatches's); the rest are in its
246
+ // cve_ids and matches_truncated.
162
247
  export const SBOM_LEAVING_CAP = 10;
248
+ // #3270: which `cap` of a row's matches a capped level keeps, by their index: those used in
249
+ // ransomware, then the other KEV-listed ones, then the highest effective_score (a match without one
250
+ // last), the API's order breaking ties; returned in the API's order, which data keeps.
251
+ export function pickMatches(ms, cap) {
252
+ const tier = (m) => (field(m, "ransomware") === true ? 0 : field(m, "kev_listed") === true ? 1 : 2);
253
+ const score = (m) => {
254
+ const s = field(m, "effective_score");
255
+ return typeof s === "number" && Number.isFinite(s) ? s : Number.NEGATIVE_INFINITY;
256
+ };
257
+ const by = (a, b) => tier(ms[a]) - tier(ms[b]) || (score(ms[b]) === score(ms[a]) ? 0 : score(ms[b]) > score(ms[a]) ? 1 : -1) || a - b;
258
+ return ms
259
+ .map((_, i) => i)
260
+ .sort(by)
261
+ .slice(0, Math.max(0, cap))
262
+ .sort((a, b) => a - b);
263
+ }
163
264
  // matches_truncated for a row whose matches data keeps `kept` of (none at levels 3 and 4); with
164
265
  // `summary`, the three lists drawn from all of the row's matches. Absent on a row with no match
165
266
  // cut, and on one with no matches.
@@ -178,26 +279,107 @@ function matchesTruncated(row, kept, summary) {
178
279
  };
179
280
  }
180
281
  const MATCHES_TRUNCATED_SAID = "carrying matches_truncated, which gives its own total and returned, and, 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)";
181
- const cappedSaid = (cap) => `each row's matches keeping at most its first ${cap} (the API's order, highest effective_score first), a row with more than that ${MATCHES_TRUNCATED_SAID}`;
282
+ const PICK_SAID = "chosen used in ransomware first, then the other KEV-listed ones, then those of highest effective_score, and listed in the API's order";
283
+ // #3269's review: the matches kept are chosen in one order and listed in another; round 1's "at
284
+ // most m, in the API's order: those KEV-listed or used in ransomware first, then those of highest
285
+ // effective_score" read as the order data lists them in. #3269's second review: and a row keeps its
286
+ // floor (matchFloor) whatever the cap, which the clause says where it is above the cap for a row.
287
+ const cappedSaid = (cap, raised) => `each row's matches keeping at most ${cap}${raised ? `, or its matches used in ransomware or KEV-listed and ${SBOM_LEAVING_CAP} more where those come to more` : ""}, ${PICK_SAID}; a row with more than that ${MATCHES_TRUNCATED_SAID}`;
288
+ // #3269's second review: the one row kept below its floor, where not even it fits at the floor.
289
+ const belowSaid = (cap) => `the one row kept keeping ${cap} of its matches, fewer than its matches used in ransomware or KEV-listed and ${SBOM_LEAVING_CAP} more, since no more fit beside it, ${PICK_SAID}, and ${MATCHES_TRUNCATED_SAID}`;
290
+ // #3269's second review: the two capped levels hold each row's floor (matchFloor) whatever the cap,
291
+ // and are taken from a cap of SBOM_LEAVING_CAP (below it every row keeps the same: its floor).
182
292
  export const SBOM_ROW_LEVELS = [
183
293
  SBOM_ROW_THIN,
184
- { keep: SBOM_ROW_THIN, capped: "matches", least: 1, mark: (row, cap) => matchesTruncated(row, cap, true), said: cappedSaid },
185
- { keep: SBOM_ROW_COMPACT, capped: "matches", mark: (row, cap) => matchesTruncated(row, cap, true), said: cappedSaid },
294
+ { keep: SBOM_ROW_THIN, capped: "matches", least: SBOM_LEAVING_CAP, floor: matchFloor, pick: pickMatches, mark: (row, cap) => matchesTruncated(row, cap, true), said: cappedSaid },
295
+ { keep: SBOM_ROW_COMPACT, capped: "matches", least: SBOM_LEAVING_CAP, floor: matchFloor, pick: pickMatches, mark: (row, cap) => matchesTruncated(row, cap, true), said: cappedSaid, below: belowSaid },
186
296
  { keep: SBOM_ROW_MIN, mark: (row) => matchesTruncated(row, 0, true), said: () => `a row that had matches ${MATCHES_TRUNCATED_SAID}` },
187
297
  { keep: SBOM_ROW_MIN, mark: (row) => matchesTruncated(row, 0, false), said: () => "a row that had matches carrying matches_truncated with its total and returned 0" },
188
298
  ];
189
- const SBOM_LEAVE_AT = { level: 2, cap: SBOM_LEAVING_CAP };
190
- // The page sentence (#3132): never "0 or fewer". Rows leave only once no level keeps them all, so a
191
- // call of as many purls as data kept rows keeps each of its rows; when not one row fits, no page
192
- // size does, and the note says where a row is read instead.
299
+ // #3269's second review: rows leave at the compact level at their floor (`floor`: levels 4 and 5
300
+ // are not taken while a row other than a not_affected or not_assessed one has matches), and of
301
+ // those rows, the ones with a KEV-listed or ransomware match last (SBOM_LEAVE_LAST).
302
+ const SBOM_LEAVE_AT = { level: 2, cap: SBOM_LEAVING_CAP, floor: true };
303
+ const hasKev = (row) => {
304
+ const ms = field(row, "matches");
305
+ return Array.isArray(ms) && ms.some((m) => field(m, "ransomware") === true || field(m, "kev_listed") === true);
306
+ };
307
+ export const SBOM_LEAVE_LAST = {
308
+ has: hasKev,
309
+ said: "a KEV-listed or ransomware match",
310
+ id: "index",
311
+ rest: ["affected or undetermined row", "affected or undetermined rows"],
312
+ chosen: "kept: rows with one first, then the others, in input order, each that fits",
313
+ };
314
+ // #3269's third review: the most check_sbom calls the prompts make to check again the rows a DATA
315
+ // CUT left out (prompts.ts workload_triage's step 6 and sbom_review), and so the calls whose sizes
316
+ // the note gives one by one.
317
+ export const RECHECK_CALLS = 5;
318
+ // check_sbom's sentence on checking again the affected and undetermined rows that left data, in
319
+ // the order the note names them (those with a KEV-listed or ransomware match first): the sizes of
320
+ // the first RECHECK_CALLS calls, each the most of the rest that fits, then at most `m` a call
321
+ // (textBudget.ts PageAt.againFit, counted for those rows, where the page sentence's N holds for
322
+ // the first N purls of the call only: re-check calls of N dropped 504 of 2,540 rows on realistic
323
+ // Debian images). One size for every call, any m in a row (the review's "calls of at most M"),
324
+ // put 201 of 255 KEV-bearing rows of six old unpatched images in view after 5 calls, and the best
325
+ // single size, found by real calls, 224, against 228 for round 3's step 6 on the same images with
326
+ // their KEV-bearing purls sent first; the first-fit sizes put 238. A row whose floor alone is past
327
+ // the budget goes in a call of its own, which keeps it under its floor.
328
+ export const sbomAgainSaid = ({ k, sizes, m, alone, aloneCount }) => {
329
+ const own = aloneCount === 0
330
+ ? ""
331
+ : `${aloneCount === 1 ? `The purl at index ${alone} goes` : `Each of the purls at index ${alone} goes`} in a call of its own: its row's matches used in ransomware or KEV-listed and ${SBOM_LEAVING_CAP} more are past the budget even alone, so that call keeps the row with the most of them that fit, and its note says how many.`;
332
+ if (aloneCount === k)
333
+ return own;
334
+ const these = aloneCount === 0 ? `these ${k} purls` : `the other ${k - aloneCount} of these purls`;
335
+ const calls = sizes.length === 1 && m === 0
336
+ ? `Re-check ${these} in one call: it keeps every row in data.`
337
+ : `Re-check ${these} in this order, in calls of ${listedAnd(sizes.map(String))}${m > 0 ? `, then of at most ${m}` : ""}: each such call keeps every row in data.`;
338
+ return own ? `${calls} ${own}` : calls;
339
+ };
340
+ const listedAnd = (xs) => (xs.length < 2 ? xs.join("") : `${xs.slice(0, -1).join(", ")} and ${xs[xs.length - 1]}`);
341
+ // #3269's review: the floor of matches an affected row keeps, once the rows must be capped anyway,
342
+ // before a clean or not-assessed row leaves for it: every match used in ransomware or KEV-listed,
343
+ // and SBOM_LEAVING_CAP more, the highest effective_score (pickMatches keeps them, at a cap of this
344
+ // many). SBOM_LEAVING_CAP because it is what the ladder already gives a row when rows leave
345
+ // (SBOM_LEAVE_AT): a clean row stays at SBOM_ROW_MIN while every affected row can keep at least
346
+ // that, and its KEV-listed matches beside it. Round 1 had no floor: once a row had to be capped, the
347
+ // clean rows left so that the cap could rise, and came back only into the room the cap left.
348
+ // linux-libc-dev 6.1.55-1 (200 matches) with 100 clean Debian purls kept 1 row of 101, and the page
349
+ // sentence said "check 1 or fewer purls per call": 101 calls to read 100 not_affected verdicts.
350
+ export function matchFloor(row) {
351
+ const ms = field(row, "matches");
352
+ if (!Array.isArray(ms))
353
+ return 0;
354
+ const kev = ms.filter((m) => field(m, "ransomware") === true || field(m, "kev_listed") === true).length;
355
+ return Math.min(ms.length, kev + SBOM_LEAVING_CAP);
356
+ }
357
+ // #3269: the clean and not-assessed rows are cut to SBOM_ROW_MIN, then leave, before any other row
358
+ // is cut to SBOM_ROW_LEVELS[1] (its matches capped); see SBOM_ROW_COMPACT's comment. #3269's
359
+ // review: where the other rows must be capped even with every one of them gone, they stay while the
360
+ // others fit at matchFloor, at SBOM_ROW_LEVELS[1] or [2] (textBudget.ts OutFirst.floor).
361
+ const SBOM_OUT_FIRST = { before: 1, level: SBOM_ROW_MIN, floor: { at: 2 } };
362
+ // The page sentence (#3132): never "0 or fewer". #3269's review: the number it names is what a
363
+ // call of the first that many purls keeps whole (PageAt.firstFit), not the number of rows this
364
+ // call's data kept. Round 1 named the rows kept, and the clean rows that leave first made that
365
+ // false: Juice Shop 11.1.2 with magento/community-edition 2.3.0 said "check 19 or fewer purls per
366
+ // call", and a call of its first 19 purls kept 14 of them (and one of 14 kept 12). firstFit counts
367
+ // every first n' up to the number it names, so "or fewer" holds too; a later page is not this
368
+ // call's, so its own note says what its data leaves out. When not one row fits, no page size does,
369
+ // and the note says where a row is read instead.
193
370
  // #3132's review: the lists beside the rows go first, scan_manifest's not_checked before
194
371
  // not_sent_purls, which keeps every purl or none (whole): those purls are the list a client
195
372
  // retries, and round 1 kept 3 of the rate-limited case's 800. With none kept, the note gives them
196
373
  // exactly as positions in the input (sendingSentences: coverage.sent + 1 to
197
374
  // coverage.distinct_purls), and the room the rows leave goes back to not_sent_purls before
198
375
  // not_checked.
199
- export function sbomData(sides, page) {
376
+ // #3269's third review: `again`, check_sbom's sentence on checking the left-out rows again
377
+ // (sbomAgainSaid), with the note's own growth for such a call (match_batch.ts affectedSaidOver);
378
+ // scan_manifest's rows are checked again through check_sbom, by purls its note does not give, so it
379
+ // names none.
380
+ export function sbomData(sides, page, again) {
200
381
  const others = sides.filter((x) => x !== "not_sent_purls");
382
+ const leaveLast = again ? { ...SBOM_LEAVE_LAST, again, againCalls: RECHECK_CALLS, noteOver: affectedSaidOver } : SBOM_LEAVE_LAST;
201
383
  return {
202
384
  steps: [
203
385
  ...(others.length ? [{ lists: others }] : []),
@@ -206,18 +388,27 @@ export function sbomData(sides, page) {
206
388
  lists: ["results"],
207
389
  thin: SBOM_ROW_LEVELS,
208
390
  leaveOutFirst: { field: "verdict", values: ["not_affected", "not_assessed"], why: "data.summary counts every verdict" },
391
+ leaveLast,
392
+ outFirst: SBOM_OUT_FIRST,
209
393
  leaveAt: SBOM_LEAVE_AT,
210
394
  giveBack: true,
211
395
  },
212
396
  ],
213
- page: (t) => t.results && t.results.returned < t.results.total
214
- ? t.results.returned > 0
215
- ? page(t.results.returned)
216
- : `Not one row fits data even cut to ${SBOM_ROW_COMPACT.map((x) => (typeof x === "string" ? x : x[0])).join(", ")}, so no number of purls per call does: the same call with full: true returns every row whole.`
217
- : "",
397
+ page: (t, _data, at) => {
398
+ if (!t.results || t.results.returned >= t.results.total)
399
+ return "";
400
+ if (t.results.returned === 0)
401
+ return `Not one row fits data even cut to ${SBOM_ROW_MIN.join(", ")}, so no number of purls per call does: the same call with full: true returns every row whole.`;
402
+ const k = at.firstFit("results");
403
+ return k > 0 ? page(k) : "Not even a call of the first of these purls is sure to keep its row in data: the same call with full: true returns every row whole.";
404
+ },
218
405
  };
219
406
  }
220
- const SBOM_DATA = sbomData(["not_sent_purls"], (k) => `To read every row in data, check ${k} or fewer purls per call.`);
407
+ // #3269's second review: the sentence promises only what holds. Its round-2 opening, "To read every
408
+ // row in data, check N or fewer purls per call", read as "page the SBOM in calls of N", and a call
409
+ // of a later N purls need not keep every row (it lost rows in 55% of the paged mixes).
410
+ export const sbomPageSaid = (k) => `A call of the first ${k} of these purls, or of fewer, keeps every row in data; a call of other purls says in its own note what it leaves out.`;
411
+ const SBOM_DATA = sbomData(["not_sent_purls"], sbomPageSaid, sbomAgainSaid);
221
412
  // The purls of a CycloneDX JSON document: every components[] entry, nested ones included.
222
413
  // metadata.component (the subject of the SBOM, not a dependency of it) is not checked.
223
414
  function fromCycloneDX(doc) {
@@ -407,9 +598,16 @@ export function registerCheckSbom(server, deps) {
407
598
  title: CHECK_SBOM_TITLE,
408
599
  description: CHECK_SBOM_DESCRIPTION,
409
600
  inputSchema: z.object({
410
- purls: z.array(z.string()).optional().describe("package URLs to check, e.g. pkg:npm/lodash@4.17.20 or pkg:deb/debian/openssl@3.0.11-1~deb12u1?distro=debian-12 (up to 2,000 distinct, sent in batches of 200)"),
601
+ // #3345: bounded before any element is parsed (inputBounds.ts): MAX_PURL_ENTRIES entries,
602
+ // repeats included (the MAX_PURLS distinct a call checks is checkSbom's own rule, above),
603
+ // and an object document's top level to MAX_SBOM_MEMBERS.
604
+ purls: z
605
+ .array(z.string())
606
+ .max(MAX_PURL_ENTRIES)
607
+ .optional()
608
+ .describe("package URLs to check, e.g. pkg:npm/lodash@4.17.20 or pkg:deb/debian/openssl@3.0.11-1~deb12u1?distro=debian-12 (up to 2,000 distinct, sent in batches of 200), in a list of at most 10,000 entries, repeats included"),
411
609
  sbom: z
412
- .union([z.string(), z.record(z.string(), z.unknown())])
610
+ .union([z.string(), boundedRecord(z.unknown(), MAX_SBOM_MEMBERS)])
413
611
  .optional()
414
612
  .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"),
415
613
  full: FULL_ARG,