echelongraph-mcp 2.6.5 → 2.6.7
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 +140 -0
- package/README.md +51 -11
- package/dist/http.js +74 -8
- package/dist/index.js +28 -8
- package/dist/prompts.js +23 -12
- package/dist/tools/check_affected.js +4 -3
- package/dist/tools/check_sbom.js +2 -1
- package/dist/tools/cve_intel.js +69 -2
- package/dist/tools/epss_history.js +1 -1
- package/dist/tools/vendor_advisories.js +53 -18
- package/package.json +1 -1
- package/server.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,146 @@ 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
|
+
## 2.6.7 — 2026-10-05
|
|
10
|
+
|
|
11
|
+
- Tool descriptions now state facts only: no reporting instructions to the model and no references
|
|
12
|
+
to other tools, for the Anthropic MCP Directory policy ("Tool descriptions contain no
|
|
13
|
+
instructions about model behavior, other tools, or external instruction sources, and no hidden
|
|
14
|
+
or encoded text"). Tool behavior, results and their notes are unchanged. Reworded:
|
|
15
|
+
- get_cve, search_cves: "The note labels each such CVE NOT YET SCORED: report it that way, never
|
|
16
|
+
as a score of 0." now reads "The note labels each such CVE NOT YET SCORED, which is not a score
|
|
17
|
+
of 0."
|
|
18
|
+
- cve_summary: summary.rejected's "report them as withdrawn records, never as vulnerabilities"
|
|
19
|
+
now reads "they are withdrawn records, none of them a vulnerability"; summary.none's "report
|
|
20
|
+
those CVEs as not yet scored, not as CVEs rated None" now reads "and names those CVEs not yet
|
|
21
|
+
scored, not CVEs rated None".
|
|
22
|
+
- get_vendor_advisory: "(the vendor and vendor_advisory_id fields of a row from
|
|
23
|
+
search_vendor_advisories or vendor_advisories_for_cve)" now reads "(the vendor and
|
|
24
|
+
vendor_advisory_id of a vendor advisory row)".
|
|
25
|
+
- exposure_radar: "and cve_exposure says per CVE whether the radar tracks it" now reads "and this
|
|
26
|
+
answer does not say whether the radar tracks that CVE: tracking is answered per CVE".
|
|
27
|
+
- epss_history: "Never interpolate it into a daily series." now reads "A daily series
|
|
28
|
+
interpolated from it holds values EchelonGraph never recorded, so the series is never a daily
|
|
29
|
+
series."
|
|
30
|
+
- check_affected: "Read assessed before count: … a count of 0 there must never be reported as
|
|
31
|
+
not affected" now reads "count depends on assessed: … a count of 0 there is not a finding of
|
|
32
|
+
not affected"; the outputSchema description of assessed, "Read first.", now reads "count
|
|
33
|
+
depends on it.".
|
|
34
|
+
- vendor_advisories_for_cve, get_vendor_advisory, search_vendor_advisories: the outputSchema
|
|
35
|
+
description of withdrawn, "report it as withdrawn", now reads "so it is a withdrawn advisory,
|
|
36
|
+
not a current one".
|
|
37
|
+
- test/description-policy.test.mjs lists the tools over stdio and fails if any title, description
|
|
38
|
+
or input/output schema description names another tool, matches a model-directed imperative
|
|
39
|
+
pattern, or carries zero-width, bidi or control characters or a base64-looking run.
|
|
40
|
+
|
|
41
|
+
## 2.6.6 — 2026-10-05
|
|
42
|
+
|
|
43
|
+
- Correction to 2.6.5 (#2775). Its entry says: "On the hosted endpoint it is the client going away
|
|
44
|
+
(its own timeout, a closed tab): every API call of that request is cancelled". That was not true
|
|
45
|
+
of the hosted endpoint. Cloud Run does not pass a client's disconnect on to a container it
|
|
46
|
+
speaks HTTP/1.1 to ("When you use HTTP/1.1 on Cloud Run, client disconnect events are not
|
|
47
|
+
propagated to the Cloud Run container",
|
|
48
|
+
<https://docs.cloud.google.com/run/docs/troubleshooting>). In production on 2026-10-05, four
|
|
49
|
+
`check_sbom` calls had their client abort after 0.4 s or 1.5 s. Each was logged `status` 200
|
|
50
|
+
with `client_closed` false and ran to its end, 2.0 to 5.3 s. Together they held both large-body
|
|
51
|
+
slots, so a large body sent 1 s later was answered 503. The same page says HTTP/2 does pass the
|
|
52
|
+
disconnect on. So `dist/http.js` now serves HTTP/2 cleartext (h2c) when `MCP_H2C=1`, and the
|
|
53
|
+
hosted deployment sets it together with Cloud Run's `--use-http2`. With that flag, Cloud Run
|
|
54
|
+
speaks only h2c to the container. The server acts on a reset stream the moment it arrives, with
|
|
55
|
+
any error code including `NO_ERROR`: the API calls are cancelled, `check_sbom` sends no further
|
|
56
|
+
batch and ends a `Retry-After` wait, the large-body slot is freed, and the `mcp_request` line
|
|
57
|
+
says `status` 499 with `client_closed` true. What Cloud Run forwards is only partly measured.
|
|
58
|
+
On test deployments with `--use-http2` on 2026-10-05, ordinary calls, sessions and `/health`
|
|
59
|
+
were all answered over h2c. A client that stopped reading a streamed answer had its stream reset
|
|
60
|
+
by Cloud Run 15 s in (`rst_code` 2), and that was logged 499. A client that aborts mid-call
|
|
61
|
+
before any answer was not observed, because every probe call was answered first, so whether
|
|
62
|
+
Cloud Run forwards that reset at once is not yet shown. Until it is, an abandoned call may still
|
|
63
|
+
run to its end, a `check_sbom` call for at most its 50-second budget, holding its slot. A new line, `mcp_client_gone`,
|
|
64
|
+
records when the server learned the client went: `after_ms` from the request's start, whether
|
|
65
|
+
the answer had started (`answer_started`), and an HTTP/2 stream's `rst_code`. It holds nothing
|
|
66
|
+
of the request. Run yourself, `dist/http.js` speaks HTTP/1.1 as before. With `MCP_H2C=1` it
|
|
67
|
+
speaks h2c with prior knowledge only, and an HTTP/1.1 request gets no answer.
|
|
68
|
+
- README, "Privacy: what is sent where": the hosted endpoint's paragraph now also says what the API logs
|
|
69
|
+
of the calls it makes for you (#2797): each under your address, with its URL path (for a CVE, CWE or
|
|
70
|
+
advisory lookup, the ID), and not its query string, the header-carried terms or which purls. The
|
|
71
|
+
privacy policy's new Section 11, https://echelongraph.io/privacy#hosted-mcp-endpoint , says the same.
|
|
72
|
+
- `exposure_radar` relays and labels `mcp_servers.withheld_opted_out` (#2822): how many hostnames
|
|
73
|
+
whose owner opted out of scanning the API left out of every other `mcp_servers` number. Such a
|
|
74
|
+
hostname is not checked again while the opt-out stands, so its last verdict cannot be re-checked;
|
|
75
|
+
the API now withholds it from the MCP-server counts as its unparameterised AI-exposure answer
|
|
76
|
+
already did. An answer without the field, from an API that predates it, counted those hostnames,
|
|
77
|
+
and the note says so instead of reading the absence as 0; a value that is not a whole number is
|
|
78
|
+
left out and named.
|
|
79
|
+
- Claude Desktop in one click (#2815). From this release on, every GitHub release of
|
|
80
|
+
[github.com/echelongraph/echelongraph-mcp](https://github.com/echelongraph/echelongraph-mcp/releases)
|
|
81
|
+
carries `echelongraph-mcp.mcpb`, a Claude Desktop extension: open it and choose Install. It needs
|
|
82
|
+
no Node.js on your machine, since Claude Desktop runs it with the Node.js it ships, and no JSON to edit.
|
|
83
|
+
The README's Claude Desktop section links it as "Install in Claude Desktop", ahead of the custom
|
|
84
|
+
connector and the `npx` setup. The release workflow builds it from the release tag, the same tag
|
|
85
|
+
the npm package is published from: `listings/mcpb/build.sh` validates the manifest with the pinned
|
|
86
|
+
MCPB CLI and packs. Then, on a separate read-only runner that never ran the MCPB CLI,
|
|
87
|
+
`listings/mcpb/check-bundle.mjs` refuses a bundle that holds a top-level file `build.sh` does not
|
|
88
|
+
stage, a symlink, or a file where `build.sh` stages a directory; that lacks the icon or entry point
|
|
89
|
+
its manifest names, or `node_modules/`; whose `package.json`, `LICENSE`, `README.md` or `dist/` is not the tested npm
|
|
90
|
+
tarball's, byte for byte (a file changed, missing or added); whose `node_modules/` adds or changes
|
|
91
|
+
a file a production install from the lockfile does not have; that is at another version; or whose
|
|
92
|
+
own server, started with `PATH`, `HOME` and `ECHELONGRAPH_API_BASE` only (none of the caller's
|
|
93
|
+
`NODE_PATH`, `NODE_OPTIONS` or other variables), does not list the
|
|
94
|
+
manifest's 14 tools with their titles. npm publishes only after that check passes, so a manifest
|
|
95
|
+
or bundle defect stops the release before npm has the version. A separate job, which runs no
|
|
96
|
+
dependency code, signs the checked bundle with a SLSA build-provenance attestation (Sigstore,
|
|
97
|
+
keyless), attaches it and its Sigstore bundle to the release, each on its own, and checks the
|
|
98
|
+
file the release serves with `gh attestation verify`. To check a download yourself:
|
|
99
|
+
`gh attestation verify echelongraph-mcp.mcpb --repo echelongraph/echelongraph-mcp`.
|
|
100
|
+
Releases up to 2.6.5 carry no bundle.
|
|
101
|
+
- Repository only, not in the package: `listings/mcpb/check-bundle.mjs` and
|
|
102
|
+
`test/mcpb-release.test.mjs`; `test/mcp-stdio-client.mjs` takes a `cwd`.
|
|
103
|
+
- `sbom_review` reads the advisory interval in `check_sbom`'s `match_reason` by its closing
|
|
104
|
+
bracket, without the hedge of 2.6.5 and earlier. `[A, B)` is a fixed bound: B, the first version the advisory
|
|
105
|
+
records as not affected, is given as the fixed version (`[A, ∞)` names none). `[A, B] (B is the
|
|
106
|
+
last affected version, not a fix)` is an OSV `last_affected` bound: B is still affected and the
|
|
107
|
+
range records no fix, so neither B nor any version at or below it is given as a fixed version.
|
|
108
|
+
The bracket alone decides: a cut text clips `match_reason` to 200 characters, which can drop the
|
|
109
|
+
words after it for a long package name, and where it is clipped before the closing bracket
|
|
110
|
+
(`[A, B…`), no fixed version is taken from that `match_reason`.
|
|
111
|
+
Through 2.6.5 core-backend rendered both bounds as `[A, B)`, so the prompt gave B only as "the
|
|
112
|
+
end of that interval"; core-backend now renders a `last_affected` bound closed (#2830). This
|
|
113
|
+
wording assumes the API serves the new rendering: it is released after core-backend is deployed.
|
|
114
|
+
- `cve_intel` relays `fixed_branches` on each `affected_packages` row: every affected range of the
|
|
115
|
+
package on record, each with `introduced` and `fixed` (the range's fix) or, where no fix is on
|
|
116
|
+
record, `last_affected` (never in a field named fixed), and with `advisory_id` (the OSV record that
|
|
117
|
+
published the range) and `source` (the loader's label for that record), from core-backend's
|
|
118
|
+
`/api/v1/public/cves/:id/enrichment` since #2817. Ranges that name their record are in the order
|
|
119
|
+
the record lists them; ranges loaded before the record was stored (both null) are oldest first.
|
|
120
|
+
`fixed_branches` is null where a package's ranges were never loaded, and `[]` where no version
|
|
121
|
+
range is on record (none in the advisory, only commit ranges, or more than are stored), which is
|
|
122
|
+
not a finding that no fix exists. `fixed_version` is one range's fix per package,
|
|
123
|
+
kept for compatibility: for CVE-2021-44228 production served log4j-core 2.12.2 alone (2026-10-04),
|
|
124
|
+
the fix for 2.4 to 2.12.1, older than an affected 2.14.1, while the advisory
|
|
125
|
+
(GHSA-jfh8-c2jp-5v3q) gives three ranges, fixed in 2.3.1, 2.12.2 and 2.15.0. The description says
|
|
126
|
+
how to pick the range for an installed version, and the note counts the rows with more than one
|
|
127
|
+
range, and those with no range on record (`fixed_branches` null, or `[]` beside a `fixed_version`),
|
|
128
|
+
where `fixed_version` is all the answer holds. An API older than #2817 is said to be one (#2817).
|
|
129
|
+
- `triage_cve`'s patch line gives each affected range with its fix from `fixed_branches`, and
|
|
130
|
+
`fixed_version` only for a package without them; `sbom_review` gives a component the fix of the
|
|
131
|
+
`fixed_branches` range that holds its installed version (log4j-core 2.14.1: 2.15.0), after the
|
|
132
|
+
advisory interval in `check_sbom`'s `match_reason`, and `fixed_version` only for a package without
|
|
133
|
+
`fixed_branches` (#2817).
|
|
134
|
+
- `vendor_advisories_for_cve`, `search_vendor_advisories` and `get_vendor_advisory` relay each
|
|
135
|
+
vendor's `held_since`, which the coverage API now serves: the start of the window EchelonGraph has
|
|
136
|
+
read the vendor's advisories from, before which an advisory may not be held.
|
|
137
|
+
`vendors_not_fully_held` reads it, not the earliest held advisory. For a vendor with no history read
|
|
138
|
+
the earliest held advisory is not where its window begins: Cisco's poller first read the advisories
|
|
139
|
+
Cisco had changed in the last 90 days, Red Hat's and GitHub's the last 7, so an older advisory the
|
|
140
|
+
vendor revised later is held while the ones published beside it are not. Cisco's earliest held
|
|
141
|
+
advisory is dated 2024-10-23 and Red Hat's 2009-02-04, so CVE-2025-20188 left out Cisco and
|
|
142
|
+
CVE-2014-0160 left out Red Hat, and their empty answers read as the vendor having published nothing;
|
|
143
|
+
both are listed now. GitHub is now listed too for any CVE ID year that begins before the start of
|
|
144
|
+
its first poll's 7-day window (`held_since`), not only for years up to that of its earliest held
|
|
145
|
+
advisory (2017). Against an API without `held_since`, a vendor is taken as held from its earliest
|
|
146
|
+
advisory only when its history read is complete, and otherwise listed, its window written "not known
|
|
147
|
+
to be without a gap" (#2803).
|
|
148
|
+
|
|
9
149
|
## 2.6.5 — 2026-10-05
|
|
10
150
|
|
|
11
151
|
2.6.4 was tagged and never published: its Release run failed `test/http-memory.test.mjs` on Node 24,
|
package/README.md
CHANGED
|
@@ -34,21 +34,32 @@ API call a tool needs to answer.
|
|
|
34
34
|
## Quick start
|
|
35
35
|
|
|
36
36
|
The local server runs through `npx` and needs Node.js 20 or later; nothing is installed
|
|
37
|
-
globally. Add one of the blocks below to your client, restart it, then ask: *"Is
|
|
37
|
+
globally. Claude Desktop's one-click extension, below, needs neither. Add one of the blocks below to your client, restart it, then ask: *"Is
|
|
38
38
|
CVE-2023-44487 actively exploited, and how many exposed services does EchelonGraph's radar
|
|
39
39
|
have on record for it?"*
|
|
40
40
|
|
|
41
41
|
### Claude Desktop
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
Three ways in. The first two need no Node.js: a one-click extension that runs the server on your
|
|
44
|
+
machine, and a custom connector to the hosted endpoint. The third runs the server through `npx`.
|
|
44
45
|
|
|
45
|
-
**1.
|
|
46
|
+
**1. [Install in Claude Desktop](https://github.com/echelongraph/echelongraph-mcp/releases/latest/download/echelongraph-mcp.mcpb)
|
|
47
|
+
(one click, no Node.js).** The link downloads `echelongraph-mcp.mcpb`, a Claude Desktop extension.
|
|
48
|
+
Open it, or in Claude Desktop choose Settings → Extensions → Advanced settings → Install Extension
|
|
49
|
+
and pick it, then choose **Install**. Claude Desktop runs the server with the Node.js it ships and
|
|
50
|
+
writes its own configuration: no PATH and no JSON to edit. To update, install the next release's
|
|
51
|
+
bundle the same way. Every new release on GitHub carries the bundle (releases up to 2.6.5 do not),
|
|
52
|
+
built from the release's tag by the workflow that publishes the npm package, and signed with a SLSA
|
|
53
|
+
build-provenance attestation. To check a download:
|
|
54
|
+
`gh attestation verify echelongraph-mcp.mcpb --repo echelongraph/echelongraph-mcp`.
|
|
55
|
+
|
|
56
|
+
**2. Custom connector (no install).** Claude Desktop uses your claude.ai account's connectors:
|
|
46
57
|
Customize → Connectors → **+ Add** → **Add custom connector**. Name it `EchelonGraph`, paste
|
|
47
58
|
`https://mcp.echelongraph.io/mcp`, choose **No sign in**, and add it. No Node.js, no PATH and no
|
|
48
59
|
JSON to edit. A connector added on claude.ai shows up in Claude Desktop too, and the plan rules
|
|
49
60
|
are claude.ai's (see "Remote (no install)" below).
|
|
50
61
|
|
|
51
|
-
**
|
|
62
|
+
**3. Local server through `npx`.**
|
|
52
63
|
|
|
53
64
|
1. **Check Node.js.** In Terminal (macOS) or PowerShell (Windows), run `node -v`. It must print
|
|
54
65
|
`v20` or later. If it prints an older version or `command not found`, install the LTS release
|
|
@@ -228,7 +239,7 @@ The example questions are ones a client can answer with that tool alone.
|
|
|
228
239
|
| `epss_history` | EPSS change history for one CVE | How one CVE's EPSS score has changed, as EchelonGraph recorded it: one point per recorded change (`series_kind` `change_only`), never a daily series, with the value now and `series_starts_at`, when recording began; before it a missing point means not recorded, not unchanged. | *How has CVE-2023-44487's EPSS score changed, as EchelonGraph recorded it?* |
|
|
229
240
|
| `check_affected` | Am I affected? (product or package at a version) | Whether a product (its NVD CPE product token) or a registry package (`ecosystem` and `package`) at a given version is affected by known CVEs, from the matcher behind echelongraph.io/am-i-affected: `assessed` first (false: not evaluated, with `not_assessed_reason`, and a count of 0 then is not "not affected"), the matching CVEs with `kev_listed`, `ransomware`, `epss_score`, `effective_score` and `score_assessed`, and advisories it cannot decide counted as `undetermined_count`, never as safe. What you look up travels in request headers, never in the URL. | *Is openssl 3.0.0 affected by known CVEs? Is lodash 4.17.15 on npm?* |
|
|
230
241
|
| `check_sbom` | Check an SBOM against the advisory corpus | A dependency list checked against EchelonGraph's advisory corpus (OSV.dev records), one verdict per component: `affected`, `not_affected`, `undetermined` or `not_assessed`, each with its `not_assessed_reason`. Pass up to 2,000 distinct purls, or a CycloneDX JSON or SPDX JSON document: the purls are read from it by the MCP server and only they are sent to the API, in POST bodies of at most 200 each, one after another; the document itself is not sent on. Run from npm, the server is on your machine; over the hosted endpoint (`mcp.echelongraph.io`) it is EchelonGraph's, and the document is the request body. When the API's per-component rate limit answers 429, the tool waits the `Retry-After` it names, within 50 seconds per call; past that it answers what it has, with the purls not sent counted in `coverage.not_sent`, the reason in `coverage.not_sent_reason`, and the list in `data.not_sent_purls`, never dropped silently. A deb, apk or rpm purl without a `distro` qualifier naming its release is not assessed (`distro_release_unknown`): EchelonGraph does not guess a release. Only `not_affected` is clean. No ranking, no score. | *Check this CycloneDX SBOM against the advisory corpus.* |
|
|
231
|
-
| `cve_intel` | CVE weakness, exploits and packages | Weakness, public exploit code, affected packages and fixed versions for one CVE, from EchelonGraph's per-CVE enrichment: `cwes`, `exploits` (at most 10, verified first) with `exploits_total`, `exploits_capped`, `exploits_by_kind` and `exploits_by_status`, `affected_packages`, `fixed_versions` and `timeline`. `verified_status` is the label stored with each reference, not a guarantee that the exploit works. An empty `exploits` list is not evidence that no public exploit exists; a section the API could not read is named in `coverage.sections_failed`, never relayed as an empty list. | *Is there public exploit code for CVE-2021-44228, and which versions fix it?* |
|
|
242
|
+
| `cve_intel` | CVE weakness, exploits and packages | Weakness, public exploit code, affected packages and fixed versions for one CVE, from EchelonGraph's per-CVE enrichment: `cwes`, `exploits` (at most 10, verified first) with `exploits_total`, `exploits_capped`, `exploits_by_kind` and `exploits_by_status`, `affected_packages`, `fixed_versions` and `timeline`. Each `affected_packages` row carries `fixed_branches`, every affected range of the package with its fix (`introduced`, and `fixed` or, where no fix is on record, `last_affected`), each naming the OSV record that published it (`advisory_id`, `source`) and in that record's order: an installed version's fix is the `fixed` of the range that holds it, compared in the ecosystem's version order. `fixed_version` is one range's fix, kept for compatibility, not the fix for every range (CVE-2021-44228's log4j-core: 2.12.2, the fix for 2.4 to 2.12.1, while 2.14.1's range is fixed in 2.15.0); `fixed_branches` is null where a package's ranges were never loaded, and `[]` where no version range is on record, which is not a finding that no fix exists. `verified_status` is the label stored with each reference, not a guarantee that the exploit works. An empty `exploits` list is not evidence that no public exploit exists; a section the API could not read is named in `coverage.sections_failed`, never relayed as an empty list. | *Is there public exploit code for CVE-2021-44228, and which versions fix it?* |
|
|
232
243
|
| `get_cwe` | CWE and its CVEs | One CWE (weakness class) and the CVEs classified under it: `name` and `description` from the MITRE CWE catalog EchelonGraph embeds, `total`, and one page of 50 `cves`, ordered as `order` states (CISA-KEV-listed first, then EchelonGraph score). A `total` of 0 says that no CVE in EchelonGraph's feed is classified under that CWE, not that none exists. | *Which CVEs are classified under CWE-79, CISA-KEV-listed first?* |
|
|
233
244
|
| `vendor_advisories_for_cve` | Vendor advisories for one CVE | The vendor-published advisories (Microsoft MSRC, Red Hat, Cisco, Palo Alto Networks, GitHub GHSA and the other feeds EchelonGraph polls) that name one CVE, newest first, at most 20. | *Which vendor advisories name CVE-2024-3400?* |
|
|
234
245
|
| `get_vendor_advisory` | Vendor advisory detail | One vendor advisory in full: description, severity, `cve_ids` and the subset with a CVE record here (`known_cve_ids`), `affected_products`, `remediation` and `references`. | *Show one of those advisories in full: affected products, remediation and references.* |
|
|
@@ -238,6 +249,18 @@ The three vendor-advisory tools relay `vendor_published_at` (the vendor's date),
|
|
|
238
249
|
(when EchelonGraph first recorded the advisory) and `withdrawn` (the vendor rescinded it; the note
|
|
239
250
|
names each one, and the search leaves them out).
|
|
240
251
|
|
|
252
|
+
Each also relays every vendor's window in `coverage` (`vendor_windows`, or `vendor_window` for the
|
|
253
|
+
detail): the advisories held, the earliest and latest `vendor_published_at` among them,
|
|
254
|
+
`held_since` and `history_backfill`. `held_since` is the start of the window EchelonGraph has read the
|
|
255
|
+
vendor's advisories from: one published before it may not be held. For a vendor with no history read (`history_backfill`
|
|
256
|
+
`not_supported`) it can be years after the earliest advisory held: Cisco's poller first read the
|
|
257
|
+
advisories Cisco had changed in the last 90 days, Red Hat's and GitHub's the last 7, so an older
|
|
258
|
+
advisory the vendor revised later is held while the ones published beside it are not.
|
|
259
|
+
`vendor_advisories_for_cve` lists in `vendors_not_fully_held` each vendor with no advisory in the
|
|
260
|
+
answer of which EchelonGraph holds none, whose `held_since` is after 1 January of the CVE ID's year
|
|
261
|
+
or not known, or whose history is still being read: no advisory from such a vendor is not a finding
|
|
262
|
+
that it published none.
|
|
263
|
+
|
|
241
264
|
Every figure is as fresh as the schedule that refreshes it. The CVE feed is polled from its
|
|
242
265
|
sources on a schedule; each radar refreshes on its own.
|
|
243
266
|
|
|
@@ -443,8 +466,9 @@ feed, matched by hostname pattern; no Shodan data is used. Each is checked by Ec
|
|
|
443
466
|
identified MCP probe: a `server/discover` request, and `initialize` only if that is refused. It
|
|
444
467
|
never sends `tools/call`. The answer is counts and timestamps only, with no hostname, version or
|
|
445
468
|
server name, and the tool repeats no string it carries but its timestamps. Each hostname is
|
|
446
|
-
counted once, by its latest verdict on record
|
|
447
|
-
out
|
|
469
|
+
counted once, by its latest verdict on record. EchelonGraph's own control servers are left
|
|
470
|
+
out, and so is every hostname whose owner opted out of scanning, since it is not checked again
|
|
471
|
+
while the opt-out stands and its last verdict cannot be re-checked. The unit is a hostname, not an ip:port service.
|
|
448
472
|
|
|
449
473
|
| Field | What it counts |
|
|
450
474
|
|---|---|
|
|
@@ -454,6 +478,7 @@ out. The unit is a hostname, not an ip:port service.
|
|
|
454
478
|
| `mcp_servers.pending_readjudication` | Verdicts decided by a rule EchelonGraph has since replaced and not yet re-checked under the current rules. They are in neither `mcp_servers.protected` nor `mcp_servers.not_assessed`. |
|
|
455
479
|
| `mcp_servers.not_assessed` | The rest, whose protection the radar could not assess. Not assessed does not mean unprotected. `mcp_servers.not_assessed_by_reason` puts each of them in exactly one bucket (below). |
|
|
456
480
|
| `mcp_servers.own_controls_excluded` | EchelonGraph's own control servers, left out of every other `mcp_servers` number. |
|
|
481
|
+
| `mcp_servers.withheld_opted_out` | Hostnames whose owner opted out of scanning, left out of every other `mcp_servers` number. Such a hostname is not checked again while the opt-out stands, so its last verdict cannot be re-checked; the API's unparameterised answer withholds the same hostnames under the same name. An answer that does not carry this field (an API that predates it) counted them, and the note says so. |
|
|
457
482
|
| `mcp_servers.window.from`, `mcp_servers.window.to` | Timestamps, not counts: when the oldest and the newest of the verdicts counted were last checked. The counts are each hostname's latest verdict, not one sweep at one time. With no verdict counted there is no window. |
|
|
458
483
|
| `mcp_servers.last_run_at` | A timestamp, not a count: when the AI-exposure radar, which checks other AI services as well as MCP servers, last completed a check (a cycle whose reads succeeded and whose scan opt-out register answered). It is not the time of every verdict counted. |
|
|
459
484
|
| `mcp_servers.enabled` | Whether the API reports the radar running: true when a check completed within 45 minutes of its answer. |
|
|
@@ -544,10 +569,10 @@ never "not affected", and each figure cites its `measured_at`. A prompt makes no
|
|
|
544
569
|
|
|
545
570
|
| Prompt | Arguments | What it asks the model to do |
|
|
546
571
|
|---|---|---|
|
|
547
|
-
| `triage_cve` | `cve_id` | `get_cve` (with CISA's due date, `kev_due_date`, when the CVE is KEV-listed), `cve_intel`, `vendor_advisories_for_cve`, `epss_history` and `cve_exposure`, then a decision template: exploited, likelihood, reachable, patch, deadline, decision. The patch line gives `cve_intel`'s `fixed_version` as what
|
|
572
|
+
| `triage_cve` | `cve_id` | `get_cve` (with CISA's due date, `kev_due_date`, when the CVE is KEV-listed), `cve_intel`, `vendor_advisories_for_cve`, `epss_history` and `cve_exposure`, then a decision template: exploited, likelihood, reachable, patch, deadline, decision. The patch line gives each affected range with its fix from `cve_intel`'s `fixed_branches`; where a package has none, it gives `fixed_version` as what `cve_intel` records for the package, one per package (the advisory's last range's as a rule), never as the fix for every affected branch, and points to `get_cve`'s references tagged Vendor Advisory or Patch for a branch's fix. |
|
|
548
573
|
| `kev_weekly_brief` | `days` (1 to 365, default 7) | Work out the start date from `days`, page through `kev_recent` from it 50 rows a page, and write a brief grouped by vendor with ransomware flags and due dates. |
|
|
549
574
|
| `am_i_affected` | `product`, or `ecosystem` and `package`; `version` | `check_affected`, read `assessed` before `count`, with the not-assessed wording. |
|
|
550
|
-
| `sbom_review` | `sbom` (CycloneDX or SPDX JSON text, up to 5,000,000 characters) | Pass the document to `check_sbom` (and, once more, the purls a rate limit or the time budget left unsent: `data.not_sent_purls`, or, where the text cuts that list, the document's purls from the position and in the order its note gives), read each component's ecosystem, package and version from its purl, as `check_sbom` maps them (every purl type it maps, and `deb`, `apk`, `alpine` and `rpm` by a `distro` qualifier naming a Debian, Ubuntu or Alpine release; any other is unknown, and given no fixed version), look up each affected CVE with `get_cve` and `cve_intel`, and write a fix list ordered by CISA-KEV listing, then EPSS, then score, each key stated. A fixed version is given where it is above the component's installed version: the advisory interval in `check_sbom`'s `match_reason` first, else `cve_intel`'s `fixed_version`, which is one per package (the advisory's last range's) and need not be the fix on the component's branch; otherwise the line says the results give no fixed version for that branch and points to the CVE's advisory. |
|
|
575
|
+
| `sbom_review` | `sbom` (CycloneDX or SPDX JSON text, up to 5,000,000 characters) | Pass the document to `check_sbom` (and, once more, the purls a rate limit or the time budget left unsent: `data.not_sent_purls`, or, where the text cuts that list, the document's purls from the position and in the order its note gives), read each component's ecosystem, package and version from its purl, as `check_sbom` maps them (every purl type it maps, and `deb`, `apk`, `alpine` and `rpm` by a `distro` qualifier naming a Debian, Ubuntu or Alpine release; any other is unknown, and given no fixed version), look up each affected CVE with `get_cve` and `cve_intel`, and write a fix list ordered by CISA-KEV listing, then EPSS, then score, each key stated. A fixed version is given where it is above the component's installed version: the advisory interval in `check_sbom`'s `match_reason` first (B of `[A, B)`, a fixed bound; the B of `[A, B]`, read by its closing bracket alone, is the last affected version, still affected, and neither it nor any version at or below it is given; an interval clipped before its closing bracket gives none), else the `fixed` of the `cve_intel` `fixed_branches` range that holds the installed version, else, for a package without `fixed_branches`, `cve_intel`'s `fixed_version`, which is one per package (the advisory's last range's) and need not be the fix on the component's branch; otherwise the line says the results give no fixed version for that branch and points to the CVE's advisory. |
|
|
551
576
|
|
|
552
577
|
## Resources
|
|
553
578
|
|
|
@@ -631,7 +656,7 @@ pretty-printed and whole. The same answer is always cut the same way. The `cve:/
|
|
|
631
656
|
resource cuts its `data` as `get_cve`'s first text block is cut, and says so in a note starting
|
|
632
657
|
`DATA CUT`. The prompts ask only for what the text keeps: `kev_weekly_brief` reads `kev_recent`
|
|
633
658
|
50 rows a page, which the text holds whole; `triage_cve` reads CISA's due date from `get_cve`;
|
|
634
|
-
and `sbom_review` reads fixed versions from `cve_intel`.
|
|
659
|
+
and `sbom_review` reads fixed versions from `cve_intel`, the fix of each affected range in `fixed_branches`.
|
|
635
660
|
|
|
636
661
|
## Structured results
|
|
637
662
|
|
|
@@ -768,7 +793,11 @@ only those to the API, and neither logs nor keeps the document. To keep the docu
|
|
|
768
793
|
machine, run the npm package, or pass `check_sbom` the purls. When a client passes the
|
|
769
794
|
per-client limit it logs that client's address key. It passes your client's public address to
|
|
770
795
|
the API, in a header the API trusts only with a token it checks, so the API's rate limit counts
|
|
771
|
-
you and not the hosted service.
|
|
796
|
+
you and not the hosted service. The API's access line then records each of those calls under
|
|
797
|
+
your address (an IPv6 address by its /64) with its URL path: for a CVE, CWE or advisory lookup,
|
|
798
|
+
the ID you asked about. It does not record the query string or the header-carried terms above
|
|
799
|
+
(for `search_cves`, whether a term was given and its length, not the term), and of `check_sbom`'s
|
|
800
|
+
purls it records how many, not which.
|
|
772
801
|
|
|
773
802
|
EchelonGraph's privacy policy: <https://echelongraph.io/privacy>.
|
|
774
803
|
|
|
@@ -808,6 +837,16 @@ instance serves at most 64 requests at once; the next waits up to 10 seconds for
|
|
|
808
837
|
and is then answered the same way. A `subscriptions/listen` stream, which stays open until its
|
|
809
838
|
client leaves, is not one of the 64.
|
|
810
839
|
|
|
840
|
+
A call whose client goes away (its own timeout, a closed tab) is stopped only once the server
|
|
841
|
+
learns of it. Its API calls are then cancelled, `check_sbom` sends no further batch, and its
|
|
842
|
+
large-body slot is freed. Over HTTP/1.1, Cloud Run does not pass a client's disconnect on to the
|
|
843
|
+
server at all (<https://docs.cloud.google.com/run/docs/troubleshooting>). The hosted endpoint
|
|
844
|
+
speaks HTTP/2 to Cloud Run, where the same page says a disconnect is passed on. Measured on
|
|
845
|
+
2026-10-05, Cloud Run reset the stream of a client that stopped reading its answer 15 s in, and the
|
|
846
|
+
call was logged as a client that closed. Whether a client that aborts mid-call is passed on at once
|
|
847
|
+
has not been observed yet. Until it is, an abandoned `check_sbom` call may run for up to its
|
|
848
|
+
50-second budget and hold its slot that long.
|
|
849
|
+
|
|
811
850
|
## Configuration
|
|
812
851
|
|
|
813
852
|
The npm package (stdio):
|
|
@@ -835,6 +874,7 @@ It reads the three variables above and:
|
|
|
835
874
|
| `MCP_LARGE_BODY_SLOTS` | `2` | How many bodies over `MCP_MAX_BODY_BYTES` it reads and serves at once; past that, 503 with `Retry-After`. |
|
|
836
875
|
| `MCP_MAX_IN_FLIGHT` | `64` | How many requests it serves at once, not counting `subscriptions/listen` streams; the next, its body read, waits. |
|
|
837
876
|
| `MCP_ADMISSION_WAIT_MS` | `10000` | How long a request waits to be served before it is answered 503 with `Retry-After`. |
|
|
877
|
+
| `MCP_H2C` | unset | `1`: serve HTTP/2 cleartext (h2c, prior knowledge) instead of HTTP/1.1, as Cloud Run's `--use-http2` requires; an HTTP/1.1 request then gets no answer. Over h2c, a stream the client resets stops its call. |
|
|
838
878
|
| `ECHELONGRAPH_FORWARD_TOKEN`, `ECHELONGRAPH_FORWARD_HOST`, `MCP_REQUIRE_FORWARD_TOKEN` | unset | EchelonGraph's hosted deployment only: the token with which it names each client to the API. Leave them unset when you run it yourself; the API then counts every request as coming from your server. |
|
|
839
879
|
|
|
840
880
|
## Develop
|
package/dist/http.js
CHANGED
|
@@ -13,6 +13,17 @@
|
|
|
13
13
|
// GET /health liveness. Not /healthz: Cloud Run's front end reserves paths ending in "z"
|
|
14
14
|
// and answers them itself, so a /healthz would never reach this process.
|
|
15
15
|
//
|
|
16
|
+
// HTTP/1.1 by default; HTTP/2 cleartext (h2c, prior knowledge) ONLY when MCP_H2C=1 (#2775). Cloud
|
|
17
|
+
// Run tells a container that its client went away only over HTTP/2 or a WebSocket ("When you use
|
|
18
|
+
// HTTP/1.1 on Cloud Run, client disconnect events are not propagated to the Cloud Run container",
|
|
19
|
+
// https://docs.cloud.google.com/run/docs/troubleshooting): over HTTP/1.1 its front end reads the
|
|
20
|
+
// whole answer itself, so an abandoned call ran to its end, holding its large-body slot. With
|
|
21
|
+
// `--use-http2` Cloud Run sends the container h2c and nothing else, and a client that goes resets
|
|
22
|
+
// its stream, which this server acts on at once (serveMcp). But on a canary of 2026-10-05 that reset
|
|
23
|
+
// came only when the answer was written, 15 and 34 s in, not when the client went:
|
|
24
|
+
// mcp_client_gone (after_ms, answer_started) is how production shows which. The flag and
|
|
25
|
+
// MCP_H2C=1 go together (deploy-all.sh mcp-remote): either one alone answers no request at all.
|
|
26
|
+
//
|
|
16
27
|
// No auth: the endpoint serves only public data (MCP makes authorization optional), and the
|
|
17
28
|
// server's instructions say what it is. The edge rules — Origin policy, per-client limit, body
|
|
18
29
|
// cap, what is logged — are in httpPolicy.ts, each with its reasoning.
|
|
@@ -22,6 +33,7 @@
|
|
|
22
33
|
// waf/mcpforward.go, #2212). MCP_REQUIRE_FORWARD_TOKEN=1 (set by deploy-all.sh) makes a missing
|
|
23
34
|
// token fatal at start-up, because without it every remote user would share one API budget.
|
|
24
35
|
import http from "node:http";
|
|
36
|
+
import http2 from "node:http2";
|
|
25
37
|
import v8 from "node:v8";
|
|
26
38
|
import { createMcpHandler } from "@modelcontextprotocol/server";
|
|
27
39
|
import { forwardToken, markHttpEntrypoint, requestSettled, SYNTHETIC_UA_FAMILY, withClient } from "./runtime.js";
|
|
@@ -49,6 +61,9 @@ const ADMISSION_WAIT_MS = positiveInt(process.env.MCP_ADMISSION_WAIT_MS, DEFAULT
|
|
|
49
61
|
// A large body's wait for a slot is not queued: the client is told to come back. Nor is a
|
|
50
62
|
// request that waited ADMISSION_WAIT_MS for admission.
|
|
51
63
|
const BUSY_RETRY_AFTER_SEC = 5;
|
|
64
|
+
// #2775: serve h2c instead of HTTP/1.1 (see the top of this file). Exactly "1": anything else is
|
|
65
|
+
// HTTP/1.1, the default for a local run and for every test that does not ask.
|
|
66
|
+
const H2C = process.env.MCP_H2C === "1";
|
|
52
67
|
const MCP_PATH = "/mcp";
|
|
53
68
|
const HEALTH_PATH = "/health";
|
|
54
69
|
// One JSON object per line on stdout: Cloud Logging reads `severity` and `message`.
|
|
@@ -87,8 +102,17 @@ const PREFLIGHT_HEADERS = {
|
|
|
87
102
|
"Access-Control-Max-Age": "600",
|
|
88
103
|
};
|
|
89
104
|
const HOP_BY_HOP = new Set(["host", "connection", "content-length", "transfer-encoding", "keep-alive", "upgrade", "expect"]);
|
|
105
|
+
// HTTP/2 has no connection-specific headers (RFC 9113 8.2.2): Node drops them from an h2 answer
|
|
106
|
+
// with a warning, so they are never set on one. A refusal's "Connection: close" (the rest of the
|
|
107
|
+
// body is discarded unread) is the stream's own end there.
|
|
108
|
+
const CONNECTION_SPECIFIC = new Set(["connection", "keep-alive", "proxy-connection", "transfer-encoding", "upgrade"]);
|
|
109
|
+
function forProtocol(res, headers) {
|
|
110
|
+
if (!(res instanceof http2.Http2ServerResponse))
|
|
111
|
+
return headers;
|
|
112
|
+
return Object.fromEntries(Object.entries(headers).filter(([k]) => !CONNECTION_SPECIFIC.has(k.toLowerCase())));
|
|
113
|
+
}
|
|
90
114
|
function send(res, status, body, headers = {}) {
|
|
91
|
-
res.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store", ...headers });
|
|
115
|
+
res.writeHead(status, forProtocol(res, { "Content-Type": "application/json", "Cache-Control": "no-store", ...headers }));
|
|
92
116
|
res.end(body);
|
|
93
117
|
}
|
|
94
118
|
function readBody(req) {
|
|
@@ -133,8 +157,14 @@ function readBody(req) {
|
|
|
133
157
|
chunks.push(chunk);
|
|
134
158
|
});
|
|
135
159
|
req.on("end", () => {
|
|
160
|
+
if (done)
|
|
161
|
+
return;
|
|
162
|
+
done = true;
|
|
163
|
+
resolve({ body: Buffer.concat(chunks), slot });
|
|
164
|
+
});
|
|
165
|
+
req.on("close", () => {
|
|
136
166
|
if (!done)
|
|
137
|
-
|
|
167
|
+
stop("gone");
|
|
138
168
|
});
|
|
139
169
|
req.on("error", (e) => {
|
|
140
170
|
if (done)
|
|
@@ -179,11 +209,35 @@ async function serveMcp(req, res, pathname) {
|
|
|
179
209
|
// #2775: the client has gone when the response closes before it has finished. That aborts the
|
|
180
210
|
// request: its wait for admission, the SDK's handling of it (which cancels the tool's call), and
|
|
181
211
|
// its API calls (runtime.ts requestSignal).
|
|
212
|
+
// Over h2c the response of a reset stream closes marked finished (node:http2's compatibility
|
|
213
|
+
// API ends it), so there the stream itself is watched, from the moment the reset arrives:
|
|
214
|
+
// 'aborted' (a reset before the answer was written, any error code) and a 'close' before this
|
|
215
|
+
// server ended its answer (a reset with NO_ERROR emits no 'aborted').
|
|
216
|
+
// mcp_client_gone says WHEN this server learned it: after_ms from the request's start, and
|
|
217
|
+
// whether its answer had started (answer_started). On 2026-10-05 a canary behind Cloud Run's
|
|
218
|
+
// --use-http2 logged its aborted calls 499 only when their answers were written, 15 and 34 s
|
|
219
|
+
// in: this line tells a reset that arrived then from one that arrived early and was not acted on
|
|
220
|
+
// (the access line's duration_ms is when the call actually stopped).
|
|
182
221
|
const gone = new AbortController();
|
|
222
|
+
const t0 = Date.now();
|
|
223
|
+
const h2stream = res.stream;
|
|
224
|
+
const clientWent = () => {
|
|
225
|
+
if (gone.signal.aborted)
|
|
226
|
+
return;
|
|
227
|
+
gone.abort();
|
|
228
|
+
log("INFO", "mcp_client_gone", { after_ms: Date.now() - t0, answer_started: res.headersSent, rst_code: h2stream?.rstCode ?? null });
|
|
229
|
+
};
|
|
183
230
|
res.on("close", () => {
|
|
184
|
-
if (!res.writableFinished)
|
|
185
|
-
|
|
231
|
+
if (!res.writableFinished || h2stream?.aborted)
|
|
232
|
+
clientWent();
|
|
186
233
|
});
|
|
234
|
+
if (h2stream) {
|
|
235
|
+
h2stream.once("aborted", clientWent);
|
|
236
|
+
h2stream.once("close", () => {
|
|
237
|
+
if (!res.writableEnded)
|
|
238
|
+
clientWent();
|
|
239
|
+
});
|
|
240
|
+
}
|
|
187
241
|
// The body is read BEFORE the request waits for admission (#2773), so a client that uploads
|
|
188
242
|
// slowly holds its socket, and a large body its large-body slot, as before admission existed,
|
|
189
243
|
// never one of the MAX_IN_FLIGHT places every other request needs. What a waiting request holds
|
|
@@ -203,6 +257,8 @@ async function serveMcp(req, res, pathname) {
|
|
|
203
257
|
return { status: 499, refused: "client_closed", clientClosed: true };
|
|
204
258
|
throw e;
|
|
205
259
|
}
|
|
260
|
+
if (read === "gone")
|
|
261
|
+
return { status: 499, refused: "client_closed", clientClosed: true };
|
|
206
262
|
if (read === "too_large")
|
|
207
263
|
return tooLarge(res, cors);
|
|
208
264
|
if (read === "busy") {
|
|
@@ -259,7 +315,8 @@ async function serveMcp(req, res, pathname) {
|
|
|
259
315
|
async function relay(req, res, pathname, method, cors, client, body, gone, work) {
|
|
260
316
|
const headers = new Headers();
|
|
261
317
|
for (const [k, v] of Object.entries(req.headers)) {
|
|
262
|
-
|
|
318
|
+
// ":method", ":path", ":authority", ":scheme": an h2 request's pseudo-headers, which a Request cannot carry.
|
|
319
|
+
if (v === undefined || HOP_BY_HOP.has(k) || k.startsWith(":"))
|
|
263
320
|
continue;
|
|
264
321
|
for (const one of Array.isArray(v) ? v : [v])
|
|
265
322
|
headers.append(k, one);
|
|
@@ -279,7 +336,7 @@ async function relay(req, res, pathname, method, cors, client, body, gone, work)
|
|
|
279
336
|
response.headers.forEach((v, k) => {
|
|
280
337
|
out[k] = v;
|
|
281
338
|
});
|
|
282
|
-
res.writeHead(response.status, out);
|
|
339
|
+
res.writeHead(response.status, forProtocol(res, out));
|
|
283
340
|
if (response.body) {
|
|
284
341
|
for await (const chunk of response.body)
|
|
285
342
|
res.write(chunk);
|
|
@@ -290,7 +347,7 @@ async function relay(req, res, pathname, method, cors, client, body, gone, work)
|
|
|
290
347
|
return { status: 499, body, clientPublic, clientClosed: true };
|
|
291
348
|
return { status: response.status, body, clientPublic };
|
|
292
349
|
}
|
|
293
|
-
|
|
350
|
+
function onRequest(req, res) {
|
|
294
351
|
const start = process.hrtime.bigint();
|
|
295
352
|
// The path only: a query string is never read, kept or logged.
|
|
296
353
|
let pathname = "/";
|
|
@@ -341,7 +398,15 @@ const server = http.createServer((req, res) => {
|
|
|
341
398
|
res.end();
|
|
342
399
|
finish({ status: 500 });
|
|
343
400
|
});
|
|
344
|
-
}
|
|
401
|
+
}
|
|
402
|
+
// #2775: with MCP_H2C=1, node:http2's compatibility API, whose request and response carry the
|
|
403
|
+
// IncomingMessage / ServerResponse members used above. Where they differ the code says so:
|
|
404
|
+
// pseudo-headers (relay), connection-specific headers (forProtocol), a stream reset mid-upload
|
|
405
|
+
// (readBody "gone"). A stream reset after that closes the response unfinished, which aborts
|
|
406
|
+
// `gone` in serveMcp exactly as a closed HTTP/1.1 socket does.
|
|
407
|
+
const server = H2C
|
|
408
|
+
? http2.createServer((req, res) => onRequest(req, res))
|
|
409
|
+
: http.createServer(onRequest);
|
|
345
410
|
// #2773: the heap V8 may grow to, and the memory the container allows (0 when none is known). A
|
|
346
411
|
// heap allowed past the container's memory is collected only when the kernel kills the instance:
|
|
347
412
|
// Dockerfile.http caps it, and this says when a process was started without that cap. It can say
|
|
@@ -362,6 +427,7 @@ server.listen(PORT, () => {
|
|
|
362
427
|
const addr = server.address();
|
|
363
428
|
log("INFO", "mcp_remote_listening", {
|
|
364
429
|
port: typeof addr === "object" && addr ? addr.port : PORT,
|
|
430
|
+
protocol: H2C ? "h2c" : "http/1.1",
|
|
365
431
|
name: NAME,
|
|
366
432
|
version: VERSION,
|
|
367
433
|
api_base: SHOWN_BASE,
|
package/dist/index.js
CHANGED
|
@@ -1567,8 +1567,9 @@ function readShadowAI(sa) {
|
|
|
1567
1567
|
//
|
|
1568
1568
|
// core-backend/internal/aiexposure (handler.go MCPPublicCounts, store.go): the AI-exposure
|
|
1569
1569
|
// radar's MCP rows, one per hostname, each counted once by its latest verdict on record, with
|
|
1570
|
-
// EchelonGraph's own control servers left out and counted in own_controls_excluded
|
|
1571
|
-
//
|
|
1570
|
+
// EchelonGraph's own control servers left out and counted in own_controls_excluded, and every
|
|
1571
|
+
// hostname whose owner opted out of scanning left out and counted in withheld_opted_out (#2822).
|
|
1572
|
+
// Counts, timestamps and one constant (service "mcp"); no hostname, version or server name.
|
|
1572
1573
|
// total protected + pending_readjudication + not_assessed
|
|
1573
1574
|
// protected the /mcp endpoint answered 401/403 and its RFC 9728 protected-resource
|
|
1574
1575
|
// metadata validated (prm.go); prm_via divides exactly these rows by
|
|
@@ -1581,6 +1582,12 @@ function readShadowAI(sa) {
|
|
|
1581
1582
|
// (a challenge recorded before #2312), no_http_answer, not_identified_as_mcp
|
|
1582
1583
|
// era, transport divide every counted row; a row written before #2314 recorded an era
|
|
1583
1584
|
// is not_measured (era.go says what each value rests on)
|
|
1585
|
+
// withheld_opted_out hostnames a verified scan opt-out covered when the radar last reached
|
|
1586
|
+
// them (store.go opted_out_at, #2363): not re-checked while it stands, so
|
|
1587
|
+
// their last verdict is frozen, and in no other count (#2822). The unparameterised
|
|
1588
|
+
// answer withholds the same rows under the same key. An API older than
|
|
1589
|
+
// #2822 carries no such field and counted those rows; the note says so
|
|
1590
|
+
// rather than reading the absence as 0.
|
|
1584
1591
|
// window from/to: the oldest and newest last-check time over the counted rows
|
|
1585
1592
|
// enabled, last_run_at exactly as the unparameterised answer computes them: the AI-exposure
|
|
1586
1593
|
// radar's fleet-wide liveness and last completed check, over every AI
|
|
@@ -1636,7 +1643,7 @@ const MCP_PARTITIONS = {
|
|
|
1636
1643
|
};
|
|
1637
1644
|
const MCP_PARTITION_NAMES = Object.keys(MCP_PARTITIONS);
|
|
1638
1645
|
const MCP_INSTANTS = ["last_run_at", "counted_at"];
|
|
1639
|
-
const MCP_KNOWN = new Set(["service", ...MCP_HEADLINE, ...MCP_PARTITION_NAMES, "own_controls_excluded", "window", "enabled", ...MCP_INSTANTS]);
|
|
1646
|
+
const MCP_KNOWN = new Set(["service", ...MCP_HEADLINE, ...MCP_PARTITION_NAMES, "own_controls_excluded", "withheld_opted_out", "window", "enabled", ...MCP_INSTANTS]);
|
|
1640
1647
|
// What each challenge reason code says (prm.go prmReasonCodes), for the note.
|
|
1641
1648
|
const MCP_CHALLENGE_MEANS = {
|
|
1642
1649
|
resource_mismatch: "the document's resource is absent or not identical to the server's identifier",
|
|
@@ -1704,6 +1711,12 @@ function readMCPServers(src) {
|
|
|
1704
1711
|
data.own_controls_excluded = src.own_controls_excluded;
|
|
1705
1712
|
else
|
|
1706
1713
|
unusable.push("own_controls_excluded");
|
|
1714
|
+
// An API older than #2822 carries no withheld_opted_out, and counted those rows (the note says
|
|
1715
|
+
// so); one that carries it in another shape has it left out and named.
|
|
1716
|
+
if (isWhole(src.withheld_opted_out))
|
|
1717
|
+
data.withheld_opted_out = src.withheld_opted_out;
|
|
1718
|
+
else if (src.withheld_opted_out !== undefined)
|
|
1719
|
+
unusable.push("withheld_opted_out");
|
|
1707
1720
|
// window: from and to, both real instants, from not after to. With no row counted the API has
|
|
1708
1721
|
// no window to give (null, or from and to null), and none is relayed.
|
|
1709
1722
|
const w = src.window;
|
|
@@ -1752,6 +1765,12 @@ function readMCPServers(src) {
|
|
|
1752
1765
|
if (data.own_controls_excluded !== undefined) {
|
|
1753
1766
|
sentences.push(`mcp_servers.own_controls_excluded (${n("own_controls_excluded")}) counts EchelonGraph's own control servers, which are left out of every other mcp_servers number.`);
|
|
1754
1767
|
}
|
|
1768
|
+
if (data.withheld_opted_out !== undefined) {
|
|
1769
|
+
sentences.push(`mcp_servers.withheld_opted_out (${n("withheld_opted_out")}) counts hostnames whose owner opted out of EchelonGraph's scanning: they are not checked again while the opt-out stands, so their last verdict cannot be re-checked, and they are left out of every other mcp_servers number.`);
|
|
1770
|
+
}
|
|
1771
|
+
else if (src.withheld_opted_out === undefined) {
|
|
1772
|
+
sentences.push("The answer does not say how many hostnames whose owner opted out of scanning it withheld (mcp_servers.withheld_opted_out), so its counts may include such a hostname's last verdict, which is not re-checked while the opt-out stands.");
|
|
1773
|
+
}
|
|
1755
1774
|
if (data.window) {
|
|
1756
1775
|
sentences.push(`mcp_servers.window says when the verdicts counted were last checked: the oldest at ${from} (mcp_servers.window.from) and the newest at ${to} (mcp_servers.window.to), so the counts are each hostname's latest verdict, not one sweep at one time.`);
|
|
1757
1776
|
}
|
|
@@ -1777,11 +1796,11 @@ function readMCPServers(src) {
|
|
|
1777
1796
|
return { data, sentences };
|
|
1778
1797
|
}
|
|
1779
1798
|
const EXPOSURE_RADAR_DESCRIPTION = `Aggregate totals from EchelonGraph's internet-exposure radars, each refreshed on its own schedule: internet-facing services running actively-exploited (CISA-KEV) CVEs, plus the ransomware-linked subset, derived from Shodan data; unauthenticated data stores and observability UIs, found through Shodan (LeakIX when Shodan query credits run low) and then confirmed by EchelonGraph's own identified check (not a pure read: on Redis it names its client, and on ClickHouse its query is recorded in the server's query log); leaked credentials sampled from public GitHub push events; and shadow AI services found through Certificate Transparency logs and Shodan and then checked by EchelonGraph's identified probes. It also gives MCP-server counts: hostnames named like an MCP server in EchelonGraph's own Certificate Transparency feed (no Shodan data), each counted once by its latest verdict from EchelonGraph's identified MCP probe, which never sends tools/call. Every number in the result is labelled here and in the result's note by what it counts; a field this version cannot label is left out and named in the note. ` +
|
|
1780
|
-
`kev_exposure: kev_exposure.distinct_hosts counts distinct ip:port services, not machines (a machine answering on two ports counts twice), with at least one CISA-KEV-listed CVE on record, and kev_exposure.ransomware_hosts those with a ransomware-linked one; kev_exposure.kev_cves_exposed and kev_exposure.ransomware_cves count distinct CVEs with at least one such service; kev_exposure.correlations counts service×CVE pairs, not services, so a service with three KEV CVEs counts three times. kev_exposure.top_products, kev_exposure.top_countries and kev_exposure.top_cves rank up to 12 products, 10 countries and 12 CVEs by those services; kev_exposure.top_cves[].cvss_v3_score and kev_exposure.top_cves[].epss_score are the highest CVSS v3 base score and EPSS probability recorded on that CVE's observations. kev_exposure.trend counts service×CVE pairs still on record by the week in which each was first recorded, over 12 weeks, so earlier weeks read low. kev_exposure.newest_kev lists the 15 CVEs that EchelonGraph's CVE records most recently mark as CISA-KEV-listed, each with an exposure_state: exposed, where kev_exposure.newest_kev[].exposed_hosts counts distinct ip:port services on record with it; or not_assessed, where the API's answer holds no measurement for that CVE (its 0 is not one) and no count is relayed, and
|
|
1799
|
+
`kev_exposure: kev_exposure.distinct_hosts counts distinct ip:port services, not machines (a machine answering on two ports counts twice), with at least one CISA-KEV-listed CVE on record, and kev_exposure.ransomware_hosts those with a ransomware-linked one; kev_exposure.kev_cves_exposed and kev_exposure.ransomware_cves count distinct CVEs with at least one such service; kev_exposure.correlations counts service×CVE pairs, not services, so a service with three KEV CVEs counts three times. kev_exposure.top_products, kev_exposure.top_countries and kev_exposure.top_cves rank up to 12 products, 10 countries and 12 CVEs by those services; kev_exposure.top_cves[].cvss_v3_score and kev_exposure.top_cves[].epss_score are the highest CVSS v3 base score and EPSS probability recorded on that CVE's observations. kev_exposure.trend counts service×CVE pairs still on record by the week in which each was first recorded, over 12 weeks, so earlier weeks read low. kev_exposure.newest_kev lists the 15 CVEs that EchelonGraph's CVE records most recently mark as CISA-KEV-listed, each with an exposure_state: exposed, where kev_exposure.newest_kev[].exposed_hosts counts distinct ip:port services on record with it; or not_assessed, where the API's answer holds no measurement for that CVE (its 0 is not one) and no count is relayed, and this answer does not say whether the radar tracks that CVE: tracking is answered per CVE. kev_exposure.newest_kev[].cvss_v3_score and kev_exposure.newest_kev[].epss_score are the CVE record's CVSS v3 base score and EPSS probability. ` +
|
|
1781
1800
|
`exposed_databases: exposed_databases.distinct_hosts counts distinct ip:port services the check confirmed answering without authentication, and exposed_databases.engines the distinct engine types among them; exposed_databases.top_engines and exposed_databases.top_countries rank up to 15 engines and 10 countries by those services. exposed_databases.pii_likely and exposed_databases.pci_likely count services whose schema names (never record values) pass a high-confidence, precision-first gate for personal or payment-card data, so a service outside them is not shown to hold no such data. exposed_databases.window.from and exposed_databases.window.to are timestamps, not counts: when EchelonGraph's check last confirmed the oldest and the newest of the services counted, a span of checks made at different times; with a counted service that has no such time on record there is no window. ` +
|
|
1782
1801
|
`leaked_credentials: leaked_credentials.total counts (repository, secret) pairs, not distinct secrets, so one secret in three repositories counts three times; leaked_credentials.distinct_secrets counts each secret once and leaked_credentials.distinct_repos counts repositories; leaked_credentials.top_providers and leaked_credentials.top_types rank up to 15 providers and secret types by those pairs. None is validated: each is a credential-shaped string that passed EchelonGraph's filters, at most structurally checked and never tested against its provider. leaked_credentials.window.from and leaked_credentials.window.to are timestamps, not counts: when EchelonGraph's detector last found the oldest and the newest of the pairs counted. Each of those three radars also carries generated_at, when the API computed its totals, and, when the API can tell, last_run_at. kev_exposure.last_run_at, exposed_databases.last_run_at and leaked_credentials.last_run_at are timestamps, not counts: each is when that radar last completed a check, a cycle whose reads succeeded, among them a Shodan search (for exposed_databases, or its LeakIX fallback) that answered at least one query, or for leaked_credentials a read of the public GitHub event stream. A cycle that read nothing does not move it, and it is not the time of every record a radar's numbers count, which cover everything still on record, not only what the last check found. A radar whose answer carries no last_run_at has none in the result, and the note says nothing about it. ` +
|
|
1783
1802
|
`shadow_ai: the result is regrouped by what each number counts. shadow_ai.confirmed_exposed counts services EchelonGraph's probes found answering without an authentication gate (liveness active, or rechecking during a re-check): confirmed_exposed.total is the sum of confirmed_exposed.by_category, and confirmed_exposed.last_24h counts those first recorded in the last 24 h. Of the shadow_ai numbers, only confirmed_exposed counts exposed services. confirmed_exposed.window.from and confirmed_exposed.window.to are timestamps, not counts: when EchelonGraph's verifier ran the probe that last decided the oldest and the newest of those services, a span of probes made at different times. shadow_ai.observed counts every Certificate Transparency or Shodan observation on record, whatever its verification state: its numbers are observed, not exposed. They are observed.total; observed.by_category (the same observations by category); observed.last_24h (those first recorded in the last 24 h); observed.trend_30d (observations per UTC day over the last 30 days); and observed.top_products, observed.top_countries and observed.top_issuers (up to ten products, countries and issuers ranked by observations, where an issuer is the certificate's CA for a Certificate Transparency observation and the hosting operator Shodan reports for a Shodan one). shadow_ai.authentication counts observations by probe outcome: authentication.observed where a probe observed an authentication gate (a 401/403, a login page or an auth marker), and authentication.not_determined where the service answered but no probe could tell. Neither authentication count is part of confirmed_exposed, and observed.total minus confirmed_exposed.total is not a count of secured services. The shadow-AI poller block carries only running and last_run_at: last_run_at is when the radar's leader last completed a Certificate Transparency (crt.sh) cycle, and its running is true only when that was within ${SHADOW_AI_SILENT_AFTER} of the answer. ` +
|
|
1784
|
-
`mcp_servers: counts and timestamps only, no hostname. mcp_servers.total counts hostnames the AI-exposure radar has checked for an MCP server, each once by its latest verdict on record, and not every one is an MCP server; EchelonGraph's own control servers are left out, and mcp_servers.own_controls_excluded counts them. mcp_servers.total is mcp_servers.protected plus mcp_servers.pending_readjudication plus mcp_servers.not_assessed. mcp_servers.protected counts hostnames whose /mcp endpoint asked for credentials and whose OAuth protected-resource metadata validated under RFC 9728; mcp_servers.prm_via divides them by where that document was found: mcp_servers.prm_via.header, mcp_servers.prm_via.wellknown_path and mcp_servers.prm_via.wellknown_root. mcp_servers.pending_readjudication counts verdicts of a rule since replaced, not yet re-checked. mcp_servers.not_assessed counts the rest, whose protection the radar could not assess (not assessed does not mean unprotected), and mcp_servers.not_assessed_by_reason puts each in one bucket. mcp_servers.not_assessed_by_reason.identified_no_challenge holds servers that identified themselves as MCP servers and did not ask for credentials at the handshake. That is normal in MCP: authorization is optional in the spec, and a server can enforce it at tools/call instead, which EchelonGraph never sends, so this bucket is not a finding of exposure. ${MCP_CHALLENGE_REASON_FIELDS} hold endpoints that asked for credentials but whose RFC 9728 metadata did not validate, by why, so none of them is shown to lack protection; mcp_servers.not_assessed_by_reason.challenge_unadjudicated holds endpoints that asked for credentials before that check existed, not yet re-checked. mcp_servers.not_assessed_by_reason.no_http_answer holds hostnames that gave no HTTP answer, and mcp_servers.not_assessed_by_reason.not_identified_as_mcp hostnames whose HTTP answer identified no MCP server. mcp_servers.era divides every counted hostname by protocol era: mcp_servers.era.legacy; mcp_servers.era.dual, a lower bound; mcp_servers.era.modern, not proven modern-only; mcp_servers.era.unknown; and mcp_servers.era.not_measured, recorded before the era probe and not re-checked since. mcp_servers.transport divides them by transport: mcp_servers.transport.streamable_http, mcp_servers.transport.legacy_sse, mcp_servers.transport.unknown and mcp_servers.transport.not_measured. mcp_servers.window.from and mcp_servers.window.to are when the oldest and the newest of the verdicts counted were last checked. mcp_servers.last_run_at is a timestamp, not a count: when the AI-exposure radar, which checks other AI services too, last completed a check; mcp_servers.enabled is whether one completed within ${MCP_ENABLED_WITHIN} of the answer, and mcp_servers.counted_at is when the API read the counts. A partition whose buckets do not add up to the count it divides is left out and named; an answer that does not say it is the MCP-server counts, or whose mcp_servers.total, mcp_servers.protected, mcp_servers.pending_readjudication and mcp_servers.not_assessed are missing or contradict each other, is a failure. ${SHODAN_OWNERSHIP}`;
|
|
1803
|
+
`mcp_servers: counts and timestamps only, no hostname. mcp_servers.total counts hostnames the AI-exposure radar has checked for an MCP server, each once by its latest verdict on record, and not every one is an MCP server; EchelonGraph's own control servers are left out, and mcp_servers.own_controls_excluded counts them; hostnames whose owner opted out of scanning are not checked again while the opt-out stands, so their last verdict is left out too, and mcp_servers.withheld_opted_out counts them. mcp_servers.total is mcp_servers.protected plus mcp_servers.pending_readjudication plus mcp_servers.not_assessed. mcp_servers.protected counts hostnames whose /mcp endpoint asked for credentials and whose OAuth protected-resource metadata validated under RFC 9728; mcp_servers.prm_via divides them by where that document was found: mcp_servers.prm_via.header, mcp_servers.prm_via.wellknown_path and mcp_servers.prm_via.wellknown_root. mcp_servers.pending_readjudication counts verdicts of a rule since replaced, not yet re-checked. mcp_servers.not_assessed counts the rest, whose protection the radar could not assess (not assessed does not mean unprotected), and mcp_servers.not_assessed_by_reason puts each in one bucket. mcp_servers.not_assessed_by_reason.identified_no_challenge holds servers that identified themselves as MCP servers and did not ask for credentials at the handshake. That is normal in MCP: authorization is optional in the spec, and a server can enforce it at tools/call instead, which EchelonGraph never sends, so this bucket is not a finding of exposure. ${MCP_CHALLENGE_REASON_FIELDS} hold endpoints that asked for credentials but whose RFC 9728 metadata did not validate, by why, so none of them is shown to lack protection; mcp_servers.not_assessed_by_reason.challenge_unadjudicated holds endpoints that asked for credentials before that check existed, not yet re-checked. mcp_servers.not_assessed_by_reason.no_http_answer holds hostnames that gave no HTTP answer, and mcp_servers.not_assessed_by_reason.not_identified_as_mcp hostnames whose HTTP answer identified no MCP server. mcp_servers.era divides every counted hostname by protocol era: mcp_servers.era.legacy; mcp_servers.era.dual, a lower bound; mcp_servers.era.modern, not proven modern-only; mcp_servers.era.unknown; and mcp_servers.era.not_measured, recorded before the era probe and not re-checked since. mcp_servers.transport divides them by transport: mcp_servers.transport.streamable_http, mcp_servers.transport.legacy_sse, mcp_servers.transport.unknown and mcp_servers.transport.not_measured. mcp_servers.window.from and mcp_servers.window.to are when the oldest and the newest of the verdicts counted were last checked. mcp_servers.last_run_at is a timestamp, not a count: when the AI-exposure radar, which checks other AI services too, last completed a check; mcp_servers.enabled is whether one completed within ${MCP_ENABLED_WITHIN} of the answer, and mcp_servers.counted_at is when the API read the counts. A partition whose buckets do not add up to the count it divides is left out and named; an answer that does not say it is the MCP-server counts, or whose mcp_servers.total, mcp_servers.protected, mcp_servers.pending_readjudication and mcp_servers.not_assessed are missing or contradict each other, is a failure. ${SHODAN_OWNERSHIP}`;
|
|
1785
1804
|
// exposure_radar's envelope. Every radar answered, but no stats answer says when the services
|
|
1786
1805
|
// or records it counts were observed: each count covers everything that radar still holds on
|
|
1787
1806
|
// record, and the answer carries only generated_at (when the API computed the totals) and
|
|
@@ -2106,6 +2125,7 @@ const MCPServersData = z.strictObject({
|
|
|
2106
2125
|
...Object.fromEntries(MCP_HEADLINE.map((k) => [k, Whole])),
|
|
2107
2126
|
...Object.fromEntries(MCP_PARTITION_NAMES.map((k) => [k, z.strictObject(Object.fromEntries(MCP_PARTITIONS[k].buckets.map((b) => [b, Whole]))).optional()])),
|
|
2108
2127
|
own_controls_excluded: Whole.optional(),
|
|
2128
|
+
withheld_opted_out: Whole.optional(),
|
|
2109
2129
|
window: z.strictObject({ from: Instant, to: Instant }).optional(),
|
|
2110
2130
|
enabled: z.boolean().optional(),
|
|
2111
2131
|
...Object.fromEntries(MCP_INSTANTS.map((k) => [k, Instant.optional()])),
|
|
@@ -2176,17 +2196,17 @@ const FEED_ENVELOPE = "Its structured result carries state (measured), measured_
|
|
|
2176
2196
|
const CVE_SUMMARY_ENVELOPE = "Its structured result carries state (measured), measured_at, method, coverage, freshness (null: the feed serves no poll-completion time) and notes, with data equal to the API's JSON less each poller field (or the whole poller) the note names as left out; the result's last text block repeats it without data (the first text block) and without the note's sentences (the text block before it), with which notes ends.";
|
|
2177
2197
|
// #2610: what cve_summary says summary.none is (summaryNoneNote), one constant string for the same
|
|
2178
2198
|
// reason as SCORE_ASSESSED_DESCRIPTION below.
|
|
2179
|
-
const SUMMARY_NONE_DESCRIPTION = "summary.none is not a severity rating of None: it counts the active CVEs with no severity band from any source, that is, CVEs not yet scored, and the answer may carry the same count again as summary.unscored. Whenever summary.none is above zero the note says so
|
|
2199
|
+
const SUMMARY_NONE_DESCRIPTION = "summary.none is not a severity rating of None: it counts the active CVEs with no severity band from any source, that is, CVEs not yet scored, and the answer may carry the same count again as summary.unscored. Whenever summary.none is above zero the note says so, and names those CVEs not yet scored, not CVEs rated None.";
|
|
2180
2200
|
// #2641: what cve_summary says the NVD histogram and summary.rejected are (summaryNVDNote,
|
|
2181
2201
|
// summaryRejectedNote), constant strings for the same reason. Neither uses the words "any", "not",
|
|
2182
2202
|
// "rating", "scored", "source" or "yet": the site's #2610 control (marketing-site
|
|
2183
2203
|
// lib/mcpToolClaims.test.ts) holds that exactly those words of the /pulse/mcp cve_summary row are
|
|
2184
2204
|
// backed by SUMMARY_NONE_DESCRIPTION alone, and fails if another sentence backs one.
|
|
2185
2205
|
const SUMMARY_NVD_DESCRIPTION = "summary.nvd_critical, summary.nvd_high, summary.nvd_medium, summary.nvd_low and summary.nvd_none count the same active CVEs as summary.total by NVD's CVSS severity label (v3.x, else v4.0; before NVD's record arrives, or where it gives none, a pre-NVD label from the CVE.org record or a GitHub advisory can stand in): provenance, never EchelonGraph's severity band. summary.nvd_none counts the active CVEs with no Critical, High, Medium or Low label there, CVEs NVD never labelled under CVSS v3 among them, and many of those carry an NVD CVSS v2 score instead: it is neither a count of CVEs rated None nor the count of CVEs with no severity, which is summary.none. Whenever summary.nvd_none is above zero the note says what it counts, with its count.";
|
|
2186
|
-
const SUMMARY_REJECTED_DESCRIPTION = "summary.rejected counts the CVE records rejected (withdrawn) by their numbering authority, which summary.total and the other counts above leave out:
|
|
2206
|
+
const SUMMARY_REJECTED_DESCRIPTION = "summary.rejected counts the CVE records rejected (withdrawn) by their numbering authority, which summary.total and the other counts above leave out: they are withdrawn records, none of them a vulnerability.";
|
|
2187
2207
|
// #2535: what search_cves and get_cve say about score_assessed, one constant string, since the
|
|
2188
2208
|
// site's tool-claims check (marketing-site lib/mcpToolClaims.test.ts) folds only constant strings.
|
|
2189
|
-
const SCORE_ASSESSED_DESCRIPTION = "echelongraph_score, echelongraph_severity and echelongraph_risk are EchelonGraph's score only when score_assessed is true. With score_assessed false the CVE is NOT YET SCORED, not scored 0: any of those three it carries (0, NONE, 0) is a placeholder, not a rating, and does not mean the CVE is harmless; the API may leave them out instead, score_confidence is NONE, and score_unassessed_reason says why (a rejected record, withdrawn by its numbering authority, is never scored, and the note says NOT SCORED). The note labels each such CVE NOT YET SCORED
|
|
2209
|
+
const SCORE_ASSESSED_DESCRIPTION = "echelongraph_score, echelongraph_severity and echelongraph_risk are EchelonGraph's score only when score_assessed is true. With score_assessed false the CVE is NOT YET SCORED, not scored 0: any of those three it carries (0, NONE, 0) is a placeholder, not a rating, and does not mean the CVE is harmless; the API may leave them out instead, score_confidence is NONE, and score_unassessed_reason says why (a rejected record, withdrawn by its numbering authority, is never scored, and the note says NOT SCORED). The note labels each such CVE NOT YET SCORED, which is not a score of 0. An answer with no score_assessed (an API older than that field) does not say whether the CVE was scored, the note says so, and a 0 there is not a rating either.";
|
|
2190
2210
|
// The shared helpers a tool in src/tools/ is handed (#2718), so its file holds its own logic.
|
|
2191
2211
|
const TOOL_KIT = { api, succeeded, failed, badInput, crashed, checked, okHead, envelopeSchema, annotations: ANNOTATIONS, cveIdArg: CVE_ID_ARG, instant: Instant };
|
|
2192
2212
|
// One server, built per connection by serveStdio for whichever era the client opens with, and
|
package/dist/prompts.js
CHANGED
|
@@ -36,14 +36,17 @@ const text = (t) => ({ messages: [{ role: "user", content: { type: "text", text:
|
|
|
36
36
|
// Its Patch line (#2817): cve_intel's fixed_version is one value per (CVE, ecosystem, package), the
|
|
37
37
|
// advisory's last range's (core-backend cve/advisory.go UpsertAffectedPackages), and triage_cve has
|
|
38
38
|
// no installed version to hold it against: for CVE-2021-44228 production's cve_intel gives
|
|
39
|
-
// log4j-core 2.12.2 (2026-10-04, 2.6.3), below an affected 2.14.1.
|
|
40
|
-
//
|
|
41
|
-
//
|
|
39
|
+
// log4j-core 2.12.2 (2026-10-04, 2.6.3), below an affected 2.14.1. Since #2817 each
|
|
40
|
+
// affected_packages row also carries fixed_branches, every range with its fix (core-backend
|
|
41
|
+
// cve/fixed_branches.go), so the line gives those, range by range, and gives fixed_version only
|
|
42
|
+
// where a row has no fixed_branches, as what cve_intel records for the package, never as the fix
|
|
43
|
+
// for every affected branch, sending the reader to get_cve's references tagged Vendor Advisory or
|
|
44
|
+
// Patch for a branch's fix.
|
|
42
45
|
export function triageCveText(cve) {
|
|
43
46
|
return [
|
|
44
47
|
`Triage ${cve} using the EchelonGraph MCP tools. Call them in this order, each with cve_id ${cve}:`,
|
|
45
48
|
"1. get_cve: the record. Read score_assessed before echelongraph_score (false: not yet scored, which is not a score of 0); read severity, cvss_v3_score, epss_score and epss_percentile, and the CISA-KEV fields kev_listed, kev_added_date, kev_due_date and kev_ransomware.",
|
|
46
|
-
"2. cve_intel: weaknesses (cwes), public exploit code (exploits and exploits_total; verified_status is the label stored with each reference, not a guarantee that it works, and an empty list is not evidence that no exploit exists), affected_packages and fixed_versions.",
|
|
49
|
+
"2. cve_intel: weaknesses (cwes), public exploit code (exploits and exploits_total; verified_status is the label stored with each reference, not a guarantee that it works, and an empty list is not evidence that no exploit exists), affected_packages (each package's affected ranges with their fixes in fixed_branches) and fixed_versions.",
|
|
47
50
|
"3. vendor_advisories_for_cve: the vendor advisories that name it. Call get_vendor_advisory for one whose remediation you need in full.",
|
|
48
51
|
"4. epss_history: how its EPSS score changed, one point per recorded change (series_kind change_only): before series_starts_at a missing point means not recorded, not unchanged.",
|
|
49
52
|
"5. cve_exposure: internet exposure on record. exposure_state says what the count is; exposed_hosts counts distinct ip:port services, not machines; the result is not_assessed with measured_at null, so present any count as undated and on record, and exposure_state not_assessed as not assessed.",
|
|
@@ -52,7 +55,7 @@ export function triageCveText(cve) {
|
|
|
52
55
|
"- Exploited? CISA-KEV listing and its date, known ransomware use, and public exploit code on record.",
|
|
53
56
|
"- Likelihood: EPSS score and percentile, and how it has moved.",
|
|
54
57
|
"- Reachable? exposure_state and the count on record, or not assessed.",
|
|
55
|
-
`- Patch: fixed versions and vendor advisories; where the results hold none, write "none on record", which is not a finding that no fix exists. cve_intel keeps one fixed_version per ecosystem and package, its advisory's last range's, which need not be the fix for every affected version of the package: give
|
|
58
|
+
`- Patch: fixed versions and vendor advisories; where the results hold none, write "none on record", which is not a finding that no fix exists. For each affected package whose cve_intel row carries fixed_branches, give every range with its fix: from introduced ("0" is the first version) up to its fixed, or up to and including last_affected, where no fix is on record for that range; never one range's fix as the fix for every affected branch or the version every install should move to. cve_intel keeps one fixed_version per ecosystem and package, its advisory's last range's as a rule, which need not be the fix for every affected version of the package: where a row has no fixed_branches (null or absent), give fixed_version as the fixed version cve_intel records for that package, never as the fix for every affected branch, and for the fix on a given branch point to get_cve's references tagged Vendor Advisory or Patch, where it has them, and to the vendor advisories.`,
|
|
56
59
|
"- Deadline: get_cve's kev_due_date when the CVE is KEV-listed (CISA's due date for US federal civilian agencies); otherwise write that the results give no CISA deadline.",
|
|
57
60
|
"- Decision: patch now, schedule, or monitor, with a one-line reason drawn from the lines above.",
|
|
58
61
|
].join("\n");
|
|
@@ -90,17 +93,25 @@ export function amIAffectedText(a) {
|
|
|
90
93
|
// rebuilds them from the document, from the position check_sbom's note gives, in the order it
|
|
91
94
|
// gives (check_sbom.ts READ_ORDER).
|
|
92
95
|
//
|
|
93
|
-
// Fixed versions (#2799 review, #2817). cve_intel's fixed_version is one value per (CVE, ecosystem,
|
|
96
|
+
// Fixed versions (#2799 review, #2817, #2830). cve_intel's fixed_version is one value per (CVE, ecosystem,
|
|
94
97
|
// package), the advisory's last range's (core-backend cve/advisory.go UpsertAffectedPackages), so
|
|
95
98
|
// it need not be the fix for the installed version's branch: for CVE-2021-44228, production's
|
|
96
99
|
// cve_intel gives log4j-core 2.12.2 (2026-10-04, 2.6.3), older than an installed 2.14.1, and
|
|
97
100
|
// check_sbom's matches for 2.14.1 say it "is listed verbatim in the advisory's affected versions",
|
|
98
101
|
// which names no fix. So a fixed version is given only where it is strictly greater than the
|
|
99
102
|
// installed version, and otherwise the line says the results give none for the component's branch
|
|
100
|
-
// and points to the advisory. The advisory interval in a match's match_reason
|
|
101
|
-
// cve/pkgmatch.go
|
|
102
|
-
// a Juice Shop SBOM of 50 components is cut past it)
|
|
103
|
-
//
|
|
103
|
+
// and points to the advisory. The advisory interval in a match's match_reason (core-backend
|
|
104
|
+
// cve/pkgmatch.go renderInterval) is preferred where the text keeps it (its first cut level;
|
|
105
|
+
// a Juice Shop SBOM of 50 components is cut past it). Since #2830 its closing bracket carries the
|
|
106
|
+
// bound's meaning: "[A, B)" is a fixed bound, B the first version not affected, so B is given as
|
|
107
|
+
// the fix; "[A, B] (B is the last affected version, not a fix)" is an OSV last_affected bound,
|
|
108
|
+
// B still affected, so B (or any version at or below it) is never given as a fix; "[A, ∞)" has no
|
|
109
|
+
// end on record; a text clipped before the closing bracket ("[A, B…") gives no fix from it.
|
|
110
|
+
// Through 2.6.5 core-backend rendered a last_affected bound as "[A, B)" too, and the prompt
|
|
111
|
+
// hedged B as "the end of that interval". Since #2817 cve_intel's affected_packages rows carry
|
|
112
|
+
// fixed_branches, every range with its fix, so after the match_reason interval the rule reads the
|
|
113
|
+
// range that holds the installed version (2.14.1: [2.13.0, 2.15.0), so 2.15.0), and falls back to
|
|
114
|
+
// fixed_version only for a row without fixed_branches.
|
|
104
115
|
// A cut text keeps index, purl, verdict, not_assessed_reason and cve_ids on a row, not its
|
|
105
116
|
// ecosystem, package or version, so the model reads those from the purl, as core-backend's
|
|
106
117
|
// matcher does (cve/matchbatch.go purlNameVersion, shared/pkg/purlosv): step 2 lists every type
|
|
@@ -113,11 +124,11 @@ export function sbomReviewText(sbom) {
|
|
|
113
124
|
"Review the SBOM below for known vulnerabilities, using the EchelonGraph MCP tools.",
|
|
114
125
|
`1. Call check_sbom with sbom set to the document below, as given. It reads the document's purls and checks up to ${MAX_PURLS} distinct purls per call, sending them in batches of ${MAX_COMPONENTS}; a document with more is refused, not truncated. If it is refused, say why from the result and do not guess at the components. If coverage.not_sent is above 0, the API's rate limit or the call's time budget stopped it early: say how many purls were not sent and coverage.not_sent_reason, and that they are not checked (never clean). After a minute, call check_sbom once more with purls set to the purls not sent, and merge what it returns. data.not_sent_purls lists them; where the note says the first text block cuts not_sent_purls to its first entries, rebuild the list from the document instead, as the note says: the document's distinct purls from the position the note gives on, counted in the order it gives.`,
|
|
115
126
|
"2. Read each component's ecosystem, package and version from its purl, which every row of data.results in the text carries (the leanest cut of the text leaves out the row's own ecosystem, package and version), as check_sbom maps them. A purl is pkg:type/namespace/name@version?qualifiers, each part percent-decoded (%40 is @), its type and distro qualifier in any case. The version is what follows the last @, up to any ? or #. The ecosystem follows from the type: npm is npm, pypi PyPI, maven Maven, golang Go, cargo crates.io, gem RubyGems, nuget NuGet, composer Packagist, hex Hex, pub Pub, swift SwiftURL, hackage Hackage, cran CRAN, bitnami Bitnami, conan ConanCenter and githubact GitHub Actions. For deb, apk, alpine and rpm it is the release the distro qualifier names, which must be one of these three kinds: distro=debian-N is Debian:N, the major release alone (debian-12 and debian-12.5 are Debian:12); distro=ubuntu-V is Ubuntu:V as given (ubuntu-22.04 is Ubuntu:22.04); distro=alpine-X.Y is Alpine:vX.Y, cut to major.minor (alpine-3.20 and alpine-3.20.10 are Alpine:v3.20; alpine-3 names none). For any other type, and a deb, apk, alpine or rpm purl without such a qualifier, the ecosystem is unknown: check_sbom does not check the component (not_assessed, never clean), and the fix list gives no version for it. The package is namespace:name for maven (pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1 is the package org.apache.logging.log4j:log4j-core in Maven, at version 2.14.1), namespace/name for npm, golang, composer, swift and githubact, and the name alone for every other type (pkg:deb/debian/openssl@3.0.15-1~deb12u1?distro=debian-12 is openssl in Debian:12).",
|
|
116
|
-
"3. For each distinct cve_id on a component whose verdict is affected, call get_cve, to read kev_listed, epss_score, score_assessed, echelongraph_score and cvss_v3_score, and cve_intel, to read fixed_version from its affected_packages and fixed_versions rows for the component's ecosystem and package (package_name). If there are more than 30 distinct CVEs, do this for 30 of them and list the rest as not looked up, unranked.",
|
|
127
|
+
"3. For each distinct cve_id on a component whose verdict is affected, call get_cve, to read kev_listed, epss_score, score_assessed, echelongraph_score and cvss_v3_score, and cve_intel, to read fixed_branches and fixed_version from its affected_packages rows, and fixed_version from its fixed_versions rows, for the component's ecosystem and package (package_name). If there are more than 30 distinct CVEs, do this for 30 of them and list the rest as not looked up, unranked.",
|
|
117
128
|
ENVELOPE_RULES,
|
|
118
129
|
"Read each component's verdict: not_affected is the one clean verdict; undetermined and not_assessed are not clean, and not_assessed_reason says why (for example distro_release_unknown: a deb, apk or rpm purl without a distro qualifier, which EchelonGraph does not guess).",
|
|
119
130
|
"Then write a prioritised fix list of the affected components. State the ordering keys, in this order: 1. CISA-KEV listed (kev_listed true) first; 2. then EPSS score, highest first; 3. then echelongraph_score where score_assessed is true, else cvss_v3_score, highest first. A CVE with no value for a key sorts after those with one, and its line says which key was missing.",
|
|
120
|
-
"For each line give the component (purl), its cve_ids, the key values used, and for each CVE a fixed version where the results support one, by this rule. Never give a fixed version that is not strictly greater than the component's installed version (the version in its purl), compared in the ecosystem's version order: release numbers part by part, as numbers, not as text (2.9.1 is below 2.12.2, and 2.12.2 is below 2.14.1); where you are not sure of the order (pre-releases, qualifiers and distro revisions have their own rules), say so and do not give it. Prefer the match's match_reason where the text keeps it: \"falls inside the advisory interval [A, B)\" says the advisory's range that holds the installed version
|
|
131
|
+
"For each line give the component (purl), its cve_ids, the key values used, and for each CVE a fixed version where the results support one, by this rule. Never give a fixed version that is not strictly greater than the component's installed version (the version in its purl), compared in the ecosystem's version order: release numbers part by part, as numbers, not as text (2.9.1 is below 2.12.2, and 2.12.2 is below 2.14.1); where you are not sure of the order (pre-releases, qualifiers and distro revisions have their own rules), say so and do not give it. Prefer the match's match_reason where the text keeps it: \"falls inside the advisory interval [A, B)\", closed by a parenthesis, says B is the advisory's fixed version for the range that holds the installed version, so give B as the fixed version, quoting it (\"[A, ∞)\": the range has no end on record and names no fixed version). \"falls inside the advisory interval [A, B]\", closed by a square bracket, says B is the range's last affected version, still affected, and the range records no fix (core-backend adds \"(B is the last affected version, not a fix)\", which a cut text can clip, so the bracket alone decides): never give B, or any version at or below it, as a fixed version. Where the text is cut before the interval's closing bracket (\"[A, B…\", neither \")\" nor \"]\"), it does not say which bound B is: give no fixed version from that match_reason. Otherwise, where cve_intel's affected_packages row for the component's ecosystem and package carries fixed_branches, give the fixed of the range that holds the installed version: at or above its introduced (\"0\" is the first version) and below its fixed, or at or below its last_affected, in the same version order; a range with last_affected and no fixed has no fix on record, and a version in no range is given no fixed version from them; where fixed_branches is there, fixed_version is not used. Where it is not (null or absent), give cve_intel's fixed_version for the component's ecosystem and package (or the recorded fix a match_reason names, the same value): cve_intel keeps one fixed_version per package, its advisory's last range's, which need not be the fix on the component's branch, so say so beside it. Where no version meets the rule (\"listed verbatim in the advisory's affected versions\" names none), write that the results give no fixed version for the component's branch, which is not a finding that no fix exists, and point to the CVE's advisory for it: get_cve's references tagged Vendor Advisory or Patch, where it has them.",
|
|
121
132
|
"After the list, give data.summary's counts, and list the undetermined and not_assessed components with their reasons as not checked, never as clean.",
|
|
122
133
|
"Where the note says that rows of data.results are left out of the first text block, list the components its rows carry, say how many affected components the text leaves out (data.summary.affected, less those listed), and give the undetermined and not_assessed components it leaves out as data.summary's counts, by not_assessed_reason, as not checked, never as clean.",
|
|
123
134
|
"",
|
|
@@ -89,7 +89,7 @@ const DATA = z.looseObject({
|
|
|
89
89
|
ecosystem: opt(z.string()),
|
|
90
90
|
ecosystem_recognised: opt(z.boolean()),
|
|
91
91
|
package: opt(z.string()),
|
|
92
|
-
assessed: opt(z.boolean()).describe("
|
|
92
|
+
assessed: opt(z.boolean()).describe("count depends on it. true: the lookup evaluated this component. false: it did not, and count 0 says nothing about whether this version is affected."),
|
|
93
93
|
not_assessed_reason: opt(REASON),
|
|
94
94
|
match_layer: opt(z.string()),
|
|
95
95
|
cve_ids: opt(z.array(z.string())),
|
|
@@ -126,7 +126,7 @@ const COVERAGE = z.strictObject({
|
|
|
126
126
|
// Constant strings, so marketing-site lib/mcpToolClaims.test.ts can fold them should it read
|
|
127
127
|
// this file.
|
|
128
128
|
export const CHECK_AFFECTED_TITLE = "Am I affected? (product or package at a version)";
|
|
129
|
-
export const CHECK_AFFECTED_DESCRIPTION = "Whether a product or package at a given version is affected by known CVEs, from the same matcher as echelongraph.io/am-i-affected. Two lookup paths. The CPE path takes product (the NVD CPE product token, such as openssl or nginx) and version, and returns the CVEs whose NVD CPE match criteria name that product with a version range that includes the version; it matches the token across vendors, so each match names the vendor NVD asserts (cpe_vendor) and whether that vendor was verified (vendor_unknown). The registry path takes ecosystem (npm, PyPI, Maven and other OSV ecosystem names), package and version, and decides each OSV advisory record EchelonGraph holds for that package as affected, not affected or undetermined.
|
|
129
|
+
export const CHECK_AFFECTED_DESCRIPTION = "Whether a product or package at a given version is affected by known CVEs, from the same matcher as echelongraph.io/am-i-affected. Two lookup paths. The CPE path takes product (the NVD CPE product token, such as openssl or nginx) and version, and returns the CVEs whose NVD CPE match criteria name that product with a version range that includes the version; it matches the token across vendors, so each match names the vendor NVD asserts (cpe_vendor) and whether that vendor was verified (vendor_unknown). The registry path takes ecosystem (npm, PyPI, Maven and other OSV ecosystem names), package and version, and decides each OSV advisory record EchelonGraph holds for that package as affected, not affected or undetermined. count depends on assessed: assessed false means the lookup did not evaluate this component, not_assessed_reason says why (product_not_in_cpe_corpus, package_not_cpe_nameable, candidate_load_pending, candidate_window_truncated, package_not_in_advisory_corpus, no_decidable_advisory), the structured result's state is not_assessed, and a count of 0 there is not a finding of not affected. An advisory whose version range cannot be decided at this version is reported as undetermined (undetermined_count, and up to 50 of them in undetermined), never as safe. The answer also carries match_layer (which path answered), capped (the match list stopped at its cap), candidates_capped (not every candidate CVE was loaded), excluded_count (CPE candidates suppressed by the vendor or platform gate), not_affected_count (advisories decided in your favour) and degraded (the lookup ran out of time). Each match carries cve_id, kev_listed, ransomware, epss_score, effective_score, effective_severity and score_assessed (false: EchelonGraph has not scored the CVE yet, so its echelongraph_score is withheld). Product, version, ecosystem and package travel in request headers, never in the URL. Its structured result carries state (measured only when the answer says assessed true), measured_at (null), method (which matcher answered), coverage (assessed and not_assessed_reason first, then the lookup path and the counts above), freshness (null) and notes, with data equal to the API's JSON." +
|
|
130
130
|
" " +
|
|
131
131
|
TEXT_BUDGET_DESCRIPTION +
|
|
132
132
|
" Cut, the excluded and undetermined samples keep their first 10 entries, each its cve_id and reason, cve_ids keeps its first 10 (each match carries its cve_id), and each match keeps fewer fields, cve_id, kev_listed, ransomware, epss_score, effective_score and score_assessed at least, so that every match stays in the text.";
|
|
@@ -172,7 +172,8 @@ function matchesNote(matches) {
|
|
|
172
172
|
// description and match_reason, so openssl 3.0.0 (71 matches) was 156,058 characters of text and
|
|
173
173
|
// linux_kernel 5.10.0 (200, the API's cap) 364,408. Past DATA_TEXT_BUDGET each match in the first
|
|
174
174
|
// text block keeps its scores, flags, vendor attribution and match_reason (for a registry match,
|
|
175
|
-
// the advisory interval its version falls inside,
|
|
175
|
+
// the advisory interval its version falls inside: "[A, B)" names the fixed version B, "[A, B]" the
|
|
176
|
+
// last affected one, #2830) with the
|
|
176
177
|
// description cut to 200 characters; then the same without the description; then only the fields
|
|
177
178
|
// the description promises; then, for a list at the cap, without cpe_vendor and vendor_unknown;
|
|
178
179
|
// and last without effective_severity, the band of effective_score. kev_listed, ransomware,
|
package/dist/tools/check_sbom.js
CHANGED
|
@@ -63,7 +63,8 @@ export const CHECK_SBOM_DESCRIPTION = "Check a dependency list against EchelonGr
|
|
|
63
63
|
// a CVE with its description, so the 50-component Juice Shop document was 112,820 characters of
|
|
64
64
|
// text, and 2,000 purls about 1,200,000. Past DATA_TEXT_BUDGET each row in the first text block
|
|
65
65
|
// keeps its verdict, counts and cve_ids, and each match its scores, flags and match_reason (the
|
|
66
|
-
// advisory interval the version falls inside,
|
|
66
|
+
// advisory interval the version falls inside: "[A, B)" names the fixed version B, "[A, B]" the last
|
|
67
|
+
// affected one, #2830) without the
|
|
67
68
|
// description; then the same without match_reason; then only index, purl, verdict,
|
|
68
69
|
// not_assessed_reason and cve_ids. When rows must still go, the clean ones (not_affected) leave
|
|
69
70
|
// first, then the not_assessed ones, both counted in data.summary and the note, so the affected
|
package/dist/tools/cve_intel.js
CHANGED
|
@@ -24,6 +24,15 @@
|
|
|
24
24
|
// EXPLOIT_NUCLEI_ENABLED, EXPLOIT_POCGITHUB_ENABLED; core-backend cmd/server/main.go). The answer
|
|
25
25
|
// does not say which ran, so no text here says a source is polled: it names the sources the data
|
|
26
26
|
// model holds, and says coverage depends on which pollers run.
|
|
27
|
+
//
|
|
28
|
+
// Fixed versions (#2817). /enrichment keeps one affected_packages row per (CVE, ecosystem,
|
|
29
|
+
// package), and its fixed_version is one range's fix: the last range's as the advisory was read
|
|
30
|
+
// (core-backend cve/backfill_osv_packages.go summariseAffected). For CVE-2021-44228 production
|
|
31
|
+
// served log4j-core 2.12.2 alone (2026-10-04), the fix for 2.4-2.12.1, below an affected 2.14.1.
|
|
32
|
+
// Since #2817 each row also carries fixed_branches, the fix for every range (core-backend
|
|
33
|
+
// cve/fixed_branches.go, from the version_intervals the matcher evaluates): this tool relays it as
|
|
34
|
+
// sent, its description says how to pick the range for an installed version, and the note counts
|
|
35
|
+
// the rows where fixed_version is not the whole answer.
|
|
27
36
|
import * as z from "zod";
|
|
28
37
|
import { ANNOTATIONS, CVE_ID, NOT_A_FINDING, SHOWN_BASE, api, badInput, boolAt, checked, crashed, envelopeSchema, failed, failure, field, numAt, okHead, opt, strAt, succeeded, } from "../index.js";
|
|
29
38
|
import { TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
|
|
@@ -60,7 +69,8 @@ const API_ROW_LIMIT = { exploits: 10, affected_packages: 50, fixed_versions: 200
|
|
|
60
69
|
const TIMELINE_RELAYED = 20;
|
|
61
70
|
const CVE_INTEL_METHOD = "EchelonGraph's per-CVE enrichment (GET /api/v1/public/cves/:id/enrichment). cwes: CWE classifications stored from NVD, GitHub (GHSA) and CVE.org records, each with the source that gave it, named from the MITRE CWE catalog EchelonGraph embeds. exploits: public exploit references from EchelonGraph's cve_exploits records, whose data model holds Metasploit modules (metasploit), Exploit-DB entries (exploit_db), nuclei templates (nuclei_template), GitHub proof-of-concept repositories (github_poc) and vendor proofs of concept (vendor_poc), filled by a curated seed and by one poller per automated source, each run only where it is enabled, so which sources are current depends on the deployment. affected_packages and fixed_versions: package-ecosystem advisory data, each row naming its source. timeline: EchelonGraph's enrichment history for the CVE, newest first.";
|
|
62
71
|
const VERIFIED_STATUS_DESCRIPTION = "verified_status is the label stored with each reference: verified for a Metasploit module, for an Exploit-DB entry Exploit-DB marks verified, and for curated seed rows marked so; reported for a public artefact nothing has confirmed works (nuclei templates, GitHub proofs of concept, unverified Exploit-DB entries); unconfirmed where a curated row says so. It is a label from the source, not a guarantee that the exploit works against a given system.";
|
|
63
|
-
|
|
72
|
+
const FIXED_BRANCHES_DESCRIPTION = 'fixed_branches lists each affected range of the package on record: introduced, and fixed (the range\'s fix) or last_affected (its last affected version, where no fix is on record), each null when absent, with advisory_id (the OSV record that published the range) and source (the loader\'s label for that record), both null on a row loaded before they were stored. Ranges with an advisory_id are in the order their record lists them (records in id order); ranges without one are oldest first. To pick the range for an installed version, compare in the ecosystem\'s version order (release numbers part by part, as numbers: 2.4 is below 2.13.0): the version is in a range when it is at or above introduced ("0" is the first version) and below fixed, or at or below last_affected; that range\'s fixed is the fix for it, and a version in no range is outside every range on record. fixed_branches is null where the package\'s ranges were never loaded, and [] where no version range is on record (the advisory gives none, only commit ranges, or more than are stored), which is not a finding that no fix exists: fixed_version can still name one. fixed_version is one range\'s fix, kept for compatibility (one per package: the range the loader kept, the advisory\'s last as a rule), not the fix for every affected range: for CVE-2021-44228, its advisory gives log4j-core three ranges, and fixed_version 2.12.2 is the fix for the one from 2.4, while the range holding 2.14.1 (from 2.13.0) is fixed in 2.15.0.';
|
|
73
|
+
export const CVE_INTEL_DESCRIPTION = `Weakness, public exploit code, affected packages and fixed versions for one CVE, from EchelonGraph's per-CVE enrichment. Returns cwes (each cwe_id with its name and source), exploits (each with kind, source_name, source_url, first_seen_at and verified_status; at most 10, verified first) with exploits_total (every reference on record), exploits_capped (true when exploits lists fewer than exploits_total), exploits_by_kind and exploits_by_status, affected_packages (ecosystem, package_name, version_range, fixed_version, fixed_branches), fixed_versions (ecosystem, package_name, vulnerable_range, fixed_version: one row per fixed version, with the vulnerable_range it fixes where the source gives one) and timeline (the newest enrichment-history rows, with timeline_total). ${FIXED_BRANCHES_DESCRIPTION} ${VERIFIED_STATUS_DESCRIPTION} An empty exploits list is not evidence that no public exploit exists: it covers only the sources EchelonGraph ingests, and which of them are polled depends on the deployment. A section the API could not read is named in coverage.sections_failed and left out of data, never relayed as an empty list. Vendor advisories, patches, generated summaries, trending signals and historical incidents are not relayed. Pass a CVE ID like CVE-2021-44228. Its structured result carries state (measured), measured_at (null: each row carries its own time), method, coverage (the sections relayed, failed and left out, and per-list counts), freshness (null) and notes, with data, which the first text block holds whole up to 30,000 characters; the result's last text block repeats it without data and without the note's sentences (the text block before it), with which notes ends. ${TEXT_BUDGET_DESCRIPTION}`;
|
|
64
74
|
// ── outputSchema ──
|
|
65
75
|
const Rows = (shape) => z.array(z.looseObject(shape));
|
|
66
76
|
const CountMap = z.record(z.string(), z.number());
|
|
@@ -88,7 +98,18 @@ function schemas() {
|
|
|
88
98
|
ecosystem: opt(z.string()),
|
|
89
99
|
package_name: opt(z.string()),
|
|
90
100
|
version_range: opt(z.string()),
|
|
91
|
-
fixed_version: opt(z.string()),
|
|
101
|
+
fixed_version: opt(z.string()).describe("One range's fix, kept for compatibility; not the fix for every affected range: read fixed_branches."),
|
|
102
|
+
fixed_branches: z
|
|
103
|
+
.array(z.looseObject({
|
|
104
|
+
introduced: opt(z.string()).describe('The range\'s lower bound, inclusive; "0" is the first version.'),
|
|
105
|
+
fixed: opt(z.string()).describe("The range's fix: the first version past it. null where no fix is on record."),
|
|
106
|
+
last_affected: opt(z.string()).describe("The range's last affected version, inclusive, where no fix is on record; never a fix."),
|
|
107
|
+
advisory_id: opt(z.string()).describe("The OSV record that published the range (GHSA-…, PYSEC-…, CVE-…); null on a row loaded before it was stored."),
|
|
108
|
+
source: opt(z.string()).describe("The loader's label for that record (osv_bulk, osv); null on a row loaded before it was stored."),
|
|
109
|
+
}))
|
|
110
|
+
.nullable()
|
|
111
|
+
.optional()
|
|
112
|
+
.describe(FIXED_BRANCHES_DESCRIPTION),
|
|
92
113
|
dependents_count: opt(z.number()),
|
|
93
114
|
source: opt(z.string()),
|
|
94
115
|
}).optional(),
|
|
@@ -170,6 +191,50 @@ function listSentence(name, rows) {
|
|
|
170
191
|
const cut = rows.length >= limit ? `, the most the API returns per CVE, so there may be more` : "";
|
|
171
192
|
return `${name} lists ${rows.length} row${rows.length === 1 ? "" : "s"}${cut}.`;
|
|
172
193
|
}
|
|
194
|
+
// #2817: where fixed_version is not the whole answer. A row with more than one range in
|
|
195
|
+
// fixed_branches has a fix per range; a row with fixed_branches null has only fixed_version on
|
|
196
|
+
// record, and so does a row with fixed_branches [] and a fixed_version (no version range on
|
|
197
|
+
// record: none in the advisory, only commit ranges, or more than core-backend stores); an answer
|
|
198
|
+
// whose rows carry no fixed_branches is an API older than #2817.
|
|
199
|
+
function branchSentences(rows) {
|
|
200
|
+
if (rows.length === 0)
|
|
201
|
+
return [];
|
|
202
|
+
const named = (r, i) => {
|
|
203
|
+
const eco = strAt(r, "ecosystem");
|
|
204
|
+
const pkg = strAt(r, "package_name") ?? `affected_packages[${i}]`;
|
|
205
|
+
return eco ? `${pkg} (${eco})` : pkg;
|
|
206
|
+
};
|
|
207
|
+
const withField = rows.filter((r) => field(r, "fixed_branches") !== undefined);
|
|
208
|
+
if (withField.length === 0) {
|
|
209
|
+
return [
|
|
210
|
+
"No affected_packages row carries fixed_branches (an API older than that field): each row's fixed_version is one range's fix, which need not be the fix for a given installed version.",
|
|
211
|
+
];
|
|
212
|
+
}
|
|
213
|
+
const out = [];
|
|
214
|
+
const multi = [];
|
|
215
|
+
const unknown = [];
|
|
216
|
+
const noRange = [];
|
|
217
|
+
rows.forEach((r, i) => {
|
|
218
|
+
const fb = field(r, "fixed_branches");
|
|
219
|
+
if (Array.isArray(fb) && fb.length > 1)
|
|
220
|
+
multi.push(named(r, i));
|
|
221
|
+
if (fb === null)
|
|
222
|
+
unknown.push(named(r, i));
|
|
223
|
+
if (Array.isArray(fb) && fb.length === 0 && strAt(r, "fixed_version"))
|
|
224
|
+
noRange.push(named(r, i));
|
|
225
|
+
});
|
|
226
|
+
const few = (xs) => (xs.length <= 3 ? listed(xs) : `${xs.slice(0, 3).join(", ")} and ${xs.length - 3} more`);
|
|
227
|
+
if (multi.length) {
|
|
228
|
+
out.push(`${multi.length} affected_packages row${multi.length === 1 ? " lists" : "s list"} more than one affected range in fixed_branches (${few(multi)}): there, fixed_version is one range's fix, and an installed version's fix is the fixed of the range that holds it.`);
|
|
229
|
+
}
|
|
230
|
+
if (unknown.length) {
|
|
231
|
+
out.push(`${unknown.length} affected_packages row${unknown.length === 1 ? " carries" : "s carry"} fixed_branches null (${few(unknown)}): ${unknown.length === 1 ? "its" : "their"} ranges are not on record, so fixed_version, one range's fix, is all the answer holds, and it need not be the fix for a given installed version.`);
|
|
232
|
+
}
|
|
233
|
+
if (noRange.length) {
|
|
234
|
+
out.push(`${noRange.length} affected_packages row${noRange.length === 1 ? " carries" : "s carry"} fixed_branches [] and a fixed_version (${few(noRange)}): no version range is on record there, which is not a finding that no fix exists, so fixed_version, one range's fix, is all the answer holds, and it need not be the fix for a given installed version.`);
|
|
235
|
+
}
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
173
238
|
// ── The tool ──
|
|
174
239
|
export async function cveIntel(cve_id) {
|
|
175
240
|
const id = cve_id.trim();
|
|
@@ -218,6 +283,8 @@ export async function cveIntel(cve_id) {
|
|
|
218
283
|
const xs = rows(k);
|
|
219
284
|
if (xs)
|
|
220
285
|
said.push(listSentence(k, xs));
|
|
286
|
+
if (xs && k === "affected_packages")
|
|
287
|
+
said.push(...branchSentences(xs));
|
|
221
288
|
}
|
|
222
289
|
const timeline = rows("timeline");
|
|
223
290
|
const apiTimeline = field(d, "timeline");
|
|
@@ -6,7 +6,7 @@ export const EPSS_HISTORY_METHOD = "EchelonGraph's record of FIRST's EPSS scores
|
|
|
6
6
|
const CHANGE_ONLY = "series_kind is change_only: each point is a change EchelonGraph recorded, at the time it recorded it, and a day with no point is not a recorded value, so the series must not be read or redrawn as a daily series; no point here is interpolated.";
|
|
7
7
|
const BEFORE_FIRST = "The value in force before a CVE's first point is not in the series.";
|
|
8
8
|
const NO_FRESHNESS = "freshness is null: the answer does not carry the time of the EPSS fetcher's last completed run.";
|
|
9
|
-
export const EPSS_HISTORY_DESCRIPTION = `How one CVE's EPSS score (FIRST's exploit-prediction probability, 0 to 1, with its percentile) has changed, as EchelonGraph recorded it: points, oldest first, each with at, epss_score and epss_percentile; current, the record's value now (epss_score, epss_percentile, epss_updated_at); series_kind, always change_only; series_starts_at, when EchelonGraph began recording EPSS changes for any CVE; history_rows, points_truncated and latest_point_matches_current. Pass a CVE ID like CVE-2023-44487. The series is change-only: a point is a recorded change, and a day without a point is not a recorded value.
|
|
9
|
+
export const EPSS_HISTORY_DESCRIPTION = `How one CVE's EPSS score (FIRST's exploit-prediction probability, 0 to 1, with its percentile) has changed, as EchelonGraph recorded it: points, oldest first, each with at, epss_score and epss_percentile; current, the record's value now (epss_score, epss_percentile, epss_updated_at); series_kind, always change_only; series_starts_at, when EchelonGraph began recording EPSS changes for any CVE; history_rows, points_truncated and latest_point_matches_current. Pass a CVE ID like CVE-2023-44487. The series is change-only: a point is a recorded change, and a day without a point is not a recorded value. A daily series interpolated from it holds values EchelonGraph never recorded, so the series is never a daily series. Before series_starts_at nothing was recorded, so a missing point there means not recorded, not unchanged, and the value in force before a CVE's first point is not in the series. latest_point_matches_current false means a change is missing from the series. Its structured result carries state (measured), measured_at (current.epss_updated_at: when EchelonGraph last wrote a changed value, not FIRST's score date), method, coverage (series_kind, series_starts_at, points, points_truncated), freshness (null) and notes, with data equal to the API's JSON; the result's last text block repeats it without data (the first text block) and without the note's sentences (the text block before it), with which notes ends. ${TEXT_BUDGET_DESCRIPTION}`;
|
|
10
10
|
// The API's JSON. points, series_kind and current are required: an answer without them is not
|
|
11
11
|
// this series, and a series_kind other than change_only would make every note above wrong, so
|
|
12
12
|
// either is a failure (checked), never relayed.
|
|
@@ -36,11 +36,21 @@
|
|
|
36
36
|
// CVE-2024-21412, which both vendors did publish advisories for. core-backend now reads those
|
|
37
37
|
// histories back (vendoradv backfill.go) and serves each vendor's window at
|
|
38
38
|
// /api/v1/public/vendor-advisories/coverage (coverage.go): advisories held, the earliest and latest
|
|
39
|
-
// vendor_published_at among them, and history_backfill. All three tools read it beside
|
|
40
|
-
// request and relay it in coverage, and the note says when a CVE's ID year begins before
|
|
41
|
-
// vendor
|
|
42
|
-
// is never read as "the vendor published none".
|
|
43
|
-
//
|
|
39
|
+
// vendor_published_at among them, held_since and history_backfill. All three tools read it beside
|
|
40
|
+
// their own request and relay it in coverage, and the note says when a CVE's ID year begins before
|
|
41
|
+
// the date a vendor is held from, or a vendor's history is still being read, so an empty answer
|
|
42
|
+
// is never read as "the vendor published none".
|
|
43
|
+
//
|
|
44
|
+
// held_since, not the earliest held advisory, is where a vendor's window begins (#2803). A vendor
|
|
45
|
+
// with no history read (history_backfill not_supported) is held from its poller's first poll on,
|
|
46
|
+
// and Cisco's and Red Hat's first polls read only what the vendor had CHANGED lately: an old
|
|
47
|
+
// advisory the vendor revised since is held, so the earliest held advisory reached back to 2024
|
|
48
|
+
// for Cisco and 2009 for Red Hat while the advisories published beside it are not held, and
|
|
49
|
+
// CVE-2025-20188 read Cisco as fully held for 2025. coverage.go serves held_since as the start of
|
|
50
|
+
// the window EchelonGraph has read the vendor's advisories from; an API older than that field gives none, and a vendor is
|
|
51
|
+
// then taken as held from its earliest advisory only when its history read is complete.
|
|
52
|
+
//
|
|
53
|
+
// The window is context, not the answer: when it cannot be read (an API older than the route, a failure), the answer is relayed as before, with
|
|
44
54
|
// the windows null and a note saying they are unknown, never failed. The coverage request carries
|
|
45
55
|
// nothing typed: a fixed path, no query, no search header.
|
|
46
56
|
//
|
|
@@ -113,6 +123,7 @@ function windowsOf(d) {
|
|
|
113
123
|
advisories: num(r, "advisories") ?? null,
|
|
114
124
|
earliest_vendor_published_at: instantOrNull(r, "earliest_vendor_published_at"),
|
|
115
125
|
latest_vendor_published_at: instantOrNull(r, "latest_vendor_published_at"),
|
|
126
|
+
held_since: instantOrNull(r, "held_since"),
|
|
116
127
|
history_backfill: str(r, "history_backfill") ?? null,
|
|
117
128
|
},
|
|
118
129
|
];
|
|
@@ -120,30 +131,48 @@ function windowsOf(d) {
|
|
|
120
131
|
}
|
|
121
132
|
const day = (instant) => instant.slice(0, 10);
|
|
122
133
|
const backfilling = (w) => w.history_backfill !== null && BACKFILLING.has(w.history_backfill);
|
|
134
|
+
// #2803: the start of the window a vendor's advisories have been read from: held_since as the API
|
|
135
|
+
// gives it; from an API without it, the earliest held advisory when the vendor's history read is
|
|
136
|
+
// complete (that is the archive's start), else unknown (null).
|
|
137
|
+
const heldFrom = (w) => w.held_since ?? (w.history_backfill === "complete" ? w.earliest_vendor_published_at : null);
|
|
138
|
+
// Whether a vendor's window starts on or before 1 January of a CVE ID's year.
|
|
139
|
+
const heldFromYear = (w, yearStart) => {
|
|
140
|
+
const from = heldFrom(w);
|
|
141
|
+
return from !== null && Date.parse(from) <= yearStart;
|
|
142
|
+
};
|
|
123
143
|
// What a note says of one window: what is held, and the history read when it is not done.
|
|
124
144
|
const windowItem = (w, heldOnly) => {
|
|
145
|
+
const from = heldFrom(w);
|
|
146
|
+
const to = w.latest_vendor_published_at ? ` to ${day(w.latest_vendor_published_at)}` : "";
|
|
125
147
|
const held = w.earliest_vendor_published_at === null
|
|
126
148
|
? "none held"
|
|
127
|
-
:
|
|
149
|
+
: from === null
|
|
150
|
+
? `held from ${day(w.earliest_vendor_published_at)}${to}, not known to be without a gap`
|
|
151
|
+
: `held ${heldOnly ? "only " : ""}from ${day(from)}${to}${day(w.earliest_vendor_published_at) < day(from) ? `, and before that only in part, the earliest held dated ${day(w.earliest_vendor_published_at)}` : ""}`;
|
|
128
152
|
return `${w.vendor} (${held}${backfilling(w) ? `; history_backfill ${w.history_backfill}` : ""})`;
|
|
129
153
|
};
|
|
130
154
|
// The sentence for windows that could not be read: no "none here" is a finding about a vendor.
|
|
131
155
|
const windowsUnknown = (why) => ` The vendors' coverage windows could not be read (${why}), so this answer cannot say from what date each vendor's advisories are held: no advisory here from a vendor is not a finding that it published none.`;
|
|
132
156
|
// vendor_advisories_for_cve: the vendors with no advisory in the answer that may have published
|
|
133
|
-
// one EchelonGraph does not hold: none held at all,
|
|
134
|
-
// year in the CVE ID, or a history still being read.
|
|
157
|
+
// one EchelonGraph does not hold: none held at all, held (held_since) only from after 1 January of
|
|
158
|
+
// the year in the CVE ID or from a date not known, or a history still being read. Never the
|
|
159
|
+
// earliest held advisory: for a vendor with no history read it can be years before held_since
|
|
160
|
+
// (#2803).
|
|
135
161
|
function notFullyHeld(windows, rows, year) {
|
|
136
162
|
const answered = new Set(rows.map((r) => str(r, "vendor")).filter((v) => v !== undefined));
|
|
137
163
|
const yearStart = Date.UTC(year, 0, 1);
|
|
138
|
-
return windows.filter((w) => !answered.has(w.vendor) && (w.earliest_vendor_published_at === null ||
|
|
164
|
+
return windows.filter((w) => !answered.has(w.vendor) && (w.earliest_vendor_published_at === null || !heldFromYear(w, yearStart) || backfilling(w)));
|
|
139
165
|
}
|
|
140
166
|
function notFullyHeldNote(cve, year, missing) {
|
|
141
167
|
if (!missing.length)
|
|
142
168
|
return "";
|
|
143
169
|
const k = missing.length;
|
|
144
170
|
const yearStart = Date.UTC(year, 0, 1);
|
|
145
|
-
const items = missing.map((w) =>
|
|
146
|
-
|
|
171
|
+
const items = missing.map((w) => {
|
|
172
|
+
const from = heldFrom(w);
|
|
173
|
+
return windowItem(w, from !== null && Date.parse(from) > yearStart);
|
|
174
|
+
});
|
|
175
|
+
return ` ${cve} is a ${year} CVE ID, and EchelonGraph may not hold every advisory ${plural(k, "1 vendor", `${k} vendors`)} published for it (vendors_not_fully_held): ${items.join("; ")}. An advisory a vendor published before the date EchelonGraph holds that vendor from (held_since) may not be held, even when an older one is, and while history_backfill is in_progress or not_started the vendor's older advisories are still being read, so no advisory here from ${plural(k, "that vendor", "those vendors")} is not a finding that ${plural(k, "it", "they")} published none.`;
|
|
147
176
|
}
|
|
148
177
|
// search_vendor_advisories and get_vendor_advisory: the vendors whose history is still being read.
|
|
149
178
|
function backfillingNote(windows) {
|
|
@@ -158,7 +187,7 @@ const opt = (t) => t.nullable().optional();
|
|
|
158
187
|
const DATE_FIELDS = {
|
|
159
188
|
vendor_published_at: opt(z.string()).describe("The date the vendor gives for the advisory."),
|
|
160
189
|
our_first_seen_at: opt(z.string()).describe("When EchelonGraph first recorded the advisory; not the vendor's date."),
|
|
161
|
-
withdrawn: opt(z.boolean()).describe("true: the vendor withdrew (rescinded) this advisory
|
|
190
|
+
withdrawn: opt(z.boolean()).describe("true: the vendor withdrew (rescinded) this advisory, so it is a withdrawn advisory, not a current one."),
|
|
162
191
|
};
|
|
163
192
|
const AdvisoryRow = z.looseObject({
|
|
164
193
|
advisory_id: opt(z.string()),
|
|
@@ -179,8 +208,10 @@ const DATES_DESCRIPTION = "Each advisory carries vendor_published_at (the vendor
|
|
|
179
208
|
const ENVELOPE_DESCRIPTION = "Its structured result carries state (measured), measured_at, method, coverage, freshness (null: the answer carries no poll-completion time) and notes, with data equal to the API's JSON; the result's last text block repeats it without data (the first text block) and without the note's sentences (the text block before it), with which notes ends.";
|
|
180
209
|
// #2729: what a vendor's window is.
|
|
181
210
|
const HISTORY_BACKFILL_DESCRIPTION = "history_backfill: complete (the vendor's published history has been read back as far as its source goes), in_progress or not_started (it is still being read, so the vendor's older advisories are not all held yet), or not_supported (EchelonGraph has no history read for that vendor, so what it holds is what the vendor's feed has carried)";
|
|
182
|
-
|
|
183
|
-
const
|
|
211
|
+
// #2803: where a vendor's window begins.
|
|
212
|
+
const HELD_SINCE_DESCRIPTION = "held_since (the start of the window EchelonGraph has read that vendor's advisories from, before which an advisory may not be held; null when none is held or the API does not say; for a vendor with no history read it is when its feed was first read from, and can be years after earliest_vendor_published_at, since an older advisory is held when the vendor revised it later, not the ones published beside it)";
|
|
213
|
+
const WINDOW_DESCRIPTION = `vendor, advisories (how many EchelonGraph holds from that vendor), earliest_vendor_published_at and latest_vendor_published_at (the earliest and latest vendor_published_at among them, null when none is held), ${HELD_SINCE_DESCRIPTION} and ${HISTORY_BACKFILL_DESCRIPTION}`;
|
|
214
|
+
const NOT_HELD_DESCRIPTION = "An advisory a vendor published before its held_since may not be held, even when its earliest_vendor_published_at is older, so no advisory from a vendor is not a finding that it published none.";
|
|
184
215
|
// #2728: how a search matches, and its capped total.
|
|
185
216
|
const SEARCH_MATCH_DESCRIPTION = "A query of 3 or more characters is matched case-insensitively as a substring of each advisory's title, description, vendor name, vendor_advisory_id, affected_products and cve_ids (search_match substring). A query of 1 or 2 characters matches whole words only (search_match word): it must equal, ignoring case, a whole word (a run of letters and digits) of one of those, so xz finds xz-utils but not xzibit, and a 1- or 2-character query with any other character, such as c#, matches nothing.";
|
|
186
217
|
const TOTAL_CAPPED_DESCRIPTION = "A search counts at most 1,000 matches: past that, total is 1000 and total_capped is true, which means 1,000 or more (the note writes 1,000+), never exactly 1,000; paging past it still works. Without a query, total is the exact count and total_capped is false.";
|
|
@@ -191,8 +222,12 @@ const VendorWindowSchema = z.strictObject({
|
|
|
191
222
|
earliest_vendor_published_at: z
|
|
192
223
|
.string()
|
|
193
224
|
.nullable()
|
|
194
|
-
.describe("The earliest vendor_published_at among them; null when none is held.
|
|
225
|
+
.describe("The earliest vendor_published_at among them; null when none is held. Not where the window begins: see held_since."),
|
|
195
226
|
latest_vendor_published_at: z.string().nullable().describe("The latest vendor_published_at among them; null when none is held."),
|
|
227
|
+
held_since: z
|
|
228
|
+
.string()
|
|
229
|
+
.nullable()
|
|
230
|
+
.describe("The start of the window EchelonGraph has read this vendor's advisories from; null when none is held or the API does not say. An advisory this vendor published before it may not be held, even when earliest_vendor_published_at is older."),
|
|
196
231
|
// The four values the API sends today, and any later one as a string: a new state must not turn
|
|
197
232
|
// the answer into a failure.
|
|
198
233
|
history_backfill: z
|
|
@@ -233,7 +268,7 @@ export function registerVendorAdvisoryTools(server, kit) {
|
|
|
233
268
|
vendors_not_fully_held: z
|
|
234
269
|
.array(z.string())
|
|
235
270
|
.nullable()
|
|
236
|
-
.describe("The vendors with no advisory in this answer that may have published one EchelonGraph does not hold: none held,
|
|
271
|
+
.describe("The vendors with no advisory in this answer that may have published one EchelonGraph does not hold: none held, held_since after 1 January of cve_year or not known, or history_backfill in_progress or not_started. No advisory from them is not a finding that they published none. null when the windows could not be read."),
|
|
237
272
|
}),
|
|
238
273
|
freshness: null,
|
|
239
274
|
});
|
|
@@ -473,14 +508,14 @@ export function registerVendorAdvisoryTools(server, kit) {
|
|
|
473
508
|
}
|
|
474
509
|
server.registerTool("vendor_advisories_for_cve", {
|
|
475
510
|
title: "Vendor advisories for one CVE",
|
|
476
|
-
description: `The vendor-published advisories that name one CVE, newest first, at most 20: for each, vendor, vendor_display_name, vendor_advisory_id, title, severity and cvss_v3_score where the vendor gives them. Covers the vendor feeds EchelonGraph polls, for example Microsoft MSRC, Red Hat, Cisco, Palo Alto Networks and GitHub GHSA; an empty answer means that none of the advisories EchelonGraph holds from those feeds names the CVE, not that no vendor published one (see vendor_windows and vendors_not_fully_held). ${DATES_DESCRIPTION} Pass a CVE ID like CVE-2024-21412. coverage gives returned, cap and at_cap (true: the answer is full, so there may be more); cve_year, the year in the CVE ID; vendor_windows, each vendor's window in what EchelonGraph holds: ${WINDOW_DESCRIPTION}; and vendors_not_fully_held, the vendors with no advisory in the answer of which EchelonGraph holds none, whose
|
|
511
|
+
description: `The vendor-published advisories that name one CVE, newest first, at most 20: for each, vendor, vendor_display_name, vendor_advisory_id, title, severity and cvss_v3_score where the vendor gives them. Covers the vendor feeds EchelonGraph polls, for example Microsoft MSRC, Red Hat, Cisco, Palo Alto Networks and GitHub GHSA; an empty answer means that none of the advisories EchelonGraph holds from those feeds names the CVE, not that no vendor published one (see vendor_windows and vendors_not_fully_held). ${DATES_DESCRIPTION} Pass a CVE ID like CVE-2024-21412. coverage gives returned, cap and at_cap (true: the answer is full, so there may be more); cve_year, the year in the CVE ID; vendor_windows, each vendor's window in what EchelonGraph holds: ${WINDOW_DESCRIPTION}; and vendors_not_fully_held, the vendors with no advisory in the answer of which EchelonGraph holds none, whose held_since is after 1 January of cve_year or not known, or whose history is still being read, which the note names. ${NOT_HELD_DESCRIPTION} vendor_windows and vendors_not_fully_held are null when the windows could not be read, and the note says so. measured_at is null. ${ENVELOPE_DESCRIPTION} ${TEXT_BUDGET_DESCRIPTION}${ROW_CUT_DESCRIPTION}.`,
|
|
477
512
|
inputSchema: z.object({ cve_id: z.string().describe("a CVE ID, e.g. CVE-2024-21412") }),
|
|
478
513
|
outputSchema: FOR_CVE_OUTPUT,
|
|
479
514
|
annotations: kit.annotations,
|
|
480
515
|
}, async ({ cve_id }) => kit.checked("vendor_advisories_for_cve", FOR_CVE_OUTPUT, await forCVE(cve_id)));
|
|
481
516
|
server.registerTool("get_vendor_advisory", {
|
|
482
517
|
title: "Vendor advisory detail",
|
|
483
|
-
description: `One vendor advisory in full, by vendor and the vendor's advisory ID (the vendor and vendor_advisory_id
|
|
518
|
+
description: `One vendor advisory in full, by vendor and the vendor's advisory ID (the vendor and vendor_advisory_id of a vendor advisory row): title, description, severity, cvss_v3_score, cve_ids and known_cve_ids (those with a record in EchelonGraph's CVE feed), affected_products, remediation, references, vendor_modified_at, and withdrawn_at and withdrawn_reason when the vendor withdrew it. ${DATES_DESCRIPTION} coverage.vendor_window is that vendor's window in what EchelonGraph holds: ${WINDOW_DESCRIPTION}; null when the windows could not be read or carry none for that vendor. measured_at is our_first_seen_at. ${ENVELOPE_DESCRIPTION} ${TEXT_BUDGET_DESCRIPTION}`,
|
|
484
519
|
inputSchema: z.object({
|
|
485
520
|
vendor: z.string().describe("the vendor slug, e.g. microsoft, redhat, github"),
|
|
486
521
|
advisory_id: z.string().describe("the vendor's advisory ID, e.g. RHSA-2024:1234 or GHSA-xxxx-xxxx-xxxx"),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "echelongraph-mcp",
|
|
3
|
-
"version": "2.6.
|
|
3
|
+
"version": "2.6.7",
|
|
4
4
|
"mcpName": "io.echelongraph/echelongraph-mcp",
|
|
5
5
|
"description": "MCP server for CVE lookups: EchelonGraph scores, CISA KEV, EPSS, CWE, exploits, version and SBOM checks, vendor advisories, and per-CVE exposure derived from Shodan data. Shodan data is owned by Shodan, which holds its copyright (© Shodan). Keyless.",
|
|
6
6
|
"type": "module",
|
package/server.json
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
]
|
|
18
18
|
}
|
|
19
19
|
],
|
|
20
|
-
"version": "2.6.
|
|
20
|
+
"version": "2.6.7",
|
|
21
21
|
"remotes": [
|
|
22
22
|
{
|
|
23
23
|
"type": "streamable-http",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
{
|
|
29
29
|
"registryType": "npm",
|
|
30
30
|
"identifier": "echelongraph-mcp",
|
|
31
|
-
"version": "2.6.
|
|
31
|
+
"version": "2.6.7",
|
|
32
32
|
"transport": {
|
|
33
33
|
"type": "stdio"
|
|
34
34
|
},
|