echelongraph-mcp 2.6.3 → 2.6.6
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 +299 -2
- package/README.md +174 -40
- package/dist/http.js +183 -39
- package/dist/httpPolicy.js +160 -4
- package/dist/index.js +150 -46
- package/dist/prompts.js +72 -11
- package/dist/resources.js +61 -12
- package/dist/runtime.js +26 -4
- package/dist/textBudget.js +11 -4
- package/dist/tools/check_affected.js +26 -16
- package/dist/tools/check_sbom.js +45 -9
- package/dist/tools/cve_intel.js +69 -2
- package/dist/tools/get_cwe.js +2 -2
- package/dist/tools/kev_recent.js +3 -2
- package/dist/tools/vendor_advisories.js +54 -17
- package/package.json +2 -2
- package/server.json +2 -2
package/dist/resources.js
CHANGED
|
@@ -5,16 +5,19 @@
|
|
|
5
5
|
// docs). No API request.
|
|
6
6
|
// echelongraph://sources the feeds and what the API itself reports about their polling,
|
|
7
7
|
// read from the API at request time and never hard-coded: the CVE
|
|
8
|
-
// summary's poller block (/api/v1/public/cves/summary
|
|
8
|
+
// summary's poller block (/api/v1/public/cves/summary; its interval,
|
|
9
|
+
// last poll and two counters, the answering instance's) and the KEV
|
|
9
10
|
// answer's catalog block and method (/api/v1/public/kev/recent).
|
|
10
11
|
// What no endpoint reports is named as not reported.
|
|
11
12
|
// cve://{cve_id} get_cve's structured result for one CVE, as JSON: the envelope
|
|
12
|
-
// (state, measured_at, method, notes) with data, the API's record
|
|
13
|
+
// (state, measured_at, method, notes) with data, the API's record,
|
|
14
|
+
// cut past 30,000 characters as get_cve's first text block is.
|
|
13
15
|
//
|
|
14
16
|
// A resource read either answers or is a JSON-RPC error; it never answers a failure as data that
|
|
15
17
|
// reads like a record. sources is the exception by design: each feed it reads carries its own
|
|
16
18
|
// state, and a feed that could not be read says so (state failed), never an empty block.
|
|
17
19
|
import { ProtocolError, ProtocolErrorCode, ResourceNotFoundError, ResourceTemplate } from "@modelcontextprotocol/server";
|
|
20
|
+
import { DATA_TEXT_BUDGET, dataText } from "./textBudget.js";
|
|
18
21
|
export const METHODOLOGY_URI = "echelongraph://methodology";
|
|
19
22
|
export const SOURCES_URI = "echelongraph://sources";
|
|
20
23
|
export const CVE_TEMPLATE = "cve://{cve_id}";
|
|
@@ -22,7 +25,7 @@ export const METHODOLOGY = `# How EchelonGraph MCP results are measured
|
|
|
22
25
|
|
|
23
26
|
Every tool result carries an evidence envelope in its structuredContent, and repeats it in its last text block (less what an earlier text block already carries verbatim), so a client that passes text blocks alone still sees it.
|
|
24
27
|
|
|
25
|
-
A success's first text block is the API's JSON, cut to fit 30,000 characters when it is longer: the note then says what the cut leaves out and how to read it (TEXT CUT), and structuredContent's data holds the answer whole.
|
|
28
|
+
A success's first text block is the API's JSON, cut to fit 30,000 characters when it is longer: the note then says what the cut leaves out and how to read it (TEXT CUT), and structuredContent's data holds the answer whole. The resource ${CVE_TEMPLATE} cuts its data the same way, and its notes say so (DATA CUT).
|
|
26
29
|
|
|
27
30
|
## Envelope fields
|
|
28
31
|
|
|
@@ -34,7 +37,7 @@ A success's first text block is the API's JSON, cut to fit 30,000 characters whe
|
|
|
34
37
|
| coverage | What the answer covers, where the tool can say: for example in_scope for cve_exposure, the list's account of its own count for search_cves, assessed and not_assessed_reason first for check_affected. |
|
|
35
38
|
| freshness | The producing radar's or fetcher's last completed check, where the API serves one; null where it serves none. |
|
|
36
39
|
| notes | The caveats, one sentence each. |
|
|
37
|
-
| data | On a success: the API's JSON (exposure_radar and cve_intel relay a labelled selection). |
|
|
40
|
+
| data | On a success: the API's JSON (exposure_radar and cve_intel relay a labelled selection, and cve_summary leaves out a poller field of another JSON type, named in the note). |
|
|
38
41
|
| error | On a failure: kind, path, status and message. |
|
|
39
42
|
|
|
40
43
|
## state
|
|
@@ -70,9 +73,17 @@ const KEV_PATH = "/api/v1/public/kev/recent";
|
|
|
70
73
|
const obj = (v) => (v !== null && typeof v === "object" && !Array.isArray(v) ? v : undefined);
|
|
71
74
|
const pick = (o, keys) => Object.fromEntries(keys.filter((k) => o !== undefined && o[k] !== undefined).map((k) => [k, o[k]]));
|
|
72
75
|
const failure = (r) => ({ state: "failed", error: { kind: r.kind, path: r.path, status: r.status ?? null, message: r.detail } });
|
|
73
|
-
//
|
|
74
|
-
|
|
76
|
+
// A JSON value's type in words, for a value that is not the JSON object read here.
|
|
77
|
+
const jsonKind = (v) => Array.isArray(v) ? "an array" : typeof v === "number" ? "a number" : typeof v === "boolean" ? "a boolean" : typeof v === "string" ? "a string" : typeof v;
|
|
78
|
+
// What /api/v1/public/cves/summary reports about the NVD poller: its interval and last poll, and
|
|
79
|
+
// its poll and failed-poll counts, of the server instance that answered (core-backend
|
|
80
|
+
// cve/poller.go Stats, in memory).
|
|
75
81
|
const POLLER_FIELDS = ["interval", "last_poll_at", "poll_count", "poll_errors"];
|
|
82
|
+
// #2770: poll_count and poll_errors are that instance's counters since it last started, zeroed on
|
|
83
|
+
// every restart (cve_summary's summaryPollerNote, #2647, says the same of the whole block). 2.6.3
|
|
84
|
+
// relayed them here with a note on interval and last_poll_at alone, so poll_errors 0 over
|
|
85
|
+
// poll_count 7 read as the feed's track record. Whenever reported carries either, notes says so.
|
|
86
|
+
const SOURCES_POLLER_COUNTERS_NOTE = "poll_count and poll_errors, where reported carries them, are that one instance's counters since it last started, zeroed on every restart: they count that instance's polls and failed polls, never the feed's reliability.";
|
|
76
87
|
const CATALOG_FIELDS = ["source", "feed_url", "last_successful_fetch_at", "catalog_version", "date_released", "catalog_count"];
|
|
77
88
|
export async function readSources(kit) {
|
|
78
89
|
const [summary, kev] = await Promise.all([kit.api(SUMMARY_PATH), kit.api(KEV_PATH, { headers: { "X-EG-Limit": "1" } })]);
|
|
@@ -80,16 +91,29 @@ export async function readSources(kit) {
|
|
|
80
91
|
if (!summary.ok)
|
|
81
92
|
Object.assign(nvd, failure(summary));
|
|
82
93
|
else {
|
|
83
|
-
const
|
|
84
|
-
|
|
94
|
+
const sent = obj(summary.data)?.poller;
|
|
95
|
+
const poller = obj(sent);
|
|
96
|
+
if (poller === undefined && sent !== undefined && sent !== null) {
|
|
97
|
+
// #2770's review: a poller of another JSON type (cve_summary leaves it out and names it,
|
|
98
|
+
// #2771) is one the answer carries, so it is never described as missing.
|
|
99
|
+
Object.assign(nvd, {
|
|
100
|
+
state: "not_reported",
|
|
101
|
+
reported: null,
|
|
102
|
+
notes: [`The answer carries poller as ${jsonKind(sent)}, where a JSON object was expected, so it reports neither a poll interval nor a last poll time.`],
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
else if (poller === undefined) {
|
|
85
106
|
Object.assign(nvd, { state: "not_reported", reported: null, notes: ["The answer carries no poller block, so it reports neither a poll interval nor a last poll time."] });
|
|
86
107
|
}
|
|
87
108
|
else {
|
|
109
|
+
const reported = pick(poller, POLLER_FIELDS);
|
|
110
|
+
const counted = reported.poll_count !== undefined || reported.poll_errors !== undefined;
|
|
88
111
|
Object.assign(nvd, {
|
|
89
112
|
state: "reported",
|
|
90
|
-
reported
|
|
113
|
+
reported,
|
|
91
114
|
notes: [
|
|
92
115
|
"poller is the NVD poller of the server instance that answered, held in its memory: interval is its configured interval (each cycle adds a start-up jitter), and last_poll_at is that instance's last poll, not a fleet-wide time.",
|
|
116
|
+
...(counted ? [SOURCES_POLLER_COUNTERS_NOTE] : []),
|
|
93
117
|
],
|
|
94
118
|
});
|
|
95
119
|
}
|
|
@@ -121,6 +145,31 @@ export async function readSources(kit) {
|
|
|
121
145
|
};
|
|
122
146
|
}
|
|
123
147
|
const json = (uri, v) => ({ contents: [{ uri, mimeType: "application/json", text: JSON.stringify(v, null, 2) }] });
|
|
148
|
+
// cve://{cve_id}'s text (#2801). Through 2.6.3 it was get_cve's structuredContent pretty-printed
|
|
149
|
+
// whole: 86,559 characters for CVE-2021-44228 on the hosted endpoint (2026-10-04), past #2467's
|
|
150
|
+
// ceiling of 60,000 (test/text-bound.mjs), its notes ending with get_cve's TEXT CUT sentences, which
|
|
151
|
+
// describe a first text block the resource does not have ("Every list in it keeps at most its first
|
|
152
|
+
// 64 entries: cpe_match (396)…" beside all 396 of them). A resource is one text, with no
|
|
153
|
+
// structuredContent beside it, so its data is now cut as get_cve's first text block is (dataText:
|
|
154
|
+
// the same answer, cut the same way, laid out as that block lays it out) after the envelope,
|
|
155
|
+
// pretty-printed as before; its notes leave out get_cve's sentences about its first text block and,
|
|
156
|
+
// when data is cut, end with the resource's own, starting DATA CUT, which names get_cve's
|
|
157
|
+
// structuredContent.data as the record whole. A record that fits is the structured result
|
|
158
|
+
// pretty-printed, character for character as before.
|
|
159
|
+
const CVE_RESOURCE_CUT = {
|
|
160
|
+
opens: (chars) => `DATA CUT: the API's answer is ${chars} characters of JSON, more than the ${DATA_TEXT_BUDGET} this resource's data holds, so data here is cut, and get_cve's structuredContent.data carries the answer whole.`,
|
|
161
|
+
};
|
|
162
|
+
export function cveResourceText(structured, sentences) {
|
|
163
|
+
const { data, ...envelope } = structured;
|
|
164
|
+
if (data === null || typeof data !== "object" || Array.isArray(data))
|
|
165
|
+
return JSON.stringify(structured, null, 2);
|
|
166
|
+
const firstBlock = new Set(sentences(dataText(data).said));
|
|
167
|
+
const own = dataText(data, undefined, CVE_RESOURCE_CUT);
|
|
168
|
+
const notes = Array.isArray(envelope.notes) ? [...envelope.notes.filter((n) => !firstBlock.has(n)), ...sentences(own.said)] : envelope.notes;
|
|
169
|
+
const head = JSON.stringify({ ...envelope, ...(notes === undefined ? {} : { notes }) }, null, 2);
|
|
170
|
+
const body = own.text.replace(/\n/g, "\n ");
|
|
171
|
+
return head === "{}" ? `{\n "data": ${body}\n}` : `${head.slice(0, -2)},\n "data": ${body}\n}`;
|
|
172
|
+
}
|
|
124
173
|
export function registerResources(server, kit) {
|
|
125
174
|
server.registerResource("methodology", METHODOLOGY_URI, {
|
|
126
175
|
title: "How results are measured",
|
|
@@ -129,12 +178,12 @@ export function registerResources(server, kit) {
|
|
|
129
178
|
}, async (uri) => ({ contents: [{ uri: uri.href, mimeType: "text/markdown", text: METHODOLOGY }] }));
|
|
130
179
|
server.registerResource("sources", SOURCES_URI, {
|
|
131
180
|
title: "Feed sources and polling",
|
|
132
|
-
description: "The feeds and what the API reports about their polling, read from the API when the resource is read: the NVD poller's interval
|
|
181
|
+
description: "The feeds and what the API reports about their polling, read from the API when the resource is read: the NVD poller's interval, last poll, poll_count and poll_errors, all of the one instance that answered (the two counts since that instance last started, zeroed on every restart, never the feed's reliability), and the CISA KEV catalog's last successful fetch and method. What no endpoint reports is named as not reported.",
|
|
133
182
|
mimeType: "application/json",
|
|
134
183
|
}, async (uri) => json(uri.href, await readSources(kit)));
|
|
135
184
|
server.registerResource("cve", new ResourceTemplate(CVE_TEMPLATE, { list: undefined }), {
|
|
136
185
|
title: "CVE record",
|
|
137
|
-
description: "One CVE's record as get_cve returns it, as JSON: the evidence envelope (state, measured_at, method, notes) with data, the API's record. cve_id is a CVE ID such as CVE-2024-3400.",
|
|
186
|
+
description: "One CVE's record as get_cve returns it, as JSON: the evidence envelope (state, measured_at, method, notes) with data, the API's record. Past 30,000 characters of JSON, data is cut as get_cve's first text block is, and a note starting DATA CUT says what the cut leaves out; get_cve's structured result holds the record whole. cve_id is a CVE ID such as CVE-2024-3400.",
|
|
138
187
|
mimeType: "application/json",
|
|
139
188
|
}, async (uri, vars) => {
|
|
140
189
|
const raw = Array.isArray(vars.cve_id) ? vars.cve_id[0] : vars.cve_id;
|
|
@@ -150,6 +199,6 @@ export function registerResources(server, kit) {
|
|
|
150
199
|
throw new ResourceNotFoundError(uri.href);
|
|
151
200
|
throw new ProtocolError(ProtocolErrorCode.InternalError, msg, { uri: uri.href, state: r.structuredContent.state ?? null, error: err ?? null });
|
|
152
201
|
}
|
|
153
|
-
return
|
|
202
|
+
return { contents: [{ uri: uri.href, mimeType: "application/json", text: cveResourceText(r.structuredContent, kit.sentences) }] };
|
|
154
203
|
});
|
|
155
204
|
}
|
package/dist/runtime.js
CHANGED
|
@@ -18,12 +18,34 @@ export function markHttpEntrypoint() {
|
|
|
18
18
|
export function isHttpEntrypoint() {
|
|
19
19
|
return httpEntrypoint;
|
|
20
20
|
}
|
|
21
|
-
// The client a tool call is being answered for. http.ts runs each request inside
|
|
22
|
-
// withClient(), and the SDK dispatches the tool handler inside that same async context, so
|
|
23
|
-
// api() can read it without the tool module passing it through every function.
|
|
24
21
|
const clientStore = new AsyncLocalStorage();
|
|
25
22
|
export function withClient(clientIp, fn, opts = {}) {
|
|
26
|
-
return clientStore.run({ clientIp, synthetic: opts.synthetic === true }, fn);
|
|
23
|
+
return clientStore.run({ clientIp, synthetic: opts.synthetic === true, signal: opts.signal, work: opts.work }, fn);
|
|
24
|
+
}
|
|
25
|
+
// #2775: a hosted client can go before its answer is ready (its own timeout, a closed tab, a
|
|
26
|
+
// retry). Nobody will read that answer, so the request's API calls are cancelled (index.ts api()
|
|
27
|
+
// reads this signal) and check_sbom sends no further batch: they would spend the client's API
|
|
28
|
+
// budget and hold the instance's memory for nothing. On stdio there is none: the SDK's own
|
|
29
|
+
// cancellation (notifications/cancelled) reaches check_sbom through its handler's signal.
|
|
30
|
+
export function requestSignal() {
|
|
31
|
+
return httpEntrypoint ? clientStore.getStore()?.signal : undefined;
|
|
32
|
+
}
|
|
33
|
+
/** Registers `p` as the hosted request's work, so its admission and large-body slot are held until p settles; on stdio, p untouched. */
|
|
34
|
+
export function holdRequest(p) {
|
|
35
|
+
const work = httpEntrypoint ? clientStore.getStore()?.work : undefined;
|
|
36
|
+
if (!work)
|
|
37
|
+
return p;
|
|
38
|
+
work.add(p);
|
|
39
|
+
const drop = () => {
|
|
40
|
+
work.delete(p);
|
|
41
|
+
};
|
|
42
|
+
p.then(drop, drop);
|
|
43
|
+
return p;
|
|
44
|
+
}
|
|
45
|
+
/** Resolves once everything held for a request (holdRequest) has settled, including what is held while it waits. */
|
|
46
|
+
export async function requestSettled(work) {
|
|
47
|
+
while (work.size > 0)
|
|
48
|
+
await Promise.allSettled([...work]);
|
|
27
49
|
}
|
|
28
50
|
// #2737: the production MCP synthetic probes this endpoint too, and its calls must reach the API
|
|
29
51
|
// under its own family, never as hosted MCP adoption. The synthetic names itself to this service
|
package/dist/textBudget.js
CHANGED
|
@@ -34,9 +34,13 @@
|
|
|
34
34
|
// 4. else every list keeps its first n entries, n halving, and then strings are cut too.
|
|
35
35
|
// Whenever the text is not the JSON whole, the note ends with one sentence, starting TEXT CUT,
|
|
36
36
|
// that says what the first text block leaves out, that structuredContent.data carries all of it,
|
|
37
|
-
// and how to read the rest in the text: the tool that returns one row
|
|
38
|
-
// offset at which a page fits. The cut is deterministic: the same answer is cut the same way.
|
|
37
|
+
// and how to read the rest in the text: the tool that returns one row, and what of it, and the page
|
|
38
|
+
// size or offset at which a page fits. The cut is deterministic: the same answer is cut the same way.
|
|
39
39
|
export const DATA_TEXT_BUDGET = 30_000;
|
|
40
|
+
// What a note may say of reading one CVE's record through get_cve (#2802): get_cve's own first text
|
|
41
|
+
// block is cut too past the budget (each list to its first entries, every other field kept), so
|
|
42
|
+
// only its structuredContent.data is the record whole. A row tool's `whole` sentence starts with it.
|
|
43
|
+
export const GET_CVE_WHOLE = "get_cve returns any one of these CVEs' records, whole in its structuredContent.data, and in its first text block with every field (past 30,000 characters, each list cut to its first entries)";
|
|
40
44
|
// What every tool description says about it, one constant string (marketing-site
|
|
41
45
|
// lib/mcpToolClaims.test.ts folds only constant strings); the suite holds its number to
|
|
42
46
|
// DATA_TEXT_BUDGET's. It uses none of the words that check reviews as the page's own prose (how,
|
|
@@ -158,8 +162,11 @@ function cutSides(data, sides) {
|
|
|
158
162
|
}
|
|
159
163
|
return { fields, said };
|
|
160
164
|
}
|
|
165
|
+
export const FIRST_TEXT_BLOCK = {
|
|
166
|
+
opens: (chars) => `TEXT CUT: the API's answer is ${chars} characters of JSON, more than the ${DATA_TEXT_BUDGET} the first text block holds, so that block is cut, and structuredContent.data carries the answer whole.`,
|
|
167
|
+
};
|
|
161
168
|
/** The first text block for `data` within DATA_TEXT_BUDGET, and the TEXT CUT sentence when it is cut. */
|
|
162
|
-
export function dataText(data, cut) {
|
|
169
|
+
export function dataText(data, cut, place = FIRST_TEXT_BLOCK) {
|
|
163
170
|
const pretty = JSON.stringify(data, null, 2);
|
|
164
171
|
if (pretty.length <= DATA_TEXT_BUDGET)
|
|
165
172
|
return { text: pretty, said: "" };
|
|
@@ -167,7 +174,7 @@ export function dataText(data, cut) {
|
|
|
167
174
|
const all = layout(data);
|
|
168
175
|
if (all.length <= DATA_TEXT_BUDGET)
|
|
169
176
|
return { text: all, said: "" };
|
|
170
|
-
const head =
|
|
177
|
+
const head = place.opens(pretty.length);
|
|
171
178
|
const say = (...xs) => [head, ...xs].filter((x) => Boolean(x)).join(" ");
|
|
172
179
|
let base = data;
|
|
173
180
|
let rowsSaid = "";
|
|
@@ -27,7 +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 { TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
|
|
30
|
+
import { GET_CVE_WHOLE, TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
|
|
31
31
|
const TOOL = "check_affected";
|
|
32
32
|
export const MATCH_PATH = "/api/v1/public/cves/match";
|
|
33
33
|
// handler.go, the four request headers the endpoint reads before the query string.
|
|
@@ -129,7 +129,7 @@ export const CHECK_AFFECTED_TITLE = "Am I affected? (product or package at a ver
|
|
|
129
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
130
|
" " +
|
|
131
131
|
TEXT_BUDGET_DESCRIPTION +
|
|
132
|
-
" Cut,
|
|
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.";
|
|
133
133
|
export const CHECK_AFFECTED_INPUT = z.object({
|
|
134
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."),
|
|
135
135
|
ecosystem: z.string().optional().describe("Registry path: the package's ecosystem, such as npm, PyPI or Maven. Give with package, not with product."),
|
|
@@ -172,17 +172,26 @@ function matchesNote(matches) {
|
|
|
172
172
|
// description and match_reason, so openssl 3.0.0 (71 matches) was 156,058 characters of text and
|
|
173
173
|
// linux_kernel 5.10.0 (200, the API's cap) 364,408. Past DATA_TEXT_BUDGET each match in the first
|
|
174
174
|
// text block keeps its scores, flags, vendor attribution and match_reason (for a registry match,
|
|
175
|
-
// the advisory interval its version falls inside,
|
|
175
|
+
// the advisory interval its version falls inside: "[A, B)" names the fixed version B, "[A, B]" the
|
|
176
|
+
// last affected one, #2830) with the
|
|
176
177
|
// description cut to 200 characters; then the same without the description; then only the fields
|
|
177
|
-
// the description promises;
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
178
|
+
// the description promises; then, for a list at the cap, without cpe_vendor and vendor_unknown;
|
|
179
|
+
// and last without effective_severity, the band of effective_score. kev_listed, ransomware,
|
|
180
|
+
// epss_score, effective_score and score_assessed stay in every level (#2799): the am_i_affected
|
|
181
|
+
// prompt asks for each match's ransomware and epss_score, and through 2.6.3 the last level left
|
|
182
|
+
// them out of every match of a list at the cap (hosted linux_kernel 5.10.0, 200 matches, 1 with
|
|
183
|
+
// known ransomware use, named nowhere in the text). 200 matches with effective_severity beside
|
|
184
|
+
// them are 33,240 characters, past the budget alone, so the band goes first. The lists beside the
|
|
185
|
+
// matches, the excluded sample (up to 50 CPE candidates the vendor or platform gate suppressed,
|
|
186
|
+
// each with a sentence of detail, 13,861 characters for flash_player 10.0.0) and the undetermined
|
|
187
|
+
// sample (up to 50), keep their first 10 entries, each its cve_id and reason, once the first level
|
|
188
|
+
// does not fit with them whole, before any match field that level keeps goes (50 of them beside
|
|
189
|
+
// 200 matches left the last level 120 characters past the budget for flash_player 10.0.0, and
|
|
190
|
+
// 1,071 for a registry answer at the cap with 50 undetermined); and the registry path's cve_ids,
|
|
191
|
+
// which is each match's cve_id in the order of matches (core-backend cve/handler.go), keeps its
|
|
192
|
+
// first 10, so that every match stays in the text, 200 at the cap: this tool has no page to read
|
|
193
|
+
// more from. excluded_count and undetermined_count count every entry. structuredContent.data keeps
|
|
194
|
+
// every list whole.
|
|
186
195
|
const CHECK_AFFECTED_TEXT = {
|
|
187
196
|
rows: "matches",
|
|
188
197
|
levels: [
|
|
@@ -208,14 +217,15 @@ const CHECK_AFFECTED_TEXT = {
|
|
|
208
217
|
clip: 100,
|
|
209
218
|
},
|
|
210
219
|
{ keep: ["cve_id", "kev_listed", "ransomware", "epss_score", "effective_score", "effective_severity", "score_assessed", "cpe_vendor", "vendor_unknown"], clip: 100 },
|
|
211
|
-
{ keep: ["cve_id", "kev_listed", "effective_score", "effective_severity", "score_assessed"], clip: 100 },
|
|
220
|
+
{ keep: ["cve_id", "kev_listed", "ransomware", "epss_score", "effective_score", "effective_severity", "score_assessed"], clip: 100 },
|
|
221
|
+
{ keep: ["cve_id", "kev_listed", "ransomware", "epss_score", "effective_score", "score_assessed"], clip: 100 },
|
|
212
222
|
],
|
|
213
223
|
sides: [
|
|
214
224
|
{ list: "cve_ids", cap: 10, said: () => "cve_ids lists each match's cve_id, in the order of matches." },
|
|
215
|
-
{ list: "excluded", keep: ["cve_id", "reason"], clip: 100 },
|
|
216
|
-
{ list: "undetermined", keep: ["cve_id", "reason"], clip: 100 },
|
|
225
|
+
{ list: "excluded", keep: ["cve_id", "reason"], clip: 100, cap: 10 },
|
|
226
|
+
{ list: "undetermined", keep: ["cve_id", "reason"], clip: 100, cap: 10 },
|
|
217
227
|
],
|
|
218
|
-
whole:
|
|
228
|
+
whole: `${GET_CVE_WHOLE}, and cve_intel a CVE's affected packages and fixed versions.`,
|
|
219
229
|
};
|
|
220
230
|
async function checkAffected(kit, a) {
|
|
221
231
|
const product = (a.product ?? "").trim();
|
package/dist/tools/check_sbom.js
CHANGED
|
@@ -42,6 +42,12 @@ const NOTE_AFFECTED_MAX = 25;
|
|
|
42
42
|
const NOTE_CVES_MAX = 5;
|
|
43
43
|
// CycloneDX nests components; this bounds the walk.
|
|
44
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)";
|
|
45
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.";
|
|
46
52
|
// Always in the envelope's notes, whatever the answer: the refusals a model must not read as clean.
|
|
47
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.";
|
|
@@ -57,7 +63,8 @@ export const CHECK_SBOM_DESCRIPTION = "Check a dependency list against EchelonGr
|
|
|
57
63
|
// a CVE with its description, so the 50-component Juice Shop document was 112,820 characters of
|
|
58
64
|
// text, and 2,000 purls about 1,200,000. Past DATA_TEXT_BUDGET each row in the first text block
|
|
59
65
|
// keeps its verdict, counts and cve_ids, and each match its scores, flags and match_reason (the
|
|
60
|
-
// advisory interval the version falls inside,
|
|
66
|
+
// advisory interval the version falls inside: "[A, B)" names the fixed version B, "[A, B]" the last
|
|
67
|
+
// affected one, #2830) without the
|
|
61
68
|
// description; then the same without match_reason; then only index, purl, verdict,
|
|
62
69
|
// not_assessed_reason and cve_ids. When rows must still go, the clean ones (not_affected) leave
|
|
63
70
|
// first, then the not_assessed ones, both counted in data.summary and the note, so the affected
|
|
@@ -68,7 +75,8 @@ export const CHECK_SBOM_DESCRIPTION = "Check a dependency list against EchelonGr
|
|
|
68
75
|
// fit with that list whole, the text keeps its first 10 and the note says which they are: the
|
|
69
76
|
// input's distinct purls from position `sent` + 1 on, in input order, which is how check_sbom read
|
|
70
77
|
// them (x.purls.slice(answers.length * MAX_COMPONENTS)), so a second call can send them without
|
|
71
|
-
// reading the list.
|
|
78
|
+
// reading the list. noteFor gives that order (READ_ORDER) beside the position, so this sentence
|
|
79
|
+
// points to it.
|
|
72
80
|
export function sbomText(sent) {
|
|
73
81
|
const matches = ["cve_id", "severity", "effective_severity", "effective_score", "kev_listed", "ransomware", "epss_score", "score_assessed"];
|
|
74
82
|
const row = ["index", "purl", "ecosystem", "package", "version", "verdict", "assessed", "not_assessed_reason", "count", "not_affected_count", "undetermined_count", "cve_ids"];
|
|
@@ -83,11 +91,13 @@ export function sbomText(sent) {
|
|
|
83
91
|
{
|
|
84
92
|
list: "not_sent_purls",
|
|
85
93
|
cap: 10,
|
|
86
|
-
said: (_inText, total) => `The ${total} purls not sent are the input's distinct purls from position ${sent + 1} on, in the order
|
|
94
|
+
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.`,
|
|
87
95
|
},
|
|
88
96
|
],
|
|
89
97
|
leaveOutFirst: { field: "verdict", values: ["not_affected", "not_assessed"], why: "data.summary counts every verdict" },
|
|
90
|
-
|
|
98
|
+
// check_affected cuts its text too past the budget (#2802): only its structuredContent.data is
|
|
99
|
+
// a component's matches whole.
|
|
100
|
+
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.",
|
|
91
101
|
page: (shown) => `To read every row in the text, check ${shown} or fewer purls per call.`,
|
|
92
102
|
};
|
|
93
103
|
}
|
|
@@ -236,9 +246,17 @@ function noteFor(head, x, d, g) {
|
|
|
236
246
|
if (g.waits > 0) {
|
|
237
247
|
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.`);
|
|
238
248
|
}
|
|
249
|
+
// #2799: the position, not only the list. Past DATA_TEXT_BUDGET the first text block keeps the
|
|
250
|
+
// first 10 of data.not_sent_purls (sbomText), so a model that reads the text alone cannot send
|
|
251
|
+
// "that list"; the purls not sent are always the input's distinct purls from position sent + 1 on
|
|
252
|
+
// (x.purls.slice(answers.length * MAX_COMPONENTS)), which it can rebuild from its own input, given
|
|
253
|
+
// the order they are counted in (READ_ORDER), which this sentence says whenever it gives the
|
|
254
|
+
// position, not only when the text cuts the list.
|
|
239
255
|
const notSent = g.not_sent_purls.length;
|
|
240
256
|
if (notSent > 0) {
|
|
241
|
-
|
|
257
|
+
const one = notSent === 1;
|
|
258
|
+
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`;
|
|
259
|
+
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"}.`);
|
|
242
260
|
}
|
|
243
261
|
out.push(`Of the ${components} components checked: ${affected} affected, ${notAffected} not affected, ${undetermined} undetermined, ${notAssessed} not assessed.`);
|
|
244
262
|
const by = field(s, "not_assessed_by_reason");
|
|
@@ -335,7 +353,17 @@ export function mergeAnswers(answers) {
|
|
|
335
353
|
out.results = results;
|
|
336
354
|
return out;
|
|
337
355
|
}
|
|
338
|
-
const realSleep = (ms) => new Promise((r) =>
|
|
356
|
+
const realSleep = (ms, signal) => new Promise((r) => {
|
|
357
|
+
if (signal?.aborted)
|
|
358
|
+
return r();
|
|
359
|
+
const done = () => {
|
|
360
|
+
clearTimeout(timer);
|
|
361
|
+
signal?.removeEventListener("abort", done);
|
|
362
|
+
r();
|
|
363
|
+
};
|
|
364
|
+
const timer = setTimeout(done, ms);
|
|
365
|
+
signal?.addEventListener("abort", done, { once: true });
|
|
366
|
+
});
|
|
339
367
|
export async function checkSbom(deps, a) {
|
|
340
368
|
const tool = CHECK_SBOM;
|
|
341
369
|
const sleep = deps.sleep ?? realSleep;
|
|
@@ -384,12 +412,17 @@ export async function checkSbom(deps, a) {
|
|
|
384
412
|
headers: { "Content-Type": "application/json" },
|
|
385
413
|
body: JSON.stringify({ components: batches[answers.length].map((purl) => ({ purl })) }),
|
|
386
414
|
timeoutMs: Math.max(0, left),
|
|
415
|
+
signal: deps.signal,
|
|
387
416
|
});
|
|
388
417
|
if (r.ok) {
|
|
389
418
|
answers.push(r.data);
|
|
390
419
|
status = r.status;
|
|
391
420
|
continue;
|
|
392
421
|
}
|
|
422
|
+
// Cancelled (#2775): this batch was cut off, or never sent after a wait the signal ended.
|
|
423
|
+
// Nothing more is sent, and the answer goes nowhere.
|
|
424
|
+
if (deps.signal?.aborted)
|
|
425
|
+
return deps.failed(tool, r);
|
|
393
426
|
if (r.kind === "http" && r.status === 429) {
|
|
394
427
|
if (r.retryAfter === undefined) {
|
|
395
428
|
stop = { reason: "rate_limited", detail: "the API answered 429 without a Retry-After to wait for" };
|
|
@@ -400,7 +433,7 @@ export async function checkSbom(deps, a) {
|
|
|
400
433
|
else {
|
|
401
434
|
const waitMs = Math.max(MIN_WAIT_MS, r.retryAfter * 1000);
|
|
402
435
|
if (now() - started + waitMs <= TIME_BUDGET_MS) {
|
|
403
|
-
await sleep(waitMs);
|
|
436
|
+
await sleep(waitMs, deps.signal);
|
|
404
437
|
waits++;
|
|
405
438
|
waitedMs += waitMs;
|
|
406
439
|
waitedFor = r;
|
|
@@ -567,9 +600,12 @@ export function registerCheckSbom(server, deps) {
|
|
|
567
600
|
sbom: z
|
|
568
601
|
.union([z.string(), z.record(z.string(), z.unknown())])
|
|
569
602
|
.optional()
|
|
570
|
-
.describe("a CycloneDX JSON or SPDX JSON document, as JSON text or as an object; its purls are read
|
|
603
|
+
.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"),
|
|
571
604
|
}),
|
|
572
605
|
outputSchema: output,
|
|
573
606
|
annotations: deps.annotations,
|
|
574
|
-
}, async (a) =>
|
|
607
|
+
}, async (a, ctx) => {
|
|
608
|
+
const run = checkSbom({ ...deps, signal: ctx.mcpReq.signal }, a);
|
|
609
|
+
return deps.checked(CHECK_SBOM, output, await (deps.hold ? deps.hold(run) : run));
|
|
610
|
+
});
|
|
575
611
|
}
|
package/dist/tools/cve_intel.js
CHANGED
|
@@ -24,6 +24,15 @@
|
|
|
24
24
|
// EXPLOIT_NUCLEI_ENABLED, EXPLOIT_POCGITHUB_ENABLED; core-backend cmd/server/main.go). The answer
|
|
25
25
|
// does not say which ran, so no text here says a source is polled: it names the sources the data
|
|
26
26
|
// model holds, and says coverage depends on which pollers run.
|
|
27
|
+
//
|
|
28
|
+
// Fixed versions (#2817). /enrichment keeps one affected_packages row per (CVE, ecosystem,
|
|
29
|
+
// package), and its fixed_version is one range's fix: the last range's as the advisory was read
|
|
30
|
+
// (core-backend cve/backfill_osv_packages.go summariseAffected). For CVE-2021-44228 production
|
|
31
|
+
// served log4j-core 2.12.2 alone (2026-10-04), the fix for 2.4-2.12.1, below an affected 2.14.1.
|
|
32
|
+
// Since #2817 each row also carries fixed_branches, the fix for every range (core-backend
|
|
33
|
+
// cve/fixed_branches.go, from the version_intervals the matcher evaluates): this tool relays it as
|
|
34
|
+
// sent, its description says how to pick the range for an installed version, and the note counts
|
|
35
|
+
// the rows where fixed_version is not the whole answer.
|
|
27
36
|
import * as z from "zod";
|
|
28
37
|
import { ANNOTATIONS, CVE_ID, NOT_A_FINDING, SHOWN_BASE, api, badInput, boolAt, checked, crashed, envelopeSchema, failed, failure, field, numAt, okHead, opt, strAt, succeeded, } from "../index.js";
|
|
29
38
|
import { TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
|
|
@@ -60,7 +69,8 @@ const API_ROW_LIMIT = { exploits: 10, affected_packages: 50, fixed_versions: 200
|
|
|
60
69
|
const TIMELINE_RELAYED = 20;
|
|
61
70
|
const CVE_INTEL_METHOD = "EchelonGraph's per-CVE enrichment (GET /api/v1/public/cves/:id/enrichment). cwes: CWE classifications stored from NVD, GitHub (GHSA) and CVE.org records, each with the source that gave it, named from the MITRE CWE catalog EchelonGraph embeds. exploits: public exploit references from EchelonGraph's cve_exploits records, whose data model holds Metasploit modules (metasploit), Exploit-DB entries (exploit_db), nuclei templates (nuclei_template), GitHub proof-of-concept repositories (github_poc) and vendor proofs of concept (vendor_poc), filled by a curated seed and by one poller per automated source, each run only where it is enabled, so which sources are current depends on the deployment. affected_packages and fixed_versions: package-ecosystem advisory data, each row naming its source. timeline: EchelonGraph's enrichment history for the CVE, newest first.";
|
|
62
71
|
const VERIFIED_STATUS_DESCRIPTION = "verified_status is the label stored with each reference: verified for a Metasploit module, for an Exploit-DB entry Exploit-DB marks verified, and for curated seed rows marked so; reported for a public artefact nothing has confirmed works (nuclei templates, GitHub proofs of concept, unverified Exploit-DB entries); unconfirmed where a curated row says so. It is a label from the source, not a guarantee that the exploit works against a given system.";
|
|
63
|
-
|
|
72
|
+
const FIXED_BRANCHES_DESCRIPTION = 'fixed_branches lists each affected range of the package on record: introduced, and fixed (the range\'s fix) or last_affected (its last affected version, where no fix is on record), each null when absent, with advisory_id (the OSV record that published the range) and source (the loader\'s label for that record), both null on a row loaded before they were stored. Ranges with an advisory_id are in the order their record lists them (records in id order); ranges without one are oldest first. To pick the range for an installed version, compare in the ecosystem\'s version order (release numbers part by part, as numbers: 2.4 is below 2.13.0): the version is in a range when it is at or above introduced ("0" is the first version) and below fixed, or at or below last_affected; that range\'s fixed is the fix for it, and a version in no range is outside every range on record. fixed_branches is null where the package\'s ranges were never loaded, and [] where no version range is on record (the advisory gives none, only commit ranges, or more than are stored), which is not a finding that no fix exists: fixed_version can still name one. fixed_version is one range\'s fix, kept for compatibility (one per package: the range the loader kept, the advisory\'s last as a rule), not the fix for every affected range: for CVE-2021-44228, its advisory gives log4j-core three ranges, and fixed_version 2.12.2 is the fix for the one from 2.4, while the range holding 2.14.1 (from 2.13.0) is fixed in 2.15.0.';
|
|
73
|
+
export const CVE_INTEL_DESCRIPTION = `Weakness, public exploit code, affected packages and fixed versions for one CVE, from EchelonGraph's per-CVE enrichment. Returns cwes (each cwe_id with its name and source), exploits (each with kind, source_name, source_url, first_seen_at and verified_status; at most 10, verified first) with exploits_total (every reference on record), exploits_capped (true when exploits lists fewer than exploits_total), exploits_by_kind and exploits_by_status, affected_packages (ecosystem, package_name, version_range, fixed_version, fixed_branches), fixed_versions (ecosystem, package_name, vulnerable_range, fixed_version: one row per fixed version, with the vulnerable_range it fixes where the source gives one) and timeline (the newest enrichment-history rows, with timeline_total). ${FIXED_BRANCHES_DESCRIPTION} ${VERIFIED_STATUS_DESCRIPTION} An empty exploits list is not evidence that no public exploit exists: it covers only the sources EchelonGraph ingests, and which of them are polled depends on the deployment. A section the API could not read is named in coverage.sections_failed and left out of data, never relayed as an empty list. Vendor advisories, patches, generated summaries, trending signals and historical incidents are not relayed. Pass a CVE ID like CVE-2021-44228. Its structured result carries state (measured), measured_at (null: each row carries its own time), method, coverage (the sections relayed, failed and left out, and per-list counts), freshness (null) and notes, with data, which the first text block holds whole up to 30,000 characters; the result's last text block repeats it without data and without the note's sentences (the text block before it), with which notes ends. ${TEXT_BUDGET_DESCRIPTION}`;
|
|
64
74
|
// ── outputSchema ──
|
|
65
75
|
const Rows = (shape) => z.array(z.looseObject(shape));
|
|
66
76
|
const CountMap = z.record(z.string(), z.number());
|
|
@@ -88,7 +98,18 @@ function schemas() {
|
|
|
88
98
|
ecosystem: opt(z.string()),
|
|
89
99
|
package_name: opt(z.string()),
|
|
90
100
|
version_range: opt(z.string()),
|
|
91
|
-
fixed_version: opt(z.string()),
|
|
101
|
+
fixed_version: opt(z.string()).describe("One range's fix, kept for compatibility; not the fix for every affected range: read fixed_branches."),
|
|
102
|
+
fixed_branches: z
|
|
103
|
+
.array(z.looseObject({
|
|
104
|
+
introduced: opt(z.string()).describe('The range\'s lower bound, inclusive; "0" is the first version.'),
|
|
105
|
+
fixed: opt(z.string()).describe("The range's fix: the first version past it. null where no fix is on record."),
|
|
106
|
+
last_affected: opt(z.string()).describe("The range's last affected version, inclusive, where no fix is on record; never a fix."),
|
|
107
|
+
advisory_id: opt(z.string()).describe("The OSV record that published the range (GHSA-…, PYSEC-…, CVE-…); null on a row loaded before it was stored."),
|
|
108
|
+
source: opt(z.string()).describe("The loader's label for that record (osv_bulk, osv); null on a row loaded before it was stored."),
|
|
109
|
+
}))
|
|
110
|
+
.nullable()
|
|
111
|
+
.optional()
|
|
112
|
+
.describe(FIXED_BRANCHES_DESCRIPTION),
|
|
92
113
|
dependents_count: opt(z.number()),
|
|
93
114
|
source: opt(z.string()),
|
|
94
115
|
}).optional(),
|
|
@@ -170,6 +191,50 @@ function listSentence(name, rows) {
|
|
|
170
191
|
const cut = rows.length >= limit ? `, the most the API returns per CVE, so there may be more` : "";
|
|
171
192
|
return `${name} lists ${rows.length} row${rows.length === 1 ? "" : "s"}${cut}.`;
|
|
172
193
|
}
|
|
194
|
+
// #2817: where fixed_version is not the whole answer. A row with more than one range in
|
|
195
|
+
// fixed_branches has a fix per range; a row with fixed_branches null has only fixed_version on
|
|
196
|
+
// record, and so does a row with fixed_branches [] and a fixed_version (no version range on
|
|
197
|
+
// record: none in the advisory, only commit ranges, or more than core-backend stores); an answer
|
|
198
|
+
// whose rows carry no fixed_branches is an API older than #2817.
|
|
199
|
+
function branchSentences(rows) {
|
|
200
|
+
if (rows.length === 0)
|
|
201
|
+
return [];
|
|
202
|
+
const named = (r, i) => {
|
|
203
|
+
const eco = strAt(r, "ecosystem");
|
|
204
|
+
const pkg = strAt(r, "package_name") ?? `affected_packages[${i}]`;
|
|
205
|
+
return eco ? `${pkg} (${eco})` : pkg;
|
|
206
|
+
};
|
|
207
|
+
const withField = rows.filter((r) => field(r, "fixed_branches") !== undefined);
|
|
208
|
+
if (withField.length === 0) {
|
|
209
|
+
return [
|
|
210
|
+
"No affected_packages row carries fixed_branches (an API older than that field): each row's fixed_version is one range's fix, which need not be the fix for a given installed version.",
|
|
211
|
+
];
|
|
212
|
+
}
|
|
213
|
+
const out = [];
|
|
214
|
+
const multi = [];
|
|
215
|
+
const unknown = [];
|
|
216
|
+
const noRange = [];
|
|
217
|
+
rows.forEach((r, i) => {
|
|
218
|
+
const fb = field(r, "fixed_branches");
|
|
219
|
+
if (Array.isArray(fb) && fb.length > 1)
|
|
220
|
+
multi.push(named(r, i));
|
|
221
|
+
if (fb === null)
|
|
222
|
+
unknown.push(named(r, i));
|
|
223
|
+
if (Array.isArray(fb) && fb.length === 0 && strAt(r, "fixed_version"))
|
|
224
|
+
noRange.push(named(r, i));
|
|
225
|
+
});
|
|
226
|
+
const few = (xs) => (xs.length <= 3 ? listed(xs) : `${xs.slice(0, 3).join(", ")} and ${xs.length - 3} more`);
|
|
227
|
+
if (multi.length) {
|
|
228
|
+
out.push(`${multi.length} affected_packages row${multi.length === 1 ? " lists" : "s list"} more than one affected range in fixed_branches (${few(multi)}): there, fixed_version is one range's fix, and an installed version's fix is the fixed of the range that holds it.`);
|
|
229
|
+
}
|
|
230
|
+
if (unknown.length) {
|
|
231
|
+
out.push(`${unknown.length} affected_packages row${unknown.length === 1 ? " carries" : "s carry"} fixed_branches null (${few(unknown)}): ${unknown.length === 1 ? "its" : "their"} ranges are not on record, so fixed_version, one range's fix, is all the answer holds, and it need not be the fix for a given installed version.`);
|
|
232
|
+
}
|
|
233
|
+
if (noRange.length) {
|
|
234
|
+
out.push(`${noRange.length} affected_packages row${noRange.length === 1 ? " carries" : "s carry"} fixed_branches [] and a fixed_version (${few(noRange)}): no version range is on record there, which is not a finding that no fix exists, so fixed_version, one range's fix, is all the answer holds, and it need not be the fix for a given installed version.`);
|
|
235
|
+
}
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
173
238
|
// ── The tool ──
|
|
174
239
|
export async function cveIntel(cve_id) {
|
|
175
240
|
const id = cve_id.trim();
|
|
@@ -218,6 +283,8 @@ export async function cveIntel(cve_id) {
|
|
|
218
283
|
const xs = rows(k);
|
|
219
284
|
if (xs)
|
|
220
285
|
said.push(listSentence(k, xs));
|
|
286
|
+
if (xs && k === "affected_packages")
|
|
287
|
+
said.push(...branchSentences(xs));
|
|
221
288
|
}
|
|
222
289
|
const timeline = rows("timeline");
|
|
223
290
|
const apiTimeline = field(d, "timeline");
|
package/dist/tools/get_cwe.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
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 { TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
|
|
16
|
+
import { GET_CVE_WHOLE, TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
|
|
17
17
|
const TOOL = "get_cwe";
|
|
18
18
|
const CWE_ARG = /^(?:CWE-)?0*(\d{1,6})$/i;
|
|
19
19
|
const MAX_PAGE = 200;
|
|
@@ -25,7 +25,7 @@ export const GET_CWE_DESCRIPTION = `One CWE (weakness class) and the CVEs classi
|
|
|
25
25
|
const GET_CWE_TEXT = {
|
|
26
26
|
rows: "cves",
|
|
27
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:
|
|
28
|
+
whole: `${GET_CVE_WHOLE}.`,
|
|
29
29
|
};
|
|
30
30
|
function schemas() {
|
|
31
31
|
const row = z.looseObject({
|
package/dist/tools/kev_recent.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as z from "zod";
|
|
2
|
-
import { TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
|
|
2
|
+
import { GET_CVE_WHOLE, TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
|
|
3
3
|
const TOOL = "kev_recent";
|
|
4
4
|
const PATH = "/api/v1/public/kev/recent";
|
|
5
5
|
export const KEV_RECENT_MAX_LIMIT = 200;
|
|
@@ -140,7 +140,8 @@ function kevText(a) {
|
|
|
140
140
|
{ keep: ["cve_id", "kev_added_date", "kev_vendor", "kev_product", "kev_ransomware"], clip: 40 },
|
|
141
141
|
{ keep: ["cve_id", "kev_added_date", "kev_vendor", "kev_ransomware"], clip: 40 },
|
|
142
142
|
],
|
|
143
|
-
|
|
143
|
+
// A record carries the CISA fields a row leaves out (kev_due_date, kev_product, kev_vuln_name).
|
|
144
|
+
whole: `${GET_CVE_WHOLE}.`,
|
|
144
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.`,
|
|
145
146
|
};
|
|
146
147
|
}
|