echelongraph-mcp 3.0.2 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +603 -5
- package/README.md +77 -82
- package/dist/http.js +73 -81
- package/dist/httpPolicy.js +197 -10
- package/dist/index.js +451 -44
- package/dist/logLines.js +86 -0
- package/dist/prompts.js +121 -22
- package/dist/readBody.js +140 -0
- package/dist/requiredText.js +23 -3
- package/dist/resources.js +3 -3
- package/dist/runtime.js +66 -1
- package/dist/schemaRefusal.js +138 -0
- package/dist/textBudget.js +826 -76
- package/dist/tools/check_affected.js +19 -6
- package/dist/tools/check_sbom.js +191 -22
- package/dist/tools/cve_intel.js +145 -35
- package/dist/tools/cve_remediation.js +101 -19
- package/dist/tools/epss_history.js +41 -5
- package/dist/tools/match_batch.js +57 -10
- package/dist/tools/packageRecords.js +58 -0
- package/dist/tools/scan_manifest.js +11 -4
- package/dist/tools/vendor_advisories.js +27 -4
- package/docs/exposure-radar.md +4 -0
- package/docs/rate-limits.md +6 -3
- package/docs/structured-results.md +15 -3
- package/docs/tools.md +139 -6
- package/package.json +1 -1
- package/server.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,602 @@ with a provenance attestation by the release workflow of
|
|
|
6
6
|
[github.com/echelongraph/echelongraph-mcp](https://github.com/echelongraph/echelongraph-mcp),
|
|
7
7
|
from the commit tagged `v<version>`.
|
|
8
8
|
|
|
9
|
+
## 3.1.0 — 2026-10-25
|
|
10
|
+
|
|
11
|
+
- **cve_summary's `freshness` is the fleet's last completed NVD poll** (#3276). Since #3242 the
|
|
12
|
+
summary answer carries `nvd_poll`: `last_completed_at`, when the fleet of API instances last
|
|
13
|
+
completed an NVD poll, with `scope` `fleet` and the poll's `interval`. cve_summary relayed the
|
|
14
|
+
block in `data` but answered `freshness: null`, and its note, its description and the
|
|
15
|
+
methodology resource said the feed serves no poll-completion time. `freshness.nvd_poll` now
|
|
16
|
+
holds `last_run_at`, the block's `last_completed_at` as sent, `scope` `fleet` and `interval`
|
|
17
|
+
(production, 2026-10-17: `2026-10-17T22:45:09Z`, `fleet`, `20m0s`), and the
|
|
18
|
+
envelope's note says it is the fleet's poll, never the answering instance's
|
|
19
|
+
`poller.last_poll_at`. It is relayed only as a real RFC 3339 time at most 5 minutes after this
|
|
20
|
+
server's clock, on a block whose `scope` is `fleet`; `interval` only as a Go duration, else
|
|
21
|
+
`null`. An answer without `nvd_poll` (an older API, or one holding no fleet poll it could read) keeps
|
|
22
|
+
`freshness` `null`, and the note says this answer carries no `nvd_poll` block, not that the feed
|
|
23
|
+
has no poll time; a block without a usable time keeps it `null` too, says why, and is still
|
|
24
|
+
relayed in `data`. A poll more than three of its intervals old (or more than an hour, with no
|
|
25
|
+
usable `interval`) is said to be old: the note gives its age and says the CVE counts may lag
|
|
26
|
+
NVD by about that much. search_cves' and get_cve's note and description now say their answer carries
|
|
27
|
+
no poll-completion time. The outputSchema's `freshness` admits the new object and still `null`:
|
|
28
|
+
a client that listed the tools under 3.0.2 validates cve_summary against `freshness: null`, so
|
|
29
|
+
it fails cve_summary's answers until it reconnects (Claude Code: `/mcp`, then reconnect).
|
|
30
|
+
- **exposure_radar relays the window over the dated rows, when some counted rows have no time**
|
|
31
|
+
(#2438). A radar's observation window was relayed as `window` only when every counted row
|
|
32
|
+
carried an observation time; otherwise nothing was relayed and the note said how many rows
|
|
33
|
+
carried none. On 2026-10-17, 26,855 of kev_exposure's 66,756 counted pairs were undated
|
|
34
|
+
(stored before banner times were captured), and those pairs can stay undated while the InternetDB re-check
|
|
35
|
+
keeps them on record, so its answers gave no observation time at all. `window` keeps its
|
|
36
|
+
meaning. Beside it, `dated_window` `{from, to, undated}` now carries the span over the dated
|
|
37
|
+
rows only, with the count of those without one, for kev_exposure, exposed_databases,
|
|
38
|
+
leaked_credentials and shadow_ai's confirmed_exposed. The note names both numbers (for
|
|
39
|
+
kev_exposure, the dated pairs out of `correlations`) and says the span does not describe the
|
|
40
|
+
undated rows. `state` stays `not_assessed` and `measured_at` `null`, as the 2026-10-04 decision
|
|
41
|
+
requires. Every count is unchanged. `dated_window` is a new field under objects 3.0.2 publishes
|
|
42
|
+
closed, so a client that listed the tools under 3.0.2 needs to reconnect.
|
|
43
|
+
- **A success's `outputSchema` is open at every level, not only at its root** (#3268). 3.0.2 opened
|
|
44
|
+
the root alone (#3121). Below it, 86 objects still published `additionalProperties: false`: 40 in
|
|
45
|
+
`data` (exposure_radar's and cve_intel's `data` itself among them), 23 in `coverage`, 7 in
|
|
46
|
+
`freshness`, and each tool's `truncated` entry. 12 string enums admitted no other string, among
|
|
47
|
+
them scan_manifest's `manifest.files[].format` and `not_checked[].reason` and exposure_radar's
|
|
48
|
+
`kev_exposure.newest_kev[].exposure_state`. A client holding a release's tool list refused a field
|
|
49
|
+
added under any of those objects, or a value added to any of those enums, on every call of that
|
|
50
|
+
tool until it reconnected, and 2.6.3 and 2.7.0 each added such fields. A success's `outputSchema`
|
|
51
|
+
is open at every level: each object admits fields it does not name, and each enum but `state` any
|
|
52
|
+
string beside the values it lists. The failure branch keeps every object closed, and every enum
|
|
53
|
+
but four, which already accept any string: `coverage.not_assessed_reason` (check_affected),
|
|
54
|
+
`coverage.vendor_windows[].history_backfill` (vendor_advisories_for_cve, search_vendor_advisories)
|
|
55
|
+
and `coverage.vendor_window.history_backfill` (get_vendor_advisory). The server still checks its
|
|
56
|
+
own answers against the closed schema each tool builds, and so does the test suite.
|
|
57
|
+
`test/open-success.test.mjs` fails the release run on any closed object or enum in a success
|
|
58
|
+
branch of `tools/list`, and `test/schema-compat.mjs <old> [<new>]` lists what a candidate adds
|
|
59
|
+
under an object, or to an enum, that the release before it closed. The opened schema is built
|
|
60
|
+
once per process and shared by every server the hosted endpoint builds per request.
|
|
61
|
+
- **A success's null-only `coverage` and `freshness` are published as `null` or an open object**
|
|
62
|
+
(#3268's review). 3.0.2 published `freshness` as `{"type": "null"}` in 14 tools and `coverage` so
|
|
63
|
+
in cve_summary and get_cve, so a client holding a release's list refused the object a later
|
|
64
|
+
release put there, as 3.0.2's clients refuse cve_summary's `freshness` now (#3276). In this
|
|
65
|
+
release those 15 slots (cve_summary's `freshness` now has its own schema) admit an object too,
|
|
66
|
+
while the server still answers `null` in each, as before. `test/open-success.test.mjs` finds a
|
|
67
|
+
success slot that admits only `null`, and `test/schema-compat.mjs` reports a type a candidate
|
|
68
|
+
widens at any node (`null` to `null|object`, a field made nullable, an integer made a number),
|
|
69
|
+
besides a field or value added. `scripts/npm-publish-mcp.sh`'s dry run runs it against npm's
|
|
70
|
+
latest and fails on any finding `test/schema-compat-allow.json` does not accept with a reason.
|
|
71
|
+
- **A client that listed the tools under 3.0.2 or older must reconnect once** (Claude Code:
|
|
72
|
+
`/mcp`, then reconnect `echelongraph`), or it refuses these answers, which its list's closed
|
|
73
|
+
schema does not admit: cve_summary's (`freshness.nvd_poll`, #3276), epss_history's (`coverage`'s
|
|
74
|
+
`runs_undated`, `refresh_runs_truncated` and `days_in_progress`, #3143) and exposure_radar's where
|
|
75
|
+
it relays `dated_window` (under `kev_exposure`, `exposed_databases`, `leaked_credentials` and
|
|
76
|
+
`shadow_ai.confirmed_exposed`, #2438). `test/schema-compat.mjs` against npm 3.0.2 finds these and
|
|
77
|
+
the 15 slots above, which are not refused: they are still answered `null`. From 3.1.0 on, a field
|
|
78
|
+
or value added to a success does not need a reconnect.
|
|
79
|
+
- README Versioning, and the 3.0.2 entry below, said each tool's `data` schema stays closed
|
|
80
|
+
(#3257). In 3.0.2 `data` was open at its root for 14 of 16 tools (closed for exposure_radar and
|
|
81
|
+
cve_intel), and objects below a success's root were closed in and outside `data`. Versioning now
|
|
82
|
+
says what is open and what is closed, in sentences `test/readme.test.mjs` derives from
|
|
83
|
+
`tools/list`, from "Since 3.1.0": 3.0.2 and older publish their success branches closed below the
|
|
84
|
+
root.
|
|
85
|
+
- **cve_intel counts GitHub checker repositories apart from exploit code** (#3221). #3040 defined
|
|
86
|
+
an exploit-reference kind, `github_checker`, for a GitHub repository named or described as a
|
|
87
|
+
checker or scanner and never as an exploit, and EchelonGraph's API does not count a reference of
|
|
88
|
+
that kind as exploit code. cve_intel's note counted every reference but a Nuclei template as a
|
|
89
|
+
public exploit reference, a `github_checker` one included. It now counts each reference the API
|
|
90
|
+
serves as `github_checker` apart, as a GitHub checker repository, with /pulse's note that such a
|
|
91
|
+
repository checks whether a system is affected and is not necessarily exploit code (beside a
|
|
92
|
+
Nuclei template, /pulse's two notes in turn): 1 `github_poc` and 3 `github_checker` references
|
|
93
|
+
read "1 public exploit reference and 3 GitHub checker repositories", where they read "4 public
|
|
94
|
+
exploit references". The counts are the kinds the API serves: a repository it serves as
|
|
95
|
+
`github_poc` is still a public exploit reference here, even where /pulse reads it as a checker.
|
|
96
|
+
So for CVE-2020-35489 (1 proof of concept and 3 checkers on /pulse, 2026-10-11), cve_intel gives
|
|
97
|
+
the 3 as GitHub checker repositories only once the API serves them as `github_checker`. The
|
|
98
|
+
description's head says "detection checks" where it said "detection templates", the `kind`
|
|
99
|
+
field's text splits the exploit-code kinds from `nuclei_template` and `github_checker`, and
|
|
100
|
+
`exploits_by_kind` and the method say the same.
|
|
101
|
+
- **cve_intel never counts a kind it does not classify as exploit code** (#3267). It counted every
|
|
102
|
+
reference whose kind was not one of its two detection kinds as a public exploit reference, so a
|
|
103
|
+
kind EchelonGraph's API serves that this release predates (another scanner or template source)
|
|
104
|
+
would have read "N public exploit references", the claim #3039 took out; `github_checker` did
|
|
105
|
+
that from #3040 to #3221. It now counts exploit code by name (`metasploit`, `exploit_db`,
|
|
106
|
+
`github_poc` and `vendor_poc`, the kinds the API counts as exploit code). A reference whose kind
|
|
107
|
+
is neither one of these nor one of the two detection kinds is "of a kind this server does not
|
|
108
|
+
classify": the note names the kind and counts it as neither exploit code nor a detection check.
|
|
109
|
+
2 references of an unknown kind `foo_check` read "2 references of a kind this server does not
|
|
110
|
+
classify", where they read "2 public exploit references", and a CVE holding one is never said to
|
|
111
|
+
hold no Metasploit module, Exploit-DB entry or proof of concept. The `kind` field's text says so,
|
|
112
|
+
and so do `method` and `exploits_by_kind`'s description, each now built from the same two lists
|
|
113
|
+
the note counts by: `method` adds "A kind on neither list is one this server does not classify:
|
|
114
|
+
the note counts it as neither.", and `exploits_by_kind` names the exploit-code kinds and what any
|
|
115
|
+
other kind's count is. The kinds the API serves today are all classified, so no count changes.
|
|
116
|
+
- **triage_cve gives GitHub checker repositories apart from exploit code** (#3221). Its step 2
|
|
117
|
+
says a `github_checker` row is a GitHub repository named or described as a checker or scanner,
|
|
118
|
+
which checks whether a system is affected and is not necessarily exploit code, and its
|
|
119
|
+
"Exploited?" line gives them apart, as detection checks, with Nuclei detection templates. It
|
|
120
|
+
named only `nuclei_template`, so "GitHub or vendor proofs of concept" read as taking a checker in.
|
|
121
|
+
- **cve_exposure's outputSchema says what each KEV flag is** (#3016). `kev_listed` is deprecated:
|
|
122
|
+
the same value as `kev_seen_in_observations`, the radar's observation flag, not CISA-KEV catalog
|
|
123
|
+
status, so it is false for a CISA-KEV CVE the radar holds no service for (CVE-2024-3400 on
|
|
124
|
+
2026-10-10: `kev_listed` false, `kev_catalog_listed` true). `kev_catalog_listed` is the catalog
|
|
125
|
+
flag: true when EchelonGraph's CVE record marks the CVE as CISA-KEV-listed, and false also where
|
|
126
|
+
EchelonGraph holds no record of it. The three fields had no text. `kev_listed` stays, so no
|
|
127
|
+
reader breaks; the values are unchanged.
|
|
128
|
+
- `epss_history` relays `coverage.runs_undated` and `coverage.refresh_runs_truncated` in the
|
|
129
|
+
envelope's `coverage` (#3143), and its note names each when it is not 0 or false. An undated
|
|
130
|
+
refresh is one the run log holds no FIRST score date for: its file named none, or it stopped
|
|
131
|
+
before recording it. The day it applied is not counted (held, partial or in progress), so a day
|
|
132
|
+
the note lists as missing, or does not list, may be held for the CVEs it changed, and its points
|
|
133
|
+
carry no `score_date`. Production has served `runs_undated: 1` since 2026-10-08, when a deploy killed a
|
|
134
|
+
refresh mid-run, and the note said "none missing" without naming it. Both fields are `null` in
|
|
135
|
+
`coverage` when the API does not say, never 0 or false.
|
|
136
|
+
- `epss_history` relays `coverage.days_in_progress` (#3143): the FIRST score dates a refresh that
|
|
137
|
+
has not finished is applying, held for some CVEs so far. core-backend now dates a refresh's run
|
|
138
|
+
row as soon as it reads the file's header, so its date is listed while it runs, and not in
|
|
139
|
+
`days_partial`, which means a refresh of the date failed part-way. A retry of a date whose
|
|
140
|
+
earlier refresh failed part-way is in both lists, and the note names it in both. The note never
|
|
141
|
+
says nothing failed: the API reads "not finished" off the row's status and age, so a refresh
|
|
142
|
+
killed minutes ago reads the same. `null` in `coverage` when the API does not say (an API older
|
|
143
|
+
than #3143).
|
|
144
|
+
- **check_affected and check_sbom answer each CVE once per component** (#3137). OSV can publish one
|
|
145
|
+
CVE for a package in two records, a GHSA and a package repository's own advisory, and
|
|
146
|
+
EchelonGraph's API stored and answered each: `drupal/quick_node_block` 1.0.0 on Packagist came
|
|
147
|
+
back with 4 matches for 2 CVEs, each CVE's GHSA match naming `fixed_in` 2.0.0, which the CVE's
|
|
148
|
+
Drupal advisory still holds affected. The API now decides all of a CVE's stored records for the
|
|
149
|
+
package together: one match per CVE, `fixed_in` 2.0.1 for both, and cve_remediation's
|
|
150
|
+
installed-version answer agrees. `not_affected_count` and `undetermined_count` count CVEs, not
|
|
151
|
+
advisory records (`advisories_considered` still counts the records), and the notes say so. A
|
|
152
|
+
null `fixed_in` is described as no version above this one shown to be unaffected by the CVE's
|
|
153
|
+
advisory records, which is not a finding that no fix exists. A record of the CVE that cannot
|
|
154
|
+
decide `fixed_in` (no version data, a git commit range, or one recorded fix it is at or past)
|
|
155
|
+
does not withhold it, so the same lookup at `fixed_in` can come back undetermined.
|
|
156
|
+
- **get_cve and search_cves type the scorer's three components** (#2879). Scorer v4.0 publishes
|
|
157
|
+
`eg_severity` (the impact severity alone: the CVSS base no KEV listing, EPSS or EG-KEV tier
|
|
158
|
+
raises; `assessed` false, with `unassessed_reason`, where no CVSS is published), `eg_likelihood`
|
|
159
|
+
(FIRST's EPSS probability of exploitation in the next 30 days, not EchelonGraph's own model; null,
|
|
160
|
+
with `null_reason`, where no EPSS is on record; `exploited` reports confirmed exploitation apart,
|
|
161
|
+
never folded in) and `eg_priority` (the SSVC decision, the BOD 26-04 timeline and their inputs).
|
|
162
|
+
The `outputSchema` types all three on get_cve's `data`, where `GET /cves/:id` serves them at the
|
|
163
|
+
top level, and on search_cves' `data.cves[]` rows (the list route carries them in
|
|
164
|
+
`score_factors`, whose description now names them); `echelongraph_score` is described as
|
|
165
|
+
severity escalated by confirmed exploitation, and `echelongraph_risk`'s severity term as
|
|
166
|
+
`eg_severity` from v4.0. get_cve's note adds up to two sentences, each only where the record
|
|
167
|
+
carries what it reads: where the score is above `eg_severity` because a KEV listing floors it at
|
|
168
|
+
9.0 or EPSS lifts it, that the rise is exploitation, not impact (for a KEV-listed CVE with no
|
|
169
|
+
CVSS, that the score is the floor for confirmed exploitation, not an impact rating); and where
|
|
170
|
+
`eg_likelihood.probability` is null, that the likelihood is unknown, not low. A record scored
|
|
171
|
+
before v4.0 reads as before. Additive: 3.0.2 published get_cve's `data` and search_cves' rows
|
|
172
|
+
open (`additionalProperties` `{}`), so a client holding 3.0.2's list accepts the fields.
|
|
173
|
+
- **Arguments that break a tool's inputSchema answer the full envelope, `invalid_input`** (#3015).
|
|
174
|
+
The MCP SDK checked a call's arguments against the tool's `inputSchema` before the tool ran, and
|
|
175
|
+
answered a failure itself: `search_cves` `{limit: 500}` came back as one text block, "Input
|
|
176
|
+
validation error: Invalid arguments for tool search_cves: limit: Too big: expected number to be
|
|
177
|
+
<=50", `isError` true, with no `structuredContent`, so no `state` and no notes, although the README
|
|
178
|
+
says every result carries `structuredContent`. Every tool now answers such a call as it answers
|
|
179
|
+
any refusal: `state` `invalid_input`, `error.kind` `invalid_input`, the envelope in the last text
|
|
180
|
+
block, and notes naming each argument and the rule it broke (`limit must be at most 50`,
|
|
181
|
+
`severity must be one of "Critical", "High", "Medium" or "Low"`, `cve_id is required (a string)`),
|
|
182
|
+
by JSON type, never quoting a value sent, over stdio and the hosted endpoint, in both protocol
|
|
183
|
+
eras. Nothing is looked up. This change leaves `tools/list` as it was: it publishes every
|
|
184
|
+
`inputSchema` byte for byte as before it. Against 3.0.2, 15 of the 16 are unchanged;
|
|
185
|
+
get_vendor_advisory's differs in its `advisory_id` description alone, which #3011 (below)
|
|
186
|
+
rewrote to name `vendor_advisory_id` and warn off the row's UUID.
|
|
187
|
+
- **get_vendor_advisory says which ID to pass when given a row's `advisory_id`** (#3011). A
|
|
188
|
+
vendor_advisories_for_cve or search_vendor_advisories row carries two IDs: `advisory_id`,
|
|
189
|
+
EchelonGraph's internal UUID, and `vendor_advisory_id`, the vendor's own. get_vendor_advisory's
|
|
190
|
+
argument is named `advisory_id` but takes the vendor's ID, and EchelonGraph's API has no route by
|
|
191
|
+
the UUID, so passing the row's `advisory_id` straight on (CVE-2024-3400's Palo Alto row,
|
|
192
|
+
`a68d033b-…`) answered `failed`, worded as the API holding no record, for an advisory it holds. It
|
|
193
|
+
now answers `invalid_input` naming the field to pass: the row's `vendor_advisory_id`
|
|
194
|
+
(`CVE-2024-3400`) as `advisory_id`. The API is still asked first, so a vendor's own ID shaped like a
|
|
195
|
+
UUID is looked up as before, and any other 404 is still `failed`, as no record. Both list tools'
|
|
196
|
+
`outputSchema` (and get_vendor_advisory's, which echoes it) now describe a row's `advisory_id` and
|
|
197
|
+
`vendor_advisory_id`, get_vendor_advisory's `advisory_id` argument and description name
|
|
198
|
+
`vendor_advisory_id` and warn off the UUID, and triage_cve's step 4 says to call
|
|
199
|
+
get_vendor_advisory with a row's vendor and its `vendor_advisory_id` as `advisory_id`.
|
|
200
|
+
- **scan_manifest states the API's per-minute budget, and its batch numbers are checked** (#3100).
|
|
201
|
+
Its description now says that a call takes at most 2,000 distinct purls and 50 seconds, that the
|
|
202
|
+
API allows 1,200 components a minute per caller, and that on a 429 the tool waits out
|
|
203
|
+
`Retry-After` within those 50 seconds, while a later batch cut off at them, refused or failed
|
|
204
|
+
ends the call, the rest in `data.not_sent_purls`, unchecked and not clean (3.1.0's review: the
|
|
205
|
+
first wording bound only the 429 wait by the 50 seconds, which bound the whole call, as 3.0.2's
|
|
206
|
+
"within 50 seconds" said; the room came from shorter wording elsewhere). So from a fresh minute a
|
|
207
|
+
lockfile of 1,201 to 2,000 purls answers partial by design, which the description did not say;
|
|
208
|
+
started later in the API's minute, a call can wait for the next one and check more. Its list of
|
|
209
|
+
`not_checked` reasons gave way to it: each reason is described in the `outputSchema`.
|
|
210
|
+
The description's numbers (200 per POST, 2,000 purls, 50 seconds, 1,200 a minute) were literals
|
|
211
|
+
no test read; they are now held to the constants the tool sends by, and core-backend's guards
|
|
212
|
+
hold scan_manifest's batch size and budget to the API's own, as they did check_sbom's.
|
|
213
|
+
- **cve_remediation's first text block keeps every advisory before any list beside them** (#3272).
|
|
214
|
+
#3139 fixed the cut order of `structuredContent.data` only. The first text block, which a client
|
|
215
|
+
that passes only `content` reads, still left advisories out from the end while `fixed_branches`,
|
|
216
|
+
`patches` and `fix_references` stayed whole beside them: on the recorded CVE-2023-44487 answer it
|
|
217
|
+
kept 19 of 20 advisories, and with 1, 5, 10 and 16 more `fixed_branches` packages 18, 13, 8 and 2.
|
|
218
|
+
Its TEXT CUT note named `get_vendor_advisory`, which needs the advisory's ID, and never
|
|
219
|
+
`vendor_advisories_for_cve`. The text now cuts in `data`'s order: each advisory to its leaner
|
|
220
|
+
fields first, then `fixed_branches` and then `patches` thinned to the fields `data` keeps, then
|
|
221
|
+
`fix_references`, `patches` and `fixed_branches` rows leave from the end, each list emptied
|
|
222
|
+
before the next loses one, and only then advisories. The note says what each list keeps and,
|
|
223
|
+
when an advisory is left out, names `vendor_advisories_for_cve`, which lists every one. That
|
|
224
|
+
answer keeps all 20 advisories now, beside every package, patch and reference, with 1 to 18
|
|
225
|
+
more packages too. As `data` does, once `fixed_branches` and `patches` are thinned and the text
|
|
226
|
+
still does not fit, each package's ranges are cut to the most that fit, every package kept: a
|
|
227
|
+
package so cut carries `fixed_branches_truncated` with its total and returned, and the note
|
|
228
|
+
names it and says `structuredContent.data` holds every range; only where even one range per
|
|
229
|
+
package does not fit do references, then patches, then packages leave, the room they leave going
|
|
230
|
+
back to the ranges. The note's fix rule, walked over a capped package's kept ranges alone, can
|
|
231
|
+
name a version a range left out still holds, so the text's note and `data`'s (of a package `data`
|
|
232
|
+
caps) say so and to read that package's fix with `full: true`. Before 3.1.0's review the text
|
|
233
|
+
capped no ranges: with one package of N ranges first among CVE-2023-44487's 51 it kept 11 of 20
|
|
234
|
+
references at 150 ranges, none at 200, no patch at 250, 25 packages at 300 and, from 400, no
|
|
235
|
+
package, patch or reference in 6,347 characters. It now keeps all 51 packages, 30 patches and 20
|
|
236
|
+
references from 50 to 600 ranges, the large package's ranges capped at 133 from 150 on (3.0.2's
|
|
237
|
+
every-list cap kept 32 packages, each to 32 ranges, 30 patches and 20 references). `data` keeps
|
|
238
|
+
all 51 throughout; the longer notes cost it a few references there (10 of 20 from 150 ranges, from
|
|
239
|
+
12 or 13).
|
|
240
|
+
- **cve_remediation's fix rule reads a package listed under Packagist and Packagist:<repository
|
|
241
|
+
URL> as one** (#3285). `fixed_branches` lists a package once per stored ecosystem string, so
|
|
242
|
+
CVE-2025-48013 lists `drupal/quick_node_block` under `Packagist:https://packages.drupal.org/8`
|
|
243
|
+
(fixed 2.0.1) and under `Packagist` (fixed 2.0.0). The note's rule, "the first fixed above it
|
|
244
|
+
that no range of its package holds", read entry by entry, gave a Packagist 1.0.0 the fix 2.0.0,
|
|
245
|
+
which the Drupal advisory still holds affected; the matcher reads both entries as one package
|
|
246
|
+
and answers 2.0.1. Where such entries are listed, the note now counts them as one package, says
|
|
247
|
+
that entries of one `package_name` under Packagist or Maven, bare or with a repository URL, are
|
|
248
|
+
one package, and names them. A release suffix (`Debian:12`) is never folded. `data` is
|
|
249
|
+
unchanged.
|
|
250
|
+
- **cve_intel and the prompts read them the same way.** `cve_intel`'s `affected_packages` lists
|
|
251
|
+
a package once per stored ecosystem string too, and its note's rule ("no range of the row
|
|
252
|
+
holds") and the `sbom_review` and `triage_cve` fix rules took one row at a time, so the same
|
|
253
|
+
1.0.0 was given 2.0.0. `cve_intel`'s note now says such rows are one package, names them, and
|
|
254
|
+
states the walk over all their ranges; `sbom_review`'s rule reads a Packagist or Maven
|
|
255
|
+
component's rows of its `package_name`, bare and with a repository URL, as one row, and
|
|
256
|
+
`triage_cve` gives their ranges together, never a fixed that another of them still holds.
|
|
257
|
+
Both give 1.0.0 the fix 2.0.1.
|
|
258
|
+
- **check_affected's text keeps KEV-listed and ransomware matches to the last** (#2899). Near the
|
|
259
|
+
cap of 200 registry matches the text left out the last matches by rank (`effective_score`), so
|
|
260
|
+
a KEV-listed match ranked low was in `structuredContent.data` only and the note said only how
|
|
261
|
+
many left. Now a match whose `kev_listed` or `ransomware` is true stays in the text while any
|
|
262
|
+
other match is in it, the note says so, and if one must still leave (every other already out)
|
|
263
|
+
the note names each by its `cve_id`. The structured result's cut rule is unchanged, but by
|
|
264
|
+
default `data` can hold a few entries fewer, because the longer `TEXT CUT` note sits in the same
|
|
265
|
+
envelope budget: at the cap of 200 registry matches check_affected's `data` keeps 131 where 3.0.2
|
|
266
|
+
kept 132 to 133, and cve_remediation's, with 100 packages more than CVE-2023-44487's 50, 78
|
|
267
|
+
`fixed_branches` rows where it kept 80 (and, with one package of 150 to 600 ranges among them, 11
|
|
268
|
+
of 20 `fix_references`, from 12 or 13, since the text names the package whose ranges it caps,
|
|
269
|
+
#3272). `full: true` returns `data` whole, as before.
|
|
270
|
+
- **check_sbom's first text block keeps rows with a KEV-listed or ransomware match to the last**
|
|
271
|
+
(#3284). `data` keeps those rows first, while the text left the other affected rows out from the
|
|
272
|
+
end in input order, so a KEV-bearing row could leave the text and be named in no text block and
|
|
273
|
+
in no note (the `DATA CUT` sentence names only rows left out of `data`). On the reviewer's
|
|
274
|
+
text-only client (the first text block and the note, then `workload_triage`'s step 6), the 36
|
|
275
|
+
realistic Debian 12 images had 95 of 413 KEV-bearing rows in neither; the 2,000-purl images and
|
|
276
|
+
image mixes 23 of 119. Now those rows leave the text only after every other row does, the note
|
|
277
|
+
says so ("the last 123 of the rest without a KEV-listed or ransomware match"), and where some
|
|
278
|
+
must still leave it names each by `index` ("Of the rows left out of it, 30 have a KEV-listed or
|
|
279
|
+
ransomware match: at index 511 to 513, …"). On the same sets, a row the note names so counted as
|
|
280
|
+
named: 0 of 413 and 0 of 119, and the KEV-first-sorted images 0 of 374 (1 at d42d4697). `scan_manifest`'s text, cut the same way, does
|
|
281
|
+
the same. `workload_triage` and `sbom_review`, read from the text alone, list the rows so named
|
|
282
|
+
as KEV-bearing and not checked. The 201-purl probe keeps its 43 rows in `data`. As with #2899,
|
|
283
|
+
the longer note sits in the same envelope budget: on the realistic images `data` keeps 489
|
|
284
|
+
affected rows where it kept 491, and after `workload_triage`'s 5 re-check calls 392 KEV-bearing
|
|
285
|
+
rows are in view where 396 were (KEV-first images 352 of 374, from 356); every re-check call
|
|
286
|
+
still keeps every row it sends.
|
|
287
|
+
- **workload_triage's Kubernetes exposure rule names a container `hostPort` and a Service's
|
|
288
|
+
`externalIPs`** (#3283). Its rule named a LoadBalancer or NodePort Service, an Ingress or Gateway
|
|
289
|
+
route and `hostNetwork`, and "not exposed" needed only the pod's spec and the Services read, so a
|
|
290
|
+
pod reachable on node IPs through a `hostPort` (an ingress controller or agent DaemonSet) or a
|
|
291
|
+
ClusterIP Service with `externalIPs` was labelled not exposed and sorted below the unknown ones
|
|
292
|
+
of its KEV group. Both are now exposed, and "not exposed" requires having read the containers'
|
|
293
|
+
ports and the Services' `externalIPs`. The MCP post's "Triage your pods" section states the same
|
|
294
|
+
rule, held to the prompt's text by marketing-site's prompt-claims test.
|
|
295
|
+
- **sbom_review gives a null `fixed_in`'s reason** (#2834). Where a match's `fixed_in` is null,
|
|
296
|
+
the fix list writes "no fix on record" with the match's `fixed_in_reason`, as given, and reads
|
|
297
|
+
no version from it: a version it names (an interval that ends at `last_affected`) is not a fix.
|
|
298
|
+
It wrote "no fix on record" alone. check_sbom's text keeps `fixed_in_reason` at every level
|
|
299
|
+
that keeps a match. Its structured `data` does not at the compact level (#3269): kept there, the
|
|
300
|
+
reason cost the 201-purl probe a row (43 to 42) and image mixes affected rows at their floor. So
|
|
301
|
+
the clause applies where a match carries `fixed_in_reason`, and a null `fixed_in` without one is
|
|
302
|
+
a reason the cut left out: sbom_review never calls it no fix, and checks the purl again with
|
|
303
|
+
`full: true` or reads the fix from cve_remediation or get_cve.
|
|
304
|
+
- **check_affected relays a CPE match's range end in its own field, never as `fixed_in`** (#2834).
|
|
305
|
+
Once EchelonGraph's API serves it, a CPE match carries the end of the NVD range that matched,
|
|
306
|
+
`cpe_version_end_excluding` (the first version the range no longer holds) or
|
|
307
|
+
`cpe_version_end_including` (the last one it holds). The outputSchema types both, and neither is
|
|
308
|
+
ever given as a fixed version: another range of the same CVE, or a vendor backport, can sit past
|
|
309
|
+
it. Only the first cut level of the text and the first thinned level of `data` keep them; every
|
|
310
|
+
later level leaves them out, so a CPE answer cut past its first level, as typical CPE answers
|
|
311
|
+
are, carries them only with `full: true`. A CPE match still carries no `fixed_in`.
|
|
312
|
+
- check_sbom's and scan_manifest's `matched_package` and `matched_via` are on each row the lookup
|
|
313
|
+
ran for; a row the API refused has neither (#2836). The methodology resource, /pulse/mcp's
|
|
314
|
+
check_sbom row and the SBOM coverage program say so, where they said every row.
|
|
315
|
+
- The `echelongraph://methodology` resource says what check_affected's and check_sbom's matches
|
|
316
|
+
carry for a fix (`fixed_in`, or null with `fixed_in_reason`; a CPE match's range end, which is
|
|
317
|
+
not one), and that a deb, apk or alpine purl's `upstream` qualifier is matched as the source
|
|
318
|
+
package it names (`matched_via` upstream, `matched_package` that name), while a binary package
|
|
319
|
+
sent without it comes back `not_assessed` (#2834, #2836).
|
|
320
|
+
- **check_sbom and scan_manifest drop clean rows before they cut an affected row's matches**
|
|
321
|
+
(#3269). 3.0.2's structured cut (#3132) took every row through its levels together, the clean
|
|
322
|
+
ones with the affected, and only at the last did rows leave. On an ordinary image SBOM, where
|
|
323
|
+
most rows are clean, the affected rows lost their matches to keep the clean ones. Replayed on the
|
|
324
|
+
production synthetic's recorded rows, 150 purls with 40 affected kept all 150 rows, and only 2
|
|
325
|
+
of the 40 affected rows kept their matches. 3.0.0 kept 56 rows, all 40 affected with every
|
|
326
|
+
match. Now the `not_affected` and `not_assessed` rows are cut to `index`, `purl`, `verdict`,
|
|
327
|
+
`not_assessed_reason` and `cve_ids`. They then leave, `not_affected` first, as many as must for
|
|
328
|
+
every other row to keep all its matches. `data.summary` counts them, and the note says how many
|
|
329
|
+
left. Once the cut is settled they come back, cut so, as many as the room left holds. On the same
|
|
330
|
+
replays, 150/40 keeps 52 rows, 150/30 keeps 91 and 150/20 keeps 135, and every affected row has
|
|
331
|
+
every match.
|
|
332
|
+
- **Where the affected rows must be capped anyway, the clean rows stay** (#3269's review). They
|
|
333
|
+
leave first to keep the other rows' matches whole, not to raise a cap those rows are cut to
|
|
334
|
+
anyway. So where every other row cannot keep all its matches even with every clean row gone,
|
|
335
|
+
the clean rows stay, cut as above, while every affected row keeps at least its floor: each of
|
|
336
|
+
its matches used in ransomware or KEV-listed, and 10 more of highest `effective_score`.
|
|
337
|
+
`linux-libc-dev` 6.1.55-1 (200 matches) with 100 clean Debian 12 purls keeps all 101 rows, as
|
|
338
|
+
3.0.2 did, `linux-libc-dev` with 29 of its matches; sending the clean rows out to raise the cap
|
|
339
|
+
kept 1 row of 101 in testing. Juice Shop 11.1.2 with `magento/community-edition` 2.3.0 keeps all
|
|
340
|
+
51 rows, every one of its 19 affected rows with matches, magento with 19 of its 200, both
|
|
341
|
+
KEV-listed ones among them (3.0.2 kept it 6, without CVE-2025-54236). Only where even the floor does not fit
|
|
342
|
+
with every clean row do they leave, as many as must for it to: five Debian 12 purls with 200
|
|
343
|
+
clean ones keep 69 rows.
|
|
344
|
+
- **A capped affected row never goes below its floor; past it, rows leave whole** (#3269's second
|
|
345
|
+
review). Where even the floor did not fit with every clean row gone, the cap went on below it,
|
|
346
|
+
to 1 match a row and then none, every row still kept: five Debian 12 purls beside an image's 150
|
|
347
|
+
(40 affected) kept 45 affected rows, 1 with any match, where 3.0.2 kept 26, 22 with every
|
|
348
|
+
match. Now no row's matches are capped below its floor. The other rows leave instead, whole, at
|
|
349
|
+
the compact level and their floor, and the rows kept are chosen those with a KEV-listed or
|
|
350
|
+
ransomware match first, then the others, each in input order, each one that fits beside those
|
|
351
|
+
chosen before it (a row too large to fit leaves, and the rows after it stay). The note names
|
|
352
|
+
the affected and undetermined rows that left by `index`, so a call of those purls alone checks
|
|
353
|
+
them again (those with a KEV-listed or ransomware match first and apart, below). Where not even
|
|
354
|
+
one row fits at its floor (a row whose KEV-listed matches alone pass the budget), it stays alone
|
|
355
|
+
with the most of its matches that fit, ransomware first, and the note says so: 116 of 200
|
|
356
|
+
KEV-listed matches. Over 40 Debian-image mixes (five Debian 12 purls, or `linux-libc-dev`
|
|
357
|
+
6.1.55-1, beside 100 to 400 more purls, 10 to 60 affected), 820 affected rows keep every match
|
|
358
|
+
or their floor, 720 every match, and none is
|
|
359
|
+
under its floor (round 2: 462 and 408, and 744 rows under it; 3.0.2: 929 and 834, and 116 under
|
|
360
|
+
it). 3.0.2 kept more rows because its compact matches left out `epss_score` and
|
|
361
|
+
`score_assessed` (#3270): with those two fields out, this cut keeps 1,030 and 930. The note's
|
|
362
|
+
list of affected components says that the rest are in `data.results` whole with `full: true`.
|
|
363
|
+
- **The trade against 3.0.2, plainly** (#3269, #3270). On heavy images the first call keeps fewer
|
|
364
|
+
affected rows than 3.0.2 did. Each row it keeps is whole at its floor or more, every match with
|
|
365
|
+
every field `workload_triage` ranks by (`epss_score` and `score_assessed` among them), the rows
|
|
366
|
+
with a KEV-listed or ransomware match kept first, and the rest are named by `index` for checking
|
|
367
|
+
again (next item). Measured on 36 realistic Debian 12 images (2026-10-18): 491 affected rows in
|
|
368
|
+
the first call, against 3.0.2's 686; but 367 with every match and all six ranked fields, against
|
|
369
|
+
61; 204 with a KEV-listed or ransomware match, against 39; and no KEV-listed match cut while a
|
|
370
|
+
match without one is kept, against 9 rows on 3.0.2. On three 2,000-purl images: 36 rows against
|
|
371
|
+
86, 28 against 2 with every ranked field, 31 against 4 KEV-bearing. On the 40 image mixes above:
|
|
372
|
+
820 against 1,045, 720 of them with every match and all six ranked fields, against none. After
|
|
373
|
+
`workload_triage`'s checks again, 396 of the realistic images' 413 KEV-bearing rows are in view.
|
|
374
|
+
Since those checks take the KEV-bearing rows first, 48 affected rows that 3.0.2's call showed,
|
|
375
|
+
on 3 of the 36 images (13 on one 2,000-purl image), are past its 5 calls, and it reports them
|
|
376
|
+
by `index` as unchecked.
|
|
377
|
+
- **The rows left out are named KEV-bearing first, and checked again in calls that keep every
|
|
378
|
+
row** (#3269's third review).
|
|
379
|
+
- **The order.** The note counted the KEV-bearing rows among those left out, but named all of
|
|
380
|
+
them in one list, in `index` order. `workload_triage` checked them again in that order and
|
|
381
|
+
stopped after 5 calls, so on six old unpatched Debian images, where those rows come late, 119
|
|
382
|
+
of 255 KEV-bearing rows were in view after it. The note now names them first and apart: "95
|
|
383
|
+
affected or undetermined rows: the 32 with a KEV-listed or ransomware match at index 18 to 21,
|
|
384
|
+
…; the other 63 at index 0, 2, 4 to 9, …". Both lists are of runs of indices, 100 parts at
|
|
385
|
+
most in all. `workload_triage` checks them again in that order: 238 of the 255 are in view
|
|
386
|
+
after its 5 calls, against 228 with the input itself sorted KEV-bearing first.
|
|
387
|
+
- **The size of each call.** Step 6 sized its calls by the page sentence's N, which holds for a
|
|
388
|
+
call of the first N purls sent. The rows left out are the heavy ones, so those calls dropped
|
|
389
|
+
504 of 2,540 rows on the realistic images. The note now gives the sizes for these rows, counted
|
|
390
|
+
as the cut would cut a call of them, with that call's own note (the affected components it
|
|
391
|
+
names): "Re-check these 95 purls in this order, in calls of 7, 5, 8, 8 and 16, then of at most
|
|
392
|
+
17: each such call keeps every row in data." Each of the first five calls takes the most of
|
|
393
|
+
the rows left, in that order, that the cut's own measure counts as fitting at their floor with
|
|
394
|
+
600 characters held back, a lower bound (a real call of them often keeps more); after those,
|
|
395
|
+
the number is the most such that any that many of the rest in a row count so, and a call of
|
|
396
|
+
fewer rows in a row keeps them all too. One size for every call (the "at most M" alone) put 201 of the 255 in view in
|
|
397
|
+
testing; even the best single size, found by trying real calls, would put 224. Every call these
|
|
398
|
+
sentences give, made in full, kept every row: 551 calls on the 36 realistic images (15 an
|
|
399
|
+
image), 227 on the three 2,000-purl images, 441 on the 19 KEV-first-sorted ones, 28 on the
|
|
400
|
+
image mixes and 18 on the 16 adversarial ones; within the 5-call cap, 165 calls on the
|
|
401
|
+
realistic images.
|
|
402
|
+
- **A row past the budget alone.** A row whose floor alone does not fit (200 KEV-listed
|
|
403
|
+
matches) is sent to a call of its own, which keeps it under its floor and says so: "The purl
|
|
404
|
+
at index 150 goes in a call of its own: …".
|
|
405
|
+
- **sbom_review** had no step for the affected rows a `DATA CUT` leaves out of `data`, which is
|
|
406
|
+
what Claude Code reads. Its step 1 now checks them again the same way, by `index`, those with a
|
|
407
|
+
KEV-listed or ransomware match first, at most 5 calls, and lists the rest as unchecked.
|
|
408
|
+
- **The note's length.** Over the 36 realistic images the note is 151 characters longer on
|
|
409
|
+
average, 236 on the 2,000-purl images, and 20 shorter on the image mixes, where the order the
|
|
410
|
+
rows were kept in is left unsaid when it is plain. The first call keeps 491 affected rows on the
|
|
411
|
+
realistic images (round 3: 490), 820 on the image mixes (819), 36 on the 2,000-purl images
|
|
412
|
+
(38), and the 201-purl probe's 43.
|
|
413
|
+
- **The guard.** A prompt-fields test holds each shape of the note that `workload_triage` and
|
|
414
|
+
`sbom_review` quote to a real cut's note.
|
|
415
|
+
- **What workload_triage and sbom_review do with a cut answer** (#3269's fourth review).
|
|
416
|
+
- **Rows left out of the first text block.** The text leaves rows out from the end, in input
|
|
417
|
+
order, while `data` keeps the KEV-bearing rows first, and `workload_triage` had no step for
|
|
418
|
+
the rows the text left out, so a client reading the text alone lost them unsaid, KEV-bearing
|
|
419
|
+
ones among them. Its last line now says they are in `structuredContent.data`, or, where a
|
|
420
|
+
`DATA CUT` leaves rows out of `data` too, named in its note by index for step 6's re-check,
|
|
421
|
+
and, read from the text alone, counts them from `data.summary` as unchecked, as `sbom_review`
|
|
422
|
+
does.
|
|
423
|
+
- **Indices.** `sbom_review`'s step 1 says that `metadata.component`, which check_sbom does not
|
|
424
|
+
read (Trivy puts the image's `pkg:oci` purl there), is not counted, and has the agent check
|
|
425
|
+
its index-to-purl mapping against the rows `data` keeps, which carry both, before it sends a
|
|
426
|
+
re-check call.
|
|
427
|
+
- **Past 30 CVEs.** `sbom_review`'s step 3 looked up any 30. It now takes the KEV-listed ones
|
|
428
|
+
first (a match with `kev_listed` true, or named in `matches_truncated.kev_listed_cve_ids`),
|
|
429
|
+
then the highest `epss_score`, read from check_sbom's matches, not from a `get_cve` per CVE.
|
|
430
|
+
- **The page sentence promises what holds** (#3269's review, and its second review). With clean
|
|
431
|
+
rows leaving first, the number of rows `data` keeps is no longer a page size at which a call
|
|
432
|
+
keeps them all: in testing, Juice Shop with magento named 19, and a call of its first 19 purls
|
|
433
|
+
kept 14. The sentence now names the most purls N such that a call of the first N, or of fewer,
|
|
434
|
+
keeps every row, counted as the cut would cut that call, and says only that: "A call of the
|
|
435
|
+
first N of these purls, or of fewer, keeps every row in data; a call of other purls says in its
|
|
436
|
+
own note what it leaves out." Round 2's opening, "To read every row in data, check N or fewer
|
|
437
|
+
purls per call", read as a page size for the rest, and calls of the purls N at a time lost rows
|
|
438
|
+
in 55% of the paged mixes in its review. On the replays, 150/40 names 99 and the 201-purl probe
|
|
439
|
+
42; a call of the first N, or of fewer, kept every row in each of the replayed cases, in 24
|
|
440
|
+
seeded random mixes (the suite), and in the 158 of 160 more, of up to 2,000 purls, that named
|
|
441
|
+
one, run outside the suite.
|
|
442
|
+
- **A capped check_sbom row keeps its KEV-listed matches; every level that keeps matches keeps
|
|
443
|
+
what workload_triage ranks by** (#3270).
|
|
444
|
+
- **Which matches stay.** A row whose matches were capped kept its first ones by
|
|
445
|
+
`effective_score`, so a KEV-listed CVE ranked lower was cut and only
|
|
446
|
+
`matches_truncated.kev_listed_cve_ids` named it. magento's CVE-2025-54236, at position 21 of
|
|
447
|
+
its 200 matches, was cut beside Juice Shop. Such a row now keeps the matches chosen used in
|
|
448
|
+
ransomware first, then its other KEV-listed ones, then those of highest `effective_score`;
|
|
449
|
+
`data` lists them in the API's order, and the note and `matches_truncated`'s text say both.
|
|
450
|
+
- **Which fields stay.** The compact level dropped `epss_score` and `score_assessed`, so the
|
|
451
|
+
201-purl probe kept 54 rows and not one match with either. It now keeps every field a prompt
|
|
452
|
+
ranks by: 43 rows fit, as on 3.0.0, each match with all six.
|
|
453
|
+
- **workload_triage.** Step 5 now reads `matches_truncated`'s `kev_listed_cve_ids` and
|
|
454
|
+
`ransomware_cve_ids`, and says that at the leanest cut neither list is there, so its `get_cve`
|
|
455
|
+
calls read those CVEs. It calls `get_cve` for every CVE kept without its match, and for every
|
|
456
|
+
match that lacks a field it ranks by. A new step 6 takes each image's counts from
|
|
457
|
+
`data.summary`, which counts every row whatever the cut (a call per image where several
|
|
458
|
+
images' rows were left out). A `not_affected` or `not_assessed` row left out is not checked
|
|
459
|
+
again; the affected and undetermined rows that left, which the note names by `index`, are, in
|
|
460
|
+
calls of their purls alone (KEV-bearing first, at the sizes the note gives, since #3269's
|
|
461
|
+
third review), at most 5, the rest reported as unchecked. (#3269's review first had every
|
|
462
|
+
purl left out checked again at the page size: 332 calls on a realistic Debian SBOM,
|
|
463
|
+
nearly all for clean rows.) sbom_review's fix rule now covers a CVE whose match a cut left out.
|
|
464
|
+
- **The guard.** The #3013 prompt-field test now holds every level that keeps matches, not
|
|
465
|
+
`SBOM_ROW_THIN` alone, with a control: 3.0.2's compact level fails it, naming the two fields.
|
|
466
|
+
- **check_sbom's and scan_manifest's first text block follows the same priority** (#3269). Its
|
|
467
|
+
clean and not-assessed rows left the text only once every row was cut to `index`, `purl`,
|
|
468
|
+
`verdict`, `not_assessed_reason` and `cve_ids`. So on the same replays the text kept all 150 rows
|
|
469
|
+
of the 150/40 mix and the matches of none of the 40 affected rows, on 3.0.0 and 3.0.2 alike. Now
|
|
470
|
+
those rows are cut to that level and leave the text first, as many as must. The 150/40 text keeps
|
|
471
|
+
58 rows, every affected row with every match. Where that cannot fit, the text is cut as before,
|
|
472
|
+
every row to that level: the 2,000-purl and Juice Shop texts are unchanged, and five Debian 12
|
|
473
|
+
purls with 200 clean ones keep 157 rows. The note says what those rows leave out, and that each
|
|
474
|
+
string in them keeps its first 100 characters where one is longer (a 150-character Maven purl
|
|
475
|
+
with its `repository_url` came back clipped, unsaid, in testing). The text names no page size
|
|
476
|
+
(#3269's second review): "check N or fewer purls per call", N the rows it kept, was none, and a
|
|
477
|
+
call of the first N kept fewer rows in 34 of 89 mixes, on 3.0.2 too. Its note says how many
|
|
478
|
+
rows it left out, of which kinds, and that `structuredContent.data` holds them.
|
|
479
|
+
- **A hosted handler error with JSON-RPC code -32022 is never `error_class` `other`** (#2923). A read
|
|
480
|
+
of production since 3.0.2 found one: the bundled SDK rebuilds a peer's -32022 error response with
|
|
481
|
+
the peer's own message (`ProtocolError.fromError`), which no fixed prefix can know. An error whose
|
|
482
|
+
message no prefix knows is now classified by its code where the code names a cause: -32022
|
|
483
|
+
`unsupported_protocol_version`, -32021 `missing_client_capability`, -32020 `header_mismatch`,
|
|
484
|
+
-32700, -32600, -32601 and -32602 `parse_error`, `invalid_request`, `method_not_found` and
|
|
485
|
+
`invalid_params`. The message is still never logged. Each such line now also names the request it
|
|
486
|
+
arose in by the access line's own labels, `rpc_method` and `protocol_version` (the
|
|
487
|
+
`MCP-Protocol-Version` header as a date, `(other)` or `(none)`), read from the request's own
|
|
488
|
+
context, since the SDK hands its `onerror` only the error; never a param. `rpc_method`, on this
|
|
489
|
+
line, the access line and `mcp_upstream_failed`, is now a closed set of labels: a method MCP
|
|
490
|
+
defines for a client to send (the bundled SDK's client requests and notifications, and its task
|
|
491
|
+
methods), or `(other)` for any other string. It was the client's own string, checked only for its
|
|
492
|
+
shape. The access line's `method` is closed the same way: GET, HEAD, POST, PUT, PATCH, DELETE,
|
|
493
|
+
OPTIONS, CONNECT or TRACE, else `(other)`. Over h2c, the hosted endpoint's protocol, it was any
|
|
494
|
+
`:method` token the client sent. So is `mcp_client_gone`'s `rst_code`, the reset code of a stream
|
|
495
|
+
the client reset: a code HTTP/2 defines (0 to 13), else `(other)`. It was any number the client's
|
|
496
|
+
reset carried.
|
|
497
|
+
- **On the hosted endpoint, a large body that finds both large-body slots busy waits for one** (#2775).
|
|
498
|
+
Behind Cloud Run's front end a client's close reaches the server too late to free the slot of a
|
|
499
|
+
`check_sbom` call it abandoned (a probe on 2026-10-18: both follow-ups sent 1 s after two aborted
|
|
500
|
+
calls were answered 503 while those calls ran on to 200), so a client that timed out and retried
|
|
501
|
+
was refused for the rest of its own abandoned call. Now a `check_sbom` or `scan_manifest` call or
|
|
502
|
+
an `sbom_review` prompt past 64 KiB that finds every slot taken waits up to 15 seconds for one
|
|
503
|
+
(`MCP_LARGE_BODY_WAIT_MS`), at most two at once, first come first served, and is answered 503 with
|
|
504
|
+
`Retry-After`, as before, only when its wait runs out or two already wait. The access line records
|
|
505
|
+
the wait (`slot_wait_ms`), and `mcp_large_body_busy` is written only for a refusal. A body that
|
|
506
|
+
declares its `Content-Length` waits holding nothing; a chunked body (no `Content-Length`) waits
|
|
507
|
+
holding at most the 64 KiB general cap (`MCP_MAX_BODY_BYTES`) plus the chunk that crossed it, so
|
|
508
|
+
memory stays bounded as before.
|
|
509
|
+
- **The hosted endpoint's body reader also reads whether the client had gone before it started**
|
|
510
|
+
(#2775). It learned of a close or an abort only from the listeners it attached, so a reader
|
|
511
|
+
started after the client went would have kept a large body among the waiters to the end of its
|
|
512
|
+
15 seconds, or taken a free slot and held it for good. No request to `http.js` met this: it starts
|
|
513
|
+
reading each body in the request's own event. On Node 20, 22 and 24, a stream reset or a close
|
|
514
|
+
sent in the same write as a large request's headers was logged 499 (`client_closed`) within a few
|
|
515
|
+
milliseconds, holding no slot and no place among the waiters, whether a slot was free or held. The
|
|
516
|
+
reader now reads the request and its response's signal as they are when it starts.
|
|
517
|
+
- **The hosted endpoint logs every API call that fails** (#2948). On 2026-10-07 a hosted `get_cve`
|
|
518
|
+
answered "EchelonGraph ... could not be reached" in 460 ms, and the hosted service's only line
|
|
519
|
+
for it was its access line, `status` 200: the cause went to the client and nowhere else, and was
|
|
520
|
+
lost. Each failed API call now writes one `mcp_upstream_failed` line: `failure_class` (`timeout`,
|
|
521
|
+
`dns`, `refused`, `reset`, `connect_timeout`, `unreachable`, `tls` or `network` from the error's
|
|
522
|
+
codes; `http` with its `status`; `not_json` or `not_object`; `unexpected_shape` for a 2xx the tool
|
|
523
|
+
could not use), `route` (core-backend's route template, such as `/api/v1/public/cves/:id`: never
|
|
524
|
+
the path, which holds what was looked up, and never the query string), `tool` and `rpc_method` as
|
|
525
|
+
the access line has them, `after_ms`, and `synthetic`. It is WARNING, but INFO for the API's own
|
|
526
|
+
answer about the input (a 400, or a 404 for a named record), which the tool reports as invalid
|
|
527
|
+
input or no record: a departure from #2948's every-failure-WARNING, so that a mistyped CVE ID is
|
|
528
|
+
no outage (the hosted service's monitoring still counts the INFO lines, so a 404 that is really a
|
|
529
|
+
removed route shows); and ERROR for `output_schema`, the tool's own outputSchema refusing the answer
|
|
530
|
+
it built, which is this server's fault, not the API's (#2948's review: it is no upstream failure,
|
|
531
|
+
so the upstream metric, which counts WARNING, leaves it out). A call cancelled because its client
|
|
532
|
+
went writes none. `exposure_radar` writes
|
|
533
|
+
one per radar that failed. The npm package (stdio) writes nothing: stdout is the protocol.
|
|
534
|
+
- README: "Privacy: what is sent where" names every field the hosted endpoint's access line
|
|
535
|
+
records (#2863). It named the method and path, status, duration, body size, JSON-RPC method,
|
|
536
|
+
tool name, protocol version, `Origin` presence and "the family" of the User-Agent; the line also
|
|
537
|
+
records the prompt name for `prompts/get`, how long the request waited for a place and how many
|
|
538
|
+
were served at once, whether the client's address is public (not the address), whether the client
|
|
539
|
+
closed early, and whether the request was throttled or refused, and why, as /privacy Section 11
|
|
540
|
+
says. The User-Agent is described as its first word, which is what is kept. Nothing the endpoint
|
|
541
|
+
logs changed. Each line it writes is now one of `src/logLines.ts`'s, with exactly its keys: a new
|
|
542
|
+
line or key does not build until it is listed there, and the tests hold the README to the list.
|
|
543
|
+
- Repository only, not in the package: the production synthetic now writes what each answer costs
|
|
544
|
+
a client on every probe line whose call was answered, on both the npm package and the hosted
|
|
545
|
+
endpoint: `text_chars` (every text block's characters) and, for a tool call, `structured_chars`
|
|
546
|
+
(`structuredContent` as compact JSON), measured as `test/text-bound.mjs` measures them, sizes
|
|
547
|
+
only (#2781). Until now production's size was measured only when someone ran
|
|
548
|
+
`npm run text-bound:live`. Its loopback forwarder serves under a base as long as the API base it
|
|
549
|
+
stands for, because the package quotes its base and the cut fills its budget to the character,
|
|
550
|
+
so the npm leg's sizes are the ones a client of that API receives. The synthetic's monitoring
|
|
551
|
+
script gains two size distributions, two dashboard tiles for each (each tool's mean, and its
|
|
552
|
+
largest answer of the hour to its 1,000-character bucket), and an alert past #2617's 45,000
|
|
553
|
+
characters of text or #3013's 30,000 of `structuredContent`.
|
|
554
|
+
- Repository only, not in the package: `test/text-bound-live.mjs --answers
|
|
555
|
+
test/fixtures/prod-answers --record` no longer re-dates the production record (#2782). Through
|
|
556
|
+
3.0.2 it wrote its own time as `measured_at`, so the record said the kept answers were read 6 days
|
|
557
|
+
after production served them. `measured_at` is now when production was read and `read_ms` how
|
|
558
|
+
long that read took, and a re-measure keeps both. The code that measured is recorded apart, as
|
|
559
|
+
`code_measured_at` and `code_version` (package.json's version, `+unreleased` while the CHANGELOG's
|
|
560
|
+
newest section is unreleased). The read also records how old each answer it kept was, by the
|
|
561
|
+
answer's own `generated_at` (`answers_age_ms`): the API serves a radar's aggregate stale past
|
|
562
|
+
its 60-second cache to the first request after it, and the CDN 300 seconds more, so an honest
|
|
563
|
+
read can get answers minutes old (two of 2026-10-05's were over 140 seconds), and no fixed
|
|
564
|
+
allowance holds. The suite fails when `measured_at` less a kept answer's `generated_at` is not
|
|
565
|
+
the age the read recorded for it. The record's `about` is now written by the harness. The suite
|
|
566
|
+
also holds each production-shaped case's `structuredContent` size within 10% of production's
|
|
567
|
+
recorded one, as it held the text (#3273). search_cves' default page now repeats rows of the
|
|
568
|
+
default page itself, and lands where production's size does: 8,927 characters against
|
|
569
|
+
production's 9,064, where it was 28,336. Production's cut level for that call is inferred from
|
|
570
|
+
its size, not measured, until the next `--hosted --record` writes its `truncated`. Two cases
|
|
571
|
+
past 10% are named with what clears each (check_sbom's Juice Shop fixture and
|
|
572
|
+
epss_history's record), and `--record` writes each case's `data_truncated` and `truncated`.
|
|
573
|
+
- Hosted endpoint only: `Dockerfile.http` builds both its stages on
|
|
574
|
+
`node:22.23.3-alpine3.24@sha256:0a7108bf…`, pinned by its index digest, the base `Dockerfile`
|
|
575
|
+
(the stdio image) pins (#3018). It named the moving tag `node:22-alpine` (the same image on
|
|
576
|
+
2026-10-18), so two deploys of one commit could ship different base images with no record of
|
|
577
|
+
which. A test holds both files to one pinned base. A production deploy of the hosted endpoint
|
|
578
|
+
(EchelonGraph's deploy-all.sh run for it, or for every service) also scans the image it just built
|
|
579
|
+
for vulnerabilities with Trivy, before the push, and only that run does: no other deploy scans
|
|
580
|
+
it, and CI's scan job for it is not running yet. A HIGH or CRITICAL vulnerability with a fixed
|
|
581
|
+
version refuses the deploy; one with no fixed version is named and does not refuse; a scan that
|
|
582
|
+
did not run or cannot be read refuses. The scanner runs only as a pinned Trivy release's binary
|
|
583
|
+
(v0.75.0, by its sha256), from an empty directory with no `TRIVY_*` variable, its severities (HIGH,
|
|
584
|
+
CRITICAL) and an empty ignore file on its command line, so neither the deployer's environment
|
|
585
|
+
nor a stray `.trivyignore` or `trivy.yaml` narrows the scan. Another binary runs only with a
|
|
586
|
+
reason given (`MCP_REMOTE_TRIVY_OVERRIDE_REASON`), after a warning that names both sha256s and
|
|
587
|
+
its version, and the revision is then labelled `mcp-trivy=override`, never `clean`. A binary
|
|
588
|
+
that reports Trivy 0.69.4, 0.69.5 or 0.69.6 (the releases GHSA-69fq-xp46-6x23 names) is refused.
|
|
589
|
+
An operator can deploy anyway with `MCP_REMOTE_SCAN_OK=1` and a reason, and the revision is then
|
|
590
|
+
labelled `mcp-trivy=overridden`. The scan's summary names the image by its local config ID, not the
|
|
591
|
+
registry digest a deployed revision reports; a release revision records that digest
|
|
592
|
+
(`mcp-release-digest`).
|
|
593
|
+
- Repository only, not in the package: the production synthetic compares the hosted endpoint with
|
|
594
|
+
the npm package it runs (#3097). When the endpoint reports the version the npm leg runs, each
|
|
595
|
+
era's tools (every field `tools/list` gives: name, title, description, inputSchema, outputSchema,
|
|
596
|
+
annotations), prompts, resources and instructions are compared, and one more probe line per era,
|
|
597
|
+
tool `(hosted-vs-npm)` on transport `http`, fails naming the first differences (`tool cve_intel:
|
|
598
|
+
description differs at character 221`). On 2026-10-11 the two both reported 2.7.1 and served
|
|
599
|
+
different cve_intel text, and each leg alone passed. When the endpoint reports another version
|
|
600
|
+
than the npm leg runs (a rollback to an older release), it is compared with npm's own package of
|
|
601
|
+
the version it reports, installed once for the run, and an endpoint reporting a version npm does
|
|
602
|
+
not serve fails too (`hosted_version_not_on_npm`); the run-complete line's `hosted_vs_npm` and
|
|
603
|
+
`hosted_vs_npm_at` say what was compared.
|
|
604
|
+
|
|
9
605
|
## 3.0.2 — 2026-10-17
|
|
10
606
|
|
|
11
607
|
3.0.1 was tagged but never published to npm: its release run's memory test
|
|
@@ -79,9 +675,10 @@ flight, the hosted endpoint needed the whole of its image's 128 MiB heap, and V8
|
|
|
79
675
|
again. In 3.0.0 a Claude Code session that listed the tools under 2.7.2 failed every tool's
|
|
80
676
|
call: "data must NOT have additional properties", from `data_truncated`, which 3.0.0 added to
|
|
81
677
|
every success. A field added at the top level in a minor release no longer breaks such a client.
|
|
82
|
-
The failure branch
|
|
83
|
-
|
|
84
|
-
|
|
678
|
+
The failure branch stays closed, and the server still checks its own answers against the closed
|
|
679
|
+
success branch (`test/checked-strict.test.mjs`). This entry said each tool's `data` schema stays
|
|
680
|
+
closed too: `data` was open at its root for 14 of 16 tools, and 86 objects below a success's
|
|
681
|
+
root were closed, in and outside `data`, until #3268 (3.1.0). If you see "does not match the tool's output schema" after an upgrade, reconnect
|
|
85
682
|
the client (README, Versioning).
|
|
86
683
|
- `test/text-bound-live.mjs --hosted` calls a 3.0.0 or later endpoint with `full: true`. It
|
|
87
684
|
refuses to replay a cut `data` as the API's answer (#3140). Without `full: true`, get_cve
|
|
@@ -289,8 +886,9 @@ structured result needs no change; it gets an answer where 2.7.x gave Claude Cod
|
|
|
289
886
|
references NVD tagged Patch or Mitigation, and patches. Nothing is generated; every text is its
|
|
290
887
|
source's, untested by EchelonGraph. none_in_source, not_parsed and an empty list are not a
|
|
291
888
|
finding that no fix exists; a REJECTED CVE answers not_assessed. 15 tools. Its text for
|
|
292
|
-
CVE-2021-44228 on production's data
|
|
293
|
-
deployed;
|
|
889
|
+
CVE-2021-44228 on production's data was 20,644 characters, assembled before the route was
|
|
890
|
+
deployed; measured live on 2026-10-18, 22,571 characters on the hosted endpoint and on npm 3.0.2,
|
|
891
|
+
and 18,293 with an installed version (#2841).
|
|
294
892
|
- get_cve relays remediation, a capped summary (at most 1,500 characters of JSON) of the same.
|
|
295
893
|
- vendor_advisories_for_cve rows carry remediation_kinds and remediation_state, and
|
|
296
894
|
get_vendor_advisory carries remediations, each item with its kind, verbatim text and URL
|