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.
- package/CHANGELOG.md +676 -5
- package/README.md +79 -84
- package/dist/http.js +77 -84
- package/dist/httpPolicy.js +206 -14
- package/dist/index.js +489 -49
- package/dist/inputBounds.js +38 -0
- package/dist/logLines.js +86 -0
- package/dist/prompts.js +122 -23
- 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 +147 -0
- package/dist/textBudget.js +826 -76
- package/dist/tools/check_affected.js +19 -6
- package/dist/tools/check_sbom.js +223 -25
- 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 +13 -4
- package/dist/tools/vendor_advisories.js +27 -4
- package/docs/exposure-radar.md +4 -0
- package/docs/rate-limits.md +9 -5
- package/docs/structured-results.md +23 -3
- package/docs/tools.md +139 -6
- package/package.json +3 -3
- package/server.json +2 -2
|
@@ -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("
|
|
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
|
|
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, "
|
|
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, "
|
|
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.";
|
package/dist/tools/check_sbom.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|
|
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", "
|
|
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 (
|
|
161
|
-
//
|
|
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
|
|
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:
|
|
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
|
-
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
|
|
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
|
-
|
|
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) =>
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(),
|
|
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,
|