echelongraph-mcp 2.6.2 → 2.6.5
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 +254 -0
- package/README.md +169 -35
- package/dist/http.js +111 -33
- package/dist/httpPolicy.js +160 -4
- package/dist/index.js +240 -66
- package/dist/prompts.js +60 -10
- package/dist/resources.js +62 -11
- package/dist/runtime.js +26 -4
- package/dist/textBudget.js +254 -0
- package/dist/tools/check_affected.js +64 -2
- package/dist/tools/check_sbom.js +105 -9
- package/dist/tools/cve_intel.js +2 -1
- package/dist/tools/epss_history.js +2 -1
- package/dist/tools/get_cwe.js +11 -2
- package/dist/tools/kev_recent.js +26 -2
- package/dist/tools/vendor_advisories.js +206 -17
- package/package.json +2 -2
- package/server.json +2 -2
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
// The JSON is relayed verbatim as data (#1874): every match keeps kev_listed, ransomware,
|
|
28
28
|
// epss_score, effective_score, effective_severity and score_assessed as the API sent them.
|
|
29
29
|
import * as z from "zod";
|
|
30
|
+
import { GET_CVE_WHOLE, TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
|
|
30
31
|
const TOOL = "check_affected";
|
|
31
32
|
export const MATCH_PATH = "/api/v1/public/cves/match";
|
|
32
33
|
// handler.go, the four request headers the endpoint reads before the query string.
|
|
@@ -125,7 +126,10 @@ const COVERAGE = z.strictObject({
|
|
|
125
126
|
// Constant strings, so marketing-site lib/mcpToolClaims.test.ts can fold them should it read
|
|
126
127
|
// this file.
|
|
127
128
|
export const CHECK_AFFECTED_TITLE = "Am I affected? (product or package at a version)";
|
|
128
|
-
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. Read assessed before count: 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 must never be reported as 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."
|
|
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. Read assessed before count: 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 must never be reported as 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
|
+
" " +
|
|
131
|
+
TEXT_BUDGET_DESCRIPTION +
|
|
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.";
|
|
129
133
|
export const CHECK_AFFECTED_INPUT = z.object({
|
|
130
134
|
product: z.string().optional().describe("CPE path: the NVD CPE product token, such as openssl, nginx or linux_kernel. Leave out for the registry path."),
|
|
131
135
|
ecosystem: z.string().optional().describe("Registry path: the package's ecosystem, such as npm, PyPI or Maven. Give with package, not with product."),
|
|
@@ -164,6 +168,64 @@ function matchesNote(matches) {
|
|
|
164
168
|
}
|
|
165
169
|
return out;
|
|
166
170
|
}
|
|
171
|
+
// #2783: a production match is about 2,100 characters as pretty JSON, most of it the CVE's
|
|
172
|
+
// description and match_reason, so openssl 3.0.0 (71 matches) was 156,058 characters of text and
|
|
173
|
+
// linux_kernel 5.10.0 (200, the API's cap) 364,408. Past DATA_TEXT_BUDGET each match in the first
|
|
174
|
+
// text block keeps its scores, flags, vendor attribution and match_reason (for a registry match,
|
|
175
|
+
// the advisory interval its version falls inside, which names the fixed version) with the
|
|
176
|
+
// description cut to 200 characters; then the same without the description; then only the fields
|
|
177
|
+
// the description promises; then, for a list at the cap, without cpe_vendor and vendor_unknown;
|
|
178
|
+
// and last without effective_severity, the band of effective_score. kev_listed, ransomware,
|
|
179
|
+
// epss_score, effective_score and score_assessed stay in every level (#2799): the am_i_affected
|
|
180
|
+
// prompt asks for each match's ransomware and epss_score, and through 2.6.3 the last level left
|
|
181
|
+
// them out of every match of a list at the cap (hosted linux_kernel 5.10.0, 200 matches, 1 with
|
|
182
|
+
// known ransomware use, named nowhere in the text). 200 matches with effective_severity beside
|
|
183
|
+
// them are 33,240 characters, past the budget alone, so the band goes first. The lists beside the
|
|
184
|
+
// matches, the excluded sample (up to 50 CPE candidates the vendor or platform gate suppressed,
|
|
185
|
+
// each with a sentence of detail, 13,861 characters for flash_player 10.0.0) and the undetermined
|
|
186
|
+
// sample (up to 50), keep their first 10 entries, each its cve_id and reason, once the first level
|
|
187
|
+
// does not fit with them whole, before any match field that level keeps goes (50 of them beside
|
|
188
|
+
// 200 matches left the last level 120 characters past the budget for flash_player 10.0.0, and
|
|
189
|
+
// 1,071 for a registry answer at the cap with 50 undetermined); and the registry path's cve_ids,
|
|
190
|
+
// which is each match's cve_id in the order of matches (core-backend cve/handler.go), keeps its
|
|
191
|
+
// first 10, so that every match stays in the text, 200 at the cap: this tool has no page to read
|
|
192
|
+
// more from. excluded_count and undetermined_count count every entry. structuredContent.data keeps
|
|
193
|
+
// every list whole.
|
|
194
|
+
const CHECK_AFFECTED_TEXT = {
|
|
195
|
+
rows: "matches",
|
|
196
|
+
levels: [
|
|
197
|
+
{
|
|
198
|
+
keep: [
|
|
199
|
+
"cve_id", "severity", "cvss_v3_score", "description", "kev_listed", "ransomware", "epss_score", "echelongraph_score", "echelongraph_severity", "effective_score", "effective_severity",
|
|
200
|
+
"score_assessed", "score_unassessed_reason", "matched_criteria", "cpe_vendor", "vendor_unknown", "match_confidence", "match_reason", "version_evidence",
|
|
201
|
+
],
|
|
202
|
+
clip: 200,
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
keep: [
|
|
206
|
+
"cve_id", "severity", "cvss_v3_score", "kev_listed", "ransomware", "epss_score", "echelongraph_score", "effective_score", "effective_severity", "score_assessed", "matched_criteria",
|
|
207
|
+
"cpe_vendor", "vendor_unknown", "match_reason",
|
|
208
|
+
],
|
|
209
|
+
clip: 200,
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
keep: [
|
|
213
|
+
"cve_id", "severity", "cvss_v3_score", "kev_listed", "ransomware", "epss_score", "echelongraph_score", "effective_score", "effective_severity", "score_assessed", "matched_criteria",
|
|
214
|
+
"cpe_vendor", "vendor_unknown",
|
|
215
|
+
],
|
|
216
|
+
clip: 100,
|
|
217
|
+
},
|
|
218
|
+
{ keep: ["cve_id", "kev_listed", "ransomware", "epss_score", "effective_score", "effective_severity", "score_assessed", "cpe_vendor", "vendor_unknown"], clip: 100 },
|
|
219
|
+
{ keep: ["cve_id", "kev_listed", "ransomware", "epss_score", "effective_score", "effective_severity", "score_assessed"], clip: 100 },
|
|
220
|
+
{ keep: ["cve_id", "kev_listed", "ransomware", "epss_score", "effective_score", "score_assessed"], clip: 100 },
|
|
221
|
+
],
|
|
222
|
+
sides: [
|
|
223
|
+
{ list: "cve_ids", cap: 10, said: () => "cve_ids lists each match's cve_id, in the order of matches." },
|
|
224
|
+
{ list: "excluded", keep: ["cve_id", "reason"], clip: 100, cap: 10 },
|
|
225
|
+
{ list: "undetermined", keep: ["cve_id", "reason"], clip: 100, cap: 10 },
|
|
226
|
+
],
|
|
227
|
+
whole: `${GET_CVE_WHOLE}, and cve_intel a CVE's affected packages and fixed versions.`,
|
|
228
|
+
};
|
|
167
229
|
async function checkAffected(kit, a) {
|
|
168
230
|
const product = (a.product ?? "").trim();
|
|
169
231
|
const ecosystem = (a.ecosystem ?? "").trim();
|
|
@@ -251,7 +313,7 @@ async function checkAffected(kit, a) {
|
|
|
251
313
|
},
|
|
252
314
|
freshness: null,
|
|
253
315
|
notes: [NO_TIME, NO_FRESHNESS],
|
|
254
|
-
});
|
|
316
|
+
}, CHECK_AFFECTED_TEXT);
|
|
255
317
|
}
|
|
256
318
|
catch (e) {
|
|
257
319
|
return kit.crashed(TOOL, e);
|
package/dist/tools/check_sbom.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as z from "zod";
|
|
2
|
+
import { TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
|
|
2
3
|
export const CHECK_SBOM = "check_sbom";
|
|
3
4
|
export const BATCH_PATH = "/api/v1/public/cves/match/batch";
|
|
4
5
|
// The backend's cap per request (cveBatchMaxComponents): the batch size. A longer list is sent
|
|
@@ -21,6 +22,10 @@ export const MAX_PURLS = 2000;
|
|
|
21
22
|
// 60 s), and a call that outlives its client returns nothing at all — not even the partial answer
|
|
22
23
|
// below. From a fresh budget, 1,200 purls (6 batches) go at once; a larger list answers partial
|
|
23
24
|
// with the rest in data.not_sent_purls, for a call a minute later.
|
|
25
|
+
// The budget bounds the whole call, not only when a batch may start (#2756): each batch's request
|
|
26
|
+
// may take at most what is left of it (api()'s init.timeoutMs), so a batch started at 49.9 s is cut
|
|
27
|
+
// off at 50 s and its purls answered as not sent (time_budget), instead of running on for the
|
|
28
|
+
// request timeout (15 s) past the client's and the hosted endpoint's 60 s.
|
|
24
29
|
export const TIME_BUDGET_MS = 50_000;
|
|
25
30
|
// Retry-After 0 is waited this long; a 429 without Retry-After is not retried. At most MAX_WAITS
|
|
26
31
|
// waits per call.
|
|
@@ -37,6 +42,12 @@ const NOTE_AFFECTED_MAX = 25;
|
|
|
37
42
|
const NOTE_CVES_MAX = 5;
|
|
38
43
|
// CycloneDX nests components; this bounds the walk.
|
|
39
44
|
const MAX_DEPTH = 32;
|
|
45
|
+
// The order in which extract() reads and counts the distinct purls (#2799 review): purls as listed;
|
|
46
|
+
// a CycloneDX document's components depth first, each one's nested components right after it
|
|
47
|
+
// (fromCycloneDX); an SPDX document's packages as listed; each purl trimmed and kept at its first
|
|
48
|
+
// place (dedupe). The note says it beside the position from which the purls not sent run, so a
|
|
49
|
+
// second call can rebuild them from its own input whether or not the text cuts not_sent_purls.
|
|
50
|
+
const READ_ORDER = "counted in the order this tool read them (the order of purls, or of the document's components or packages, each component's nested components right after it and before its next sibling, each purl at its first place)";
|
|
40
51
|
const METHOD = "EchelonGraph's registry advisory matcher (POST /api/v1/public/cves/match/batch): each component's 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, the same matcher GET /api/v1/public/cves/match uses when given an ecosystem. No CPE matching, no score, no ranking.";
|
|
41
52
|
// Always in the envelope's notes, whatever the answer: the refusals a model must not read as clean.
|
|
42
53
|
const DISTRO_NOTE = "A deb, apk or rpm purl without a distro qualifier naming its release (for example ?distro=debian-12 or ?distro=alpine-3.20) is not assessed (distro_release_unknown): the advisory corpus is keyed per distro release, and EchelonGraph does not guess one.";
|
|
@@ -44,7 +55,51 @@ const CLEAN_NOTE = "Only not_affected is a clean verdict, and only for the advis
|
|
|
44
55
|
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.";
|
|
45
56
|
const FRESHNESS_NOTE = "freshness is null: the answer carries no time at which the advisory corpus was last refreshed.";
|
|
46
57
|
export const CHECK_SBOM_TITLE = "Check an SBOM against the advisory corpus";
|
|
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 by this MCP server and only they are sent to the API, in POST bodies of at most 200 purls each, one after another, never in a URL; the document itself is not sent on. Run from npm, this server is on your machine; over the hosted endpoint (mcp.echelongraph.io) it is EchelonGraph's, and the document is the request body, accepted up to 6 MiB. 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."
|
|
58
|
+
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 by this MCP server and only they are sent to the API, in POST bodies of at most 200 purls each, one after another, never in a URL; the document itself is not sent on. Run from npm, this server is on your machine; over the hosted endpoint (mcp.echelongraph.io) it is EchelonGraph's, and the document is the request body, accepted up to 6 MiB. 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." +
|
|
59
|
+
" " +
|
|
60
|
+
TEXT_BUDGET_DESCRIPTION +
|
|
61
|
+
" Cut, each row keeps index, purl, verdict, not_assessed_reason and cve_ids at least, and the rows whose verdict is not_affected, then not_assessed, are left out of the text before any other; not_sent_purls keeps its first 10, and the note says from which position of the input the purls not sent run.";
|
|
62
|
+
// #2783: a production row is about 2,200 characters as pretty JSON, most of it its matches, each
|
|
63
|
+
// a CVE with its description, so the 50-component Juice Shop document was 112,820 characters of
|
|
64
|
+
// text, and 2,000 purls about 1,200,000. Past DATA_TEXT_BUDGET each row in the first text block
|
|
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, which names the fixed version) without the
|
|
67
|
+
// description; then the same without match_reason; then only index, purl, verdict,
|
|
68
|
+
// not_assessed_reason and cve_ids. When rows must still go, the clean ones (not_affected) leave
|
|
69
|
+
// first, then the not_assessed ones, both counted in data.summary and the note, so the affected
|
|
70
|
+
// and undetermined rows are the last to leave the text.
|
|
71
|
+
// A call that stops early (a 2,000-purl call from a fresh budget answers 1,200 and is refused the
|
|
72
|
+
// rest with a 429) lists the purls not sent in data.not_sent_purls: 800 of them are some 20,000
|
|
73
|
+
// to 60,000 characters, which the text cannot hold beside the rows. Once the first level does not
|
|
74
|
+
// fit with that list whole, the text keeps its first 10 and the note says which they are: the
|
|
75
|
+
// input's distinct purls from position `sent` + 1 on, in input order, which is how check_sbom read
|
|
76
|
+
// them (x.purls.slice(answers.length * MAX_COMPONENTS)), so a second call can send them without
|
|
77
|
+
// reading the list. noteFor gives that order (READ_ORDER) beside the position, so this sentence
|
|
78
|
+
// points to it.
|
|
79
|
+
export function sbomText(sent) {
|
|
80
|
+
const matches = ["cve_id", "severity", "effective_severity", "effective_score", "kev_listed", "ransomware", "epss_score", "score_assessed"];
|
|
81
|
+
const row = ["index", "purl", "ecosystem", "package", "version", "verdict", "assessed", "not_assessed_reason", "count", "not_affected_count", "undetermined_count", "cve_ids"];
|
|
82
|
+
return {
|
|
83
|
+
rows: "results",
|
|
84
|
+
levels: [
|
|
85
|
+
{ keep: [...row, ["matches", [...matches, "match_reason"]]], clip: 200 },
|
|
86
|
+
{ keep: [...row, ["matches", matches]], clip: 200 },
|
|
87
|
+
{ keep: ["index", "purl", "verdict", "not_assessed_reason", "cve_ids"], clip: 100 },
|
|
88
|
+
],
|
|
89
|
+
sides: [
|
|
90
|
+
{
|
|
91
|
+
list: "not_sent_purls",
|
|
92
|
+
cap: 10,
|
|
93
|
+
said: (_inText, total) => `The ${total} purls not sent are the input's distinct purls from position ${sent + 1} on, counted in the order the note gives for them, so a second call can send them without reading the list.`,
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
leaveOutFirst: { field: "verdict", values: ["not_affected", "not_assessed"], why: "data.summary counts every verdict" },
|
|
97
|
+
// check_affected cuts its text too past the budget (#2802): only its structuredContent.data is
|
|
98
|
+
// a component's matches whole.
|
|
99
|
+
whole: "check_affected, given a row's ecosystem, package and version, returns that component's matches, whole in its structuredContent.data, and cve_intel a CVE's affected packages and fixed versions.",
|
|
100
|
+
page: (shown) => `To read every row in the text, check ${shown} or fewer purls per call.`,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
48
103
|
const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
49
104
|
function dedupe(all) {
|
|
50
105
|
const seen = new Set();
|
|
@@ -190,9 +245,17 @@ function noteFor(head, x, d, g) {
|
|
|
190
245
|
if (g.waits > 0) {
|
|
191
246
|
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
247
|
}
|
|
248
|
+
// #2799: the position, not only the list. Past DATA_TEXT_BUDGET the first text block keeps the
|
|
249
|
+
// first 10 of data.not_sent_purls (sbomText), so a model that reads the text alone cannot send
|
|
250
|
+
// "that list"; the purls not sent are always the input's distinct purls from position sent + 1 on
|
|
251
|
+
// (x.purls.slice(answers.length * MAX_COMPONENTS)), which it can rebuild from its own input, given
|
|
252
|
+
// the order they are counted in (READ_ORDER), which this sentence says whenever it gives the
|
|
253
|
+
// position, not only when the text cuts the list.
|
|
193
254
|
const notSent = g.not_sent_purls.length;
|
|
194
255
|
if (notSent > 0) {
|
|
195
|
-
|
|
256
|
+
const one = notSent === 1;
|
|
257
|
+
const where = one ? `it is the input's distinct purl at position ${g.sent + 1}` : `they are the input's distinct purls from position ${g.sent + 1} on`;
|
|
258
|
+
out.push(`${plural(notSent, "purl was", "purls were")} NOT sent (not_sent_reason ${g.not_sent_reason}: ${g.not_sent_detail}), so ${one ? "it is" : "they are"} not checked and not clean; ${where}, ${READ_ORDER}, and data.not_sent_purls lists ${one ? "it" : "them"}: call check_sbom again with purls set to ${one ? "it" : "them"}${g.not_sent_reason === "request_failed" ? "" : " after a minute"}.`);
|
|
196
259
|
}
|
|
197
260
|
out.push(`Of the ${components} components checked: ${affected} affected, ${notAffected} not affected, ${undetermined} undetermined, ${notAssessed} not assessed.`);
|
|
198
261
|
const by = field(s, "not_assessed_by_reason");
|
|
@@ -289,7 +352,17 @@ export function mergeAnswers(answers) {
|
|
|
289
352
|
out.results = results;
|
|
290
353
|
return out;
|
|
291
354
|
}
|
|
292
|
-
const realSleep = (ms) => new Promise((r) =>
|
|
355
|
+
const realSleep = (ms, signal) => new Promise((r) => {
|
|
356
|
+
if (signal?.aborted)
|
|
357
|
+
return r();
|
|
358
|
+
const done = () => {
|
|
359
|
+
clearTimeout(timer);
|
|
360
|
+
signal?.removeEventListener("abort", done);
|
|
361
|
+
r();
|
|
362
|
+
};
|
|
363
|
+
const timer = setTimeout(done, ms);
|
|
364
|
+
signal?.addEventListener("abort", done, { once: true });
|
|
365
|
+
});
|
|
293
366
|
export async function checkSbom(deps, a) {
|
|
294
367
|
const tool = CHECK_SBOM;
|
|
295
368
|
const sleep = deps.sleep ?? realSleep;
|
|
@@ -317,23 +390,38 @@ export async function checkSbom(deps, a) {
|
|
|
317
390
|
let waits = 0;
|
|
318
391
|
let waitedMs = 0;
|
|
319
392
|
let stop = null;
|
|
393
|
+
// The 429 last waited out, for the one way the budget can be gone before anything answered.
|
|
394
|
+
let waitedFor;
|
|
320
395
|
// One batch at a time, in order; a 429 with Retry-After is waited out and the same batch sent
|
|
321
396
|
// again, while the wait fits in the call's budget.
|
|
322
397
|
while (answers.length < batches.length) {
|
|
323
|
-
|
|
398
|
+
const left = TIME_BUDGET_MS - (now() - started);
|
|
399
|
+
if (left <= 0 && answers.length === 0 && waitedFor) {
|
|
400
|
+
// A wait that ended at the budget's end: no time is left to send the first batch again, and
|
|
401
|
+
// nothing was measured, so that 429 is the answer, not a request given no time at all.
|
|
402
|
+
return deps.failed(tool, waitedFor);
|
|
403
|
+
}
|
|
404
|
+
if (answers.length > 0 && left <= 0) {
|
|
324
405
|
stop = { reason: "time_budget", detail: `the call's ${budgetS} s budget ran out after ${answers.length} of ${batches.length} batches` };
|
|
325
406
|
break;
|
|
326
407
|
}
|
|
408
|
+
// At most what is left of the budget (#2756): a slow batch is cut off at its end.
|
|
327
409
|
const r = await deps.api(BATCH_PATH, {
|
|
328
410
|
method: "POST",
|
|
329
411
|
headers: { "Content-Type": "application/json" },
|
|
330
412
|
body: JSON.stringify({ components: batches[answers.length].map((purl) => ({ purl })) }),
|
|
413
|
+
timeoutMs: Math.max(0, left),
|
|
414
|
+
signal: deps.signal,
|
|
331
415
|
});
|
|
332
416
|
if (r.ok) {
|
|
333
417
|
answers.push(r.data);
|
|
334
418
|
status = r.status;
|
|
335
419
|
continue;
|
|
336
420
|
}
|
|
421
|
+
// Cancelled (#2775): this batch was cut off, or never sent after a wait the signal ended.
|
|
422
|
+
// Nothing more is sent, and the answer goes nowhere.
|
|
423
|
+
if (deps.signal?.aborted)
|
|
424
|
+
return deps.failed(tool, r);
|
|
337
425
|
if (r.kind === "http" && r.status === 429) {
|
|
338
426
|
if (r.retryAfter === undefined) {
|
|
339
427
|
stop = { reason: "rate_limited", detail: "the API answered 429 without a Retry-After to wait for" };
|
|
@@ -344,14 +432,19 @@ export async function checkSbom(deps, a) {
|
|
|
344
432
|
else {
|
|
345
433
|
const waitMs = Math.max(MIN_WAIT_MS, r.retryAfter * 1000);
|
|
346
434
|
if (now() - started + waitMs <= TIME_BUDGET_MS) {
|
|
347
|
-
await sleep(waitMs);
|
|
435
|
+
await sleep(waitMs, deps.signal);
|
|
348
436
|
waits++;
|
|
349
437
|
waitedMs += waitMs;
|
|
438
|
+
waitedFor = r;
|
|
350
439
|
continue;
|
|
351
440
|
}
|
|
352
441
|
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
442
|
}
|
|
354
443
|
}
|
|
444
|
+
else if (r.kind === "timeout" && now() - started >= TIME_BUDGET_MS) {
|
|
445
|
+
// Cut off by the budget, not by the API failing: the purls are not sent for want of time.
|
|
446
|
+
stop = { reason: "time_budget", detail: `the call's ${budgetS} s budget ran out while batch ${answers.length + 1} of ${batches.length} was unanswered, so it was cut off` };
|
|
447
|
+
}
|
|
355
448
|
else {
|
|
356
449
|
stop = { reason: "request_failed", detail: deps.describeFailure(r).replace(/\.$/, "") };
|
|
357
450
|
}
|
|
@@ -402,7 +495,7 @@ export async function checkSbom(deps, a) {
|
|
|
402
495
|
},
|
|
403
496
|
freshness: null,
|
|
404
497
|
notes: [DISTRO_NOTE, CLEAN_NOTE, MEASURED_AT_NOTE, FRESHNESS_NOTE],
|
|
405
|
-
});
|
|
498
|
+
}, sbomText(g.sent));
|
|
406
499
|
}
|
|
407
500
|
catch (e) {
|
|
408
501
|
return deps.crashed(tool, e);
|
|
@@ -481,7 +574,7 @@ export function checkSbomOutput(envelopeSchema) {
|
|
|
481
574
|
not_sent_reason: z
|
|
482
575
|
.enum(["time_budget", "rate_limited", "request_failed"])
|
|
483
576
|
.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."),
|
|
577
|
+
.describe("Why not_sent is above 0: time_budget (the call's 50 s budget ran out, before a batch or while one was unanswered, which is then cut off; 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
578
|
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
579
|
waited_ms: z.number().int().describe("Milliseconds spent in those waits."),
|
|
487
580
|
not_assessed: z.number().int().nullable().describe("Of those sent, the components with no verdict, as the answer counts them."),
|
|
@@ -506,9 +599,12 @@ export function registerCheckSbom(server, deps) {
|
|
|
506
599
|
sbom: z
|
|
507
600
|
.union([z.string(), z.record(z.string(), z.unknown())])
|
|
508
601
|
.optional()
|
|
509
|
-
.describe("a CycloneDX JSON or SPDX JSON document, as JSON text or as an object; its purls are read
|
|
602
|
+
.describe("a CycloneDX JSON or SPDX JSON document, as JSON text or as an object; its purls are read by this MCP server and only they are sent to the API; over the hosted endpoint (mcp.echelongraph.io) the document is the request body"),
|
|
510
603
|
}),
|
|
511
604
|
outputSchema: output,
|
|
512
605
|
annotations: deps.annotations,
|
|
513
|
-
}, async (a) =>
|
|
606
|
+
}, async (a, ctx) => {
|
|
607
|
+
const run = checkSbom({ ...deps, signal: ctx.mcpReq.signal }, a);
|
|
608
|
+
return deps.checked(CHECK_SBOM, output, await (deps.hold ? deps.hold(run) : run));
|
|
609
|
+
});
|
|
514
610
|
}
|
package/dist/tools/cve_intel.js
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
// model holds, and says coverage depends on which pollers run.
|
|
27
27
|
import * as z from "zod";
|
|
28
28
|
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
|
+
import { TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
|
|
29
30
|
const TOOL = "cve_intel";
|
|
30
31
|
// The sections relayed, in the answer's order, and the keys each failed_sections name covers.
|
|
31
32
|
const LISTS = ["cwes", "exploits", "affected_packages", "fixed_versions", "timeline"];
|
|
@@ -59,7 +60,7 @@ const API_ROW_LIMIT = { exploits: 10, affected_packages: 50, fixed_versions: 200
|
|
|
59
60
|
const TIMELINE_RELAYED = 20;
|
|
60
61
|
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.";
|
|
61
62
|
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.";
|
|
62
|
-
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_versions (ecosystem, package_name, vulnerable_range, fixed_version) and timeline (the newest enrichment-history rows, with timeline_total). ${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
|
|
63
|
+
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_versions (ecosystem, package_name, vulnerable_range, fixed_version) and timeline (the newest enrichment-history rows, with timeline_total). ${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}`;
|
|
63
64
|
// ── outputSchema ──
|
|
64
65
|
const Rows = (shape) => z.array(z.looseObject(shape));
|
|
65
66
|
const CountMap = z.record(z.string(), z.number());
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import * as z from "zod";
|
|
2
|
+
import { TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
|
|
2
3
|
const TOOL = "epss_history";
|
|
3
4
|
const CVE_ID = /^CVE-\d{4}-\d{4,}$/i;
|
|
4
5
|
export const EPSS_HISTORY_METHOD = "EchelonGraph's record of FIRST's EPSS scores: the nightly EPSS fetcher overwrites each CVE's epss_score and epss_percentile from FIRST's published file and, for every CVE whose value changed, records the new values with the time it wrote them; this series is those records, oldest first, with repeated records of one change collapsed into the first.";
|
|
5
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.";
|
|
6
7
|
const BEFORE_FIRST = "The value in force before a CVE's first point is not in the series.";
|
|
7
8
|
const NO_FRESHNESS = "freshness is null: the answer does not carry the time of the EPSS fetcher's last completed run.";
|
|
8
|
-
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. Never interpolate it into 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
|
|
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. Never interpolate it into 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}`;
|
|
9
10
|
// The API's JSON. points, series_kind and current are required: an answer without them is not
|
|
10
11
|
// this series, and a series_kind other than change_only would make every note above wrong, so
|
|
11
12
|
// either is a failure (checked), never relayed.
|
package/dist/tools/get_cwe.js
CHANGED
|
@@ -13,11 +13,20 @@
|
|
|
13
13
|
// the note says it can lag the feed.
|
|
14
14
|
import * as z from "zod";
|
|
15
15
|
import { ANNOTATIONS, api, badInput, checked, crashed, envelopeSchema, failed, field, lenAt, numAt, okHead, opt, rowsScoreNote, strAt, succeeded, } from "../index.js";
|
|
16
|
+
import { GET_CVE_WHOLE, TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
|
|
16
17
|
const TOOL = "get_cwe";
|
|
17
18
|
const CWE_ARG = /^(?:CWE-)?0*(\d{1,6})$/i;
|
|
18
19
|
const MAX_PAGE = 200;
|
|
19
20
|
const GET_CWE_METHOD = "EchelonGraph's per-CWE listing (GET /api/v1/public/cwes/:cwe_id): the CWE's name and description from the MITRE CWE catalog EchelonGraph embeds (catalog_version), and the active CVEs in EchelonGraph's CVE feed that an NVD, GitHub (GHSA) or CVE.org record classifies under it, rejected and reserved records left out, 50 to a page.";
|
|
20
|
-
export const GET_CWE_DESCRIPTION = `One CWE (weakness class) and the CVEs classified under it. Returns cwe_id, name and description (from the MITRE CWE catalog EchelonGraph embeds, version catalog_version), total (the active CVEs in EchelonGraph's feed that an NVD, GitHub or CVE.org record classifies under it, rejected and reserved records left out), and cves, one page of 50: each with cve_id, severity, cvss_v3_score, echelongraph_score, echelongraph_severity, score_assessed, kev_listed, published and a shortened description. order says how the rows are sorted (CISA-KEV-listed first, then EchelonGraph score); an answer without order does not state its order, and the note says so. page is the page served, page_size its size and max_page the last page the API serves: a later page is answered as max_page, so past max_page pages total counts CVEs no page lists. echelongraph_score is EchelonGraph's score only when score_assessed is true; the note labels each row that is NOT YET SCORED. A total of 0 says that no CVE in EchelonGraph's feed is classified under that CWE, not that none exists. Pass cwe_id like CWE-79 (or 79) and an optional page (1-${MAX_PAGE}). Its structured result carries state (measured), measured_at (null), method, coverage (total, returned, page, page_requested, page_size, max_page), 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
|
|
21
|
+
export const GET_CWE_DESCRIPTION = `One CWE (weakness class) and the CVEs classified under it. Returns cwe_id, name and description (from the MITRE CWE catalog EchelonGraph embeds, version catalog_version), total (the active CVEs in EchelonGraph's feed that an NVD, GitHub or CVE.org record classifies under it, rejected and reserved records left out), and cves, one page of 50: each with cve_id, severity, cvss_v3_score, echelongraph_score, echelongraph_severity, score_assessed, kev_listed, published and a shortened description. order says how the rows are sorted (CISA-KEV-listed first, then EchelonGraph score); an answer without order does not state its order, and the note says so. page is the page served, page_size its size and max_page the last page the API serves: a later page is answered as max_page, so past max_page pages total counts CVEs no page lists. echelongraph_score is EchelonGraph's score only when score_assessed is true; the note labels each row that is NOT YET SCORED. A total of 0 says that no CVE in EchelonGraph's feed is classified under that CWE, not that none exists. Pass cwe_id like CWE-79 (or 79) and an optional page (1-${MAX_PAGE}). Its structured result carries state (measured), measured_at (null), method, coverage (total, returned, page, page_requested, page_size, max_page), 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}`;
|
|
22
|
+
// #2783: a page of 50 production rows is about 27,000 characters as pretty JSON (CWE-79, page 1),
|
|
23
|
+
// under the budget once each row is on a line of its own. Past it, each row keeps the fields the
|
|
24
|
+
// description names with its description cut to 120 characters, then rows are left out.
|
|
25
|
+
const GET_CWE_TEXT = {
|
|
26
|
+
rows: "cves",
|
|
27
|
+
levels: [{ keep: ["cve_id", "severity", "cvss_v3_score", "echelongraph_score", "echelongraph_severity", "score_assessed", "score_unassessed_reason", "kev_listed", "published", "description"], clip: 120 }],
|
|
28
|
+
whole: `${GET_CVE_WHOLE}.`,
|
|
29
|
+
};
|
|
21
30
|
function schemas() {
|
|
22
31
|
const row = z.looseObject({
|
|
23
32
|
cve_id: opt(z.string()),
|
|
@@ -127,7 +136,7 @@ export async function getCwe(cwe_id, page) {
|
|
|
127
136
|
"measured_at is null: a page of CVEs has no single observation time; each row carries its own published date.",
|
|
128
137
|
"freshness is null: the answer does not say when its cached page was computed.",
|
|
129
138
|
],
|
|
130
|
-
});
|
|
139
|
+
}, GET_CWE_TEXT);
|
|
131
140
|
}
|
|
132
141
|
catch (e) {
|
|
133
142
|
return crashed(TOOL, e);
|
package/dist/tools/kev_recent.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as z from "zod";
|
|
2
|
+
import { GET_CVE_WHOLE, TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
|
|
2
3
|
const TOOL = "kev_recent";
|
|
3
4
|
const PATH = "/api/v1/public/kev/recent";
|
|
4
5
|
export const KEV_RECENT_MAX_LIMIT = 200;
|
|
@@ -6,7 +7,10 @@ const MAX_VENDOR = 128;
|
|
|
6
7
|
const MAX_CURSOR = 128;
|
|
7
8
|
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
9
|
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; an RFC 3339 timestamp, such as the kev_added_date 2024-04-12T00:00:00Z that CVE records carry, is read as the date written in it, with the time and offset dropped, 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
|
+
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 the date written in it, with the time and offset dropped, 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." +
|
|
11
|
+
" " +
|
|
12
|
+
TEXT_BUDGET_DESCRIPTION +
|
|
13
|
+
" Cut, each row keeps fewer fields, cve_id, kev_added_date, kev_vendor and kev_ransomware at least, or rows are left out, and next_cursor still continues after the last row of the page, not of the text.";
|
|
10
14
|
const opt = (t) => t.nullable().optional();
|
|
11
15
|
const KEVRow = z.looseObject({
|
|
12
16
|
cve_id: opt(z.string()),
|
|
@@ -121,6 +125,26 @@ const str = (o, ...k) => {
|
|
|
121
125
|
return typeof v === "string" && v ? v : null;
|
|
122
126
|
};
|
|
123
127
|
const realInstant = (s) => s !== null && Date.parse(s) > 0;
|
|
128
|
+
// #2783: a production row is about 520 characters as pretty JSON, so a page of 200 was 105,925
|
|
129
|
+
// characters of text. Past DATA_TEXT_BUDGET each row in the first text block keeps CISA's fields
|
|
130
|
+
// and EchelonGraph's severity, cvss_v3_score and epss_score; then, if the page is still too long,
|
|
131
|
+
// only cve_id, kev_added_date, kev_vendor, kev_product and kev_ransomware, names cut to 40
|
|
132
|
+
// characters; then without kev_product, which fits a page of 200.
|
|
133
|
+
// next_cursor continues after the page's last row, so the note says how to read any row the text
|
|
134
|
+
// leaves out without skipping it.
|
|
135
|
+
function kevText(a) {
|
|
136
|
+
return {
|
|
137
|
+
rows: "kev",
|
|
138
|
+
levels: [
|
|
139
|
+
{ keep: ["cve_id", "kev_added_date", "kev_due_date", "kev_vendor", "kev_product", "kev_vuln_name", "kev_ransomware", "severity", "cvss_v3_score", "epss_score"], clip: 200 },
|
|
140
|
+
{ keep: ["cve_id", "kev_added_date", "kev_vendor", "kev_product", "kev_ransomware"], clip: 40 },
|
|
141
|
+
{ keep: ["cve_id", "kev_added_date", "kev_vendor", "kev_ransomware"], clip: 40 },
|
|
142
|
+
],
|
|
143
|
+
// A record carries the CISA fields a row leaves out (kev_due_date, kev_product, kev_vuln_name).
|
|
144
|
+
whole: `${GET_CVE_WHOLE}.`,
|
|
145
|
+
page: (shown) => `next_cursor continues after the last row of this page, not after the last row in this text: to read the rows left out in the text, call kev_recent again with the same filters${a.cursor?.trim() ? " and cursor" : ""} and a limit of ${shown}, whose rows are the ones in this text, then with that page's next_cursor and a limit of ${shown} or fewer.`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
124
148
|
async function kevRecent(d, a) {
|
|
125
149
|
try {
|
|
126
150
|
const h = headersFor(a);
|
|
@@ -178,7 +202,7 @@ async function kevRecent(d, a) {
|
|
|
178
202
|
? "measured_at is EchelonGraph's last successful fetch of CISA's feed, the time this copy of the catalog was last confirmed."
|
|
179
203
|
: "measured_at is null: the answer does not give EchelonGraph's last successful fetch of CISA's feed.",
|
|
180
204
|
],
|
|
181
|
-
});
|
|
205
|
+
}, kevText(a));
|
|
182
206
|
}
|
|
183
207
|
catch (e) {
|
|
184
208
|
return d.crashed(TOOL, e);
|