echelongraph-mcp 2.3.3 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +79 -4
- package/dist/http.js +240 -0
- package/dist/httpPolicy.js +244 -0
- package/dist/index.js +179 -50
- package/dist/runtime.js +65 -0
- package/dist/tools/check_affected.js +269 -0
- package/dist/tools/check_sbom.js +329 -0
- package/dist/tools/cve_intel.js +276 -0
- package/dist/tools/epss_history.js +125 -0
- package/dist/tools/get_cwe.js +148 -0
- package/dist/tools/kev_recent.js +190 -0
- package/dist/tools/vendor_advisories.js +316 -0
- package/package.json +4 -2
- package/server.json +8 -2
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// cve_intel (#2720): what weakness a CVE is, whether public exploit code for it is on record,
|
|
2
|
+
// and which packages and versions it affects and fixes — from GET
|
|
3
|
+
// /api/v1/public/cves/:id/enrichment (core-backend cve/handler_enrichment.go).
|
|
4
|
+
//
|
|
5
|
+
// Not /blast-radius: that GET can trigger a live OSV fetch and a database upsert
|
|
6
|
+
// (core-backend cve/handler.go GetBlastRadius), and this server makes no request but a read.
|
|
7
|
+
//
|
|
8
|
+
// The relay is a cut, like exposure_radar's. /enrichment also serves vendor_advisories, patches,
|
|
9
|
+
// ai (generated summaries), trending and historical_incidents; this tool relays only the sections
|
|
10
|
+
// it can label — cwes, exploits with their counts, affected_packages, fixed_versions and timeline —
|
|
11
|
+
// and the note names what it left out. Within those, nothing is rewritten: each relayed section is
|
|
12
|
+
// the API's own value, except timeline, cut to its TIMELINE_RELAYED newest rows (the API's are
|
|
13
|
+
// newest first) with the cut in coverage.
|
|
14
|
+
//
|
|
15
|
+
// An empty section is not "none" unless the answer says it was read. /enrichment fills a section
|
|
16
|
+
// that failed or ran out of its time budget with its empty value. Since #2720 it names each such
|
|
17
|
+
// section in failed_sections; this tool then leaves that section out of data and says so. An
|
|
18
|
+
// answer without failed_sections (an API older than #2720) cannot tell an empty section from a
|
|
19
|
+
// failed one, and the note says so for each empty section it relays.
|
|
20
|
+
//
|
|
21
|
+
// Exploit coverage. cve_exploits is filled by a curated seed and by four pollers (Metasploit,
|
|
22
|
+
// Exploit-DB, nuclei templates, GitHub PoCs), each behind its own environment flag
|
|
23
|
+
// (EXPLOIT_INTEL_ENABLED plus EXPLOIT_METASPLOIT_ENABLED, EXPLOIT_EXPLOITDB_ENABLED,
|
|
24
|
+
// EXPLOIT_NUCLEI_ENABLED, EXPLOIT_POCGITHUB_ENABLED; core-backend cmd/server/main.go). The answer
|
|
25
|
+
// does not say which ran, so no text here says a source is polled: it names the sources the data
|
|
26
|
+
// model holds, and says coverage depends on which pollers run.
|
|
27
|
+
import * as z from "zod";
|
|
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
|
+
const TOOL = "cve_intel";
|
|
30
|
+
// The sections relayed, in the answer's order, and the keys each failed_sections name covers.
|
|
31
|
+
const LISTS = ["cwes", "exploits", "affected_packages", "fixed_versions", "timeline"];
|
|
32
|
+
const SECTION_KEYS = {
|
|
33
|
+
cwes: ["cwes"],
|
|
34
|
+
exploits: ["exploits"],
|
|
35
|
+
exploits_total: ["exploits_total", "exploits_capped", "exploits_by_kind", "exploits_by_status"],
|
|
36
|
+
affected_packages: ["affected_packages"],
|
|
37
|
+
fixed_versions: ["fixed_versions"],
|
|
38
|
+
timeline: ["timeline"],
|
|
39
|
+
timeline_stats: ["timeline_total", "timeline_count_7d", "timeline_count_30d"],
|
|
40
|
+
};
|
|
41
|
+
const RELAYED_KEYS = [
|
|
42
|
+
"cwes",
|
|
43
|
+
"exploits",
|
|
44
|
+
"exploits_total",
|
|
45
|
+
"exploits_capped",
|
|
46
|
+
"exploits_by_kind",
|
|
47
|
+
"exploits_by_status",
|
|
48
|
+
"affected_packages",
|
|
49
|
+
"fixed_versions",
|
|
50
|
+
"timeline",
|
|
51
|
+
"timeline_total",
|
|
52
|
+
"timeline_count_7d",
|
|
53
|
+
"timeline_count_30d",
|
|
54
|
+
];
|
|
55
|
+
// The /enrichment sections this tool does not relay, named in every note.
|
|
56
|
+
const LEFT_OUT = ["vendor_advisories", "patches", "ai", "trending", "historical_incidents"];
|
|
57
|
+
// Per-CVE row limits of the API (core-backend cve/enrichment_store.go): a list this long may be cut.
|
|
58
|
+
const API_ROW_LIMIT = { exploits: 10, affected_packages: 50, fixed_versions: 200, timeline: 100 };
|
|
59
|
+
const TIMELINE_RELAYED = 20;
|
|
60
|
+
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
|
+
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 equal to the first text block; 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.`;
|
|
63
|
+
// ── outputSchema ──
|
|
64
|
+
const Rows = (shape) => z.array(z.looseObject(shape));
|
|
65
|
+
const CountMap = z.record(z.string(), z.number());
|
|
66
|
+
const ListCount = z.strictObject({
|
|
67
|
+
returned: z.number().int().nullable().describe("Rows the API returned; null when the section was not read."),
|
|
68
|
+
api_row_limit: z.number().int().describe("The most rows the API returns for this list per CVE: a list this long may be cut."),
|
|
69
|
+
});
|
|
70
|
+
function schemas() {
|
|
71
|
+
const data = z.strictObject({
|
|
72
|
+
cve_id: z.string(),
|
|
73
|
+
cwes: Rows({ cwe_id: opt(z.string()), name: opt(z.string()), source: opt(z.string()) }).optional(),
|
|
74
|
+
exploits: Rows({
|
|
75
|
+
kind: opt(z.string()).describe("metasploit, exploit_db, nuclei_template, github_poc or vendor_poc."),
|
|
76
|
+
source_name: opt(z.string()),
|
|
77
|
+
source_url: opt(z.string()),
|
|
78
|
+
description: opt(z.string()),
|
|
79
|
+
first_seen_at: opt(z.string()),
|
|
80
|
+
verified_status: opt(z.string()).describe(VERIFIED_STATUS_DESCRIPTION),
|
|
81
|
+
}).optional(),
|
|
82
|
+
exploits_total: z.number().int().nullable().optional().describe("Every exploit reference on record for the CVE, not only those listed in exploits."),
|
|
83
|
+
exploits_capped: z.boolean().optional().describe("true: exploits lists fewer references than exploits_total."),
|
|
84
|
+
exploits_by_kind: CountMap.optional(),
|
|
85
|
+
exploits_by_status: CountMap.optional(),
|
|
86
|
+
affected_packages: Rows({
|
|
87
|
+
ecosystem: opt(z.string()),
|
|
88
|
+
package_name: opt(z.string()),
|
|
89
|
+
version_range: opt(z.string()),
|
|
90
|
+
fixed_version: opt(z.string()),
|
|
91
|
+
dependents_count: opt(z.number()),
|
|
92
|
+
source: opt(z.string()),
|
|
93
|
+
}).optional(),
|
|
94
|
+
fixed_versions: Rows({
|
|
95
|
+
ecosystem: opt(z.string()),
|
|
96
|
+
package_name: opt(z.string()),
|
|
97
|
+
vulnerable_range: opt(z.string()),
|
|
98
|
+
fixed_version: opt(z.string()),
|
|
99
|
+
source: opt(z.string()),
|
|
100
|
+
}).optional(),
|
|
101
|
+
timeline: Rows({ enriched_at: opt(z.string()), enrichment_kind: opt(z.string()), fields_changed: z.unknown().optional(), score_delta: opt(z.number()) }).optional(),
|
|
102
|
+
timeline_total: z.number().int().optional(),
|
|
103
|
+
timeline_count_7d: z.number().int().optional(),
|
|
104
|
+
timeline_count_30d: z.number().int().optional(),
|
|
105
|
+
});
|
|
106
|
+
const coverage = z.strictObject({
|
|
107
|
+
sections_relayed: z.array(z.string()).describe("The sections of the answer relayed in data."),
|
|
108
|
+
sections_failed: z
|
|
109
|
+
.array(z.string())
|
|
110
|
+
.nullable()
|
|
111
|
+
.describe("The sections the API says it could not read (its failed_sections), left out of data; null when the answer does not say (an API older than failed_sections), so an empty list may be an unread section."),
|
|
112
|
+
sections_left_out: z.array(z.string()).describe("The sections of the answer this tool does not relay."),
|
|
113
|
+
exploits: ListCount,
|
|
114
|
+
affected_packages: ListCount,
|
|
115
|
+
fixed_versions: ListCount,
|
|
116
|
+
timeline: z.strictObject({
|
|
117
|
+
returned: z.number().int().nullable(),
|
|
118
|
+
relayed: z.number().int().nullable().describe("The newest rows relayed in data.timeline."),
|
|
119
|
+
api_row_limit: z.number().int(),
|
|
120
|
+
}),
|
|
121
|
+
});
|
|
122
|
+
return { output: envelopeSchema({ data, coverage, freshness: null }) };
|
|
123
|
+
}
|
|
124
|
+
let built;
|
|
125
|
+
// Built on first use: this file is imported by index.ts, so index.ts's own exports are not
|
|
126
|
+
// initialised while this module's top level runs.
|
|
127
|
+
const schemasOnce = () => (built ??= schemas());
|
|
128
|
+
// ── The note ──
|
|
129
|
+
const listed = (xs) => (xs.length <= 1 ? (xs[0] ?? "") : `${xs.slice(0, -1).join(", ")} and ${xs.at(-1)}`);
|
|
130
|
+
function cweSentence(cve, cwes) {
|
|
131
|
+
if (cwes.length === 0) {
|
|
132
|
+
return `No CWE classification is on record for ${cve} (cwes is empty): no NVD, GitHub or CVE.org record EchelonGraph holds names one, which is not a finding that the CVE has no weakness class.`;
|
|
133
|
+
}
|
|
134
|
+
const named = cwes.map((c, i) => {
|
|
135
|
+
const id = strAt(c, "cwe_id") ?? `cwes[${i}]`;
|
|
136
|
+
const name = strAt(c, "name");
|
|
137
|
+
return name ? `${id} (${name})` : id;
|
|
138
|
+
});
|
|
139
|
+
return `${cve} is classified under ${listed(named)}.`;
|
|
140
|
+
}
|
|
141
|
+
function exploitSentences(cve, d, rows) {
|
|
142
|
+
const total = numAt(d, "exploits_total");
|
|
143
|
+
const verified = numAt(d, "exploits_by_status", "verified");
|
|
144
|
+
const capped = boolAt(d, "exploits_capped");
|
|
145
|
+
const verifiedRows = rows.filter((r) => strAt(r, "verified_status") === "verified").length;
|
|
146
|
+
if (total === undefined) {
|
|
147
|
+
// An API older than exploits_total, or the count could not be read.
|
|
148
|
+
const cut = rows.length >= (API_ROW_LIMIT.exploits ?? 10) ? ` The API lists at most ${API_ROW_LIMIT.exploits} references, so there may be more.` : "";
|
|
149
|
+
if (rows.length === 0) {
|
|
150
|
+
return [
|
|
151
|
+
`exploits is empty and the answer carries no exploits_total, so it does not count the references on record for ${cve}; an empty list is not evidence that no public exploit exists.`,
|
|
152
|
+
];
|
|
153
|
+
}
|
|
154
|
+
return [`exploits lists ${rows.length} public exploit reference${rows.length === 1 ? "" : "s"} for ${cve}, ${verifiedRows} with verified_status verified; the answer carries no exploits_total, so it does not say how many are on record.${cut}`];
|
|
155
|
+
}
|
|
156
|
+
if (total === 0) {
|
|
157
|
+
return [
|
|
158
|
+
`EchelonGraph holds no public exploit reference for ${cve} (exploits_total 0). That is not evidence that no public exploit exists: references come only from the sources EchelonGraph ingests, and which of them are polled depends on the deployment.`,
|
|
159
|
+
];
|
|
160
|
+
}
|
|
161
|
+
const v = verified === undefined ? "" : `, ${verified} of them with verified_status verified`;
|
|
162
|
+
const cut = capped === true ? ` exploits_capped is true: exploits lists ${rows.length} of the ${total}, verified first.` : "";
|
|
163
|
+
return [`EchelonGraph holds ${total} public exploit reference${total === 1 ? "" : "s"} for ${cve} (exploits_total)${v}.${cut}`];
|
|
164
|
+
}
|
|
165
|
+
function listSentence(name, rows) {
|
|
166
|
+
const limit = API_ROW_LIMIT[name] ?? 0;
|
|
167
|
+
if (rows.length === 0)
|
|
168
|
+
return `${name} is empty: EchelonGraph holds no ${name === "affected_packages" ? "affected package" : "fixed version"} row for this CVE, which is not a finding that no package is affected or fixed.`;
|
|
169
|
+
const cut = rows.length >= limit ? `, the most the API returns per CVE, so there may be more` : "";
|
|
170
|
+
return `${name} lists ${rows.length} row${rows.length === 1 ? "" : "s"}${cut}.`;
|
|
171
|
+
}
|
|
172
|
+
// ── The tool ──
|
|
173
|
+
export async function cveIntel(cve_id) {
|
|
174
|
+
const id = cve_id.trim();
|
|
175
|
+
if (!id)
|
|
176
|
+
return badInput(TOOL, "cve_id is required");
|
|
177
|
+
if (!CVE_ID.test(id))
|
|
178
|
+
return badInput(TOOL, `cve_id ${JSON.stringify(id.slice(0, 32))} is not a CVE id (expected the form CVE-2021-44228)`);
|
|
179
|
+
const cve = id.toUpperCase();
|
|
180
|
+
try {
|
|
181
|
+
const r = await api(`/api/v1/public/cves/${encodeURIComponent(cve)}/enrichment`);
|
|
182
|
+
if (!r.ok)
|
|
183
|
+
return failed(TOOL, r);
|
|
184
|
+
const d = r.data;
|
|
185
|
+
const reported = field(d, "failed_sections");
|
|
186
|
+
const sectionsFailed = Array.isArray(reported) ? reported.filter((x) => typeof x === "string") : null;
|
|
187
|
+
const failedKeys = new Set((sectionsFailed ?? []).flatMap((s) => SECTION_KEYS[s] ?? [s]));
|
|
188
|
+
const data = { cve_id: cve };
|
|
189
|
+
for (const k of RELAYED_KEYS) {
|
|
190
|
+
const v = field(d, k);
|
|
191
|
+
if (v === undefined || failedKeys.has(k))
|
|
192
|
+
continue;
|
|
193
|
+
data[k] = k === "timeline" && Array.isArray(v) ? v.slice(0, TIMELINE_RELAYED) : v;
|
|
194
|
+
}
|
|
195
|
+
const relayed = LISTS.filter((k) => k in data);
|
|
196
|
+
// Every list this tool relays could not be read: nothing was measured.
|
|
197
|
+
if (relayed.length === 0) {
|
|
198
|
+
const why = sectionsFailed?.length
|
|
199
|
+
? `the API answered but could not read any section this tool relays (failed_sections: ${sectionsFailed.join(", ")})`
|
|
200
|
+
: "the answer carries none of the sections this tool relays (cwes, exploits, affected_packages, fixed_versions, timeline)";
|
|
201
|
+
return failure("failed", `${TOOL} FAILED: EchelonGraph answered HTTP ${r.status} from ${SHOWN_BASE} for GET /api/v1/public/cves/${cve}/enrichment, but ${why}. ${NOT_A_FINDING}`, {
|
|
202
|
+
kind: "unexpected_shape",
|
|
203
|
+
path: `/api/v1/public/cves/${cve}/enrichment`,
|
|
204
|
+
status: r.status,
|
|
205
|
+
message: why,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
const rows = (k) => (Array.isArray(data[k]) ? data[k] : null);
|
|
209
|
+
const said = [];
|
|
210
|
+
const cwes = rows("cwes");
|
|
211
|
+
if (cwes)
|
|
212
|
+
said.push(cweSentence(cve, cwes));
|
|
213
|
+
const exploits = rows("exploits");
|
|
214
|
+
if (exploits)
|
|
215
|
+
said.push(...exploitSentences(cve, data, exploits));
|
|
216
|
+
for (const k of ["affected_packages", "fixed_versions"]) {
|
|
217
|
+
const xs = rows(k);
|
|
218
|
+
if (xs)
|
|
219
|
+
said.push(listSentence(k, xs));
|
|
220
|
+
}
|
|
221
|
+
const timeline = rows("timeline");
|
|
222
|
+
const apiTimeline = field(d, "timeline");
|
|
223
|
+
const timelineReturned = Array.isArray(apiTimeline) ? apiTimeline.length : null;
|
|
224
|
+
const timelineTotal = numAt(data, "timeline_total");
|
|
225
|
+
if (timeline && timelineReturned !== null && timelineReturned > timeline.length) {
|
|
226
|
+
said.push(`timeline relays the ${timeline.length} newest of the ${timelineReturned} enrichment-history rows the API returned${timelineTotal === undefined ? "" : ` (timeline_total ${timelineTotal})`}.`);
|
|
227
|
+
}
|
|
228
|
+
if (sectionsFailed === null) {
|
|
229
|
+
const empty = relayed.filter((k) => rows(k)?.length === 0);
|
|
230
|
+
if (empty.length) {
|
|
231
|
+
said.push(`The answer carries no failed_sections (an API older than that field), so it does not say whether ${listed(empty)} ${empty.length === 1 ? "was" : "were"} read: an empty list there may be a section the API could not read within its time budget, not a finding of none.`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
else if (sectionsFailed.length) {
|
|
235
|
+
said.push(`The API could not read ${listed(sectionsFailed)} (failed_sections): ${sectionsFailed.length === 1 ? "it is" : "they are"} left out of data, and the absence is not a finding of none.`);
|
|
236
|
+
}
|
|
237
|
+
said.push(`Not relayed from the answer: ${listed(LEFT_OUT)}.`);
|
|
238
|
+
const count = (k) => {
|
|
239
|
+
const v = field(d, k);
|
|
240
|
+
return Array.isArray(v) && !failedKeys.has(k) ? v.length : null;
|
|
241
|
+
};
|
|
242
|
+
const coverage = {
|
|
243
|
+
sections_relayed: relayed,
|
|
244
|
+
sections_failed: sectionsFailed,
|
|
245
|
+
sections_left_out: [...LEFT_OUT],
|
|
246
|
+
exploits: { returned: count("exploits"), api_row_limit: API_ROW_LIMIT.exploits },
|
|
247
|
+
affected_packages: { returned: count("affected_packages"), api_row_limit: API_ROW_LIMIT.affected_packages },
|
|
248
|
+
fixed_versions: { returned: count("fixed_versions"), api_row_limit: API_ROW_LIMIT.fixed_versions },
|
|
249
|
+
timeline: { returned: count("timeline"), relayed: timeline ? timeline.length : null, api_row_limit: API_ROW_LIMIT.timeline },
|
|
250
|
+
};
|
|
251
|
+
return succeeded(data, `${okHead(TOOL, r.status)} ${said.join(" ")}`, {
|
|
252
|
+
state: "measured",
|
|
253
|
+
measured_at: null,
|
|
254
|
+
method: CVE_INTEL_METHOD,
|
|
255
|
+
coverage,
|
|
256
|
+
freshness: null,
|
|
257
|
+
notes: [
|
|
258
|
+
"measured_at is null: the answer has no single observation time; each row carries its own (first_seen_at, enriched_at).",
|
|
259
|
+
"freshness is null: the answer does not say when each source was last polled.",
|
|
260
|
+
],
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
catch (e) {
|
|
264
|
+
return crashed(TOOL, e);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
export function registerCveIntel(server) {
|
|
268
|
+
const { output } = schemasOnce();
|
|
269
|
+
server.registerTool("cve_intel", {
|
|
270
|
+
title: "CVE weakness, exploits and packages",
|
|
271
|
+
description: CVE_INTEL_DESCRIPTION,
|
|
272
|
+
inputSchema: z.object({ cve_id: z.string().describe("a CVE ID, e.g. CVE-2021-44228") }),
|
|
273
|
+
outputSchema: output,
|
|
274
|
+
annotations: ANNOTATIONS,
|
|
275
|
+
}, async ({ cve_id }) => checked(TOOL, output, await cveIntel(cve_id)));
|
|
276
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
const TOOL = "epss_history";
|
|
3
|
+
const CVE_ID = /^CVE-\d{4}-\d{4,}$/i;
|
|
4
|
+
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
|
+
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
|
+
const BEFORE_FIRST = "The value in force before a CVE's first point is not in the series.";
|
|
7
|
+
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
|
+
// The API's JSON. points, series_kind and current are required: an answer without them is not
|
|
10
|
+
// this series, and a series_kind other than change_only would make every note above wrong, so
|
|
11
|
+
// either is a failure (checked), never relayed.
|
|
12
|
+
const opt = (t) => t.nullable().optional();
|
|
13
|
+
const Point = (instant) => z.looseObject({
|
|
14
|
+
at: instant.describe("When EchelonGraph recorded this change."),
|
|
15
|
+
epss_score: z.number().describe("The EPSS probability (0 to 1) from that change on."),
|
|
16
|
+
epss_percentile: opt(z.number()).describe("Its percentile (0 to 1), null where the record held none."),
|
|
17
|
+
});
|
|
18
|
+
const data = (instant) => z.looseObject({
|
|
19
|
+
cve_id: z.string(),
|
|
20
|
+
series_kind: z.enum(["change_only"]).describe("Always change_only: one point per recorded change, none for a day without one."),
|
|
21
|
+
series_starts_at: instant.nullable().describe("The earliest EPSS change EchelonGraph recorded for any CVE: when recording began. Null when nothing has been recorded."),
|
|
22
|
+
current: z.looseObject({
|
|
23
|
+
epss_score: opt(z.number()).describe("The record's EPSS probability now; null when it holds none."),
|
|
24
|
+
epss_percentile: opt(z.number()),
|
|
25
|
+
epss_updated_at: opt(instant).describe("When EchelonGraph last wrote a changed EPSS value for this CVE."),
|
|
26
|
+
}),
|
|
27
|
+
points: z.array(Point(instant)).describe("Recorded changes, oldest first. Never a daily series."),
|
|
28
|
+
history_rows: opt(z.number().int().min(0)).describe("Raw history rows read, before repeated records of one change were collapsed."),
|
|
29
|
+
points_truncated: opt(z.boolean()).describe("True when only the newest rows were read, so the earliest points are missing."),
|
|
30
|
+
latest_point_matches_current: opt(z.boolean()).describe("Whether the newest point equals current; false means a change is missing from the series; null when there is no point or no current value."),
|
|
31
|
+
});
|
|
32
|
+
export function epssHistoryOutput(kit) {
|
|
33
|
+
return kit.envelopeSchema({
|
|
34
|
+
data: data(kit.instant),
|
|
35
|
+
coverage: z.strictObject({
|
|
36
|
+
series_kind: z.enum(["change_only"]),
|
|
37
|
+
series_starts_at: kit.instant.nullable(),
|
|
38
|
+
points: z.number().int().min(0).describe("How many points the series holds."),
|
|
39
|
+
points_truncated: z.boolean(),
|
|
40
|
+
}),
|
|
41
|
+
freshness: null,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
const isNum = (v) => typeof v === "number" && Number.isFinite(v);
|
|
45
|
+
const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
46
|
+
const realInstant = (v) => typeof v === "string" && Date.parse(v) > 0;
|
|
47
|
+
// The note: what came back, in words, and what it does not say.
|
|
48
|
+
function note(head, id, d) {
|
|
49
|
+
const points = Array.isArray(d.points) ? d.points.filter(isObj) : [];
|
|
50
|
+
const cur = isObj(d.current) ? d.current : {};
|
|
51
|
+
const curScore = isNum(cur.epss_score) ? cur.epss_score : undefined;
|
|
52
|
+
const start = realInstant(d.series_starts_at) ? d.series_starts_at : undefined;
|
|
53
|
+
const s = [head];
|
|
54
|
+
s.push(curScore === undefined
|
|
55
|
+
? `EchelonGraph's record holds no EPSS score for ${id} (current.epss_score is null).`
|
|
56
|
+
: `${id}'s EPSS score in EchelonGraph's record is ${curScore}${isNum(cur.epss_percentile) ? ` (percentile ${cur.epss_percentile})` : ""}.`);
|
|
57
|
+
if (points.length === 0) {
|
|
58
|
+
s.push(start
|
|
59
|
+
? `No EPSS change is recorded for ${id} since recording began at ${start}: a measured empty series, not a lookup failure.`
|
|
60
|
+
: "No EPSS change has been recorded for any CVE yet (series_starts_at is null), so this empty series says nothing about how the score moved.");
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
const first = points[0];
|
|
64
|
+
const last = points[points.length - 1];
|
|
65
|
+
s.push(`${points.length} recorded change${points.length === 1 ? "" : "s"}, from ${first.epss_score} at ${first.at} to ${last.epss_score} at ${last.at}.`);
|
|
66
|
+
}
|
|
67
|
+
if (start)
|
|
68
|
+
s.push(`Recording began at ${start} (series_starts_at): before it a missing point means not recorded, not unchanged.`);
|
|
69
|
+
if (d.points_truncated === true)
|
|
70
|
+
s.push("points_truncated is true: only the newest records were read, so the earliest changes are missing from this series.");
|
|
71
|
+
if (d.latest_point_matches_current === false) {
|
|
72
|
+
s.push("latest_point_matches_current is false: the newest recorded point is not the record's current value, so at least one change is missing from the series.");
|
|
73
|
+
}
|
|
74
|
+
return { note: s.join(" "), own: [CHANGE_ONLY, BEFORE_FIRST] };
|
|
75
|
+
}
|
|
76
|
+
export async function epssHistory(kit, cve_id) {
|
|
77
|
+
const id = cve_id.trim();
|
|
78
|
+
if (!id)
|
|
79
|
+
return kit.badInput(TOOL, "cve_id is required");
|
|
80
|
+
if (!CVE_ID.test(id))
|
|
81
|
+
return kit.badInput(TOOL, `cve_id ${JSON.stringify(id.slice(0, 32))} is not a CVE id (expected the form CVE-2023-44487)`);
|
|
82
|
+
const cve = id.toUpperCase();
|
|
83
|
+
try {
|
|
84
|
+
const r = await kit.api(`/api/v1/public/cves/${encodeURIComponent(cve)}/epss-history`);
|
|
85
|
+
if (!r.ok)
|
|
86
|
+
return kit.failed(TOOL, r);
|
|
87
|
+
const d = r.data;
|
|
88
|
+
const name = typeof d.cve_id === "string" && d.cve_id ? d.cve_id : cve;
|
|
89
|
+
const { note: n, own } = note(kit.okHead(TOOL, r.status), name, d);
|
|
90
|
+
const cur = isObj(d.current) ? d.current : {};
|
|
91
|
+
const written = realInstant(cur.epss_updated_at) ? cur.epss_updated_at : null;
|
|
92
|
+
return kit.succeeded(r.data, n, {
|
|
93
|
+
state: "measured",
|
|
94
|
+
measured_at: written,
|
|
95
|
+
method: EPSS_HISTORY_METHOD,
|
|
96
|
+
coverage: {
|
|
97
|
+
series_kind: d.series_kind,
|
|
98
|
+
series_starts_at: d.series_starts_at ?? null,
|
|
99
|
+
points: Array.isArray(d.points) ? d.points.length : 0,
|
|
100
|
+
points_truncated: d.points_truncated === true,
|
|
101
|
+
},
|
|
102
|
+
freshness: null,
|
|
103
|
+
notes: [
|
|
104
|
+
...own,
|
|
105
|
+
written
|
|
106
|
+
? "measured_at is current.epss_updated_at: when EchelonGraph last wrote a changed EPSS value for this CVE; values are written only on change, so it is not when the value was last checked, and it is not FIRST's score date."
|
|
107
|
+
: "measured_at is null: the record carries no epss_updated_at.",
|
|
108
|
+
NO_FRESHNESS,
|
|
109
|
+
],
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
catch (e) {
|
|
113
|
+
return kit.crashed(TOOL, e);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
export function registerEPSSHistory(server, kit) {
|
|
117
|
+
const output = epssHistoryOutput(kit);
|
|
118
|
+
server.registerTool(TOOL, {
|
|
119
|
+
title: "EPSS change history for one CVE",
|
|
120
|
+
description: EPSS_HISTORY_DESCRIPTION,
|
|
121
|
+
inputSchema: z.object({ cve_id: kit.cveIdArg }),
|
|
122
|
+
outputSchema: output,
|
|
123
|
+
annotations: kit.annotations,
|
|
124
|
+
}, async ({ cve_id }) => kit.checked(TOOL, output, await epssHistory(kit, cve_id)));
|
|
125
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// get_cwe (#2720): one CWE's name and description, and a page of the active CVEs classified
|
|
2
|
+
// under it, from GET /api/v1/public/cwes/:cwe_id?page=N (core-backend cve/handler_cwe.go
|
|
3
|
+
// GetCWELanding, cwe_landing_store.go). The JSON is relayed as the API sent it.
|
|
4
|
+
//
|
|
5
|
+
// The order is the API's to state. Since #2720 the answer carries `order` (KEV-listed first);
|
|
6
|
+
// before it, a DISTINCT ON put cve_id first in the sort, so page 1 was the oldest CVE ids. So the
|
|
7
|
+
// note repeats `order` when the answer has it, and otherwise says the answer does not state its
|
|
8
|
+
// order, rather than calling the rows KEV-first.
|
|
9
|
+
//
|
|
10
|
+
// The API answers a page past max_page as max_page, and an id it does not know with total 0, so
|
|
11
|
+
// the note compares the page asked for with the page served, and says when total counts CVEs no
|
|
12
|
+
// page lists. Each page is cached by the API for up to an hour (cweLandingTTL) behind a CDN, so
|
|
13
|
+
// the note says it can lag the feed.
|
|
14
|
+
import * as z from "zod";
|
|
15
|
+
import { ANNOTATIONS, api, badInput, checked, crashed, envelopeSchema, failed, field, lenAt, numAt, okHead, opt, rowsScoreNote, strAt, succeeded, } from "../index.js";
|
|
16
|
+
const TOOL = "get_cwe";
|
|
17
|
+
const CWE_ARG = /^(?:CWE-)?0*(\d{1,6})$/i;
|
|
18
|
+
const MAX_PAGE = 200;
|
|
19
|
+
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
|
+
function schemas() {
|
|
22
|
+
const row = z.looseObject({
|
|
23
|
+
cve_id: opt(z.string()),
|
|
24
|
+
severity: opt(z.string()),
|
|
25
|
+
cvss_v3_score: opt(z.number()),
|
|
26
|
+
cvss_v2_score: opt(z.number()),
|
|
27
|
+
cvss_v2_severity: opt(z.string()),
|
|
28
|
+
echelongraph_score: opt(z.number()).describe("EchelonGraph's 0-10 score: a score only when score_assessed is true."),
|
|
29
|
+
echelongraph_severity: opt(z.string()),
|
|
30
|
+
score_assessed: opt(z.boolean()).describe("Whether EchelonGraph has scored the CVE; false: NOT YET SCORED, not scored 0."),
|
|
31
|
+
kev_listed: opt(z.boolean()),
|
|
32
|
+
published: opt(z.string()),
|
|
33
|
+
description: opt(z.string()),
|
|
34
|
+
});
|
|
35
|
+
const data = z.looseObject({
|
|
36
|
+
cwe_id: opt(z.string()),
|
|
37
|
+
name: opt(z.string()),
|
|
38
|
+
description: opt(z.string()),
|
|
39
|
+
catalog_version: opt(z.string()).describe("The version of the MITRE CWE catalog name and description come from."),
|
|
40
|
+
total: opt(z.number()).describe("The active CVEs classified under the CWE, rejected and reserved records left out."),
|
|
41
|
+
cves: z.array(row).optional(),
|
|
42
|
+
page: opt(z.number()),
|
|
43
|
+
page_size: opt(z.number()),
|
|
44
|
+
max_page: opt(z.number()).describe("The last page the API serves; a later page is answered as this one."),
|
|
45
|
+
order: opt(z.string()).describe("How cves is sorted, as the API states it; absent from an API older than the field, which sorted by CVE id."),
|
|
46
|
+
});
|
|
47
|
+
const coverage = z.strictObject({
|
|
48
|
+
total: z.number().nullable(),
|
|
49
|
+
returned: z.number().nullable(),
|
|
50
|
+
page: z.number().nullable().describe("The page served."),
|
|
51
|
+
page_requested: z.number().describe("The page asked for."),
|
|
52
|
+
page_size: z.number().nullable(),
|
|
53
|
+
max_page: z.number().nullable(),
|
|
54
|
+
});
|
|
55
|
+
return { output: envelopeSchema({ data, coverage, freshness: null }) };
|
|
56
|
+
}
|
|
57
|
+
let built;
|
|
58
|
+
// Built on first use: index.ts imports this file, so its exports are not initialised while this
|
|
59
|
+
// module's top level runs.
|
|
60
|
+
const schemasOnce = () => (built ??= schemas());
|
|
61
|
+
export async function getCwe(cwe_id, page) {
|
|
62
|
+
const raw = cwe_id.trim();
|
|
63
|
+
if (!raw)
|
|
64
|
+
return badInput(TOOL, "cwe_id is required");
|
|
65
|
+
const m = CWE_ARG.exec(raw);
|
|
66
|
+
if (!m)
|
|
67
|
+
return badInput(TOOL, `cwe_id ${JSON.stringify(raw.slice(0, 32))} is not a CWE id (expected the form CWE-79)`);
|
|
68
|
+
const asked = page ?? 1;
|
|
69
|
+
if (!Number.isInteger(asked) || asked < 1 || asked > MAX_PAGE)
|
|
70
|
+
return badInput(TOOL, `page must be a whole number from 1 to ${MAX_PAGE}`);
|
|
71
|
+
const cwe = `CWE-${Number(m[1])}`;
|
|
72
|
+
try {
|
|
73
|
+
const r = await api(`/api/v1/public/cwes/${encodeURIComponent(cwe)}${asked > 1 ? `?page=${asked}` : ""}`);
|
|
74
|
+
if (!r.ok)
|
|
75
|
+
return failed(TOOL, r);
|
|
76
|
+
const d = r.data;
|
|
77
|
+
const id = strAt(d, "cwe_id") ?? cwe;
|
|
78
|
+
const total = numAt(d, "total");
|
|
79
|
+
const returned = lenAt(d, "cves");
|
|
80
|
+
const served = numAt(d, "page");
|
|
81
|
+
const size = numAt(d, "page_size");
|
|
82
|
+
const maxPage = numAt(d, "max_page");
|
|
83
|
+
const order = strAt(d, "order");
|
|
84
|
+
const name = strAt(d, "name");
|
|
85
|
+
const version = strAt(d, "catalog_version");
|
|
86
|
+
const said = [];
|
|
87
|
+
said.push(name
|
|
88
|
+
? `${id} is ${name} in the MITRE CWE catalog${version ? ` (version ${version})` : ""}.`
|
|
89
|
+
: `The answer carries no name for ${id}: the MITRE CWE catalog EchelonGraph embeds has no entry for it, or the API sent none.`);
|
|
90
|
+
if (total === 0) {
|
|
91
|
+
said.push(`No active CVE in EchelonGraph's feed is classified under ${id} (total 0): a measured empty result (we looked and found nothing), which says only that no NVD, GitHub or CVE.org record EchelonGraph holds puts a CVE in this class, not that none exists.`);
|
|
92
|
+
}
|
|
93
|
+
else if (total !== undefined) {
|
|
94
|
+
const of = returned === undefined ? "" : `; this page lists ${returned}`;
|
|
95
|
+
const pg = served === undefined ? "" : ` (page ${served}${maxPage === undefined ? "" : ` of at most ${maxPage}`})`;
|
|
96
|
+
said.push(`EchelonGraph's feed holds ${total} active CVEs classified under ${id} (total, rejected and reserved records left out)${of}${pg}.`);
|
|
97
|
+
if (maxPage !== undefined && size !== undefined && total > maxPage * size) {
|
|
98
|
+
said.push(`Only the first ${maxPage * size} of them can be paged to: the API serves no page past max_page ${maxPage}.`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (served !== undefined && served !== asked) {
|
|
102
|
+
said.push(`Page ${asked} was asked for and the API served page ${served}.`);
|
|
103
|
+
}
|
|
104
|
+
if (returned) {
|
|
105
|
+
const rows = field(d, "cves");
|
|
106
|
+
const kev = Array.isArray(rows) ? rows.filter((c) => field(c, "kev_listed") === true).length : 0;
|
|
107
|
+
said.push(order
|
|
108
|
+
? `The rows are sorted by ${order}, as the answer states (order); ${kev} of the ${returned} on this page are CISA-KEV-listed (kev_listed).`
|
|
109
|
+
: `The answer does not state how its rows are sorted (no order field; an API older than it sorted them by CVE id), so the first rows are not necessarily the CISA-KEV-listed or highest-scored ones; ${kev} of the ${returned} on this page are CISA-KEV-listed (kev_listed).`);
|
|
110
|
+
}
|
|
111
|
+
said.push("The API serves each page from a cache refreshed at most hourly, behind a CDN, so it can lag the feed by an hour or more.");
|
|
112
|
+
const scores = rowsScoreNote(d);
|
|
113
|
+
return succeeded(d, `${okHead(TOOL, r.status)} ${said.join(" ")}${scores}`, {
|
|
114
|
+
state: "measured",
|
|
115
|
+
measured_at: null,
|
|
116
|
+
method: GET_CWE_METHOD,
|
|
117
|
+
coverage: {
|
|
118
|
+
total: total ?? null,
|
|
119
|
+
returned: returned ?? null,
|
|
120
|
+
page: served ?? null,
|
|
121
|
+
page_requested: asked,
|
|
122
|
+
page_size: size ?? null,
|
|
123
|
+
max_page: maxPage ?? null,
|
|
124
|
+
},
|
|
125
|
+
freshness: null,
|
|
126
|
+
notes: [
|
|
127
|
+
"measured_at is null: a page of CVEs has no single observation time; each row carries its own published date.",
|
|
128
|
+
"freshness is null: the answer does not say when its cached page was computed.",
|
|
129
|
+
],
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
catch (e) {
|
|
133
|
+
return crashed(TOOL, e);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
export function registerGetCwe(server) {
|
|
137
|
+
const { output } = schemasOnce();
|
|
138
|
+
server.registerTool("get_cwe", {
|
|
139
|
+
title: "CWE and its CVEs",
|
|
140
|
+
description: GET_CWE_DESCRIPTION,
|
|
141
|
+
inputSchema: z.object({
|
|
142
|
+
cwe_id: z.string().describe("a CWE ID, e.g. CWE-79 (or 79)"),
|
|
143
|
+
page: z.number().int().min(1).max(MAX_PAGE).optional().describe(`page of 50 CVEs (default 1, max ${MAX_PAGE})`),
|
|
144
|
+
}),
|
|
145
|
+
outputSchema: output,
|
|
146
|
+
annotations: ANNOTATIONS,
|
|
147
|
+
}, async ({ cwe_id, page }) => checked(TOOL, output, await getCwe(cwe_id, page)));
|
|
148
|
+
}
|