echelongraph-mcp 2.5.0 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -1
- package/README.md +13 -7
- package/dist/index.js +21 -7
- package/dist/prompts.js +4 -4
- package/dist/tools/check_sbom.js +209 -24
- package/dist/tools/kev_recent.js +26 -13
- package/package.json +1 -1
- package/server.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,11 +1,36 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
3
|
Every version of `echelongraph-mcp` published to npm, newest first. Dates are the npm publish
|
|
4
|
-
dates (UTC). Issue numbers refer to EchelonGraph's tracker. Since 2.3.3 each version is published
|
|
4
|
+
dates (UTC). Issue numbers (#NNNN) refer to EchelonGraph's internal issue tracker, which is not public: they are not issues of the public repository, and cannot be followed from it. Since 2.3.3 each version is published
|
|
5
5
|
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.0 — 2026-10-04
|
|
10
|
+
|
|
11
|
+
- `check_sbom` checks up to 2,000 distinct purls per call, instead of refusing more than 200: it
|
|
12
|
+
sends them as consecutive batches of at most 200, merges the answers (rows in input order with
|
|
13
|
+
`index` counted across batches; summary counts and `not_assessed_by_reason` summed; `partial`
|
|
14
|
+
true when any batch's was), and waits out a 429's `Retry-After` within 50 seconds per call.
|
|
15
|
+
When a wait would pass that, or a batch after the first fails, it answers what it has, with the
|
|
16
|
+
purls not sent counted in `coverage.not_sent`, the reason in `coverage.not_sent_reason`, and
|
|
17
|
+
the list in `data.not_sent_purls`, never dropped silently. More than 2,000 is refused. New
|
|
18
|
+
coverage fields: `distinct_purls`, `batch_size`, `batches`, `batches_sent`, `not_sent`,
|
|
19
|
+
`not_sent_reason`, `rate_limit_waits`, `waited_ms`. The `sbom_review` prompt says so (#2734).
|
|
20
|
+
- `kev_recent` reads an RFC 3339 timestamp for `since` or `until` (such as the
|
|
21
|
+
`kev_added_date` `get_cve` returns, `2024-04-12T00:00:00Z`) as its UTC date; other text is still
|
|
22
|
+
refused. The `triage_cve` prompt's step 6 says to pass the date part of `kev_added_date` (#2736).
|
|
23
|
+
- README: the hosted endpoint, `https://mcp.echelongraph.io/mcp`, is described as in service,
|
|
24
|
+
no longer as being rolled out (#2739).
|
|
25
|
+
- This changelog's preamble says the issue numbers refer to EchelonGraph's internal tracker,
|
|
26
|
+
which is not public (#2740).
|
|
27
|
+
|
|
28
|
+
## 2.5.1 — 2026-10-04
|
|
29
|
+
|
|
30
|
+
- `search_cves` sends its search text in the `X-EG-Search` request header instead of the URL
|
|
31
|
+
query string, so the text is kept out of request URLs, which the hosting platform's tracing
|
|
32
|
+
records (#1983). Severity, minimum CVSS, sort and limit stay in the query string.
|
|
33
|
+
|
|
9
34
|
## 2.5.0 — 2026-10-04
|
|
10
35
|
|
|
11
36
|
- Four prompts (`triage_cve`, `kev_weekly_brief`, `am_i_affected`, `sbom_review`) and three
|
package/README.md
CHANGED
|
@@ -111,9 +111,9 @@ The same 14 tools, four prompts and three resources are served over Streamable H
|
|
|
111
111
|
https://mcp.echelongraph.io/mcp
|
|
112
112
|
```
|
|
113
113
|
|
|
114
|
-
Keyless, no sign-in, stateless; both protocol eras. The hosted endpoint is
|
|
115
|
-
|
|
116
|
-
|
|
114
|
+
Keyless, no sign-in, stateless; both protocol eras. The hosted endpoint is in service:
|
|
115
|
+
`https://mcp.echelongraph.io/health` answers `{"status":"ok","version":…}` with the package
|
|
116
|
+
version it runs.
|
|
117
117
|
|
|
118
118
|
- **claude.ai** (Free, Pro, Max, Team and Enterprise plans):
|
|
119
119
|
Customize → Connectors → **+ Add** → **Add custom connector**. Name it `EchelonGraph`, paste
|
|
@@ -162,7 +162,7 @@ The example questions are ones a client can answer with that tool alone.
|
|
|
162
162
|
| `kev_recent` | Recent CISA KEV additions | The CVEs CISA has added to its Known Exploited Vulnerabilities catalog, newest first (`kev_added_date`), from EchelonGraph's copy of the catalog, polled from CISA every 5 minutes: due date, vendor, product, known ransomware use, EchelonGraph's severity, CVSS, EPSS and `eg_kev_tier`, and `our_first_seen_kev`. Filter by date range, ransomware and vendor; page with `limit` and `next_cursor`. Dated by `last_successful_fetch_at`, our last successful fetch of CISA's feed; the filters travel as request headers, never in the URL. | *Which CVEs has CISA added to KEV since 1 September, and which have known ransomware use?* |
|
|
163
163
|
| `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?* |
|
|
164
164
|
| `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?* |
|
|
165
|
-
| `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
|
|
165
|
+
| `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 on your machine and only they are sent, in POST bodies of at most 200 each, one after another; the document is not. 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.* |
|
|
166
166
|
| `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?* |
|
|
167
167
|
| `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?* |
|
|
168
168
|
| `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?* |
|
|
@@ -626,11 +626,11 @@ Where each tool's input travels:
|
|
|
626
626
|
| Input | Where it is sent |
|
|
627
627
|
|---|---|
|
|
628
628
|
| A CVE ID (`get_cve`, `cve_exposure`, `epss_history`, `cve_intel`, `vendor_advisories_for_cve`), a CWE ID and page (`get_cwe`), a vendor and advisory ID (`get_vendor_advisory`) | The URL path. |
|
|
629
|
-
| `search_cves`:
|
|
629
|
+
| `search_cves`: `search` | The `X-EG-Search` request header, never the URL. Its severity, minimum CVSS, sort and limit go in the query string. |
|
|
630
630
|
| `search_vendor_advisories`: `query` | The `X-EG-Advisory-Search` request header, never the URL. Its vendor, severity, CVE filter, limit and offset go in the query string. |
|
|
631
631
|
| `check_affected`: `product`, `version`, `ecosystem`, `package` | The `X-EG-Product`, `X-EG-Version`, `X-EG-Ecosystem` and `X-EG-Package` request headers, never the URL. |
|
|
632
632
|
| `kev_recent`: `since`, `until`, `ransomware`, `vendor`, `limit`, `cursor` | The `X-EG-Since`, `X-EG-Until`, `X-EG-Ransomware`, `X-EG-Vendor`, `X-EG-Limit` and `X-EG-Cursor` request headers, never the URL. |
|
|
633
|
-
| `check_sbom`: `purls`, or an `sbom` document | The purls, in
|
|
633
|
+
| `check_sbom`: `purls`, or an `sbom` document | The purls, in POST bodies of at most 200 each. A CycloneDX or SPDX document is read on your machine and only the purls in it are sent; the document is not. |
|
|
634
634
|
| `cve_summary`, `exposure_radar` | No input. |
|
|
635
635
|
|
|
636
636
|
The API marks its answers to header-carried lookups `no-store`, so no shared cache keeps them,
|
|
@@ -663,7 +663,13 @@ Because the count is shared, heavy use of one path also uses up the lower ceilin
|
|
|
663
663
|
that, 500 requests per second per caller is a burst cap on each serving instance, and
|
|
664
664
|
`check_sbom`'s batch route is charged per component: 1,200 components a minute per caller.
|
|
665
665
|
Over a limit the API answers 429 with `Retry-After`, and the tool returns a failure
|
|
666
|
-
(`state` `failed`, `error.status` 429), never empty data.
|
|
666
|
+
(`state` `failed`, `error.status` 429), never empty data. `check_sbom` is the exception: it
|
|
667
|
+
sends a long list as batches of 200, waits out a 429's `Retry-After` and sends the batch again,
|
|
668
|
+
within 50 seconds per call (MCP clients time a call out at about 60 s). From a fresh budget a
|
|
669
|
+
list of up to 1,200 distinct purls finishes in one call; a longer one (the cap is 2,000) is
|
|
670
|
+
checked in two calls a minute apart. When a wait would pass the 50 seconds, it
|
|
671
|
+
answers the batches it has as `measured` with `coverage.partial` true and the rest counted in
|
|
672
|
+
`coverage.not_sent` (`not_sent_reason` `time_budget`); only a 429 on the first batch is a failure. The API's own documentation of these
|
|
667
673
|
limits: <https://echelongraph.io/pulse/api>.
|
|
668
674
|
|
|
669
675
|
The hosted endpoint adds its own limit before the API's: 120 MCP requests a minute per client
|
package/dist/index.js
CHANGED
|
@@ -154,6 +154,17 @@ function apiMessage(body) {
|
|
|
154
154
|
}
|
|
155
155
|
return snippet(body);
|
|
156
156
|
}
|
|
157
|
+
// A Retry-After header as whole seconds from now: delta-seconds, or an HTTP-date (RFC 9110
|
|
158
|
+
// 10.2.3). undefined when absent or unreadable.
|
|
159
|
+
function retryAfterSeconds(v) {
|
|
160
|
+
const t = v?.trim();
|
|
161
|
+
if (!t)
|
|
162
|
+
return undefined;
|
|
163
|
+
if (/^\d{1,9}$/.test(t))
|
|
164
|
+
return Number(t);
|
|
165
|
+
const at = Date.parse(t);
|
|
166
|
+
return Number.isFinite(at) ? Math.max(0, Math.ceil((at - Date.now()) / 1000)) : undefined;
|
|
167
|
+
}
|
|
157
168
|
// One request against the API, a GET unless init says otherwise. Never throws: every way the call
|
|
158
169
|
// can fail comes back as a typed Failure so the tool renders it as an error result. A 2xx whose
|
|
159
170
|
// body is not a JSON object (an SPA shell, an edge challenge page, a literal null) is a failure
|
|
@@ -190,8 +201,10 @@ export async function api(path, init) {
|
|
|
190
201
|
catch (e) {
|
|
191
202
|
return timedOut ? timeout() : { ok: false, kind: "network", path, ...m, detail: `reading the body failed: ${describeError(e)}` };
|
|
192
203
|
}
|
|
193
|
-
if (!res.ok)
|
|
194
|
-
|
|
204
|
+
if (!res.ok) {
|
|
205
|
+
const retryAfter = retryAfterSeconds(res.headers.get("retry-after"));
|
|
206
|
+
return { ok: false, kind: "http", path, ...m, status: res.status, ...(retryAfter === undefined ? {} : { retryAfter }), detail: apiMessage(body) };
|
|
207
|
+
}
|
|
195
208
|
let data;
|
|
196
209
|
try {
|
|
197
210
|
data = JSON.parse(body);
|
|
@@ -220,7 +233,7 @@ export const sentences = (t) => t
|
|
|
220
233
|
// A timestamp worth relaying as one: see realInstant below.
|
|
221
234
|
export const instantOrNull = (s) => (realInstant(s) ? s : null);
|
|
222
235
|
// One sentence per failure kind: where we looked, for what, and what came back.
|
|
223
|
-
function describeFailure(f) {
|
|
236
|
+
export function describeFailure(f) {
|
|
224
237
|
const verb = f.method ?? "GET";
|
|
225
238
|
const where = `from ${SHOWN_BASE} for ${verb} ${f.path}`;
|
|
226
239
|
switch (f.kind) {
|
|
@@ -521,9 +534,10 @@ async function cveSummary() {
|
|
|
521
534
|
async function searchCVEs(a) {
|
|
522
535
|
const tool = "search_cves";
|
|
523
536
|
try {
|
|
537
|
+
// #1983: the free-text search travels percent-encoded in X-EG-Search, never in the URL. Cloud
|
|
538
|
+
// Run keeps every request URL in its trace spans for 30 days and no request header; core-backend
|
|
539
|
+
// (cve/handler.go listSearchTerm) reads the header first and decodes it with url.PathUnescape.
|
|
524
540
|
const q = new URLSearchParams();
|
|
525
|
-
if (a.search)
|
|
526
|
-
q.set("search", a.search);
|
|
527
541
|
if (a.severity)
|
|
528
542
|
q.set("severity", a.severity);
|
|
529
543
|
if (a.min_cvss !== undefined)
|
|
@@ -531,7 +545,7 @@ async function searchCVEs(a) {
|
|
|
531
545
|
if (a.sort)
|
|
532
546
|
q.set("sort", a.sort);
|
|
533
547
|
q.set("limit", String(a.limit ?? 20));
|
|
534
|
-
const r = await api(`/api/v1/public/cves?${q.toString()}
|
|
548
|
+
const r = await api(`/api/v1/public/cves?${q.toString()}`, a.search ? { headers: { "X-EG-Search": encodeURIComponent(a.search) } } : undefined);
|
|
535
549
|
if (!r.ok)
|
|
536
550
|
return failed(tool, r);
|
|
537
551
|
const head = okHead(tool, r.status);
|
|
@@ -2009,7 +2023,7 @@ export function createServer() {
|
|
|
2009
2023
|
// #2716: its logic, schemas and description live in tools/check_affected.ts.
|
|
2010
2024
|
registerCheckAffected(server, { api, succeeded, failed, badInput, crashed, checked, okHead, envelopeSchema, annotations: ANNOTATIONS });
|
|
2011
2025
|
// #2721: tools/check_sbom.ts, on this file's api(), envelope and failure contract.
|
|
2012
|
-
registerCheckSbom(server, { api, failed, badInput, crashed, checked, succeeded, okHead, envelopeSchema, annotations: ANNOTATIONS });
|
|
2026
|
+
registerCheckSbom(server, { api, failed, describeFailure, badInput, crashed, checked, succeeded, okHead, envelopeSchema, annotations: ANNOTATIONS });
|
|
2013
2027
|
registerCveIntel(server);
|
|
2014
2028
|
registerGetCwe(server);
|
|
2015
2029
|
// #2719: vendor_advisories_for_cve, get_vendor_advisory, search_vendor_advisories.
|
package/dist/prompts.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as z from "zod";
|
|
2
|
-
import { MAX_COMPONENTS, MAX_SBOM_CHARS } from "./tools/check_sbom.js";
|
|
2
|
+
import { MAX_COMPONENTS, MAX_PURLS, MAX_SBOM_CHARS } from "./tools/check_sbom.js";
|
|
3
3
|
export const KEV_BRIEF_DEFAULT_DAYS = 7;
|
|
4
4
|
export const KEV_BRIEF_MAX_DAYS = 365;
|
|
5
5
|
// The envelope rules every prompt carries, word for word.
|
|
@@ -22,7 +22,7 @@ export function triageCveText(cve) {
|
|
|
22
22
|
"3. vendor_advisories_for_cve: the vendor advisories that name it. Call get_vendor_advisory for one whose remediation you need in full.",
|
|
23
23
|
"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.",
|
|
24
24
|
"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.",
|
|
25
|
-
`6. If get_cve says kev_listed is true: kev_recent with since and until both set to its kev_added_date, to read kev_due_date from the row for ${cve}.`,
|
|
25
|
+
`6. If get_cve says kev_listed is true: kev_recent with since and until both set to the date part (YYYY-MM-DD) of its kev_added_date (for 2024-04-12T00:00:00Z, 2024-04-12), to read kev_due_date from the row for ${cve}.`,
|
|
26
26
|
ENVELOPE_RULES,
|
|
27
27
|
"Then answer with this template, one line each, naming the tool and the measured_at behind every line:",
|
|
28
28
|
"- Exploited? CISA-KEV listing and its date, known ransomware use, and public exploit code on record.",
|
|
@@ -64,7 +64,7 @@ export function amIAffectedText(a) {
|
|
|
64
64
|
export function sbomReviewText(sbom) {
|
|
65
65
|
return [
|
|
66
66
|
"Review the SBOM below for known vulnerabilities, using the EchelonGraph MCP tools.",
|
|
67
|
-
`1. Call check_sbom with sbom set to the document below, as given. It reads the document's purls and checks up to ${
|
|
67
|
+
`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, list them as not checked (never as clean), and call check_sbom again with purls set to data.not_sent_purls once, after a minute, merging what it returns.`,
|
|
68
68
|
"2. 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. If there are more than 30 distinct CVEs, do this for 30 of them and list the rest as not looked up, unranked.",
|
|
69
69
|
ENVELOPE_RULES,
|
|
70
70
|
"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).",
|
|
@@ -125,7 +125,7 @@ export function registerPrompts(server, kit) {
|
|
|
125
125
|
}, (a) => text(amIAffectedText(a)));
|
|
126
126
|
server.registerPrompt("sbom_review", {
|
|
127
127
|
title: "SBOM review",
|
|
128
|
-
description: `Review an SBOM (CycloneDX JSON or SPDX JSON text, up to ${MAX_SBOM_CHARS} characters): it is passed to check_sbom, which checks up to ${
|
|
128
|
+
description: `Review an SBOM (CycloneDX JSON or SPDX JSON text, up to ${MAX_SBOM_CHARS} characters): it is passed to check_sbom, which checks up to ${MAX_PURLS} distinct purls per call in batches of ${MAX_COMPONENTS}, then get_cve for each affected CVE, and a prioritised fix list ordered by CISA-KEV listing, then EPSS, then score, each key stated. undetermined and not_assessed components are listed as not checked, never as clean.`,
|
|
129
129
|
argsSchema: z.object({
|
|
130
130
|
sbom: z
|
|
131
131
|
.string()
|
package/dist/tools/check_sbom.js
CHANGED
|
@@ -1,9 +1,31 @@
|
|
|
1
1
|
import * as z from "zod";
|
|
2
2
|
export const CHECK_SBOM = "check_sbom";
|
|
3
3
|
export const BATCH_PATH = "/api/v1/public/cves/match/batch";
|
|
4
|
-
// The backend's cap (cveBatchMaxComponents). A
|
|
5
|
-
//
|
|
4
|
+
// The backend's cap per request (cveBatchMaxComponents): the batch size. A longer list is sent
|
|
5
|
+
// as consecutive batches of at most this many, one after another (#2734).
|
|
6
6
|
export const MAX_COMPONENTS = 200;
|
|
7
|
+
// The backend charges the batch route per component: 1,200 components a minute per caller
|
|
8
|
+
// (core-backend internal/waf/weighted.go, CVEMatchBatchComponentsPerWindow). Over it, it answers
|
|
9
|
+
// 429 with Retry-After and looks nothing up.
|
|
10
|
+
export const API_COMPONENTS_PER_MINUTE = 1200;
|
|
11
|
+
// The most distinct purls one call checks: two minutes of that budget. From a fresh budget, 2,000
|
|
12
|
+
// purls are 10 batches: six go at once, the API then asks for a wait of at most a minute, and the
|
|
13
|
+
// other four follow, and waiting out that minute would pass TIME_BUDGET_MS, so the call answers partial:
|
|
14
|
+
// the first 1,200 checked, the rest named for a second call. A list with
|
|
15
|
+
// more is refused before any request, never truncated: a dropped component would read as clean.
|
|
16
|
+
export const MAX_PURLS = 2000;
|
|
17
|
+
// How long one call may spend, waits for Retry-After included. When the next wait would pass it,
|
|
18
|
+
// the call stops and answers what it has, with the purls not sent counted in coverage.not_sent
|
|
19
|
+
// and listed in data.not_sent_purls, never dropped silently.
|
|
20
|
+
// 50 s, not more: MCP clients time a tools/call out (the TypeScript SDK's default request timeout is
|
|
21
|
+
// 60 s), and a call that outlives its client returns nothing at all — not even the partial answer
|
|
22
|
+
// below. From a fresh budget, 1,200 purls (6 batches) go at once; a larger list answers partial
|
|
23
|
+
// with the rest in data.not_sent_purls, for a call a minute later.
|
|
24
|
+
export const TIME_BUDGET_MS = 50_000;
|
|
25
|
+
// Retry-After 0 is waited this long; a 429 without Retry-After is not retried. At most MAX_WAITS
|
|
26
|
+
// waits per call.
|
|
27
|
+
const MIN_WAIT_MS = 1000;
|
|
28
|
+
const MAX_WAITS = 10;
|
|
7
29
|
// The backend's per-purl cap (cveBatchMaxPurlLen).
|
|
8
30
|
const MAX_PURL_LEN = 512;
|
|
9
31
|
// The document's size cap, as JSON text. OWASP Juice Shop 11.1.2's full CycloneDX SBOM (840
|
|
@@ -22,7 +44,7 @@ const CLEAN_NOTE = "Only not_affected is a clean verdict, and only for the advis
|
|
|
22
44
|
const MEASURED_AT_NOTE = "measured_at is null: the corpus rows are read through a cache whose maximum age the answer states (summary.corpus_cache_max_age_ms), so the answer gives no single time at which they were read.";
|
|
23
45
|
const FRESHNESS_NOTE = "freshness is null: the answer carries no time at which the advisory corpus was last refreshed.";
|
|
24
46
|
export const CHECK_SBOM_TITLE = "Check an SBOM against the advisory corpus";
|
|
25
|
-
export const CHECK_SBOM_DESCRIPTION = "Check a dependency list against EchelonGraph's advisory corpus, one verdict per component. Pass purls (package URLs, up to
|
|
47
|
+
export const CHECK_SBOM_DESCRIPTION = "Check a dependency list against EchelonGraph's advisory corpus, one verdict per component. Pass purls (package URLs, up to 2,000 distinct) or sbom (a CycloneDX JSON or SPDX JSON document, as JSON text or as an object, up to 5,000,000 characters). The purls are read from the document on this machine and only they are sent, in POST bodies of at most 200 purls each, one after another, never in a URL; the document itself is not sent. A component without a purl is counted and not checked. Each purl is mapped to its OSV ecosystem, package name and version and matched against the affected version ranges EchelonGraph holds from OSV.dev advisory records; there is no ranking and no score. data.results holds one row per component sent, in order (index counts across batches), with verdict (affected, not_affected, undetermined or not_assessed), assessed, not_assessed_reason, cve_ids, matches, count, not_affected_count and undetermined_count; data.summary counts the verdicts, summed over the batches (partial is true when any batch's was). not_affected is the only clean verdict. undetermined: advisories name the package but at least one could not be decided at this version and none matched. not_assessed: no verdict at all, because the package is not in the corpus, the purl type has no OSV ecosystem, a deb, apk or rpm purl carries no distro qualifier naming its release (EchelonGraph does not guess one), the version is missing, the lookup failed, or the batch's time budget ran out first (time_budget). Neither undetermined nor not_assessed is clean, and the note gives their counts. The API allows 1,200 components a minute per caller; when it answers 429 with Retry-After, the tool waits as asked and sends the batch again, within 50 seconds per call. When the next wait would pass that, or a batch after the first fails, the tool stops and answers what it has: coverage.not_sent counts the purls not sent and coverage.not_sent_reason says why (time_budget, rate_limited or request_failed), data.not_sent_purls lists them for a later call, and they are not checked and not clean. A list or document with more than 2,000 distinct purls is refused, not truncated: split it. Its structured result carries state (measured when at least one component got a verdict, else not_assessed), measured_at (null: the corpus is read through a cache, so no single read time exists), method, coverage (what the input held, what was sent in how many batches, and what was not sent and why), freshness (null) and notes, with data equal to the API's JSON (for more than one batch, the batches' answers merged); 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.";
|
|
26
48
|
const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
27
49
|
function dedupe(all) {
|
|
28
50
|
const seen = new Set();
|
|
@@ -146,21 +168,31 @@ const count = (o, k) => {
|
|
|
146
168
|
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
|
147
169
|
};
|
|
148
170
|
const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
|
149
|
-
function noteFor(head, x, d) {
|
|
171
|
+
function noteFor(head, x, d, g) {
|
|
150
172
|
const s = field(d, "summary");
|
|
151
173
|
const affected = count(s, "affected") ?? 0;
|
|
152
174
|
const notAffected = count(s, "not_affected") ?? 0;
|
|
153
175
|
const undetermined = count(s, "undetermined") ?? 0;
|
|
154
176
|
const notAssessed = count(s, "not_assessed") ?? 0;
|
|
155
|
-
const components = count(s, "components") ??
|
|
177
|
+
const components = count(s, "components") ?? g.sent;
|
|
156
178
|
const out = [head];
|
|
157
179
|
if (x.input === "purls") {
|
|
158
|
-
out.push(`Sent ${plural(
|
|
180
|
+
out.push(`Sent ${plural(g.sent, "purl", "purls")}${x.duplicates_removed ? ` (${plural(x.duplicates_removed, "duplicate was", "duplicates were")} sent once)` : ""}.`);
|
|
159
181
|
}
|
|
160
182
|
else {
|
|
161
183
|
const kind = x.input === "cyclonedx" ? "CycloneDX" : "SPDX";
|
|
162
184
|
const unit = x.input === "cyclonedx" ? "component" : "package";
|
|
163
|
-
out.push(`Read ${plural(x.components_in_document ?? 0, unit, `${unit}s`)} from the ${kind} document: ${x.with_purl} with a purl${x.without_purl ? `, and ${x.without_purl} without one, which ${x.without_purl === 1 ? "was" : "were"} not checked and ${x.without_purl === 1 ? "is" : "are"} not clean` : ""}${x.duplicates_removed ? `; ${plural(x.duplicates_removed, "duplicate purl was", "duplicate purls were")} sent once` : ""}; sent ${
|
|
185
|
+
out.push(`Read ${plural(x.components_in_document ?? 0, unit, `${unit}s`)} from the ${kind} document: ${x.with_purl} with a purl${x.without_purl ? `, and ${x.without_purl} without one, which ${x.without_purl === 1 ? "was" : "were"} not checked and ${x.without_purl === 1 ? "is" : "are"} not clean` : ""}${x.duplicates_removed ? `; ${plural(x.duplicates_removed, "duplicate purl was", "duplicate purls were")} sent once` : ""}; sent ${g.sent}.`);
|
|
186
|
+
}
|
|
187
|
+
if (g.batches > 1) {
|
|
188
|
+
out.push(`The ${x.purls.length} distinct purls make ${g.batches} batches of at most ${MAX_COMPONENTS}, sent one after another; the API answered ${g.batches_sent} of them, and data.summary sums those answers.`);
|
|
189
|
+
}
|
|
190
|
+
if (g.waits > 0) {
|
|
191
|
+
out.push(`The API's component budget (${API_COMPONENTS_PER_MINUTE} components a minute) ran out ${g.waits === 1 ? "once" : `${g.waits} times`}: the tool waited ${Math.round(g.waited_ms / 1000)} s in all, as its Retry-After asked, and sent the batch again.`);
|
|
192
|
+
}
|
|
193
|
+
const notSent = g.not_sent_purls.length;
|
|
194
|
+
if (notSent > 0) {
|
|
195
|
+
out.push(`${plural(notSent, "purl was", "purls were")} NOT sent (not_sent_reason ${g.not_sent_reason}: ${g.not_sent_detail}), so ${notSent === 1 ? "it is" : "they are"} not checked and not clean; data.not_sent_purls lists ${notSent === 1 ? "it" : "them"}: call check_sbom again with purls set to that list${g.not_sent_reason === "request_failed" ? "" : " after a minute"}.`);
|
|
164
196
|
}
|
|
165
197
|
out.push(`Of the ${components} components checked: ${affected} affected, ${notAffected} not affected, ${undetermined} undetermined, ${notAssessed} not assessed.`);
|
|
166
198
|
const by = field(s, "not_assessed_by_reason");
|
|
@@ -192,8 +224,76 @@ function noteFor(head, x, d) {
|
|
|
192
224
|
}
|
|
193
225
|
return { note: out.join(" "), assessed: affected + notAffected + undetermined };
|
|
194
226
|
}
|
|
227
|
+
// Summary fields summed over the batches, and bounds that hold per batch (the largest held for all).
|
|
228
|
+
const SUMMED = ["components", "affected", "not_affected", "undetermined", "not_assessed", "lookups", "elapsed_ms"];
|
|
229
|
+
const MAXED = ["time_budget_ms", "corpus_cache_max_age_ms"];
|
|
230
|
+
// The batches' answers as one (#2734): results concatenated with index counted across batches
|
|
231
|
+
// (batch b's row i becomes index b*MAX_COMPONENTS+i, its position in the list sent), summaries
|
|
232
|
+
// summed, not_assessed_by_reason summed per reason, partial true when any batch's was, answered_at
|
|
233
|
+
// the last batch's. A count some batch did not send is left out, never summed as 0.
|
|
234
|
+
export function mergeAnswers(answers) {
|
|
235
|
+
const out = { ...answers[0] };
|
|
236
|
+
const results = [];
|
|
237
|
+
answers.forEach((a, b) => {
|
|
238
|
+
const rows = field(a, "results");
|
|
239
|
+
if (!Array.isArray(rows))
|
|
240
|
+
return;
|
|
241
|
+
for (const r of rows) {
|
|
242
|
+
const i = field(r, "index");
|
|
243
|
+
results.push(isObj(r) && typeof i === "number" ? { ...r, index: i + b * MAX_COMPONENTS } : r);
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
const total = (vs) => vs.every((v) => typeof v === "number" && Number.isFinite(v)) ? vs.reduce((t, v) => t + v, 0) : undefined;
|
|
247
|
+
const comps = total(answers.map((a) => field(a, "components")));
|
|
248
|
+
if (comps === undefined)
|
|
249
|
+
delete out.components;
|
|
250
|
+
else
|
|
251
|
+
out.components = comps;
|
|
252
|
+
const ss = answers.map((a) => field(a, "summary"));
|
|
253
|
+
if (ss.every(isObj)) {
|
|
254
|
+
const sums = ss;
|
|
255
|
+
const m = { ...sums[0] };
|
|
256
|
+
for (const k of SUMMED) {
|
|
257
|
+
const t = total(sums.map((x) => x[k]));
|
|
258
|
+
if (t === undefined)
|
|
259
|
+
delete m[k];
|
|
260
|
+
else
|
|
261
|
+
m[k] = t;
|
|
262
|
+
}
|
|
263
|
+
for (const k of MAXED) {
|
|
264
|
+
const vs = sums.map((x) => x[k]).filter((v) => typeof v === "number");
|
|
265
|
+
if (vs.length)
|
|
266
|
+
m[k] = Math.max(...vs);
|
|
267
|
+
}
|
|
268
|
+
if (sums.some((x) => typeof x.partial === "boolean"))
|
|
269
|
+
m.partial = sums.some((x) => x.partial === true);
|
|
270
|
+
if (sums.some((x) => isObj(x.not_assessed_by_reason))) {
|
|
271
|
+
const by = {};
|
|
272
|
+
for (const x of sums) {
|
|
273
|
+
if (!isObj(x.not_assessed_by_reason))
|
|
274
|
+
continue;
|
|
275
|
+
for (const [r, n] of Object.entries(x.not_assessed_by_reason))
|
|
276
|
+
if (typeof n === "number")
|
|
277
|
+
by[r] = (by[r] ?? 0) + n;
|
|
278
|
+
}
|
|
279
|
+
m.not_assessed_by_reason = by;
|
|
280
|
+
}
|
|
281
|
+
out.summary = m;
|
|
282
|
+
}
|
|
283
|
+
else {
|
|
284
|
+
delete out.summary;
|
|
285
|
+
}
|
|
286
|
+
const last = answers[answers.length - 1];
|
|
287
|
+
if (field(last, "answered_at") !== undefined)
|
|
288
|
+
out.answered_at = field(last, "answered_at");
|
|
289
|
+
out.results = results;
|
|
290
|
+
return out;
|
|
291
|
+
}
|
|
292
|
+
const realSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
195
293
|
export async function checkSbom(deps, a) {
|
|
196
294
|
const tool = CHECK_SBOM;
|
|
295
|
+
const sleep = deps.sleep ?? realSleep;
|
|
296
|
+
const now = deps.now ?? Date.now;
|
|
197
297
|
try {
|
|
198
298
|
const x = extract(a);
|
|
199
299
|
if (typeof x === "string")
|
|
@@ -201,22 +301,84 @@ export async function checkSbom(deps, a) {
|
|
|
201
301
|
if (x.purls.length === 0) {
|
|
202
302
|
return deps.badInput(tool, x.input === "purls" ? "purls holds no package URL" : `the document holds ${x.components_in_document ?? 0} components and none carries a purl, so there is nothing to check`);
|
|
203
303
|
}
|
|
204
|
-
if (x.purls.length >
|
|
205
|
-
return deps.badInput(tool, `${x.purls.length} distinct purls; at most ${
|
|
304
|
+
if (x.purls.length > MAX_PURLS) {
|
|
305
|
+
return deps.badInput(tool, `${x.purls.length} distinct purls; at most ${MAX_PURLS} are checked per call (in batches of ${MAX_COMPONENTS}, within the API's budget of ${API_COMPONENTS_PER_MINUTE} components a minute), and none is dropped silently: split the list into calls of ${MAX_PURLS} or fewer`);
|
|
206
306
|
}
|
|
207
307
|
const long = x.purls.findIndex((p) => p.length > MAX_PURL_LEN);
|
|
208
308
|
if (long >= 0)
|
|
209
309
|
return deps.badInput(tool, `purl ${long + 1} is longer than ${MAX_PURL_LEN} characters`);
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
310
|
+
const batches = [];
|
|
311
|
+
for (let i = 0; i < x.purls.length; i += MAX_COMPONENTS)
|
|
312
|
+
batches.push(x.purls.slice(i, i + MAX_COMPONENTS));
|
|
313
|
+
const started = now();
|
|
314
|
+
const budgetS = TIME_BUDGET_MS / 1000;
|
|
315
|
+
const answers = [];
|
|
316
|
+
let status = 200;
|
|
317
|
+
let waits = 0;
|
|
318
|
+
let waitedMs = 0;
|
|
319
|
+
let stop = null;
|
|
320
|
+
// One batch at a time, in order; a 429 with Retry-After is waited out and the same batch sent
|
|
321
|
+
// again, while the wait fits in the call's budget.
|
|
322
|
+
while (answers.length < batches.length) {
|
|
323
|
+
if (answers.length > 0 && now() - started >= TIME_BUDGET_MS) {
|
|
324
|
+
stop = { reason: "time_budget", detail: `the call's ${budgetS} s budget ran out after ${answers.length} of ${batches.length} batches` };
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
const r = await deps.api(BATCH_PATH, {
|
|
328
|
+
method: "POST",
|
|
329
|
+
headers: { "Content-Type": "application/json" },
|
|
330
|
+
body: JSON.stringify({ components: batches[answers.length].map((purl) => ({ purl })) }),
|
|
331
|
+
});
|
|
332
|
+
if (r.ok) {
|
|
333
|
+
answers.push(r.data);
|
|
334
|
+
status = r.status;
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
if (r.kind === "http" && r.status === 429) {
|
|
338
|
+
if (r.retryAfter === undefined) {
|
|
339
|
+
stop = { reason: "rate_limited", detail: "the API answered 429 without a Retry-After to wait for" };
|
|
340
|
+
}
|
|
341
|
+
else if (waits >= MAX_WAITS) {
|
|
342
|
+
stop = { reason: "rate_limited", detail: `the API answered 429 again after ${MAX_WAITS} waits` };
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
const waitMs = Math.max(MIN_WAIT_MS, r.retryAfter * 1000);
|
|
346
|
+
if (now() - started + waitMs <= TIME_BUDGET_MS) {
|
|
347
|
+
await sleep(waitMs);
|
|
348
|
+
waits++;
|
|
349
|
+
waitedMs += waitMs;
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
stop = { reason: "time_budget", detail: `the API answered 429 asking for a wait of ${r.retryAfter} s (Retry-After), which would pass the call's ${budgetS} s budget` };
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
else {
|
|
356
|
+
stop = { reason: "request_failed", detail: deps.describeFailure(r).replace(/\.$/, "") };
|
|
357
|
+
}
|
|
358
|
+
// Nothing answered yet: nothing was measured, so the failure is the answer.
|
|
359
|
+
if (answers.length === 0)
|
|
360
|
+
return deps.failed(tool, r);
|
|
361
|
+
break;
|
|
362
|
+
}
|
|
363
|
+
const notSentPurls = x.purls.slice(answers.length * MAX_COMPONENTS);
|
|
364
|
+
const g = {
|
|
365
|
+
batches: batches.length,
|
|
366
|
+
batches_sent: answers.length,
|
|
367
|
+
sent: x.purls.length - notSentPurls.length,
|
|
368
|
+
not_sent_purls: notSentPurls,
|
|
369
|
+
not_sent_reason: notSentPurls.length ? (stop?.reason ?? null) : null,
|
|
370
|
+
not_sent_detail: notSentPurls.length ? (stop?.detail ?? null) : null,
|
|
371
|
+
waits,
|
|
372
|
+
waited_ms: waitedMs,
|
|
373
|
+
};
|
|
374
|
+
// One batch: data is the API's JSON as it came. More: the merged answer.
|
|
375
|
+
const data = batches.length === 1 ? answers[0] : mergeAnswers(answers);
|
|
376
|
+
if (notSentPurls.length)
|
|
377
|
+
data.not_sent_purls = notSentPurls;
|
|
378
|
+
const { note, assessed } = noteFor(deps.okHead(tool, status), x, data, g);
|
|
379
|
+
const s = field(data, "summary");
|
|
380
|
+
const summaryPartial = typeof field(s, "partial") === "boolean" ? field(s, "partial") : null;
|
|
381
|
+
return deps.succeeded(data, note, {
|
|
220
382
|
state: assessed > 0 ? "measured" : "not_assessed",
|
|
221
383
|
measured_at: null,
|
|
222
384
|
method: METHOD,
|
|
@@ -226,9 +388,17 @@ export async function checkSbom(deps, a) {
|
|
|
226
388
|
with_purl: x.with_purl,
|
|
227
389
|
without_purl: x.without_purl,
|
|
228
390
|
duplicates_removed: x.duplicates_removed,
|
|
229
|
-
|
|
391
|
+
distinct_purls: x.purls.length,
|
|
392
|
+
batch_size: MAX_COMPONENTS,
|
|
393
|
+
batches: g.batches,
|
|
394
|
+
batches_sent: g.batches_sent,
|
|
395
|
+
sent: g.sent,
|
|
396
|
+
not_sent: notSentPurls.length,
|
|
397
|
+
not_sent_reason: g.not_sent_reason,
|
|
398
|
+
rate_limit_waits: waits,
|
|
399
|
+
waited_ms: waitedMs,
|
|
230
400
|
not_assessed: count(s, "not_assessed") ?? null,
|
|
231
|
-
partial:
|
|
401
|
+
partial: notSentPurls.length > 0 ? true : summaryPartial,
|
|
232
402
|
},
|
|
233
403
|
freshness: null,
|
|
234
404
|
notes: [DISTRO_NOTE, CLEAN_NOTE, MEASURED_AT_NOTE, FRESHNESS_NOTE],
|
|
@@ -294,6 +464,7 @@ export function checkSbomOutput(envelopeSchema) {
|
|
|
294
464
|
.optional(),
|
|
295
465
|
answered_at: opt(z.string()),
|
|
296
466
|
results: z.array(Row).optional(),
|
|
467
|
+
not_sent_purls: opt(z.array(z.string())).describe("The distinct purls not sent (coverage.not_sent_reason says why): not checked, and not clean. Present only when some were not sent."),
|
|
297
468
|
}),
|
|
298
469
|
coverage: z.strictObject({
|
|
299
470
|
input: z.enum(["purls", "cyclonedx", "spdx"]).describe("What was passed: a purl list, a CycloneDX JSON document or an SPDX JSON document."),
|
|
@@ -301,9 +472,23 @@ export function checkSbomOutput(envelopeSchema) {
|
|
|
301
472
|
with_purl: z.number().int().describe("Of those, the ones carrying a purl."),
|
|
302
473
|
without_purl: z.number().int().describe("The ones without a purl: not checked, and not clean."),
|
|
303
474
|
duplicates_removed: z.number().int().describe("Purls that appeared more than once and were sent once."),
|
|
304
|
-
|
|
475
|
+
distinct_purls: z.number().int().describe("Distinct purls to check: sent plus not_sent."),
|
|
476
|
+
batch_size: z.number().int().describe("The most purls one request carries (the API's cap per request)."),
|
|
477
|
+
batches: z.number().int().describe("Requests the distinct purls make, at batch_size each."),
|
|
478
|
+
batches_sent: z.number().int().describe("Of those, the ones the API answered."),
|
|
479
|
+
sent: z.number().int().describe("Distinct purls sent and answered."),
|
|
480
|
+
not_sent: z.number().int().describe("Distinct purls not sent, listed in data.not_sent_purls: not checked, and not clean."),
|
|
481
|
+
not_sent_reason: z
|
|
482
|
+
.enum(["time_budget", "rate_limited", "request_failed"])
|
|
483
|
+
.nullable()
|
|
484
|
+
.describe("Why not_sent is above 0: time_budget (the call's 50 s budget ran out, or waiting out the API's Retry-After would pass it), rate_limited (a 429 without Retry-After, or a 429 after 10 waits), request_failed (a batch after the first failed; the note quotes how). null when every purl was sent."),
|
|
485
|
+
rate_limit_waits: z.number().int().describe("How many times the API answered 429 and the tool waited its Retry-After before sending the batch again."),
|
|
486
|
+
waited_ms: z.number().int().describe("Milliseconds spent in those waits."),
|
|
305
487
|
not_assessed: z.number().int().nullable().describe("Of those sent, the components with no verdict, as the answer counts them."),
|
|
306
|
-
partial: z
|
|
488
|
+
partial: z
|
|
489
|
+
.boolean()
|
|
490
|
+
.nullable()
|
|
491
|
+
.describe("true when not every purl was sent (not_sent above 0) or any batch's summary.partial was true (its time budget ran out first); otherwise the answer's summary.partial."),
|
|
307
492
|
}),
|
|
308
493
|
freshness: null,
|
|
309
494
|
});
|
|
@@ -317,7 +502,7 @@ export function registerCheckSbom(server, deps) {
|
|
|
317
502
|
title: CHECK_SBOM_TITLE,
|
|
318
503
|
description: CHECK_SBOM_DESCRIPTION,
|
|
319
504
|
inputSchema: z.object({
|
|
320
|
-
purls: z.array(z.string()).optional().describe("package URLs to check, e.g. pkg:npm/lodash@4.17.20 or pkg:deb/debian/openssl@3.0.11-1~deb12u1?distro=debian-12 (up to 200)"),
|
|
505
|
+
purls: z.array(z.string()).optional().describe("package URLs to check, e.g. pkg:npm/lodash@4.17.20 or pkg:deb/debian/openssl@3.0.11-1~deb12u1?distro=debian-12 (up to 2,000 distinct, sent in batches of 200)"),
|
|
321
506
|
sbom: z
|
|
322
507
|
.union([z.string(), z.record(z.string(), z.unknown())])
|
|
323
508
|
.optional()
|
package/dist/tools/kev_recent.js
CHANGED
|
@@ -6,7 +6,7 @@ const MAX_VENDOR = 128;
|
|
|
6
6
|
const MAX_CURSOR = 128;
|
|
7
7
|
const KEV_RECENT_METHOD = "EchelonGraph's copy of the CISA KEV catalog: CISA's feed is polled every 5 minutes and each entry is written onto its CVE's record; rows are read from those records, newest kev_added_date first.";
|
|
8
8
|
const KEV_RECENT_COVERAGE = "CISA KEV catalog";
|
|
9
|
-
const KEV_RECENT_DESCRIPTION = "The CVEs CISA has added to its Known Exploited Vulnerabilities (KEV) catalog, newest first, from EchelonGraph's copy of that catalog, which polls CISA's feed every 5 minutes. Each row in kev gives cve_id, kev_added_date (CISA's dateAdded), kev_due_date, kev_vendor, kev_product, kev_vuln_name and kev_ransomware (known ransomware-campaign use), EchelonGraph's severity, cvss_v3_score, epss_score, epss_percentile and eg_kev_tier for the CVE, and our_first_seen_kev, when EchelonGraph's poller first recorded the CVE entering the catalog (null where no such record exists). Filter by since and until (YYYY-MM-DD, inclusive, on kev_added_date), ransomware, and vendor (an exact, case-insensitive match on kev_vendor); page with limit (1 to 200, default 50) and cursor, passing back the previous page's next_cursor with the same filters. Rows within one date are ordered by cve_id. total counts the CVEs matching the filters; kev_listed_total counts every CVE EchelonGraph holds as KEV-listed, and catalog.catalog_count is CISA's own count in the catalog last fetched, so a gap between the two is entries not yet in EchelonGraph's CVE table, which no page returns. CISA's requiredAction and shortDescription are not returned. Its structured result carries state (measured), measured_at (our last successful fetch of CISA's feed, null when the API does not give it), method, coverage (the CISA KEV catalog: total, returned, kev_listed_total, catalog_count, limit, has_more), freshness (last_successful_fetch_at, catalog_version, date_released) 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.";
|
|
9
|
+
const KEV_RECENT_DESCRIPTION = "The CVEs CISA has added to its Known Exploited Vulnerabilities (KEV) catalog, newest first, from EchelonGraph's copy of that catalog, which polls CISA's feed every 5 minutes. Each row in kev gives cve_id, kev_added_date (CISA's dateAdded), kev_due_date, kev_vendor, kev_product, kev_vuln_name and kev_ransomware (known ransomware-campaign use), EchelonGraph's severity, cvss_v3_score, epss_score, epss_percentile and eg_kev_tier for the CVE, and our_first_seen_kev, when EchelonGraph's poller first recorded the CVE entering the catalog (null where no such record exists). Filter by since and until (YYYY-MM-DD, inclusive, on kev_added_date; an RFC 3339 timestamp, such as the kev_added_date 2024-04-12T00:00:00Z that CVE records carry, is read as its UTC date, and anything else is refused), ransomware, and vendor (an exact, case-insensitive match on kev_vendor); page with limit (1 to 200, default 50) and cursor, passing back the previous page's next_cursor with the same filters. Rows within one date are ordered by cve_id. total counts the CVEs matching the filters; kev_listed_total counts every CVE EchelonGraph holds as KEV-listed, and catalog.catalog_count is CISA's own count in the catalog last fetched, so a gap between the two is entries not yet in EchelonGraph's CVE table, which no page returns. CISA's requiredAction and shortDescription are not returned. Its structured result carries state (measured), measured_at (our last successful fetch of CISA's feed, null when the API does not give it), method, coverage (the CISA KEV catalog: total, returned, kev_listed_total, catalog_count, limit, has_more), freshness (last_successful_fetch_at, catalog_version, date_released) 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.";
|
|
10
10
|
const opt = (t) => t.nullable().optional();
|
|
11
11
|
const KEVRow = z.looseObject({
|
|
12
12
|
cve_id: opt(z.string()),
|
|
@@ -61,22 +61,35 @@ const Freshness = z.strictObject({
|
|
|
61
61
|
// Inputs this side refuses before any request, with the reason.
|
|
62
62
|
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
63
63
|
const isDate = (s) => DATE.test(s) && new Date(`${s}T00:00:00Z`).toISOString().slice(0, 10) === s;
|
|
64
|
+
// An RFC 3339 date-time (#2736): get_cve gives kev_added_date as one ("2024-04-12T00:00:00Z"), and a
|
|
65
|
+
// model passes it on as given. It is read as its UTC date; the time of day is dropped.
|
|
66
|
+
const RFC3339 = /^(\d{4}-\d{2}-\d{2})T([01]\d|2[0-3]):[0-5]\d:[0-5]\d(\.\d{1,9})?(Z|[+-]([01]\d|2[0-3]):[0-5]\d)$/;
|
|
67
|
+
// since or until as the YYYY-MM-DD the API takes, or null when it is neither form.
|
|
68
|
+
export function kevDate(raw) {
|
|
69
|
+
if (isDate(raw))
|
|
70
|
+
return raw;
|
|
71
|
+
const m = RFC3339.exec(raw.toUpperCase());
|
|
72
|
+
if (!m || !isDate(m[1]))
|
|
73
|
+
return null;
|
|
74
|
+
const t = Date.parse(raw.toUpperCase());
|
|
75
|
+
return Number.isFinite(t) ? new Date(t).toISOString().slice(0, 10) : null;
|
|
76
|
+
}
|
|
64
77
|
// A header value must be visible ASCII: fetch refuses others, and that refusal would read as an outage.
|
|
65
78
|
const PRINTABLE = /^[\x20-\x7e]+$/;
|
|
66
79
|
function headersFor(a) {
|
|
67
80
|
const h = {};
|
|
68
|
-
const
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
81
|
+
const sinceRaw = a.since?.trim();
|
|
82
|
+
const untilRaw = a.until?.trim();
|
|
83
|
+
const since = sinceRaw ? kevDate(sinceRaw) : undefined;
|
|
84
|
+
const until = untilRaw ? kevDate(untilRaw) : undefined;
|
|
85
|
+
if (since === null)
|
|
86
|
+
return { why: "since must be a date, YYYY-MM-DD, or an RFC 3339 timestamp such as 2024-04-12T00:00:00Z" };
|
|
87
|
+
if (until === null)
|
|
88
|
+
return { why: "until must be a date, YYYY-MM-DD, or an RFC 3339 timestamp such as 2024-04-12T00:00:00Z" };
|
|
89
|
+
if (since)
|
|
73
90
|
h["X-EG-Since"] = since;
|
|
74
|
-
|
|
75
|
-
if (until) {
|
|
76
|
-
if (!isDate(until))
|
|
77
|
-
return { why: "until must be a date, YYYY-MM-DD" };
|
|
91
|
+
if (until)
|
|
78
92
|
h["X-EG-Until"] = until;
|
|
79
|
-
}
|
|
80
93
|
if (since && until && since > until)
|
|
81
94
|
return { why: "since must not be after until" };
|
|
82
95
|
if (a.ransomware !== undefined)
|
|
@@ -177,8 +190,8 @@ export function registerKevRecent(server, d) {
|
|
|
177
190
|
title: "Recent CISA KEV additions",
|
|
178
191
|
description: KEV_RECENT_DESCRIPTION,
|
|
179
192
|
inputSchema: z.object({
|
|
180
|
-
since: z.string().optional().describe("earliest kev_added_date to include, YYYY-MM-DD"),
|
|
181
|
-
until: z.string().optional().describe("latest kev_added_date to include, YYYY-MM-DD"),
|
|
193
|
+
since: z.string().optional().describe("earliest kev_added_date to include, YYYY-MM-DD (an RFC 3339 timestamp is read as its UTC date)"),
|
|
194
|
+
until: z.string().optional().describe("latest kev_added_date to include, YYYY-MM-DD (an RFC 3339 timestamp is read as its UTC date)"),
|
|
182
195
|
ransomware: z.boolean().optional().describe("true: only CVEs with known ransomware-campaign use; false: only those without"),
|
|
183
196
|
vendor: z.string().optional().describe("CISA vendorProject, an exact case-insensitive match, e.g. 'Microsoft'"),
|
|
184
197
|
limit: z.number().int().min(1).max(KEV_RECENT_MAX_LIMIT).optional().describe("page size (default 50, max 200)"),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "echelongraph-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.0",
|
|
4
4
|
"mcpName": "io.echelongraph/echelongraph-mcp",
|
|
5
5
|
"description": "EchelonGraph CVE and internet-exposure data as an MCP server: CVEs with EchelonGraph multi-source scores, CISA-KEV and EPSS, plus a per-CVE internet-exposure footprint derived from Shodan data. Shodan data is owned by Shodan, which holds its copyright (© Shodan). Free and keyless, for Claude, Cursor and any MCP client.",
|
|
6
6
|
"type": "module",
|
package/server.json
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
]
|
|
18
18
|
}
|
|
19
19
|
],
|
|
20
|
-
"version": "2.
|
|
20
|
+
"version": "2.6.0",
|
|
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.
|
|
31
|
+
"version": "2.6.0",
|
|
32
32
|
"transport": {
|
|
33
33
|
"type": "stdio"
|
|
34
34
|
},
|