echelongraph-mcp 2.3.4 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +136 -0
- package/README.md +304 -46
- package/dist/http.js +240 -0
- package/dist/httpPolicy.js +244 -0
- package/dist/index.js +123 -46
- package/dist/prompts.js +137 -0
- package/dist/resources.js +153 -0
- 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 +5 -2
- package/server.json +18 -3
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
export const CHECK_SBOM = "check_sbom";
|
|
3
|
+
export const BATCH_PATH = "/api/v1/public/cves/match/batch";
|
|
4
|
+
// The backend's cap (cveBatchMaxComponents). A document with more is refused here, before any
|
|
5
|
+
// request, and never truncated: a dropped component would read as a clean one.
|
|
6
|
+
export const MAX_COMPONENTS = 200;
|
|
7
|
+
// The backend's per-purl cap (cveBatchMaxPurlLen).
|
|
8
|
+
const MAX_PURL_LEN = 512;
|
|
9
|
+
// The document's size cap, as JSON text. OWASP Juice Shop 11.1.2's full CycloneDX SBOM (840
|
|
10
|
+
// components) is 0.74 MB; this leaves room for a much larger one while bounding what this process
|
|
11
|
+
// parses.
|
|
12
|
+
export const MAX_SBOM_CHARS = 5_000_000;
|
|
13
|
+
// How many affected components the note names, and how many CVE ids per component.
|
|
14
|
+
const NOTE_AFFECTED_MAX = 25;
|
|
15
|
+
const NOTE_CVES_MAX = 5;
|
|
16
|
+
// CycloneDX nests components; this bounds the walk.
|
|
17
|
+
const MAX_DEPTH = 32;
|
|
18
|
+
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.";
|
|
19
|
+
// Always in the envelope's notes, whatever the answer: the refusals a model must not read as clean.
|
|
20
|
+
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.";
|
|
21
|
+
const CLEAN_NOTE = "Only not_affected is a clean verdict, and only for the advisories decided at that version: an undetermined or not_assessed component is not a finding of no vulnerability, and is reported as unchecked, never as clean.";
|
|
22
|
+
const MEASURED_AT_NOTE = "measured_at is null: the corpus rows are read through a cache whose maximum age the answer states (summary.corpus_cache_max_age_ms), so the answer gives no single time at which they were read.";
|
|
23
|
+
const FRESHNESS_NOTE = "freshness is null: the answer carries no time at which the advisory corpus was last refreshed.";
|
|
24
|
+
export const CHECK_SBOM_TITLE = "Check an SBOM against the advisory corpus";
|
|
25
|
+
export const CHECK_SBOM_DESCRIPTION = "Check a dependency list against EchelonGraph's advisory corpus, one verdict per component. Pass purls (package URLs, up to 200) or sbom (a CycloneDX JSON or SPDX JSON document, as JSON text or as an object, up to 5,000,000 characters). The purls are read from the document on this machine and only they are sent, in the body of one POST, never in a URL; the document itself is not sent. A component without a purl is counted and not checked. Each purl is mapped to its OSV ecosystem, package name and version and matched against the affected version ranges EchelonGraph holds from OSV.dev advisory records; there is no ranking and no score. data.results holds one row per component sent, in order, with verdict (affected, not_affected, undetermined or not_assessed), assessed, not_assessed_reason, cve_ids, matches, count, not_affected_count and undetermined_count; data.summary counts the verdicts. not_affected is the only clean verdict. undetermined: advisories name the package but at least one could not be decided at this version and none matched. not_assessed: no verdict at all, because the package is not in the corpus, the purl type has no OSV ecosystem, a deb, apk or rpm purl carries no distro qualifier naming its release (EchelonGraph does not guess one), the version is missing, the lookup failed, or the batch's time budget ran out first (time_budget). Neither undetermined nor not_assessed is clean, and the note gives their counts. A list or document with more than 200 distinct purls is refused, not truncated: split it. Its structured result carries state (measured when at least one component got a verdict, else not_assessed), measured_at (null: the corpus is read through a cache, so no single read time exists), method, coverage (what the input held and what was sent), 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.";
|
|
26
|
+
const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
27
|
+
function dedupe(all) {
|
|
28
|
+
const seen = new Set();
|
|
29
|
+
const purls = [];
|
|
30
|
+
for (const p of all) {
|
|
31
|
+
if (seen.has(p))
|
|
32
|
+
continue;
|
|
33
|
+
seen.add(p);
|
|
34
|
+
purls.push(p);
|
|
35
|
+
}
|
|
36
|
+
return { purls, removed: all.length - purls.length };
|
|
37
|
+
}
|
|
38
|
+
// The purls of a CycloneDX JSON document: every components[] entry, nested ones included.
|
|
39
|
+
// metadata.component (the subject of the SBOM, not a dependency of it) is not checked.
|
|
40
|
+
function fromCycloneDX(doc) {
|
|
41
|
+
if (doc.components !== undefined && !Array.isArray(doc.components))
|
|
42
|
+
return "its components is not an array";
|
|
43
|
+
let total = 0;
|
|
44
|
+
let without = 0;
|
|
45
|
+
const found = [];
|
|
46
|
+
const walk = (cs, depth) => {
|
|
47
|
+
if (!Array.isArray(cs))
|
|
48
|
+
return null;
|
|
49
|
+
if (depth > MAX_DEPTH)
|
|
50
|
+
return `its components nest deeper than ${MAX_DEPTH} levels`;
|
|
51
|
+
for (const c of cs) {
|
|
52
|
+
if (!isObj(c))
|
|
53
|
+
continue;
|
|
54
|
+
total++;
|
|
55
|
+
const p = typeof c.purl === "string" ? c.purl.trim() : "";
|
|
56
|
+
if (p)
|
|
57
|
+
found.push(p);
|
|
58
|
+
else
|
|
59
|
+
without++;
|
|
60
|
+
const err = walk(c.components, depth + 1);
|
|
61
|
+
if (err)
|
|
62
|
+
return err;
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
};
|
|
66
|
+
const err = walk(doc.components, 0);
|
|
67
|
+
if (err)
|
|
68
|
+
return err;
|
|
69
|
+
const { purls, removed } = dedupe(found);
|
|
70
|
+
return { input: "cyclonedx", components_in_document: total, with_purl: found.length, without_purl: without, duplicates_removed: removed, purls };
|
|
71
|
+
}
|
|
72
|
+
// The purls of an SPDX JSON document: each package's externalRefs entry of referenceType purl
|
|
73
|
+
// (referenceCategory PACKAGE-MANAGER, spelled PACKAGE_MANAGER by some tools; the type decides).
|
|
74
|
+
function fromSPDX(doc) {
|
|
75
|
+
if (doc.packages !== undefined && !Array.isArray(doc.packages))
|
|
76
|
+
return "its packages is not an array";
|
|
77
|
+
const pkgs = Array.isArray(doc.packages) ? doc.packages : [];
|
|
78
|
+
let total = 0;
|
|
79
|
+
let without = 0;
|
|
80
|
+
const found = [];
|
|
81
|
+
for (const p of pkgs) {
|
|
82
|
+
if (!isObj(p))
|
|
83
|
+
continue;
|
|
84
|
+
total++;
|
|
85
|
+
const refs = Array.isArray(p.externalRefs) ? p.externalRefs : [];
|
|
86
|
+
const ref = refs.find((r) => isObj(r) && typeof r.referenceType === "string" && r.referenceType.toLowerCase() === "purl" && typeof r.referenceLocator === "string" && r.referenceLocator.trim());
|
|
87
|
+
if (ref && isObj(ref))
|
|
88
|
+
found.push(String(ref.referenceLocator).trim());
|
|
89
|
+
else
|
|
90
|
+
without++;
|
|
91
|
+
}
|
|
92
|
+
const { purls, removed } = dedupe(found);
|
|
93
|
+
return { input: "spdx", components_in_document: total, with_purl: found.length, without_purl: without, duplicates_removed: removed, purls };
|
|
94
|
+
}
|
|
95
|
+
// Reads the tool's arguments into the purls to send, or says why it cannot.
|
|
96
|
+
export function extract(a) {
|
|
97
|
+
const hasPurls = a.purls !== undefined;
|
|
98
|
+
const hasSbom = a.sbom !== undefined;
|
|
99
|
+
if (hasPurls === hasSbom)
|
|
100
|
+
return "pass exactly one of purls (a list of package URLs) or sbom (a CycloneDX JSON or SPDX JSON document)";
|
|
101
|
+
if (hasPurls) {
|
|
102
|
+
if (!Array.isArray(a.purls) || a.purls.some((p) => typeof p !== "string"))
|
|
103
|
+
return "purls must be a list of strings";
|
|
104
|
+
const trimmed = a.purls.map((p) => p.trim()).filter(Boolean);
|
|
105
|
+
const { purls, removed } = dedupe(trimmed);
|
|
106
|
+
return { input: "purls", components_in_document: null, with_purl: trimmed.length, without_purl: a.purls.length - trimmed.length, duplicates_removed: removed, purls };
|
|
107
|
+
}
|
|
108
|
+
let doc = a.sbom;
|
|
109
|
+
if (typeof doc === "string") {
|
|
110
|
+
if (doc.length > MAX_SBOM_CHARS)
|
|
111
|
+
return `sbom is ${doc.length} characters; at most ${MAX_SBOM_CHARS} are read`;
|
|
112
|
+
try {
|
|
113
|
+
doc = JSON.parse(doc);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return "sbom is not JSON: pass a CycloneDX JSON or SPDX JSON document (XML and SPDX tag-value are not read)";
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
let size = 0;
|
|
121
|
+
try {
|
|
122
|
+
size = JSON.stringify(doc)?.length ?? 0;
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return "sbom could not be serialised as JSON";
|
|
126
|
+
}
|
|
127
|
+
if (size > MAX_SBOM_CHARS)
|
|
128
|
+
return `sbom is ${size} characters as JSON; at most ${MAX_SBOM_CHARS} are read`;
|
|
129
|
+
}
|
|
130
|
+
if (!isObj(doc))
|
|
131
|
+
return "sbom must be a JSON object: a CycloneDX JSON or SPDX JSON document";
|
|
132
|
+
if (typeof doc.bomFormat === "string" && doc.bomFormat.toLowerCase() === "cyclonedx") {
|
|
133
|
+
const r = fromCycloneDX(doc);
|
|
134
|
+
return typeof r === "string" ? `the CycloneDX document cannot be read: ${r}` : r;
|
|
135
|
+
}
|
|
136
|
+
if (typeof doc.spdxVersion === "string" && doc.spdxVersion.toUpperCase().startsWith("SPDX-")) {
|
|
137
|
+
const r = fromSPDX(doc);
|
|
138
|
+
return typeof r === "string" ? `the SPDX document cannot be read: ${r}` : r;
|
|
139
|
+
}
|
|
140
|
+
return 'sbom is neither a CycloneDX JSON document (bomFormat "CycloneDX") nor an SPDX JSON document (spdxVersion "SPDX-…")';
|
|
141
|
+
}
|
|
142
|
+
// ── The answer ──
|
|
143
|
+
const field = (o, k) => (isObj(o) ? o[k] : undefined);
|
|
144
|
+
const count = (o, k) => {
|
|
145
|
+
const v = field(o, k);
|
|
146
|
+
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
|
147
|
+
};
|
|
148
|
+
const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
|
149
|
+
function noteFor(head, x, d) {
|
|
150
|
+
const s = field(d, "summary");
|
|
151
|
+
const affected = count(s, "affected") ?? 0;
|
|
152
|
+
const notAffected = count(s, "not_affected") ?? 0;
|
|
153
|
+
const undetermined = count(s, "undetermined") ?? 0;
|
|
154
|
+
const notAssessed = count(s, "not_assessed") ?? 0;
|
|
155
|
+
const components = count(s, "components") ?? x.purls.length;
|
|
156
|
+
const out = [head];
|
|
157
|
+
if (x.input === "purls") {
|
|
158
|
+
out.push(`Sent ${plural(x.purls.length, "purl", "purls")}${x.duplicates_removed ? ` (${plural(x.duplicates_removed, "duplicate was", "duplicates were")} sent once)` : ""}.`);
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
const kind = x.input === "cyclonedx" ? "CycloneDX" : "SPDX";
|
|
162
|
+
const unit = x.input === "cyclonedx" ? "component" : "package";
|
|
163
|
+
out.push(`Read ${plural(x.components_in_document ?? 0, unit, `${unit}s`)} from the ${kind} document: ${x.with_purl} with a purl${x.without_purl ? `, and ${x.without_purl} without one, which ${x.without_purl === 1 ? "was" : "were"} not checked and ${x.without_purl === 1 ? "is" : "are"} not clean` : ""}${x.duplicates_removed ? `; ${plural(x.duplicates_removed, "duplicate purl was", "duplicate purls were")} sent once` : ""}; sent ${x.purls.length}.`);
|
|
164
|
+
}
|
|
165
|
+
out.push(`Of the ${components} components checked: ${affected} affected, ${notAffected} not affected, ${undetermined} undetermined, ${notAssessed} not assessed.`);
|
|
166
|
+
const by = field(s, "not_assessed_by_reason");
|
|
167
|
+
if (isObj(by)) {
|
|
168
|
+
const parts = Object.entries(by)
|
|
169
|
+
.filter(([, n]) => typeof n === "number" && n > 0)
|
|
170
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
171
|
+
.map(([r, n]) => `${r} ${n}`);
|
|
172
|
+
if (parts.length)
|
|
173
|
+
out.push(`Not assessed, by not_assessed_reason: ${parts.join(", ")}.`);
|
|
174
|
+
const tb = count(by, "time_budget") ?? 0;
|
|
175
|
+
if (tb > 0) {
|
|
176
|
+
out.push(`The batch's time budget ran out before ${plural(tb, "component was", "components were")} looked up: ${tb === 1 ? "it is" : "they are"} not assessed, not clean; check ${tb === 1 ? "it" : "them"} again.`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const rows = field(d, "results");
|
|
180
|
+
if (Array.isArray(rows)) {
|
|
181
|
+
const hits = rows.filter((r) => field(r, "verdict") === "affected");
|
|
182
|
+
if (hits.length) {
|
|
183
|
+
const named = hits.slice(0, NOTE_AFFECTED_MAX).map((r) => {
|
|
184
|
+
const who = (typeof field(r, "purl") === "string" && field(r, "purl")) || `${field(r, "ecosystem") ?? ""} ${field(r, "package") ?? ""} ${field(r, "version") ?? ""}`.trim();
|
|
185
|
+
const ids = field(r, "cve_ids");
|
|
186
|
+
const list = Array.isArray(ids) ? ids.filter((i) => typeof i === "string") : [];
|
|
187
|
+
const shown = list.slice(0, NOTE_CVES_MAX).join(", ");
|
|
188
|
+
return `${who} (${shown}${list.length > NOTE_CVES_MAX ? `, and ${list.length - NOTE_CVES_MAX} more` : ""})`;
|
|
189
|
+
});
|
|
190
|
+
out.push(`Affected: ${named.join("; ")}${hits.length > NOTE_AFFECTED_MAX ? `; and ${hits.length - NOTE_AFFECTED_MAX} more affected components in data.results` : ""}.`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return { note: out.join(" "), assessed: affected + notAffected + undetermined };
|
|
194
|
+
}
|
|
195
|
+
export async function checkSbom(deps, a) {
|
|
196
|
+
const tool = CHECK_SBOM;
|
|
197
|
+
try {
|
|
198
|
+
const x = extract(a);
|
|
199
|
+
if (typeof x === "string")
|
|
200
|
+
return deps.badInput(tool, x);
|
|
201
|
+
if (x.purls.length === 0) {
|
|
202
|
+
return deps.badInput(tool, x.input === "purls" ? "purls holds no package URL" : `the document holds ${x.components_in_document ?? 0} components and none carries a purl, so there is nothing to check`);
|
|
203
|
+
}
|
|
204
|
+
if (x.purls.length > MAX_COMPONENTS) {
|
|
205
|
+
return deps.badInput(tool, `${x.purls.length} distinct purls; at most ${MAX_COMPONENTS} are checked per call, and none is dropped silently: split the list into calls of ${MAX_COMPONENTS} or fewer`);
|
|
206
|
+
}
|
|
207
|
+
const long = x.purls.findIndex((p) => p.length > MAX_PURL_LEN);
|
|
208
|
+
if (long >= 0)
|
|
209
|
+
return deps.badInput(tool, `purl ${long + 1} is longer than ${MAX_PURL_LEN} characters`);
|
|
210
|
+
const r = await deps.api(BATCH_PATH, {
|
|
211
|
+
method: "POST",
|
|
212
|
+
headers: { "Content-Type": "application/json" },
|
|
213
|
+
body: JSON.stringify({ components: x.purls.map((purl) => ({ purl })) }),
|
|
214
|
+
});
|
|
215
|
+
if (!r.ok)
|
|
216
|
+
return deps.failed(tool, r);
|
|
217
|
+
const { note, assessed } = noteFor(deps.okHead(tool, r.status), x, r.data);
|
|
218
|
+
const s = field(r.data, "summary");
|
|
219
|
+
return deps.succeeded(r.data, note, {
|
|
220
|
+
state: assessed > 0 ? "measured" : "not_assessed",
|
|
221
|
+
measured_at: null,
|
|
222
|
+
method: METHOD,
|
|
223
|
+
coverage: {
|
|
224
|
+
input: x.input,
|
|
225
|
+
components_in_document: x.components_in_document,
|
|
226
|
+
with_purl: x.with_purl,
|
|
227
|
+
without_purl: x.without_purl,
|
|
228
|
+
duplicates_removed: x.duplicates_removed,
|
|
229
|
+
sent: x.purls.length,
|
|
230
|
+
not_assessed: count(s, "not_assessed") ?? null,
|
|
231
|
+
partial: typeof field(s, "partial") === "boolean" ? field(s, "partial") : null,
|
|
232
|
+
},
|
|
233
|
+
freshness: null,
|
|
234
|
+
notes: [DISTRO_NOTE, CLEAN_NOTE, MEASURED_AT_NOTE, FRESHNESS_NOTE],
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
catch (e) {
|
|
238
|
+
return deps.crashed(tool, e);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
// ── Schemas ──
|
|
242
|
+
const opt = (t) => t.nullable().optional();
|
|
243
|
+
const Row = z.looseObject({
|
|
244
|
+
index: opt(z.number()),
|
|
245
|
+
input_kind: opt(z.string()),
|
|
246
|
+
purl: opt(z.string()),
|
|
247
|
+
verdict: opt(z.string()).describe("affected (count > 0); not_affected (assessed, no match, nothing undetermined: the only clean verdict); undetermined (advisories name the package, at least one could not be decided, none matched: not clean); not_assessed (no verdict: not clean)."),
|
|
248
|
+
ecosystem: opt(z.string()),
|
|
249
|
+
package: opt(z.string()),
|
|
250
|
+
version: opt(z.string()),
|
|
251
|
+
assessed: opt(z.boolean()).describe("Whether the matcher produced a verdict for this component. false is never clean."),
|
|
252
|
+
not_assessed_reason: opt(z.string()).describe("Why assessed is false: package_not_in_advisory_corpus, no_decidable_advisory, advisory_lookup_failed, candidate_window_truncated, time_budget, distro_release_unknown, purl_type_unsupported, version_missing or invalid_component."),
|
|
253
|
+
code: opt(z.string()),
|
|
254
|
+
error: opt(z.string()),
|
|
255
|
+
cve_ids: opt(z.array(z.string())),
|
|
256
|
+
matches: opt(z.array(z.looseObject({ cve_id: opt(z.string()) }))),
|
|
257
|
+
count: opt(z.number()),
|
|
258
|
+
advisories_considered: opt(z.number()),
|
|
259
|
+
not_affected_count: opt(z.number()),
|
|
260
|
+
undetermined_count: opt(z.number()),
|
|
261
|
+
undetermined: opt(z.array(z.looseObject({ cve_id: opt(z.string()) }))),
|
|
262
|
+
capped: opt(z.boolean()),
|
|
263
|
+
candidates_capped: opt(z.boolean()),
|
|
264
|
+
});
|
|
265
|
+
export function checkSbomOutput(envelopeSchema) {
|
|
266
|
+
return envelopeSchema({
|
|
267
|
+
data: z.looseObject({
|
|
268
|
+
match_layer: opt(z.string()),
|
|
269
|
+
components: opt(z.number()),
|
|
270
|
+
summary: z
|
|
271
|
+
.looseObject({
|
|
272
|
+
components: opt(z.number()),
|
|
273
|
+
affected: opt(z.number()),
|
|
274
|
+
not_affected: opt(z.number()).describe("Components with a decided, clean verdict."),
|
|
275
|
+
undetermined: opt(z.number()).describe("Components whose advisories could not all be decided and none matched: not clean."),
|
|
276
|
+
not_assessed: opt(z.number()).describe("Components with no verdict: not clean."),
|
|
277
|
+
not_assessed_by_reason: opt(z.looseObject({
|
|
278
|
+
package_not_in_advisory_corpus: z.number().optional(),
|
|
279
|
+
no_decidable_advisory: z.number().optional(),
|
|
280
|
+
advisory_lookup_failed: z.number().optional(),
|
|
281
|
+
candidate_window_truncated: z.number().optional(),
|
|
282
|
+
time_budget: z.number().optional().describe("Components the batch's time budget ran out before: not clean; check them again."),
|
|
283
|
+
distro_release_unknown: z.number().optional().describe("deb, apk or rpm purls without a distro qualifier naming the release, which EchelonGraph does not guess."),
|
|
284
|
+
purl_type_unsupported: z.number().optional(),
|
|
285
|
+
version_missing: z.number().optional(),
|
|
286
|
+
invalid_component: z.number().optional(),
|
|
287
|
+
})).describe("The not_assessed components, counted by not_assessed_reason."),
|
|
288
|
+
lookups: opt(z.number()),
|
|
289
|
+
partial: opt(z.boolean()).describe("true when the time budget ran out before every component was looked up."),
|
|
290
|
+
time_budget_ms: opt(z.number()),
|
|
291
|
+
elapsed_ms: opt(z.number()),
|
|
292
|
+
corpus_cache_max_age_ms: opt(z.number()),
|
|
293
|
+
})
|
|
294
|
+
.optional(),
|
|
295
|
+
answered_at: opt(z.string()),
|
|
296
|
+
results: z.array(Row).optional(),
|
|
297
|
+
}),
|
|
298
|
+
coverage: z.strictObject({
|
|
299
|
+
input: z.enum(["purls", "cyclonedx", "spdx"]).describe("What was passed: a purl list, a CycloneDX JSON document or an SPDX JSON document."),
|
|
300
|
+
components_in_document: z.number().int().nullable().describe("Components (CycloneDX) or packages (SPDX) in the document; null for a purl list."),
|
|
301
|
+
with_purl: z.number().int().describe("Of those, the ones carrying a purl."),
|
|
302
|
+
without_purl: z.number().int().describe("The ones without a purl: not checked, and not clean."),
|
|
303
|
+
duplicates_removed: z.number().int().describe("Purls that appeared more than once and were sent once."),
|
|
304
|
+
sent: z.number().int().describe("Distinct purls sent and checked."),
|
|
305
|
+
not_assessed: z.number().int().nullable().describe("Of those sent, the components with no verdict, as the answer counts them."),
|
|
306
|
+
partial: z.boolean().nullable().describe("The answer's summary.partial: true when the time budget ran out first."),
|
|
307
|
+
}),
|
|
308
|
+
freshness: null,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
// A literal server.registerTool(name, { title, description, … }) call with constant strings:
|
|
312
|
+
// marketing-site lib/mcpToolClaims.test.ts reads it here (it follows index.ts's call to this
|
|
313
|
+
// function) and holds the /pulse/mcp row to this title and description.
|
|
314
|
+
export function registerCheckSbom(server, deps) {
|
|
315
|
+
const output = checkSbomOutput(deps.envelopeSchema);
|
|
316
|
+
server.registerTool(CHECK_SBOM, {
|
|
317
|
+
title: CHECK_SBOM_TITLE,
|
|
318
|
+
description: CHECK_SBOM_DESCRIPTION,
|
|
319
|
+
inputSchema: z.object({
|
|
320
|
+
purls: z.array(z.string()).optional().describe("package URLs to check, e.g. pkg:npm/lodash@4.17.20 or pkg:deb/debian/openssl@3.0.11-1~deb12u1?distro=debian-12 (up to 200)"),
|
|
321
|
+
sbom: z
|
|
322
|
+
.union([z.string(), z.record(z.string(), z.unknown())])
|
|
323
|
+
.optional()
|
|
324
|
+
.describe("a CycloneDX JSON or SPDX JSON document, as JSON text or as an object; its purls are read here and only they are sent"),
|
|
325
|
+
}),
|
|
326
|
+
outputSchema: output,
|
|
327
|
+
annotations: deps.annotations,
|
|
328
|
+
}, async (a) => deps.checked(CHECK_SBOM, output, await checkSbom(deps, a)));
|
|
329
|
+
}
|
|
@@ -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
|
+
}
|