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
package/dist/index.js
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
// that EchelonGraph's KEV-exposure radar derives from Shodan data. Every figure is as fresh as
|
|
6
6
|
// the schedule that refreshes it, and the tool texts say which schedule that is.
|
|
7
7
|
// stdio transport; no auth required; read-only. It makes no request other than the API call a
|
|
8
|
-
// tool needs to answer.
|
|
8
|
+
// tool needs to answer. The hosted Streamable HTTP endpoint (mcp.echelongraph.io, #2316) is
|
|
9
|
+
// http.ts, which serves this file's createServer — one tool module for both.
|
|
9
10
|
//
|
|
10
11
|
// Result contract (#1874). A tool answers in exactly one of two shapes, and they never blur:
|
|
11
12
|
// - success: content[0] is the API's JSON verbatim; content[1] is a one-line note saying
|
|
@@ -45,6 +46,16 @@ import { readFileSync } from "node:fs";
|
|
|
45
46
|
import { McpServer } from "@modelcontextprotocol/server";
|
|
46
47
|
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
47
48
|
import * as z from "zod";
|
|
49
|
+
import { registerKevRecent } from "./tools/kev_recent.js";
|
|
50
|
+
import { registerEPSSHistory } from "./tools/epss_history.js";
|
|
51
|
+
import { registerCheckAffected } from "./tools/check_affected.js";
|
|
52
|
+
import { registerCheckSbom } from "./tools/check_sbom.js";
|
|
53
|
+
import { registerCveIntel } from "./tools/cve_intel.js";
|
|
54
|
+
import { registerGetCwe } from "./tools/get_cwe.js";
|
|
55
|
+
import { isHttpEntrypoint, upstreamHeaders } from "./runtime.js";
|
|
56
|
+
import { registerVendorAdvisoryTools } from "./tools/vendor_advisories.js";
|
|
57
|
+
import { registerPrompts } from "./prompts.js";
|
|
58
|
+
import { registerResources } from "./resources.js";
|
|
48
59
|
// The package actually running, read from the package.json that ships beside dist/. The MCP
|
|
49
60
|
// handshake (serverInfo) and the User-Agent both carry its name and version, so the API's
|
|
50
61
|
// access log can count adoption per version without a code change per release.
|
|
@@ -64,14 +75,30 @@ const pkgField = (k, fallback) => {
|
|
|
64
75
|
const NAME = pkgField("name", "echelongraph-mcp");
|
|
65
76
|
const VERSION = pkgField("version", "unknown");
|
|
66
77
|
const BASE = (process.env.ECHELONGRAPH_API_BASE || "https://app.echelongraph.io").replace(/\/+$/, "");
|
|
67
|
-
|
|
78
|
+
// ECHELONGRAPH_MCP_UA (#2724): one product token, such as echelongraph-mcp-synthetic/1.0, put
|
|
79
|
+
// AHEAD of the package's own User-Agent. The API's access log reads the family from the leading
|
|
80
|
+
// token, so a caller that is not a user (our production synthetic) is filed under its own family
|
|
81
|
+
// and never counted as MCP adoption, while the package and version stay readable after it.
|
|
82
|
+
// Unset by default. A value that is not a single token (spaces, control characters, a name over 64
|
|
83
|
+
// or a version over 32 characters) is ignored, with one line on stderr naming its length, not its text.
|
|
84
|
+
const UA_TOKEN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}(?:\/[A-Za-z0-9][A-Za-z0-9._-]{0,31})?$/;
|
|
85
|
+
const UA_PREFIX = (() => {
|
|
86
|
+
const v = process.env.ECHELONGRAPH_MCP_UA;
|
|
87
|
+
if (v === undefined || v === "")
|
|
88
|
+
return "";
|
|
89
|
+
if (UA_TOKEN.test(v))
|
|
90
|
+
return `${v} `;
|
|
91
|
+
process.stderr.write(`echelongraph-mcp: ECHELONGRAPH_MCP_UA ignored: not a single product token (${v.length} characters)\n`);
|
|
92
|
+
return "";
|
|
93
|
+
})();
|
|
94
|
+
const UA = `${UA_PREFIX}${NAME}/${VERSION} (+https://echelongraph.io/pulse/mcp)`;
|
|
68
95
|
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
69
96
|
const TIMEOUT_MS = (() => {
|
|
70
97
|
const n = Number(process.env.ECHELONGRAPH_API_TIMEOUT_MS);
|
|
71
98
|
return Number.isFinite(n) && n > 0 ? Math.floor(n) : DEFAULT_TIMEOUT_MS;
|
|
72
99
|
})();
|
|
73
100
|
// The base as quoted in tool results. A proxy credential in the URL's userinfo is dropped.
|
|
74
|
-
const SHOWN_BASE = (() => {
|
|
101
|
+
export const SHOWN_BASE = (() => {
|
|
75
102
|
try {
|
|
76
103
|
const u = new URL(BASE);
|
|
77
104
|
if (!u.username && !u.password)
|
|
@@ -127,47 +154,54 @@ function apiMessage(body) {
|
|
|
127
154
|
}
|
|
128
155
|
return snippet(body);
|
|
129
156
|
}
|
|
130
|
-
// One
|
|
131
|
-
// Failure so the tool renders it as an error result. A 2xx whose
|
|
132
|
-
// (an SPA shell, an edge challenge page, a literal null) is a failure
|
|
133
|
-
|
|
157
|
+
// One request against the API, a GET unless init says otherwise. Never throws: every way the call
|
|
158
|
+
// can fail comes back as a typed Failure so the tool renders it as an error result. A 2xx whose
|
|
159
|
+
// body is not a JSON object (an SPA shell, an edge challenge page, a literal null) is a failure
|
|
160
|
+
// too — it is not data. init.headers carries what a caller typed (#1983: X-EG-* headers, never
|
|
161
|
+
// the URL); the User-Agent and Accept are this server's and are not overridden.
|
|
162
|
+
export async function api(path, init) {
|
|
134
163
|
const ctrl = new AbortController();
|
|
135
164
|
let timedOut = false;
|
|
136
165
|
const timer = setTimeout(() => {
|
|
137
166
|
timedOut = true;
|
|
138
167
|
ctrl.abort();
|
|
139
168
|
}, TIMEOUT_MS);
|
|
140
|
-
const
|
|
169
|
+
const m = init?.method && init.method.toUpperCase() !== "GET" ? { method: init.method.toUpperCase() } : {};
|
|
170
|
+
const timeout = () => ({ ok: false, kind: "timeout", path, ...m, detail: `no response within ${TIMEOUT_MS} ms` });
|
|
141
171
|
try {
|
|
142
172
|
let res;
|
|
143
173
|
try {
|
|
144
174
|
res = await fetch(`${BASE}${path}`, {
|
|
145
|
-
|
|
175
|
+
method: init?.method,
|
|
176
|
+
body: init?.body,
|
|
177
|
+
// upstreamHeaders: empty on stdio; on the hosted endpoint, the forward token and the
|
|
178
|
+
// end client's address (runtime.ts, #2316).
|
|
179
|
+
headers: { ...upstreamHeaders(`${BASE}${path}`), ...(init?.headers ?? {}), "User-Agent": UA, Accept: "application/json" },
|
|
146
180
|
signal: ctrl.signal,
|
|
147
181
|
});
|
|
148
182
|
}
|
|
149
183
|
catch (e) {
|
|
150
|
-
return timedOut ? timeout() : { ok: false, kind: "network", path, detail: describeError(e) };
|
|
184
|
+
return timedOut ? timeout() : { ok: false, kind: "network", path, ...m, detail: describeError(e) };
|
|
151
185
|
}
|
|
152
186
|
let body;
|
|
153
187
|
try {
|
|
154
188
|
body = await res.text();
|
|
155
189
|
}
|
|
156
190
|
catch (e) {
|
|
157
|
-
return timedOut ? timeout() : { ok: false, kind: "network", path, detail: `reading the body failed: ${describeError(e)}` };
|
|
191
|
+
return timedOut ? timeout() : { ok: false, kind: "network", path, ...m, detail: `reading the body failed: ${describeError(e)}` };
|
|
158
192
|
}
|
|
159
193
|
if (!res.ok)
|
|
160
|
-
return { ok: false, kind: "http", path, status: res.status, detail: apiMessage(body) };
|
|
194
|
+
return { ok: false, kind: "http", path, ...m, status: res.status, detail: apiMessage(body) };
|
|
161
195
|
let data;
|
|
162
196
|
try {
|
|
163
197
|
data = JSON.parse(body);
|
|
164
198
|
}
|
|
165
199
|
catch {
|
|
166
200
|
const ctype = res.headers.get("content-type") ?? "no content-type";
|
|
167
|
-
return { ok: false, kind: "not_json", path, status: res.status, detail: `${ctype}; body starts: ${snippet(body)}` };
|
|
201
|
+
return { ok: false, kind: "not_json", path, ...m, status: res.status, detail: `${ctype}; body starts: ${snippet(body)}` };
|
|
168
202
|
}
|
|
169
203
|
if (data === null || typeof data !== "object") {
|
|
170
|
-
return { ok: false, kind: "not_object", path, status: res.status, detail: `body was ${snippet(body)}` };
|
|
204
|
+
return { ok: false, kind: "not_object", path, ...m, status: res.status, detail: `body was ${snippet(body)}` };
|
|
171
205
|
}
|
|
172
206
|
return { ok: true, status: res.status, data };
|
|
173
207
|
}
|
|
@@ -178,21 +212,22 @@ async function api(path) {
|
|
|
178
212
|
const text = (t) => ({ type: "text", text: t });
|
|
179
213
|
// A note, one sentence per entry. Sentences end in ".", "!" or "?" before whitespace, so a URL,
|
|
180
214
|
// a version or a field path (observed.total, crt.sh) does not split one.
|
|
181
|
-
const sentences = (t) => t
|
|
215
|
+
export const sentences = (t) => t
|
|
182
216
|
.replace(/\s+/g, " ")
|
|
183
217
|
.trim()
|
|
184
218
|
.split(/(?<=[.!?])\s+/)
|
|
185
219
|
.filter(Boolean);
|
|
186
220
|
// A timestamp worth relaying as one: see realInstant below.
|
|
187
|
-
const instantOrNull = (s) => (realInstant(s) ? s : null);
|
|
221
|
+
export const instantOrNull = (s) => (realInstant(s) ? s : null);
|
|
188
222
|
// One sentence per failure kind: where we looked, for what, and what came back.
|
|
189
223
|
function describeFailure(f) {
|
|
190
|
-
const
|
|
224
|
+
const verb = f.method ?? "GET";
|
|
225
|
+
const where = `from ${SHOWN_BASE} for ${verb} ${f.path}`;
|
|
191
226
|
switch (f.kind) {
|
|
192
227
|
case "network":
|
|
193
|
-
return `EchelonGraph at ${SHOWN_BASE} could not be reached for
|
|
228
|
+
return `EchelonGraph at ${SHOWN_BASE} could not be reached for ${verb} ${f.path}: ${f.detail}.`;
|
|
194
229
|
case "timeout":
|
|
195
|
-
return `EchelonGraph at ${SHOWN_BASE} did not answer
|
|
230
|
+
return `EchelonGraph at ${SHOWN_BASE} did not answer ${verb} ${f.path} within ${TIMEOUT_MS} ms.`;
|
|
196
231
|
case "http":
|
|
197
232
|
return `EchelonGraph answered HTTP ${f.status} ${where} — the API said: ${f.detail}.`;
|
|
198
233
|
case "not_json":
|
|
@@ -204,7 +239,7 @@ function describeFailure(f) {
|
|
|
204
239
|
}
|
|
205
240
|
}
|
|
206
241
|
// What a model must not conclude from a failure.
|
|
207
|
-
const NOT_A_FINDING = "The lookup did not complete, so this is not a finding: do not report it as zero, none found, absent, or unexposed. Retry later or check ECHELONGRAPH_API_BASE.";
|
|
242
|
+
export const NOT_A_FINDING = "The lookup did not complete, so this is not a finding: do not report it as zero, none found, absent, or unexposed. Retry later or check ECHELONGRAPH_API_BASE.";
|
|
208
243
|
// A 404 is the one non-2xx that carries an answer of its own — the API's not-found message,
|
|
209
244
|
// quoted in the sentence before it — so it is worded as "no record", not as an outage.
|
|
210
245
|
const NO_RECORD = "The API returned no record for this request; the quoted message is the API's own explanation. This says nothing about internet exposure.";
|
|
@@ -242,25 +277,25 @@ const envelopeText = (structured, said, own) => {
|
|
|
242
277
|
// structuredContent and again as the last text block. The error quotes only what
|
|
243
278
|
// describeFailure already quotes (a redacted cause, the API's own message). Every note is a
|
|
244
279
|
// sentence of the message, so the text envelope carries none.
|
|
245
|
-
const failure = (state, message, error, coverage = null) => {
|
|
280
|
+
export const failure = (state, message, error, coverage = null) => {
|
|
246
281
|
const structuredContent = { state, measured_at: null, method: null, coverage, freshness: null, notes: sentences(message), error };
|
|
247
282
|
return { content: [text(message), envelopeText(structuredContent, message, [])], structuredContent, isError: true };
|
|
248
283
|
};
|
|
249
284
|
const errorOf = (f) => ({ kind: f.kind, path: f.path, status: f.status ?? null, message: f.detail });
|
|
250
|
-
const failed = (tool, f) => {
|
|
285
|
+
export const failed = (tool, f) => {
|
|
251
286
|
if (f.kind === "http" && f.status === 400) {
|
|
252
287
|
return failure("invalid_input", `${tool} FAILED ${INVALID_INPUT}: ${describeFailure(f)} ${REJECTED_INPUT}`, errorOf(f));
|
|
253
288
|
}
|
|
254
289
|
return failure("failed", `${tool} FAILED: ${describeFailure(f)} ${f.kind === "http" && f.status === 404 ? NO_RECORD : NOT_A_FINDING}`, errorOf(f));
|
|
255
290
|
};
|
|
256
|
-
const badInput = (tool, why) => failure("invalid_input", `${tool} FAILED ${INVALID_INPUT}: ${why}. Nothing was looked up, so this is not a finding.`, {
|
|
291
|
+
export const badInput = (tool, why) => failure("invalid_input", `${tool} FAILED ${INVALID_INPUT}: ${why}. Nothing was looked up, so this is not a finding.`, {
|
|
257
292
|
kind: "invalid_input",
|
|
258
293
|
path: null,
|
|
259
294
|
status: null,
|
|
260
295
|
message: why,
|
|
261
296
|
});
|
|
262
297
|
// A bug in this server is still not a finding — it must never surface as an empty success.
|
|
263
|
-
const crashed = (tool, e) => failure("failed", `${tool} FAILED inside the MCP server while querying ${SHOWN_BASE}: ${describeError(e)}. ${NOT_A_FINDING}`, {
|
|
298
|
+
export const crashed = (tool, e) => failure("failed", `${tool} FAILED inside the MCP server while querying ${SHOWN_BASE}: ${describeError(e)}. ${NOT_A_FINDING}`, {
|
|
264
299
|
kind: "internal",
|
|
265
300
|
path: null,
|
|
266
301
|
status: null,
|
|
@@ -283,7 +318,7 @@ function unexpectedShape(tool, error) {
|
|
|
283
318
|
});
|
|
284
319
|
}
|
|
285
320
|
// Every success is checked against the tool's own outputSchema before it is returned.
|
|
286
|
-
const checked = (tool, schema, r) => {
|
|
321
|
+
export const checked = (tool, schema, r) => {
|
|
287
322
|
if (r.isError)
|
|
288
323
|
return r;
|
|
289
324
|
const p = schema.safeParse(r.structuredContent);
|
|
@@ -291,35 +326,35 @@ const checked = (tool, schema, r) => {
|
|
|
291
326
|
};
|
|
292
327
|
// A success: content[0] is the data, content[1] the note, content[2] the envelope less both;
|
|
293
328
|
// the envelope carries both, the note as sentences after any the envelope adds about itself.
|
|
294
|
-
const succeeded = (data, note, env) => {
|
|
329
|
+
export const succeeded = (data, note, env) => {
|
|
295
330
|
const own = env.notes ?? [];
|
|
296
331
|
const structuredContent = { ...env, notes: [...own, ...sentences(note)], data };
|
|
297
332
|
return { content: [text(JSON.stringify(data, null, 2)), text(note), envelopeText(structuredContent, note, own)], structuredContent };
|
|
298
333
|
};
|
|
299
|
-
const okHead = (tool, status) => `${tool} OK: EchelonGraph answered${status === undefined ? "" : ` HTTP ${status}`} from ${SHOWN_BASE}.`;
|
|
334
|
+
export const okHead = (tool, status) => `${tool} OK: EchelonGraph answered${status === undefined ? "" : ` HTTP ${status}`} from ${SHOWN_BASE}.`;
|
|
300
335
|
// Tolerant readers for the note: a missing or renamed field degrades the sentence, never
|
|
301
336
|
// the result — content[0] still carries whatever the API sent.
|
|
302
|
-
const field = (o, ...keys) => keys.reduce((v, k) => (v !== null && typeof v === "object" ? v[k] : undefined), o);
|
|
303
|
-
const numAt = (o, ...keys) => {
|
|
337
|
+
export const field = (o, ...keys) => keys.reduce((v, k) => (v !== null && typeof v === "object" ? v[k] : undefined), o);
|
|
338
|
+
export const numAt = (o, ...keys) => {
|
|
304
339
|
const v = field(o, ...keys);
|
|
305
340
|
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
|
306
341
|
};
|
|
307
|
-
const strAt = (o, ...keys) => {
|
|
342
|
+
export const strAt = (o, ...keys) => {
|
|
308
343
|
const v = field(o, ...keys);
|
|
309
344
|
return typeof v === "string" && v ? v : undefined;
|
|
310
345
|
};
|
|
311
|
-
const lenAt = (o, ...keys) => {
|
|
346
|
+
export const lenAt = (o, ...keys) => {
|
|
312
347
|
const v = field(o, ...keys);
|
|
313
348
|
return Array.isArray(v) ? v.length : undefined;
|
|
314
349
|
};
|
|
315
|
-
const boolAt = (o, ...keys) => {
|
|
350
|
+
export const boolAt = (o, ...keys) => {
|
|
316
351
|
const v = field(o, ...keys);
|
|
317
352
|
return typeof v === "boolean" ? v : undefined;
|
|
318
353
|
};
|
|
319
354
|
// A timestamp worth repeating as prose. The API has been seen answering the Go zero time
|
|
320
355
|
// (0001-01-01T00:00:00Z) where it had no instant to give; that is not a time, so it is never
|
|
321
356
|
// presented as one.
|
|
322
|
-
const realInstant = (s) => s !== undefined && Date.parse(s) > 0;
|
|
357
|
+
export const realInstant = (s) => s !== undefined && Date.parse(s) > 0;
|
|
323
358
|
// ── The CVE feed tools: cve_summary, search_cves, get_cve ──
|
|
324
359
|
//
|
|
325
360
|
// Their numbers are CVE records and scores, not exposure counts, so a success is measured.
|
|
@@ -364,7 +399,7 @@ function cveScoreNote(cve, rec) {
|
|
|
364
399
|
}
|
|
365
400
|
// search_cves' sentences about its rows' scores, naming each row a sentence is about, each
|
|
366
401
|
// starting with a space; none when every row says it is scored.
|
|
367
|
-
function rowsScoreNote(d) {
|
|
402
|
+
export function rowsScoreNote(d) {
|
|
368
403
|
const rows = field(d, "cves");
|
|
369
404
|
if (!Array.isArray(rows) || rows.length === 0)
|
|
370
405
|
return "";
|
|
@@ -550,6 +585,16 @@ async function searchCVEs(a) {
|
|
|
550
585
|
return crashed(tool, e);
|
|
551
586
|
}
|
|
552
587
|
}
|
|
588
|
+
// #2720: cpe_configurations is NVD's configuration tree, stored since migration 152 and filled
|
|
589
|
+
// as the NVD poller next writes each CVE, so an answer without it is "not stored", never "no
|
|
590
|
+
// product affected". Said in the envelope's own notes, so the note in content[1] is unchanged.
|
|
591
|
+
function cpeConfigurationsNote(rec) {
|
|
592
|
+
const cfg = field(rec, "cpe_configurations");
|
|
593
|
+
if (Array.isArray(cfg)) {
|
|
594
|
+
return `cpe_configurations is NVD's configurations array as sent (${cfg.length}): a cpeMatch with vulnerable false in an AND configuration is a platform, not an affected product.`;
|
|
595
|
+
}
|
|
596
|
+
return "No cpe_configurations in the answer: EchelonGraph holds no stored NVD configuration tree for this CVE, which does not mean no product is affected; cpe_match lists its CPE criteria flattened, without the AND/OR operators.";
|
|
597
|
+
}
|
|
553
598
|
async function getCVE(cve_id) {
|
|
554
599
|
const tool = "get_cve";
|
|
555
600
|
const id = cve_id.trim();
|
|
@@ -572,6 +617,7 @@ async function getCVE(cve_id) {
|
|
|
572
617
|
? "measured_at is the record's updated_at: when EchelonGraph last wrote this record."
|
|
573
618
|
: "measured_at is null: the record carries no real updated_at.",
|
|
574
619
|
NO_FEED_FRESHNESS,
|
|
620
|
+
cpeConfigurationsNote(r.data),
|
|
575
621
|
],
|
|
576
622
|
});
|
|
577
623
|
}
|
|
@@ -600,7 +646,7 @@ async function getCVE(cve_id) {
|
|
|
600
646
|
// stored.
|
|
601
647
|
// Every text calls last_seen a write or refresh time, never a sighting ("last seen listening",
|
|
602
648
|
// "re-seen", "most recently seen"), and it is never measured_at (see observedAt).
|
|
603
|
-
const CVE_ID = /^CVE-\d{4}-\d{4,}$/i;
|
|
649
|
+
export const CVE_ID = /^CVE-\d{4}-\d{4,}$/i;
|
|
604
650
|
// Quoted when the API does not send its own `method` string.
|
|
605
651
|
const EXPOSURE_METHOD = "Shodan banner match on the radar's tracked products; up to 100 ip:port services per product query; searched every 12 h when Shodan query credits allow";
|
|
606
652
|
const SHODAN_ATTRIBUTION = "Exposure counts are derived from Shodan data.";
|
|
@@ -1645,7 +1691,7 @@ const ErrorSchema = z.strictObject({
|
|
|
1645
1691
|
status: z.number().int().nullable().describe("The HTTP status, when the API answered one."),
|
|
1646
1692
|
message: z.string().describe("The cause: the API's own message, or what went wrong."),
|
|
1647
1693
|
});
|
|
1648
|
-
const NOTES = z.array(z.string()).describe("Caveats, one sentence each.");
|
|
1694
|
+
export const NOTES = z.array(z.string()).describe("Caveats, one sentence each.");
|
|
1649
1695
|
// The success states (#2465). 2.1.0 described not_assessed as no dated measurement whose numbers
|
|
1650
1696
|
// were all denied the status of findings, on the same schema as a cve_exposure answer relaying
|
|
1651
1697
|
// services on record for a CISA-KEV CVE, which is not_assessed because its count is undated
|
|
@@ -1654,7 +1700,7 @@ const NOTES = z.array(z.string()).describe("Caveats, one sentence each.");
|
|
|
1654
1700
|
// says both, and leaves what each count is to the notes and exposure_state, which say it per
|
|
1655
1701
|
// answer; it makes no blanket claim about the numbers, in either direction.
|
|
1656
1702
|
const SUCCESS_STATE = "measured: a measurement of what was asked; an exposure count is measured only with measured_at and method. not_assessed: the answer holds no dated measurement of what was asked, so no count in it is presented as one; it can still relay a count, as what the source holds on record, undated, and its notes (and exposure_state, where the result carries it) say what each count is.";
|
|
1657
|
-
function envelopeSchema(o) {
|
|
1703
|
+
export function envelopeSchema(o) {
|
|
1658
1704
|
const coverage = o.coverage ? o.coverage.nullable() : z.null();
|
|
1659
1705
|
return z.discriminatedUnion("state", [
|
|
1660
1706
|
z.strictObject({
|
|
@@ -1679,7 +1725,11 @@ function envelopeSchema(o) {
|
|
|
1679
1725
|
]);
|
|
1680
1726
|
}
|
|
1681
1727
|
// A field of the API's JSON: optional, and null where the Go type can marshal null.
|
|
1682
|
-
const opt = (t) => t.nullable().optional();
|
|
1728
|
+
export const opt = (t) => t.nullable().optional();
|
|
1729
|
+
// #2720: what cpe_match and cpe_configurations are, one constant string each (the site's
|
|
1730
|
+
// tool-claims check folds only constant strings).
|
|
1731
|
+
const CPE_MATCH_DESCRIPTION = "The record's CPE match criteria as one flat list, every configuration's cpeMatch entries together: the AND/OR operator and negate of each configuration and node, versionStartExcluding and matchCriteriaId are not in it, so a CPE that is only a platform a product runs on reads as one more entry.";
|
|
1732
|
+
const CPE_CONFIGURATIONS_DESCRIPTION = "NVD's configurations array as NVD sent it: each configuration and node with its operator (AND, OR) and negate, and each cpeMatch with criteria, vulnerable, versionStartIncluding, versionStartExcluding, versionEndIncluding, versionEndExcluding and matchCriteriaId. A cpeMatch with vulnerable false inside an AND configuration is the platform the vulnerable product runs on, not an affected product. Absent: EchelonGraph holds no stored NVD configuration for the CVE, which is not a finding that no product is affected.";
|
|
1683
1733
|
const CVERecord = z.looseObject({
|
|
1684
1734
|
cve_id: opt(z.string()),
|
|
1685
1735
|
description: opt(z.string()),
|
|
@@ -1701,6 +1751,9 @@ const CVERecord = z.looseObject({
|
|
|
1701
1751
|
kev_added_date: opt(z.string()),
|
|
1702
1752
|
ghsa_id: opt(z.string()),
|
|
1703
1753
|
references: z.unknown().optional(),
|
|
1754
|
+
// #2720: NVD's configurations, losslessly, beside the flat list (see cpeConfigurationsNote).
|
|
1755
|
+
cpe_match: z.unknown().optional().describe(CPE_MATCH_DESCRIPTION),
|
|
1756
|
+
cpe_configurations: opt(z.array(z.unknown())).describe(CPE_CONFIGURATIONS_DESCRIPTION),
|
|
1704
1757
|
published: opt(z.string()),
|
|
1705
1758
|
modified: opt(z.string()),
|
|
1706
1759
|
updated_at: opt(z.string()),
|
|
@@ -1888,7 +1941,7 @@ const INSTRUCTIONS = [
|
|
|
1888
1941
|
`${SHODAN_ATTRIBUTION} ${SHODAN_OWNERSHIP}`,
|
|
1889
1942
|
"exposure_radar's mcp_servers counts use no Shodan data: their hostnames come from EchelonGraph's own Certificate Transparency feed.",
|
|
1890
1943
|
].join(" ");
|
|
1891
|
-
const ANNOTATIONS = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
|
|
1944
|
+
export const ANNOTATIONS = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
|
|
1892
1945
|
const CVE_ID_ARG = z.string().describe("a CVE ID, e.g. CVE-2023-44487");
|
|
1893
1946
|
// What each description says about its structured result, naming only fields its schema holds.
|
|
1894
1947
|
const FEED_ENVELOPE = "Its structured result carries state (measured), measured_at, method, coverage, freshness (null: the feed serves no poll-completion time) 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.";
|
|
@@ -1905,10 +1958,13 @@ const SUMMARY_REJECTED_DESCRIPTION = "summary.rejected counts the CVE records re
|
|
|
1905
1958
|
// #2535: what search_cves and get_cve say about score_assessed, one constant string, since the
|
|
1906
1959
|
// site's tool-claims check (marketing-site lib/mcpToolClaims.test.ts) folds only constant strings.
|
|
1907
1960
|
const SCORE_ASSESSED_DESCRIPTION = "echelongraph_score, echelongraph_severity and echelongraph_risk are EchelonGraph's score only when score_assessed is true. With score_assessed false the CVE is NOT YET SCORED, not scored 0: any of those three it carries (0, NONE, 0) is a placeholder, not a rating, and does not mean the CVE is harmless; the API may leave them out instead, score_confidence is NONE, and score_unassessed_reason says why (a rejected record, withdrawn by its numbering authority, is never scored, and the note says NOT SCORED). The note labels each such CVE NOT YET SCORED: report it that way, never as a score of 0. An answer with no score_assessed (an API older than that field) does not say whether the CVE was scored, the note says so, and a 0 there is not a rating either.";
|
|
1908
|
-
//
|
|
1961
|
+
// The shared helpers a tool in src/tools/ is handed (#2718), so its file holds its own logic.
|
|
1962
|
+
const TOOL_KIT = { api, succeeded, failed, badInput, crashed, checked, okHead, envelopeSchema, annotations: ANNOTATIONS, cveIdArg: CVE_ID_ARG, instant: Instant };
|
|
1963
|
+
// One server, built per connection by serveStdio for whichever era the client opens with, and
|
|
1964
|
+
// per request by the hosted HTTP entrypoint (http.ts, #2316): one tool module for both.
|
|
1909
1965
|
// tools/list answers in registration order, which is the order below.
|
|
1910
|
-
function createServer() {
|
|
1911
|
-
const server = new McpServer({ name: NAME, version: VERSION }, { instructions: INSTRUCTIONS, capabilities: { tools: { listChanged: false } } });
|
|
1966
|
+
export function createServer() {
|
|
1967
|
+
const server = new McpServer({ name: NAME, version: VERSION }, { instructions: INSTRUCTIONS, capabilities: { tools: { listChanged: false }, prompts: { listChanged: false }, resources: { listChanged: false } } });
|
|
1912
1968
|
server.registerTool("cve_summary", {
|
|
1913
1969
|
title: "CVE feed summary",
|
|
1914
1970
|
description: `Summary of EchelonGraph's CVE Pulse feed: summary.total active CVEs, their counts by severity band (summary.critical, summary.high, summary.medium, summary.low), the count with no band (summary.none), and summary.last_updated, the newest modification time among those records. ${SUMMARY_NONE_DESCRIPTION} ${SUMMARY_NVD_DESCRIPTION} ${SUMMARY_REJECTED_DESCRIPTION} The feed is polled from its sources on a schedule, so this is the state as of that update. ${FEED_ENVELOPE}`,
|
|
@@ -1930,7 +1986,7 @@ function createServer() {
|
|
|
1930
1986
|
}, async (a) => checked("search_cves", SEARCH_CVES_OUTPUT, await searchCVEs(a)));
|
|
1931
1987
|
server.registerTool("get_cve", {
|
|
1932
1988
|
title: "CVE detail",
|
|
1933
|
-
description: `Full record for one CVE: description, severity, cvss_v3_score, cvss_v4_score and cvss_v4_severity (when the record has a CVSS v4 score), echelongraph_score and echelongraph_severity with score_confidence (EchelonGraph's multi-source score) and score_assessed (whether EchelonGraph has scored it), epss_score and epss_percentile, CISA-KEV status (kev_listed, kev_added_date, and kev_ransomware for known ransomware-campaign use), ghsa_id (GitHub GHSA), references, published, modified and updated_at; each field only where the record has it. Pass a CVE ID like CVE-2023-44487. ${SCORE_ASSESSED_DESCRIPTION} ${FEED_ENVELOPE}`,
|
|
1989
|
+
description: `Full record for one CVE: description, severity, cvss_v3_score, cvss_v4_score and cvss_v4_severity (when the record has a CVSS v4 score), echelongraph_score and echelongraph_severity with score_confidence (EchelonGraph's multi-source score) and score_assessed (whether EchelonGraph has scored it), epss_score and epss_percentile, CISA-KEV status (kev_listed, kev_added_date, and kev_ransomware for known ransomware-campaign use), ghsa_id (GitHub GHSA), references, cpe_match (the CPE criteria as one flat list) and cpe_configurations (NVD's configurations as NVD sent them, with each AND/OR operator, negate, versionStartExcluding and matchCriteriaId; absent where EchelonGraph has stored none, which is not a finding that no product is affected), published, modified and updated_at; each field only where the record has it. Pass a CVE ID like CVE-2023-44487. ${SCORE_ASSESSED_DESCRIPTION} ${FEED_ENVELOPE}`,
|
|
1934
1990
|
inputSchema: z.object({ cve_id: CVE_ID_ARG }),
|
|
1935
1991
|
outputSchema: GET_CVE_OUTPUT,
|
|
1936
1992
|
annotations: ANNOTATIONS,
|
|
@@ -1948,9 +2004,30 @@ function createServer() {
|
|
|
1948
2004
|
outputSchema: EXPOSURE_RADAR_OUTPUT,
|
|
1949
2005
|
annotations: ANNOTATIONS,
|
|
1950
2006
|
}, async () => checked("exposure_radar", EXPOSURE_RADAR_OUTPUT, await exposureRadar()));
|
|
2007
|
+
registerKevRecent(server, { api, succeeded, failed, badInput, crashed, checked, envelopeSchema, okHead, annotations: ANNOTATIONS });
|
|
2008
|
+
registerEPSSHistory(server, TOOL_KIT);
|
|
2009
|
+
// #2716: its logic, schemas and description live in tools/check_affected.ts.
|
|
2010
|
+
registerCheckAffected(server, { api, succeeded, failed, badInput, crashed, checked, okHead, envelopeSchema, annotations: ANNOTATIONS });
|
|
2011
|
+
// #2721: tools/check_sbom.ts, on this file's api(), envelope and failure contract.
|
|
2012
|
+
registerCheckSbom(server, { api, failed, badInput, crashed, checked, succeeded, okHead, envelopeSchema, annotations: ANNOTATIONS });
|
|
2013
|
+
registerCveIntel(server);
|
|
2014
|
+
registerGetCwe(server);
|
|
2015
|
+
// #2719: vendor_advisories_for_cve, get_vendor_advisory, search_vendor_advisories.
|
|
2016
|
+
registerVendorAdvisoryTools(server, { api, failed, badInput, crashed, succeeded, checked, okHead, envelopeSchema, annotations: ANNOTATIONS });
|
|
2017
|
+
// #2722: prompts and resources, in prompts.ts and resources.ts. Both lists are fixed per
|
|
2018
|
+
// connection, so listChanged is false above.
|
|
2019
|
+
registerPrompts(server, { cveId: CVE_ID });
|
|
2020
|
+
registerResources(server, { api, cveId: CVE_ID, shownBase: SHOWN_BASE, getCve: async (id) => checked("get_cve", GET_CVE_OUTPUT, await getCVE(id)) });
|
|
1951
2021
|
return server;
|
|
1952
2022
|
}
|
|
2023
|
+
// The package identity and API settings, for the hosted HTTP entrypoint's health answer and
|
|
2024
|
+
// start-up line (http.ts).
|
|
2025
|
+
export { NAME, VERSION, TIMEOUT_MS }; // SHOWN_BASE is exported where it is declared.
|
|
1953
2026
|
// Both eras on stdio: a 2026-07-28 client's server/discover, and a 2025-era client's initialize.
|
|
1954
|
-
|
|
1955
|
-
//
|
|
1956
|
-
|
|
2027
|
+
// Not when the hosted HTTP entrypoint loaded this module (runtime.ts): it serves createServer
|
|
2028
|
+
// itself, and a stdio transport there would read the container's stdin.
|
|
2029
|
+
if (!isHttpEntrypoint()) {
|
|
2030
|
+
serveStdio(createServer, { legacy: "serve" });
|
|
2031
|
+
// MCP uses stdout for the protocol — diagnostics go to stderr.
|
|
2032
|
+
console.error(`EchelonGraph CVE MCP server ${NAME}@${VERSION} running (API: ${SHOWN_BASE}, timeout ${TIMEOUT_MS} ms)`);
|
|
2033
|
+
}
|
package/dist/prompts.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
import { MAX_COMPONENTS, MAX_SBOM_CHARS } from "./tools/check_sbom.js";
|
|
3
|
+
export const KEV_BRIEF_DEFAULT_DAYS = 7;
|
|
4
|
+
export const KEV_BRIEF_MAX_DAYS = 365;
|
|
5
|
+
// The envelope rules every prompt carries, word for word.
|
|
6
|
+
export const ENVELOPE_RULES = [
|
|
7
|
+
"Before you use any figure, read that result's evidence envelope: its structuredContent (where your client shows text blocks alone, the last text block) carries state, measured_at, method, coverage, freshness and notes.",
|
|
8
|
+
"state is measured, not_assessed, failed or invalid_input.",
|
|
9
|
+
"Report not_assessed as not assessed: never as clean, safe, unaffected or none found.",
|
|
10
|
+
'A count of 0 in a result whose coverage says assessed: false is not a finding of "not affected": say the lookup did not evaluate it, and give its not_assessed_reason.',
|
|
11
|
+
"failed and invalid_input mean nothing was looked up: say the lookup did not complete, never zero, none found or unexposed.",
|
|
12
|
+
"Cite the measured_at date behind each figure you report, and say so where measured_at is null.",
|
|
13
|
+
"Quote the notes that qualify a figure, and keep each field's meaning as the result's notes give it.",
|
|
14
|
+
"Do not add facts the results do not carry: where a result says nothing about a point, write that it does not say.",
|
|
15
|
+
].join(" ");
|
|
16
|
+
const text = (t) => ({ messages: [{ role: "user", content: { type: "text", text: t } }] });
|
|
17
|
+
export function triageCveText(cve) {
|
|
18
|
+
return [
|
|
19
|
+
`Triage ${cve} using the EchelonGraph MCP tools. Call them in this order, each with cve_id ${cve}:`,
|
|
20
|
+
"1. get_cve: the record. Read score_assessed before echelongraph_score (false: not yet scored, which is not a score of 0); read severity, cvss_v3_score, epss_score and epss_percentile, and the CISA-KEV fields kev_listed, kev_added_date and kev_ransomware.",
|
|
21
|
+
"2. cve_intel: weaknesses (cwes), public exploit code (exploits and exploits_total; verified_status is the label stored with each reference, not a guarantee that it works, and an empty list is not evidence that no exploit exists), affected_packages and fixed_versions.",
|
|
22
|
+
"3. vendor_advisories_for_cve: the vendor advisories that name it. Call get_vendor_advisory for one whose remediation you need in full.",
|
|
23
|
+
"4. epss_history: how its EPSS score changed, one point per recorded change (series_kind change_only): before series_starts_at a missing point means not recorded, not unchanged.",
|
|
24
|
+
"5. cve_exposure: internet exposure on record. exposure_state says what the count is; exposed_hosts counts distinct ip:port services, not machines; the result is not_assessed with measured_at null, so present any count as undated and on record, and exposure_state not_assessed as not assessed.",
|
|
25
|
+
`6. If get_cve says kev_listed is true: kev_recent with since and until both set to its kev_added_date, to read kev_due_date from the row for ${cve}.`,
|
|
26
|
+
ENVELOPE_RULES,
|
|
27
|
+
"Then answer with this template, one line each, naming the tool and the measured_at behind every line:",
|
|
28
|
+
"- Exploited? CISA-KEV listing and its date, known ransomware use, and public exploit code on record.",
|
|
29
|
+
"- Likelihood: EPSS score and percentile, and how it has moved.",
|
|
30
|
+
"- Reachable? exposure_state and the count on record, or not assessed.",
|
|
31
|
+
'- Patch: fixed versions and vendor advisories; where the results hold none, write "none on record", which is not a finding that no fix exists.',
|
|
32
|
+
"- Deadline: kev_due_date when the CVE is KEV-listed (CISA's due date for US federal civilian agencies); otherwise write that the results give no CISA deadline.",
|
|
33
|
+
"- Decision: patch now, schedule, or monitor, with a one-line reason drawn from the lines above.",
|
|
34
|
+
].join("\n");
|
|
35
|
+
}
|
|
36
|
+
export function kevWeeklyBriefText(days) {
|
|
37
|
+
return [
|
|
38
|
+
`Write a brief of the CVEs CISA added to its Known Exploited Vulnerabilities (KEV) catalog in the last ${days} days, using the EchelonGraph MCP tools.`,
|
|
39
|
+
`1. Compute since: today's date in UTC minus ${days} days, as YYYY-MM-DD. State the since date you used, and today's date.`,
|
|
40
|
+
"2. Call kev_recent with since set to that date and limit 200. While its coverage.has_more is true, call kev_recent again with the same since and cursor set to the previous answer's next_cursor.",
|
|
41
|
+
ENVELOPE_RULES,
|
|
42
|
+
"Then write the brief:",
|
|
43
|
+
"- Header: the date range, the number of CVEs added (total), and measured_at, EchelonGraph's last successful fetch of CISA's feed, with catalog_version.",
|
|
44
|
+
"- One section per kev_vendor, vendors in alphabetical order. Under each, one line per CVE: cve_id, kev_product, kev_vuln_name, kev_added_date, kev_due_date, and RANSOMWARE where kev_ransomware is true.",
|
|
45
|
+
"- A closing section listing again the CVEs with kev_ransomware true, and every kev_due_date in the range, earliest first.",
|
|
46
|
+
"- If total is 0, write that no CVE was added in that range in the catalog copy fetched at measured_at. If kev_recent failed, write that the catalog could not be read: never that nothing was added.",
|
|
47
|
+
].join("\n");
|
|
48
|
+
}
|
|
49
|
+
export function amIAffectedText(a) {
|
|
50
|
+
const subject = a.product ? `the product ${JSON.stringify(a.product)} (an NVD CPE product token)` : `the package ${JSON.stringify(a.package)} in the ${JSON.stringify(a.ecosystem)} ecosystem`;
|
|
51
|
+
const args = a.product ? { product: a.product, version: a.version } : { ecosystem: a.ecosystem, package: a.package, version: a.version };
|
|
52
|
+
return [
|
|
53
|
+
`Check whether ${subject} at version ${JSON.stringify(a.version)} is affected by known CVEs, using the EchelonGraph MCP tools.`,
|
|
54
|
+
`1. Call check_affected with these arguments: ${JSON.stringify(args)}.`,
|
|
55
|
+
ENVELOPE_RULES,
|
|
56
|
+
"Read coverage.assessed before count, and answer with the case that applies:",
|
|
57
|
+
"- assessed true and count above 0: affected. List each match's cve_id with kev_listed, ransomware, epss_score and effective_score (score_assessed false: not yet scored, not a score of 0), CISA-KEV-listed CVEs first.",
|
|
58
|
+
"- assessed true and count 0: no matching CVE in EchelonGraph's data at this version. Give undetermined_count too: an advisory that could not be decided at this version is not safe.",
|
|
59
|
+
"- assessed false: NOT ASSESSED. Write that the lookup did not evaluate this version, give not_assessed_reason, and never report it as not affected or clean.",
|
|
60
|
+
"- failed or invalid_input: the check did not run. Say why, from the result.",
|
|
61
|
+
"Where capped, candidates_capped or degraded is true, say the list may be incomplete and why.",
|
|
62
|
+
].join("\n");
|
|
63
|
+
}
|
|
64
|
+
export function sbomReviewText(sbom) {
|
|
65
|
+
return [
|
|
66
|
+
"Review the SBOM below for known vulnerabilities, using the EchelonGraph MCP tools.",
|
|
67
|
+
`1. Call check_sbom with sbom set to the document below, as given. It reads the document's purls and checks up to ${MAX_COMPONENTS} distinct purls per call; a document with more is refused, not truncated. If it is refused, say why from the result and do not guess at the components.`,
|
|
68
|
+
"2. For each distinct cve_id on a component whose verdict is affected, call get_cve, to read kev_listed, epss_score, score_assessed, echelongraph_score and cvss_v3_score. If there are more than 30 distinct CVEs, do this for 30 of them and list the rest as not looked up, unranked.",
|
|
69
|
+
ENVELOPE_RULES,
|
|
70
|
+
"Read each component's verdict: not_affected is the one clean verdict; undetermined and not_assessed are not clean, and not_assessed_reason says why (for example distro_release_unknown: a deb, apk or rpm purl without a distro qualifier, which EchelonGraph does not guess).",
|
|
71
|
+
"Then write a prioritised fix list of the affected components. State the ordering keys, in this order: 1. CISA-KEV listed (kev_listed true) first; 2. then EPSS score, highest first; 3. then echelongraph_score where score_assessed is true, else cvss_v3_score, highest first. A CVE with no value for a key sorts after those with one, and its line says which key was missing.",
|
|
72
|
+
"For each line give the component (purl), its cve_ids, the key values used, and fixed versions where a result gives them. After the list, give data.summary's counts, and list the undetermined and not_assessed components with their reasons as not checked, never as clean.",
|
|
73
|
+
"",
|
|
74
|
+
"SBOM:",
|
|
75
|
+
"```json",
|
|
76
|
+
sbom,
|
|
77
|
+
"```",
|
|
78
|
+
].join("\n");
|
|
79
|
+
}
|
|
80
|
+
export function registerPrompts(server, kit) {
|
|
81
|
+
server.registerPrompt("triage_cve", {
|
|
82
|
+
title: "Triage a CVE",
|
|
83
|
+
description: "Triage one CVE: get_cve, cve_intel, vendor_advisories_for_cve, epss_history and cve_exposure (and kev_recent for the due date when the CVE is KEV-listed), then a decision template: exploited, likelihood, reachable, patch, deadline. Each line cites its tool and measured_at, and not_assessed is never reported as clean.",
|
|
84
|
+
argsSchema: z.object({
|
|
85
|
+
cve_id: z
|
|
86
|
+
.string()
|
|
87
|
+
.trim()
|
|
88
|
+
.regex(kit.cveId, "cve_id must be a CVE ID such as CVE-2024-3400")
|
|
89
|
+
.describe("a CVE ID, e.g. CVE-2024-3400"),
|
|
90
|
+
}),
|
|
91
|
+
}, ({ cve_id }) => text(triageCveText(cve_id.toUpperCase())));
|
|
92
|
+
server.registerPrompt("kev_weekly_brief", {
|
|
93
|
+
title: "CISA KEV brief",
|
|
94
|
+
description: "A brief of the CVEs CISA added to its KEV catalog in the last N days (default 7): kev_recent from a since date the model computes from days, grouped by vendor, with ransomware flags and due dates, dated by EchelonGraph's last successful fetch of CISA's feed.",
|
|
95
|
+
// A client may send no arguments at all for a prompt whose arguments are all optional.
|
|
96
|
+
argsSchema: z.preprocess((v) => v ?? {}, z.object({
|
|
97
|
+
days: z
|
|
98
|
+
.string()
|
|
99
|
+
.trim()
|
|
100
|
+
.regex(/^\d{1,3}$/, `days must be a whole number from 1 to ${KEV_BRIEF_MAX_DAYS}`)
|
|
101
|
+
.refine((v) => Number(v) >= 1 && Number(v) <= KEV_BRIEF_MAX_DAYS, `days must be a whole number from 1 to ${KEV_BRIEF_MAX_DAYS}`)
|
|
102
|
+
.optional()
|
|
103
|
+
.describe(`how many days back to cover, 1 to ${KEV_BRIEF_MAX_DAYS} (default ${KEV_BRIEF_DEFAULT_DAYS})`),
|
|
104
|
+
})),
|
|
105
|
+
}, ({ days }) => text(kevWeeklyBriefText(days === undefined || days === "" ? KEV_BRIEF_DEFAULT_DAYS : Number(days))));
|
|
106
|
+
server.registerPrompt("am_i_affected", {
|
|
107
|
+
title: "Am I affected?",
|
|
108
|
+
description: "Whether a product (its NVD CPE product token) or a registry package (ecosystem and package) at a version is affected by known CVEs: check_affected, read assessed before count, with the not-assessed wording: a count of 0 under assessed false is never reported as not affected.",
|
|
109
|
+
argsSchema: z
|
|
110
|
+
.object({
|
|
111
|
+
product: z.string().trim().min(1).optional().describe("CPE path: the NVD CPE product token, such as openssl or nginx. Leave out for the registry path."),
|
|
112
|
+
ecosystem: z.string().trim().min(1).optional().describe("Registry path: the package's ecosystem, such as npm, PyPI or Maven. Give with package."),
|
|
113
|
+
package: z.string().trim().min(1).optional().describe("Registry path: the package name, such as lodash. Give with ecosystem."),
|
|
114
|
+
version: z.string().trim().min(1).describe("The version to check, such as 3.0.0."),
|
|
115
|
+
})
|
|
116
|
+
.superRefine((a, ctx) => {
|
|
117
|
+
const registry = a.ecosystem !== undefined || a.package !== undefined;
|
|
118
|
+
if (a.product !== undefined && registry) {
|
|
119
|
+
ctx.addIssue({ code: "custom", message: "give product, or ecosystem and package, not both" });
|
|
120
|
+
}
|
|
121
|
+
else if (a.product === undefined && (a.ecosystem === undefined || a.package === undefined)) {
|
|
122
|
+
ctx.addIssue({ code: "custom", message: "give product, or both ecosystem and package" });
|
|
123
|
+
}
|
|
124
|
+
}),
|
|
125
|
+
}, (a) => text(amIAffectedText(a)));
|
|
126
|
+
server.registerPrompt("sbom_review", {
|
|
127
|
+
title: "SBOM review",
|
|
128
|
+
description: `Review an SBOM (CycloneDX JSON or SPDX JSON text, up to ${MAX_SBOM_CHARS} characters): it is passed to check_sbom, which checks up to ${MAX_COMPONENTS} distinct purls per call, then get_cve for each affected CVE, and a prioritised fix list ordered by CISA-KEV listing, then EPSS, then score, each key stated. undetermined and not_assessed components are listed as not checked, never as clean.`,
|
|
129
|
+
argsSchema: z.object({
|
|
130
|
+
sbom: z
|
|
131
|
+
.string()
|
|
132
|
+
.min(1, "sbom is required")
|
|
133
|
+
.max(MAX_SBOM_CHARS, `sbom is longer than ${MAX_SBOM_CHARS} characters, check_sbom's cap`)
|
|
134
|
+
.describe(`a CycloneDX JSON or SPDX JSON document, as JSON text, up to ${MAX_SBOM_CHARS} characters; it is passed to check_sbom`),
|
|
135
|
+
}),
|
|
136
|
+
}, ({ sbom }) => text(sbomReviewText(sbom)));
|
|
137
|
+
}
|