echelongraph-mcp 2.6.8 → 2.7.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 +126 -0
- package/README.md +54 -32
- package/dist/http.js +2 -2
- package/dist/httpPolicy.js +13 -9
- package/dist/index.js +133 -62
- package/dist/prompts.js +102 -9
- package/dist/requiredText.js +118 -0
- package/dist/resources.js +2 -0
- package/dist/tools/check_affected.js +41 -18
- package/dist/tools/check_sbom.js +37 -374
- package/dist/tools/cve_intel.js +1 -1
- package/dist/tools/cve_remediation.js +257 -0
- package/dist/tools/epss_history.js +100 -6
- package/dist/tools/kev_recent.js +4 -4
- package/dist/tools/manifests/common.js +48 -0
- package/dist/tools/manifests/gomod.js +168 -0
- package/dist/tools/manifests/index.js +80 -0
- package/dist/tools/manifests/lockfiles.js +176 -0
- package/dist/tools/manifests/npmlock.js +107 -0
- package/dist/tools/manifests/requirements.js +125 -0
- package/dist/tools/manifests/toml.js +299 -0
- package/dist/tools/match_batch.js +432 -0
- package/dist/tools/scan_manifest.js +257 -0
- package/dist/tools/vendor_advisories.js +128 -20
- package/package.json +3 -1
- package/server.json +2 -2
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
import { TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
|
|
3
|
+
import { sbomText } from "./check_sbom.js";
|
|
4
|
+
import { FORMAT_NAMES, FORMATS, NOT_CHECKED_REASONS, detect, readFile } from "./manifests/index.js";
|
|
5
|
+
import { CLEAN_NOTE, DISTRO_NOTE, FRESHNESS_NOTE, MAX_PURLS, MEASURED_AT_NOTE, METHOD, batchDataShape, dedupe, field, plural, purlsRefusal, sendBatches, sendingCoverage, sendingCoverageShape, sendingSentences, verdictSentences, } from "./match_batch.js";
|
|
6
|
+
export const SCAN_MANIFEST = "scan_manifest";
|
|
7
|
+
// At most this many files per call, and this many characters of content in all: check_sbom's
|
|
8
|
+
// document cap, so the hosted endpoint's 6 MiB body cap holds a call at this size (httpPolicy.ts).
|
|
9
|
+
export const MAX_FILES = 20;
|
|
10
|
+
export const MAX_MANIFEST_CHARS = 5_000_000;
|
|
11
|
+
// How many files the note names one by one.
|
|
12
|
+
const NOTE_FILES_MAX = 20;
|
|
13
|
+
// How many entries of data.not_checked the text keeps once it must be cut.
|
|
14
|
+
const TEXT_NOT_CHECKED = 20;
|
|
15
|
+
// The order the distinct purls are counted in, for the position of those not sent (#2799).
|
|
16
|
+
const READ_ORDER = "counted in the order this tool read them (the files in the order given, each file's entries in the order the file lists them, each purl at its first place)";
|
|
17
|
+
const NOT_CHECKED_NOTE = "not_checked entries are not clean: each names a dependency this tool did not send to the API (its version is a range or unresolved, or it comes from a local path, a VCS checkout or a line this tool does not read), so no verdict was looked up for it.";
|
|
18
|
+
export const SCAN_MANIFEST_TITLE = "Check a lockfile against the advisory corpus";
|
|
19
|
+
export const SCAN_MANIFEST_DESCRIPTION = "Check a project's lockfile or pinned manifest against EchelonGraph's advisory corpus, one verdict per dependency. Pass files: 1 to 20 of filename and content (its text), up to 5,000,000 characters in all; the filename gives the format, or format sets it. Read: requirements.txt (== and === pins only), go.mod (require, with replace and exclude applied), package-lock.json and npm-shrinkwrap.json (v1 to v3), Cargo.lock, Gemfile.lock, composer.lock, poetry.lock and gradle.lockfile. package.json, pyproject.toml, Pipfile, Gemfile, Cargo.toml, composer.json and build.gradle hold ranges and are refused, each naming the lockfile to pass; go.sum is refused, as it lists versions the build does not select. The files are read into purls by this MCP server and only the purls are sent to the API, in POST bodies of at most 200 each, never in a URL; filenames and file contents are not sent on. Run from npm, this server is on your machine; over the hosted endpoint (mcp.echelongraph.io) it is EchelonGraph's, and the files are the request body, accepted up to 6 MiB. data.results holds one row per distinct purl, with verdict (affected, not_affected, undetermined or not_assessed), not_assessed_reason, cve_ids and matched_via; each match carries kev_listed, epss_score and fixed_in, or null with fixed_in_reason. not_affected is the only clean verdict. data.not_checked lists each entry not sent, with its file, line and reason: version_unpinned (a range: django>=4), version_unresolved, local_path, vcs_source or unsupported_line; not_checked entries are not clean. At most 2,000 distinct purls per call, within 50 seconds; data.not_sent_purls lists any not sent, which are not checked and not clean." +
|
|
20
|
+
" " +
|
|
21
|
+
TEXT_BUDGET_DESCRIPTION +
|
|
22
|
+
" Cut, each row keeps index, purl, verdict, not_assessed_reason and cve_ids at least; not_checked keeps its first 20.";
|
|
23
|
+
// The text's cut: check_sbom's rows and levels, and data.not_checked beside them, cut to its
|
|
24
|
+
// first TEXT_NOT_CHECKED once the first level does not fit (structuredContent.data keeps it whole).
|
|
25
|
+
export function manifestText(sent) {
|
|
26
|
+
const sbom = sbomText(sent);
|
|
27
|
+
return {
|
|
28
|
+
...sbom,
|
|
29
|
+
sides: [
|
|
30
|
+
...(sbom.sides ?? []),
|
|
31
|
+
{
|
|
32
|
+
list: "not_checked",
|
|
33
|
+
cap: TEXT_NOT_CHECKED,
|
|
34
|
+
said: (inText, total) => `coverage.not_checked_by_reason counts all ${total} entries not checked, ${inText} of them in the text.`,
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
page: (shown) => `To read every row in the text, pass files that hold ${shown} or fewer distinct purls per call.`,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export function readFiles(a) {
|
|
41
|
+
if (!Array.isArray(a.files))
|
|
42
|
+
return "files must be a list of {filename, content}";
|
|
43
|
+
if (a.files.length === 0)
|
|
44
|
+
return "files holds no file";
|
|
45
|
+
if (a.files.length > MAX_FILES)
|
|
46
|
+
return `${a.files.length} files; at most ${MAX_FILES} are read per call`;
|
|
47
|
+
let total = 0;
|
|
48
|
+
for (const [n, f] of a.files.entries()) {
|
|
49
|
+
if (f === null || typeof f !== "object" || typeof f.filename !== "string" || typeof f.content !== "string")
|
|
50
|
+
return `files[${n}] must be {filename, content}, both strings`;
|
|
51
|
+
if (f.format !== undefined && f.format !== null && !FORMAT_NAMES.includes(f.format)) {
|
|
52
|
+
return `files[${n}].format ${JSON.stringify(f.format)} is not one of ${FORMAT_NAMES.join(", ")}`;
|
|
53
|
+
}
|
|
54
|
+
total += f.content.length;
|
|
55
|
+
}
|
|
56
|
+
if (total > MAX_MANIFEST_CHARS)
|
|
57
|
+
return `the files are ${total} characters in all; at most ${MAX_MANIFEST_CHARS} are read per call`;
|
|
58
|
+
const files = [];
|
|
59
|
+
const all = [];
|
|
60
|
+
const notChecked = [];
|
|
61
|
+
for (const f of a.files) {
|
|
62
|
+
const out = { filename: f.filename, format: null, ecosystem: null, read: false, refused: null, purls: 0, not_checked: 0, skipped: 0, notes: [], replaced: [] };
|
|
63
|
+
files.push(out);
|
|
64
|
+
const d = f.format ? { format: f.format } : detect(f.filename);
|
|
65
|
+
if ("refused" in d) {
|
|
66
|
+
out.refused = d.refused;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
out.format = d.format;
|
|
70
|
+
out.ecosystem = FORMATS[d.format].ecosystem;
|
|
71
|
+
const r = readFile(d.format, f.content);
|
|
72
|
+
if ("unreadable" in r) {
|
|
73
|
+
out.refused = `read as ${d.format}, ${r.unreadable}`;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
out.read = true;
|
|
77
|
+
out.purls = r.pinned.length;
|
|
78
|
+
out.not_checked = r.not_checked.length;
|
|
79
|
+
out.skipped = r.skipped;
|
|
80
|
+
out.notes = r.notes;
|
|
81
|
+
out.replaced = r.replaced ?? [];
|
|
82
|
+
all.push(...r.pinned.map((x) => x.purl));
|
|
83
|
+
notChecked.push(...r.not_checked.map((x) => ({ file: f.filename, ...x })));
|
|
84
|
+
}
|
|
85
|
+
if (files.every((f) => !f.read))
|
|
86
|
+
return `no file could be read: ${files.map((f) => `${f.filename}: ${f.refused}`).join("; ")}`;
|
|
87
|
+
const { purls, removed } = dedupe(all);
|
|
88
|
+
return { files, purls, read_purls: all.length, duplicates_removed: removed, not_checked: notChecked };
|
|
89
|
+
}
|
|
90
|
+
// ── The answer ──
|
|
91
|
+
const byReason = (rows) => {
|
|
92
|
+
const by = Object.fromEntries(NOT_CHECKED_REASONS.map((r) => [r, 0]));
|
|
93
|
+
for (const r of rows)
|
|
94
|
+
by[r.reason]++;
|
|
95
|
+
return by;
|
|
96
|
+
};
|
|
97
|
+
function noteFor(head, x, d, g) {
|
|
98
|
+
const out = [head];
|
|
99
|
+
const read = x.files.filter((f) => f.read);
|
|
100
|
+
const shown = read.slice(0, NOTE_FILES_MAX).map((f) => `${f.filename} as ${f.format}: ${plural(f.purls, "entry", "entries")} pinned to one version${f.not_checked ? `, ${f.not_checked} not checked` : ""}${f.skipped ? `, ${f.skipped} skipped (the project's own entry)` : ""}`);
|
|
101
|
+
out.push(`Read ${plural(read.length, "file", "files")}: ${shown.join("; ")}${read.length > NOTE_FILES_MAX ? `; and ${read.length - NOTE_FILES_MAX} more in data.manifest.files` : ""}.`);
|
|
102
|
+
for (const f of x.files.filter((f) => !f.read))
|
|
103
|
+
out.push(`NOT READ, so nothing in it is checked: ${f.filename}: ${f.refused}.`);
|
|
104
|
+
for (const f of read)
|
|
105
|
+
for (const n of f.notes)
|
|
106
|
+
out.push(`${f.filename}: ${n}`);
|
|
107
|
+
const replaced = read.flatMap((f) => f.replaced);
|
|
108
|
+
if (replaced.length)
|
|
109
|
+
out.push(`${plural(replaced.length, "go.mod replace was", "go.mod replaces were")} applied, each module checked at its replacement (data.manifest.files lists them).`);
|
|
110
|
+
out.push(`${plural(x.purls.length, "distinct purl", "distinct purls")}${x.duplicates_removed ? ` (${plural(x.duplicates_removed, "duplicate was", "duplicates were")} sent once)` : ""}; sent ${g.sent}.`);
|
|
111
|
+
const by = byReason(x.not_checked);
|
|
112
|
+
const nc = x.not_checked.length;
|
|
113
|
+
if (nc) {
|
|
114
|
+
const parts = NOT_CHECKED_REASONS.filter((r) => by[r] > 0).map((r) => `${r} ${by[r]}`);
|
|
115
|
+
out.push(`${plural(nc, "entry was", "entries were")} NOT checked and ${nc === 1 ? "is" : "are"} not clean (${parts.join(", ")}): data.not_checked lists each with its file, line and reason, and none was sent to the API.`);
|
|
116
|
+
}
|
|
117
|
+
out.push(...sendingSentences(g, x.purls.length, READ_ORDER, "check_sbom with purls set to"));
|
|
118
|
+
if (d === null)
|
|
119
|
+
return { note: out.join(" "), assessed: 0 };
|
|
120
|
+
const v = verdictSentences(d, g);
|
|
121
|
+
out.push(...v.sentences);
|
|
122
|
+
return { note: out.join(" "), assessed: v.assessed };
|
|
123
|
+
}
|
|
124
|
+
// What data.manifest.files and data.not_checked carry, beside the API's answer.
|
|
125
|
+
const manifestData = (x) => ({
|
|
126
|
+
manifest: {
|
|
127
|
+
files: x.files.map((f) => ({ ...f })),
|
|
128
|
+
},
|
|
129
|
+
not_checked: x.not_checked,
|
|
130
|
+
});
|
|
131
|
+
export async function scanManifest(deps, a) {
|
|
132
|
+
const tool = SCAN_MANIFEST;
|
|
133
|
+
try {
|
|
134
|
+
const x = readFiles(a);
|
|
135
|
+
if (typeof x === "string")
|
|
136
|
+
return deps.badInput(tool, x);
|
|
137
|
+
const refused = purlsRefusal(x.purls, `pass fewer files per call (${x.files.filter((f) => f.read).map((f) => `${f.filename}: ${f.purls}`).join(", ")}), or pass a lockfile's purls to check_sbom in parts of ${MAX_PURLS} or fewer`);
|
|
138
|
+
if (refused)
|
|
139
|
+
return deps.badInput(tool, refused);
|
|
140
|
+
const by = byReason(x.not_checked);
|
|
141
|
+
const fileCoverage = {
|
|
142
|
+
files: x.files.length,
|
|
143
|
+
files_read: x.files.filter((f) => f.read).length,
|
|
144
|
+
files_not_read: x.files.filter((f) => !f.read).length,
|
|
145
|
+
entries_pinned: x.read_purls,
|
|
146
|
+
duplicates_removed: x.duplicates_removed,
|
|
147
|
+
not_checked: x.not_checked.length,
|
|
148
|
+
not_checked_by_reason: by,
|
|
149
|
+
};
|
|
150
|
+
const notes = [NOT_CHECKED_NOTE, CLEAN_NOTE, DISTRO_NOTE, MEASURED_AT_NOTE, FRESHNESS_NOTE];
|
|
151
|
+
// Nothing to send: every entry is a range, a local path, a VCS checkout or unread. The answer
|
|
152
|
+
// says so, entry by entry; no request is made, and nothing is measured.
|
|
153
|
+
if (x.purls.length === 0) {
|
|
154
|
+
const g = { batches: 0, batches_sent: 0, sent: 0, not_sent_purls: [], not_sent_reason: null, not_sent_detail: null, waits: 0, waited_ms: 0 };
|
|
155
|
+
const head = `${tool} OK: nothing was sent to EchelonGraph, as no entry in the files names one registry version this tool can check, so nothing is measured and nothing is clean.`;
|
|
156
|
+
const { note } = noteFor(head, x, null, g);
|
|
157
|
+
return deps.succeeded({ ...manifestData(x), results: [] }, note, {
|
|
158
|
+
state: "not_assessed",
|
|
159
|
+
measured_at: null,
|
|
160
|
+
method: METHOD,
|
|
161
|
+
coverage: { ...fileCoverage, ...sendingCoverage(g, 0, undefined) },
|
|
162
|
+
freshness: null,
|
|
163
|
+
notes,
|
|
164
|
+
}, manifestText(0));
|
|
165
|
+
}
|
|
166
|
+
// match_batch.ts: one batch at a time, Retry-After waited out within the budget, stopped on
|
|
167
|
+
// cancellation (#2775), exactly as check_sbom sends its purls.
|
|
168
|
+
const run = await sendBatches(deps, tool, x.purls);
|
|
169
|
+
if (!run.ok)
|
|
170
|
+
return run.result;
|
|
171
|
+
const { data, status, sending: g } = run;
|
|
172
|
+
Object.assign(data, manifestData(x));
|
|
173
|
+
const { note, assessed } = noteFor(deps.okHead(tool, status), x, data, g);
|
|
174
|
+
return deps.succeeded(data, note, {
|
|
175
|
+
state: assessed > 0 ? "measured" : "not_assessed",
|
|
176
|
+
measured_at: null,
|
|
177
|
+
method: METHOD,
|
|
178
|
+
coverage: { ...fileCoverage, ...sendingCoverage(g, x.purls.length, field(data, "summary")) },
|
|
179
|
+
freshness: null,
|
|
180
|
+
notes,
|
|
181
|
+
}, manifestText(g.sent));
|
|
182
|
+
}
|
|
183
|
+
catch (e) {
|
|
184
|
+
return deps.crashed(tool, e);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
// ── Schemas ──
|
|
188
|
+
const reasonEnum = z.enum(NOT_CHECKED_REASONS);
|
|
189
|
+
const formatEnum = z.enum(FORMAT_NAMES);
|
|
190
|
+
export function scanManifestOutput(envelopeSchema) {
|
|
191
|
+
return envelopeSchema({
|
|
192
|
+
data: z.looseObject({
|
|
193
|
+
...batchDataShape,
|
|
194
|
+
manifest: z
|
|
195
|
+
.object({
|
|
196
|
+
files: z.array(z.object({
|
|
197
|
+
filename: z.string(),
|
|
198
|
+
format: formatEnum.nullable().describe("The format the file was read as: set by format, or read from the filename; null when it was not read."),
|
|
199
|
+
ecosystem: z.string().nullable().describe("The OSV ecosystem of the purls read from it."),
|
|
200
|
+
read: z.boolean().describe("false: the file was refused or could not be read, and nothing in it is checked (not clean)."),
|
|
201
|
+
refused: z.string().nullable().describe("Why the file was not read, and the file to pass instead where there is one; null when it was read."),
|
|
202
|
+
purls: z.number().int().describe("Entries pinned to one version, read as purls (duplicates included)."),
|
|
203
|
+
not_checked: z.number().int().describe("Entries not sent, listed in data.not_checked: not clean."),
|
|
204
|
+
skipped: z.number().int().describe("Entries that are not dependencies (a package-lock's root entry, the project itself)."),
|
|
205
|
+
notes: z.array(z.string()).describe("How this file was read where that limits what its verdicts cover (a go.mod below go 1.17)."),
|
|
206
|
+
replaced: z.array(z.object({ from: z.string(), to: z.string() })).describe("go.mod replace directives applied: the module required, and the module and version checked instead."),
|
|
207
|
+
})),
|
|
208
|
+
})
|
|
209
|
+
.describe("Each file passed, in order, and how it was read. Filenames and contents are not sent to the API."),
|
|
210
|
+
not_checked: z
|
|
211
|
+
.array(z.object({
|
|
212
|
+
file: z.string(),
|
|
213
|
+
name: z.string().nullable(),
|
|
214
|
+
spec: z.string().describe("What the file says for the entry: its line, or its lockfile key and version (at most 200 characters)."),
|
|
215
|
+
reason: reasonEnum.describe("version_unpinned: a range or wildcard, not one version; version_unresolved: no single registry version (none given, a branch, or an excluded go.mod version); local_path: a directory, file or workspace member; vcs_source: a VCS checkout; unsupported_line: a line or entry this tool does not read (another file named by -r or -c, a direct URL, another registry)."),
|
|
216
|
+
line: z.number().int().nullable().describe("1-based line; null in a JSON lockfile, whose entries spec names by key."),
|
|
217
|
+
detail: z.string(),
|
|
218
|
+
}))
|
|
219
|
+
.describe("Each entry not sent to the API, with its file, line and reason: not checked, and not clean."),
|
|
220
|
+
}),
|
|
221
|
+
coverage: z.strictObject({
|
|
222
|
+
files: z.number().int().describe("Files passed."),
|
|
223
|
+
files_read: z.number().int().describe("Of those, the ones read."),
|
|
224
|
+
files_not_read: z.number().int().describe("The ones refused or unreadable: nothing in them is checked, and none is clean."),
|
|
225
|
+
entries_pinned: z.number().int().describe("Entries pinned to one version across the files, duplicates included."),
|
|
226
|
+
duplicates_removed: z.number().int().describe("Purls read more than once (the same package and version in two places) and sent once."),
|
|
227
|
+
not_checked: z.number().int().describe("Entries not sent (data.not_checked): not clean."),
|
|
228
|
+
not_checked_by_reason: z.strictObject(Object.fromEntries(NOT_CHECKED_REASONS.map((r) => [r, z.number().int()]))).describe("not_checked, counted by reason."),
|
|
229
|
+
...sendingCoverageShape,
|
|
230
|
+
}),
|
|
231
|
+
freshness: null,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
// A literal server.registerTool(name, { title, description, … }) call with constant strings:
|
|
235
|
+
// marketing-site lib/mcpToolClaims.test.ts reads it here (it follows index.ts's call to this
|
|
236
|
+
// function) and holds the /pulse/mcp row to this title and description.
|
|
237
|
+
export function registerScanManifest(server, deps) {
|
|
238
|
+
const output = scanManifestOutput(deps.envelopeSchema);
|
|
239
|
+
server.registerTool(SCAN_MANIFEST, {
|
|
240
|
+
title: SCAN_MANIFEST_TITLE,
|
|
241
|
+
description: SCAN_MANIFEST_DESCRIPTION,
|
|
242
|
+
inputSchema: z.object({
|
|
243
|
+
files: z
|
|
244
|
+
.array(z.object({
|
|
245
|
+
filename: z.string().describe("the file's name, e.g. package-lock.json or services/api/go.mod; it picks the format unless format is set"),
|
|
246
|
+
content: z.string().describe("the file's text, as it is on disk"),
|
|
247
|
+
format: formatEnum.optional().describe("the format to read the file as, when its name does not say"),
|
|
248
|
+
}))
|
|
249
|
+
.describe("1 to 20 files, up to 5,000,000 characters of content in all; read by this MCP server into purls, and only the purls are sent to the API; over the hosted endpoint (mcp.echelongraph.io) the files are the request body"),
|
|
250
|
+
}),
|
|
251
|
+
outputSchema: output,
|
|
252
|
+
annotations: deps.annotations,
|
|
253
|
+
}, async (a, ctx) => {
|
|
254
|
+
const run = scanManifest({ ...deps, signal: ctx.mcpReq.signal }, a);
|
|
255
|
+
return deps.checked(SCAN_MANIFEST, output, await (deps.hold ? deps.hold(run) : run));
|
|
256
|
+
});
|
|
257
|
+
}
|
|
@@ -8,8 +8,10 @@
|
|
|
8
8
|
// vendor_published_at is the date the vendor gives, our_first_seen_at is when EchelonGraph
|
|
9
9
|
// first recorded the advisory. withdrawn is the vendor rescinding its own advisory; the list
|
|
10
10
|
// and the search leave such advisories out, the per-CVE lookup and the detail keep them, and
|
|
11
|
-
// the note names each one.
|
|
12
|
-
//
|
|
11
|
+
// the note names each one. rejected_cve_ids (#2865) is separate from it: the CVE IDs a row names
|
|
12
|
+
// whose CVE record was rejected by its numbering authority, while the vendor's own document may
|
|
13
|
+
// still read as live; the note names those rows and the by-cve answer's cve_rejected. Advisories
|
|
14
|
+
// of a vendor feed EchelonGraph has disabled are left out of all three. None of the answers carries a time at which the pollers last completed a poll,
|
|
13
15
|
// so freshness is null.
|
|
14
16
|
//
|
|
15
17
|
// The search term never travels in a URL (#1983). search_vendor_advisories sends it in the
|
|
@@ -102,6 +104,31 @@ function withdrawnNote(rows) {
|
|
|
102
104
|
return "";
|
|
103
105
|
return ` WITHDRAWN: ${w.length} of these ${rows.length} ${plural(rows.length, "advisory", "advisories")} ${plural(w.length, "was", "were")} withdrawn (rescinded) by ${plural(w.length, "its", "their")} vendor (withdrawn true): ${w.join(", ")}. Report ${plural(w.length, "it", "them")} as withdrawn, not as a current advisory.`;
|
|
104
106
|
}
|
|
107
|
+
// #2865: a vendor advisory can name a CVE ID its numbering authority rejected while the vendor's own
|
|
108
|
+
// text still reads as a live advisory (CVE-2026-7936's MSRC entry). core-backend serves
|
|
109
|
+
// rejected_cve_ids, the CVE IDs of the row whose CVE record was rejected, beside the vendor's own
|
|
110
|
+
// withdrawn, which it leaves as the vendor set it. A row the vendor withdrew is named by
|
|
111
|
+
// withdrawnNote already, so this names the live ones.
|
|
112
|
+
const rejectedIDs = (r) => {
|
|
113
|
+
const v = field(r, "rejected_cve_ids");
|
|
114
|
+
return Array.isArray(v) ? v.filter((x) => typeof x === "string" && x !== "") : [];
|
|
115
|
+
};
|
|
116
|
+
function rejectedCVENote(rows) {
|
|
117
|
+
const hit = rows.flatMap((r, i) => field(r, "withdrawn") !== true && rejectedIDs(r).length
|
|
118
|
+
? [`${str(r, "vendor") ?? "?"}/${str(r, "vendor_advisory_id") ?? `advisories[${i}]`} (${rejectedIDs(r).join(", ")})`]
|
|
119
|
+
: []);
|
|
120
|
+
if (!hit.length)
|
|
121
|
+
return "";
|
|
122
|
+
const k = hit.length;
|
|
123
|
+
return ` CVE REJECTED: ${k} of these ${rows.length} ${plural(rows.length, "advisory", "advisories")}, not withdrawn by ${plural(k, "its", "their")} vendor, ${plural(k, "names", "name")} a CVE ID whose CVE record was rejected (withdrawn) by its numbering authority (rejected_cve_ids): ${hit.join("; ")}. Report each such CVE ID as a rejected record, not as an active vulnerability, even where the vendor's text reads as a current advisory.`;
|
|
124
|
+
}
|
|
125
|
+
// The rows' rejected_cve_ids could not be checked (null on every row that names a CVE).
|
|
126
|
+
function rejectedUnknownNote(rows) {
|
|
127
|
+
const unknown = rows.filter((r) => Array.isArray(field(r, "cve_ids")) && field(r, "cve_ids").length > 0 && field(r, "rejected_cve_ids") === null);
|
|
128
|
+
if (!unknown.length)
|
|
129
|
+
return "";
|
|
130
|
+
return ` For ${unknown.length} of these advisories rejected_cve_ids is null: whether a CVE ID they name was rejected could not be checked, which is not a finding that none was.`;
|
|
131
|
+
}
|
|
105
132
|
const instantOrNull = (o, k) => {
|
|
106
133
|
const v = field(o, k);
|
|
107
134
|
return realInstant(v) ? v : null;
|
|
@@ -182,13 +209,41 @@ function backfillingNote(windows) {
|
|
|
182
209
|
const k = b.length;
|
|
183
210
|
return ` The older advisories of ${plural(k, "1 vendor", `${k} vendors`)} are still being read (history_backfill in_progress or not_started): ${b.map((w) => windowItem(w, false)).join("; ")}. EchelonGraph does not yet hold all of ${plural(k, "its", "their")} older advisories, so none found from ${plural(k, "it", "them")} is not a finding that ${plural(k, "it", "they")} published none.`;
|
|
184
211
|
}
|
|
212
|
+
// #2840: what the rows say of their remediation, where the API sends remediation_state.
|
|
213
|
+
function remediationNote(cve, rows) {
|
|
214
|
+
const states = rows.map((r) => field(r, "remediation_state")).filter((x) => typeof x === "string");
|
|
215
|
+
if (!states.length)
|
|
216
|
+
return "";
|
|
217
|
+
const count = (st) => states.filter((x) => x === st).length;
|
|
218
|
+
const kinds = new Map();
|
|
219
|
+
for (const r of rows) {
|
|
220
|
+
const ks = field(r, "remediation_kinds");
|
|
221
|
+
if (Array.isArray(ks))
|
|
222
|
+
for (const k of ks)
|
|
223
|
+
if (typeof k === "string")
|
|
224
|
+
kinds.set(k, (kinds.get(k) ?? 0) + 1);
|
|
225
|
+
}
|
|
226
|
+
const listedKinds = [...kinds].map(([k, n]) => `${k} (${n})`).join(", ");
|
|
227
|
+
const parts = [
|
|
228
|
+
listedKinds ? ` By the vendors' own categories, the advisories list for ${cve}: ${listedKinds}; each is the vendor's text, untested by EchelonGraph, and cve_remediation or get_vendor_advisory returns it.` : "",
|
|
229
|
+
count("none_in_source") ? ` ${count("none_in_source")} ${plural(count("none_in_source"), "lists", "list")} no remediation for ${cve} (none_in_source), which is not a finding that no fix exists.` : "",
|
|
230
|
+
count("not_parsed") ? ` EchelonGraph does not read the remediation of ${count("not_parsed")} (remediation_state not_parsed), which is not a finding that ${plural(count("not_parsed"), "it lists", "they list")} none.` : "",
|
|
231
|
+
];
|
|
232
|
+
return parts.join("");
|
|
233
|
+
}
|
|
185
234
|
// ── Output schemas ──
|
|
186
235
|
const opt = (t) => t.nullable().optional();
|
|
187
236
|
const DATE_FIELDS = {
|
|
188
237
|
vendor_published_at: opt(z.string()).describe("The date the vendor gives for the advisory."),
|
|
189
238
|
our_first_seen_at: opt(z.string()).describe("When EchelonGraph first recorded the advisory; not the vendor's date."),
|
|
190
239
|
withdrawn: opt(z.boolean()).describe("true: the vendor withdrew (rescinded) this advisory, so it is a withdrawn advisory, not a current one."),
|
|
240
|
+
rejected_cve_ids: opt(z.array(z.string())).describe("The CVE IDs this advisory names whose CVE record was rejected (withdrawn) by its numbering authority: each is a rejected record, not an active vulnerability, whatever the advisory's text says. The vendor's withdrawn is separate and unchanged by it. [] when none is; null when that could not be checked."),
|
|
191
241
|
};
|
|
242
|
+
// #2840: what remediation_kinds and remediation_state are, on a by-cve row and on the detail.
|
|
243
|
+
const REMEDIATION_STATE_FIELD = "parsed; none_in_source (EchelonGraph read the vendor's remediation, and it lists none for this CVE, which is not a finding that no fix exists); not_parsed (EchelonGraph does not read this vendor's remediation, or has not yet read this advisory: not a finding of none); withdrawn (the vendor rescinded the advisory).";
|
|
244
|
+
const REMEDIATION_KINDS_FIELD = "The kinds of remediation the advisory lists (for the CVE asked about, on a by-cve row), each the vendor's own category, never read from the text: vendor_fix, workaround, mitigation, no_fix_planned, none_available or other. [] with remediation_state not_parsed is not a finding that it lists none.";
|
|
245
|
+
const REMEDIATION_KINDS = ["vendor_fix", "workaround", "mitigation", "no_fix_planned", "none_available", "other"];
|
|
246
|
+
const REMEDIATION_STATES = ["parsed", "none_in_source", "not_parsed", "withdrawn"];
|
|
192
247
|
const AdvisoryRow = z.looseObject({
|
|
193
248
|
advisory_id: opt(z.string()),
|
|
194
249
|
vendor: opt(z.string()),
|
|
@@ -201,18 +256,34 @@ const AdvisoryRow = z.looseObject({
|
|
|
201
256
|
summary: opt(z.string()),
|
|
202
257
|
affected_products: opt(z.array(z.string())),
|
|
203
258
|
...DATE_FIELDS,
|
|
259
|
+
remediation_kinds: opt(z.array(z.union([z.enum(REMEDIATION_KINDS), z.string()]))).describe(REMEDIATION_KINDS_FIELD),
|
|
260
|
+
remediation_state: opt(z.union([z.enum(REMEDIATION_STATES), z.string()])).describe(REMEDIATION_STATE_FIELD),
|
|
204
261
|
});
|
|
205
262
|
// Shared by the three descriptions. Top-level consts, so the site's tool-claims check
|
|
206
263
|
// (marketing-site/lib/mcpToolClaims.test.ts) can fold the descriptions to their text.
|
|
207
|
-
const DATES_DESCRIPTION = "Each advisory carries vendor_published_at (the vendor's date), our_first_seen_at (when EchelonGraph first recorded it) and withdrawn (true: the vendor rescinded it
|
|
208
|
-
|
|
264
|
+
const DATES_DESCRIPTION = "Each advisory carries vendor_published_at (the vendor's date), our_first_seen_at (when EchelonGraph first recorded it) and withdrawn (true: the vendor rescinded it).";
|
|
265
|
+
// #2865: the CVE-state flag beside the vendor's own withdrawn. #2842: the head the descriptions
|
|
266
|
+
// carry; rejected_cve_ids' and cve_rejected's outputSchema descriptions say the rest (null: not checked).
|
|
267
|
+
const REJECTED_CVE_DESCRIPTION = "Each advisory also carries rejected_cve_ids: the CVE IDs it names whose CVE record was rejected (withdrawn) by its numbering authority, which are not active vulnerabilities.";
|
|
268
|
+
// #2842: the generic rule for the result's last text block is in the README and the methodology
|
|
269
|
+
// resource, not in each description.
|
|
270
|
+
const ENVELOPE_DESCRIPTION = "Its freshness is null: the answer carries no poll-completion time.";
|
|
209
271
|
// #2729: what a vendor's window is.
|
|
210
272
|
const HISTORY_BACKFILL_DESCRIPTION = "history_backfill: complete (the vendor's published history has been read back as far as its source goes), in_progress or not_started (it is still being read, so the vendor's older advisories are not all held yet), or not_supported (EchelonGraph has no history read for that vendor, so what it holds is what the vendor's feed has carried)";
|
|
211
273
|
// #2803: where a vendor's window begins.
|
|
212
274
|
const HELD_SINCE_DESCRIPTION = "held_since (the start of the window EchelonGraph has read that vendor's advisories from, before which an advisory may not be held; null when none is held or the API does not say; for a vendor with no history read it is when its feed was first read from, and can be years after earliest_vendor_published_at, since an older advisory is held when the vendor revised it later, not the ones published beside it)";
|
|
213
275
|
const WINDOW_DESCRIPTION = `vendor, advisories (how many EchelonGraph holds from that vendor), earliest_vendor_published_at and latest_vendor_published_at (the earliest and latest vendor_published_at among them, null when none is held), ${HELD_SINCE_DESCRIPTION} and ${HISTORY_BACKFILL_DESCRIPTION}`;
|
|
276
|
+
// #2842: what the descriptions say of a window, within the client cut (requiredText.ts);
|
|
277
|
+
// WINDOW_DESCRIPTION in full is the outputSchema's description of a window.
|
|
278
|
+
const WINDOW_HEAD = "vendor, advisories, earliest_vendor_published_at, latest_vendor_published_at, held_since (where that window begins) and history_backfill (in_progress or not_started: older advisories not all held yet)";
|
|
214
279
|
const NOT_HELD_DESCRIPTION = "An advisory a vendor published before its held_since may not be held, even when its earliest_vendor_published_at is older, so no advisory from a vendor is not a finding that it published none.";
|
|
280
|
+
// #2842: the same rule within the client cut; NOT_HELD_DESCRIPTION whole is the windows' outputSchema description.
|
|
281
|
+
const NOT_HELD_HEAD = "An advisory published before its vendor's held_since may not be held, so no advisory from a vendor is not a finding that it published none.";
|
|
215
282
|
// #2728: how a search matches, and its capped total.
|
|
283
|
+
// #2842: what search_vendor_advisories' description says; SEARCH_MATCH_DESCRIPTION and
|
|
284
|
+
// TOTAL_CAPPED_DESCRIPTION in full are the outputSchema's descriptions of search_match and total_capped.
|
|
285
|
+
const SEARCH_MATCH_HEAD = "A query of 3 or more characters is matched case-insensitively as a substring of each advisory's title, description, vendor name, IDs and affected_products (search_match substring). A query of 1 or 2 characters matches whole words only (search_match word).";
|
|
286
|
+
const TOTAL_CAPPED_HEAD = "A search counts at most 1,000 matches: past that, total is 1000 and total_capped is true, which means 1,000 or more (the note writes 1,000+), never exactly 1,000.";
|
|
216
287
|
const SEARCH_MATCH_DESCRIPTION = "A query of 3 or more characters is matched case-insensitively as a substring of each advisory's title, description, vendor name, vendor_advisory_id, affected_products and cve_ids (search_match substring). A query of 1 or 2 characters matches whole words only (search_match word): it must equal, ignoring case, a whole word (a run of letters and digits) of one of those, so xz finds xz-utils but not xzibit, and a 1- or 2-character query with any other character, such as c#, matches nothing.";
|
|
217
288
|
const TOTAL_CAPPED_DESCRIPTION = "A search counts at most 1,000 matches: past that, total is 1000 and total_capped is true, which means 1,000 or more (the note writes 1,000+), never exactly 1,000; paging past it still works. Without a query, total is the exact count and total_capped is false.";
|
|
218
289
|
const VendorWindowSchema = z.strictObject({
|
|
@@ -234,7 +305,7 @@ const VendorWindowSchema = z.strictObject({
|
|
|
234
305
|
.union([z.enum(["complete", "in_progress", "not_started", "not_supported"]), z.string()])
|
|
235
306
|
.nullable()
|
|
236
307
|
.describe(`${HISTORY_BACKFILL_DESCRIPTION}.`),
|
|
237
|
-
});
|
|
308
|
+
}).describe(`A vendor's window in what EchelonGraph holds: ${WINDOW_DESCRIPTION}.`);
|
|
238
309
|
const WINDOWS = z.array(VendorWindowSchema).nullable().describe(`Each vendor's window in what EchelonGraph holds; null when the windows could not be read. ${NOT_HELD_DESCRIPTION}`);
|
|
239
310
|
// #2783: a production row is up to about 2,000 characters as pretty JSON (its summary, its
|
|
240
311
|
// affected_products and, for a library like log4j, dozens of cve_ids), so search_vendor_advisories
|
|
@@ -242,8 +313,8 @@ const WINDOWS = z.array(VendorWindowSchema).nullable().describe(`Each vendor's w
|
|
|
242
313
|
// text block keeps what identifies and rates the advisory, every string cut to 200 characters and
|
|
243
314
|
// every list to 20 entries; then fewer fields, strings to 100 and lists to 10.
|
|
244
315
|
const ROW_CUT_LEVELS = [
|
|
245
|
-
{ keep: ["vendor", "vendor_display_name", "vendor_advisory_id", "title", "severity", "cvss_v3_score", "cve_ids", "summary", "affected_products", "vendor_published_at", "withdrawn"], clip: 200, cap: 20 },
|
|
246
|
-
{ keep: ["vendor", "vendor_advisory_id", "title", "severity", "cve_ids", "vendor_published_at", "withdrawn"], clip: 100, cap: 10 },
|
|
316
|
+
{ keep: ["vendor", "vendor_display_name", "vendor_advisory_id", "title", "severity", "cvss_v3_score", "cve_ids", "summary", "affected_products", "vendor_published_at", "withdrawn", "rejected_cve_ids", "remediation_kinds", "remediation_state"], clip: 200, cap: 20 },
|
|
317
|
+
{ keep: ["vendor", "vendor_advisory_id", "title", "severity", "cve_ids", "vendor_published_at", "withdrawn", "rejected_cve_ids", "remediation_kinds", "remediation_state"], clip: 100, cap: 10 },
|
|
247
318
|
];
|
|
248
319
|
// get_vendor_advisory's own first text block is cut too past the budget (#2802): only its
|
|
249
320
|
// structuredContent.data is one advisory whole.
|
|
@@ -258,7 +329,12 @@ const searchText = (a) => ({
|
|
|
258
329
|
const ROW_CUT_DESCRIPTION = " Cut, each row keeps vendor, vendor_advisory_id, title, severity and cve_ids at least, its strings and lists shortened";
|
|
259
330
|
export function registerVendorAdvisoryTools(server, kit) {
|
|
260
331
|
const FOR_CVE_OUTPUT = kit.envelopeSchema({
|
|
261
|
-
data: z.looseObject({
|
|
332
|
+
data: z.looseObject({
|
|
333
|
+
cve_id: opt(z.string()),
|
|
334
|
+
advisories: opt(z.array(AdvisoryRow)),
|
|
335
|
+
total: opt(z.number()),
|
|
336
|
+
cve_rejected: opt(z.boolean()).describe("true: the CVE record of cve_id was rejected (withdrawn) by its numbering authority, so these advisories name a rejected record, not an active vulnerability; null when that could not be checked."),
|
|
337
|
+
}),
|
|
262
338
|
coverage: z.strictObject({
|
|
263
339
|
returned: z.number().nullable().describe("The advisories in this answer."),
|
|
264
340
|
cap: z.number().describe("The most the API returns for one CVE, newest first."),
|
|
@@ -276,7 +352,21 @@ export function registerVendorAdvisoryTools(server, kit) {
|
|
|
276
352
|
data: AdvisoryRow.extend({
|
|
277
353
|
description: opt(z.string()),
|
|
278
354
|
known_cve_ids: opt(z.array(z.string())).describe("The CVE IDs of cve_ids that have a record in EchelonGraph's CVE feed."),
|
|
279
|
-
remediation: opt(z.string()),
|
|
355
|
+
remediation: opt(z.string()).describe("The vendor's remediation as one text (the legacy field), beside remediations."),
|
|
356
|
+
remediations: opt(z.array(z.looseObject({
|
|
357
|
+
kind: opt(z.union([z.enum(REMEDIATION_KINDS), z.string()])),
|
|
358
|
+
source_category: opt(z.string()).describe("The vendor's own name for the category."),
|
|
359
|
+
source_subcategory: opt(z.string()),
|
|
360
|
+
text: opt(z.string()).describe("The vendor's text, verbatim but for HTML markup removed, untested by EchelonGraph; at most 2,000 characters (text_truncated, text_chars)."),
|
|
361
|
+
text_truncated: opt(z.boolean()),
|
|
362
|
+
text_chars: opt(z.number()),
|
|
363
|
+
url: opt(z.string()),
|
|
364
|
+
fixed_build: opt(z.string()),
|
|
365
|
+
cve_ids: opt(z.array(z.string())).describe("The CVEs the item covers; [] for the whole advisory."),
|
|
366
|
+
product_ids: opt(z.array(z.string())),
|
|
367
|
+
product_count: opt(z.number()),
|
|
368
|
+
}))).describe("Each remediation the vendor lists, with its kind, the vendor's own category."),
|
|
369
|
+
remediation_items_not_stored: opt(z.number()).describe("Items past the 64 stored per advisory."),
|
|
280
370
|
references: z.unknown().optional(),
|
|
281
371
|
vendor_modified_at: opt(z.string()),
|
|
282
372
|
withdrawn_at: opt(z.string()),
|
|
@@ -294,8 +384,8 @@ export function registerVendorAdvisoryTools(server, kit) {
|
|
|
294
384
|
limit: opt(z.number()),
|
|
295
385
|
offset: opt(z.number()),
|
|
296
386
|
search_applied: opt(z.boolean()).describe("Whether a free-text search filtered this answer."),
|
|
297
|
-
search_match: opt(z.string()).describe(
|
|
298
|
-
total_capped: opt(z.boolean()).describe(
|
|
387
|
+
search_match: opt(z.string()).describe(`How the query was matched: word (a query of 1 or 2 characters, whole words only) or substring; null without a query. ${SEARCH_MATCH_DESCRIPTION}`),
|
|
388
|
+
total_capped: opt(z.boolean()).describe(`true: the search stopped counting at total, so total is a lower bound (1,000+), not the count. ${TOTAL_CAPPED_DESCRIPTION}`),
|
|
299
389
|
}),
|
|
300
390
|
coverage: z.strictObject({
|
|
301
391
|
total: z.number().nullable().describe("The API's total; when total_capped is true, a lower bound, not the count."),
|
|
@@ -305,7 +395,7 @@ export function registerVendorAdvisoryTools(server, kit) {
|
|
|
305
395
|
offset: z.number().nullable(),
|
|
306
396
|
search_applied: z.boolean().nullable(),
|
|
307
397
|
search_match: z.enum(["word", "substring"]).nullable().describe("word: a query of 1 or 2 characters, matched as a whole word only; substring: a longer query; null without a query."),
|
|
308
|
-
vendor_windows: WINDOWS,
|
|
398
|
+
vendor_windows: WINDOWS.describe(`Each vendor's window in what EchelonGraph holds, only that vendor's when vendor is given; null when the windows could not be read. ${NOT_HELD_DESCRIPTION}`),
|
|
309
399
|
}),
|
|
310
400
|
freshness: null,
|
|
311
401
|
});
|
|
@@ -354,13 +444,23 @@ export function registerVendorAdvisoryTools(server, kit) {
|
|
|
354
444
|
};
|
|
355
445
|
const head = kit.okHead(tool, r.status);
|
|
356
446
|
const held = w.ok ? notFullyHeldNote(cve, year, missing ?? []) : windowsUnknown(w.why);
|
|
447
|
+
// #2865: the queried CVE's own state, first, so no advisory below reads as a live CVE's.
|
|
448
|
+
const cveState = field(r.data, "cve_rejected");
|
|
449
|
+
const rejectedHead = cveState === true
|
|
450
|
+
? ` CVE REJECTED: the CVE record of ${cve} was rejected (withdrawn) by its numbering authority (cve_rejected true). It is a rejected record, not an active vulnerability: report it that way, even where a vendor advisory below reads as a current one.`
|
|
451
|
+
: cveState === null
|
|
452
|
+
? ` Whether the CVE record of ${cve} was rejected could not be checked (cve_rejected null), which is not a finding that it was not.`
|
|
453
|
+
: "";
|
|
357
454
|
if (returned === 0) {
|
|
358
|
-
return kit.succeeded(r.data, `${head} No vendor advisory on record names ${cve}: a measured empty result, EchelonGraph was queried successfully and found nothing (we looked and found nothing). This is not a lookup failure. ${ONLY_POLLED}${held}`, env, FOR_CVE_TEXT);
|
|
455
|
+
return kit.succeeded(r.data, `${head}${rejectedHead} No vendor advisory on record names ${cve}: a measured empty result, EchelonGraph was queried successfully and found nothing (we looked and found nothing). This is not a lookup failure. ${ONLY_POLLED}${held}`, env, FOR_CVE_TEXT);
|
|
359
456
|
}
|
|
360
457
|
if (rows === undefined)
|
|
361
|
-
return kit.succeeded(r.data, `${head}${held}`, env, FOR_CVE_TEXT);
|
|
458
|
+
return kit.succeeded(r.data, `${head}${rejectedHead}${held}`, env, FOR_CVE_TEXT);
|
|
362
459
|
const cap = atCap ? ` The API returns at most ${BY_CVE_CAP} advisories for one CVE, newest first, and this answer is full (at_cap true), so there may be more.` : "";
|
|
363
|
-
|
|
460
|
+
// With cve_rejected true the head has said it of the one CVE every row names; the per-row
|
|
461
|
+
// note then only adds the other rejected IDs a multi-CVE advisory names.
|
|
462
|
+
const perRow = cveState === true ? rejectedCVENote(rows.map((x) => ({ ...x, rejected_cve_ids: rejectedIDs(x).filter((id) => id.toUpperCase() !== cve) }))) : rejectedCVENote(rows);
|
|
463
|
+
return kit.succeeded(r.data, `${head}${rejectedHead} ${rows.length} vendor ${plural(rows.length, "advisory names", "advisories name")} ${cve}.${cap}${withdrawnNote(rows)}${perRow}${rejectedUnknownNote(rows)}${remediationNote(cve, rows)} ${ONLY_POLLED}${held}`, env, FOR_CVE_TEXT);
|
|
364
464
|
}
|
|
365
465
|
catch (e) {
|
|
366
466
|
return kit.crashed(tool, e);
|
|
@@ -412,7 +512,14 @@ export function registerVendorAdvisoryTools(server, kit) {
|
|
|
412
512
|
? ` Of the ${cveIDs.length} CVE IDs it lists (cve_ids), ${known.length} ${plural(known.length, "has", "have")} a record in EchelonGraph's CVE feed (known_cve_ids); the others have no record there yet, which does not mean they are not CVEs.`
|
|
413
513
|
: "";
|
|
414
514
|
const held = !w.ok ? windowsUnknown(w.why) : window ? backfillingNote([window]) : "";
|
|
415
|
-
|
|
515
|
+
// #2865: the CVE IDs it names that were rejected, said whether or not the vendor withdrew it.
|
|
516
|
+
const rej = rejectedIDs(r.data);
|
|
517
|
+
const rejectedCVEs = rej.length
|
|
518
|
+
? ` CVE REJECTED: ${rej.length} of the CVE IDs it lists ${plural(rej.length, "was", "were")} rejected (withdrawn) by ${plural(rej.length, "its", "their")} numbering authority (rejected_cve_ids): ${rej.join(", ")}. Report ${plural(rej.length, "it", "each")} as a rejected record, not as an active vulnerability${field(r.data, "withdrawn") === true ? "" : ", even though the vendor has not withdrawn this advisory and its text reads as a current one"}.`
|
|
519
|
+
: field(r.data, "rejected_cve_ids") === null && Array.isArray(cveIDs) && cveIDs.length > 0
|
|
520
|
+
? " Whether a CVE ID it lists was rejected could not be checked (rejected_cve_ids null), which is not a finding that none was."
|
|
521
|
+
: "";
|
|
522
|
+
return kit.succeeded(r.data, `${kit.okHead(tool, r.status)} Returned the advisory ${shown}.${withdrawn}${rejectedCVEs}${cves}${held}`, env);
|
|
416
523
|
}
|
|
417
524
|
catch (e) {
|
|
418
525
|
return kit.crashed(tool, e);
|
|
@@ -500,7 +607,8 @@ export function registerVendorAdvisoryTools(server, kit) {
|
|
|
500
607
|
const counted = isCapped
|
|
501
608
|
? `${total.toLocaleString("en-US")}+ vendor advisories (total_capped true: the API stops counting a search's matches at ${total.toLocaleString("en-US")}, so total is a lower bound, not the count; paging past it still works)`
|
|
502
609
|
: `${total} vendor ${plural(total, "advisory", "advisories")}`;
|
|
503
|
-
|
|
610
|
+
const rejected = rows === undefined ? "" : `${rejectedCVENote(rows)}${rejectedUnknownNote(rows)}`;
|
|
611
|
+
return kit.succeeded(r.data, `${head} ${what} ${counted}${page}.${rejected}${wordOnly}${held}`, env, searchText(a));
|
|
504
612
|
}
|
|
505
613
|
catch (e) {
|
|
506
614
|
return kit.crashed(tool, e);
|
|
@@ -508,14 +616,14 @@ export function registerVendorAdvisoryTools(server, kit) {
|
|
|
508
616
|
}
|
|
509
617
|
server.registerTool("vendor_advisories_for_cve", {
|
|
510
618
|
title: "Vendor advisories for one CVE",
|
|
511
|
-
description: `The vendor-published advisories that name one CVE, newest first, at most 20: for each, vendor, vendor_display_name, vendor_advisory_id, title, severity and cvss_v3_score where the vendor gives them. Covers the vendor feeds EchelonGraph polls, for example Microsoft MSRC, Red Hat, Cisco, Palo Alto Networks and GitHub GHSA; an empty answer means that none of the advisories EchelonGraph holds from those feeds names the CVE, not that no vendor published one
|
|
619
|
+
description: `The vendor-published advisories that name one CVE, newest first, at most 20: for each, vendor, vendor_display_name, vendor_advisory_id, title, severity and cvss_v3_score where the vendor gives them. Covers the vendor feeds EchelonGraph polls, for example Microsoft MSRC, Red Hat, Cisco, Palo Alto Networks and GitHub GHSA; an empty answer means that none of the advisories EchelonGraph holds from those feeds names the CVE, not that no vendor published one. ${DATES_DESCRIPTION} ${REJECTED_CVE_DESCRIPTION} The answer's cve_rejected is true when the CVE record of the CVE asked for was rejected (withdrawn) by its numbering authority. Each row has remediation_kinds and remediation_state (not_parsed is not none). coverage gives returned, cap, at_cap (true: there may be more) and cve_year; vendor_windows, each vendor's window in what EchelonGraph holds: ${WINDOW_HEAD}; and vendors_not_fully_held, the vendors with no advisory in the answer of which EchelonGraph holds none, whose held_since is after 1 January of cve_year or not known, or whose history is still being read, which the note names. ${NOT_HELD_HEAD} ${TEXT_BUDGET_DESCRIPTION}${ROW_CUT_DESCRIPTION}.`,
|
|
512
620
|
inputSchema: z.object({ cve_id: z.string().describe("a CVE ID, e.g. CVE-2024-21412") }),
|
|
513
621
|
outputSchema: FOR_CVE_OUTPUT,
|
|
514
622
|
annotations: kit.annotations,
|
|
515
623
|
}, async ({ cve_id }) => kit.checked("vendor_advisories_for_cve", FOR_CVE_OUTPUT, await forCVE(cve_id)));
|
|
516
624
|
server.registerTool("get_vendor_advisory", {
|
|
517
625
|
title: "Vendor advisory detail",
|
|
518
|
-
description: `One vendor advisory in full, by vendor and the vendor's advisory ID (the vendor and vendor_advisory_id of a vendor advisory row): title, description, severity, cvss_v3_score, cve_ids and known_cve_ids (those with a record in EchelonGraph's CVE feed), affected_products, remediation, references, vendor_modified_at, and withdrawn_at and withdrawn_reason when the vendor withdrew it. ${DATES_DESCRIPTION} coverage.vendor_window is that vendor's window in what EchelonGraph holds: ${
|
|
626
|
+
description: `One vendor advisory in full, by vendor and the vendor's advisory ID (the vendor and vendor_advisory_id of a vendor advisory row): title, description, severity, cvss_v3_score, cve_ids and known_cve_ids (those with a record in EchelonGraph's CVE feed), affected_products, remediation, references, vendor_modified_at, and withdrawn_at and withdrawn_reason when the vendor withdrew it. remediations lists each remediation the vendor lists, with kind (the vendor's own category, never read from the text: vendor_fix, workaround, mitigation, no_fix_planned, none_available or other), source_category, text, url and the cve_ids and product_ids it covers; each text is the vendor's, verbatim, untested by EchelonGraph. remediation_state is parsed, none_in_source, not_parsed or withdrawn: none_in_source and not_parsed are not a finding that no fix exists. ${DATES_DESCRIPTION} ${REJECTED_CVE_DESCRIPTION} coverage.vendor_window is that vendor's window in what EchelonGraph holds: ${WINDOW_HEAD}; null when the windows could not be read or carry none for that vendor. measured_at is our_first_seen_at. ${ENVELOPE_DESCRIPTION} ${TEXT_BUDGET_DESCRIPTION}`,
|
|
519
627
|
inputSchema: z.object({
|
|
520
628
|
vendor: z.string().describe("the vendor slug, e.g. microsoft, redhat, github"),
|
|
521
629
|
advisory_id: z.string().describe("the vendor's advisory ID, e.g. RHSA-2024:1234 or GHSA-xxxx-xxxx-xxxx"),
|
|
@@ -525,7 +633,7 @@ export function registerVendorAdvisoryTools(server, kit) {
|
|
|
525
633
|
}, async ({ vendor, advisory_id }) => kit.checked("get_vendor_advisory", DETAIL_OUTPUT, await detail(vendor, advisory_id)));
|
|
526
634
|
server.registerTool("search_vendor_advisories", {
|
|
527
635
|
title: "Search vendor advisories",
|
|
528
|
-
description: `Search or list vendor-published advisories, newest first. ${
|
|
636
|
+
description: `Search or list vendor-published advisories, newest first. ${SEARCH_MATCH_HEAD} Filter by vendor (slug), severity band and whether the advisory names any CVE ID. Advisories their vendor withdrew are left out. Returns advisories, each with vendor, vendor_advisory_id, title, severity, cvss_v3_score, cve_ids, summary and affected_products where present, and total, total_capped, limit, offset, search_applied and search_match. ${TOTAL_CAPPED_HEAD} ${REJECTED_CVE_DESCRIPTION} The query is sent in a request header, never in the URL. coverage repeats those beside returned, and gives vendor_windows, each vendor's window in what EchelonGraph holds: ${WINDOW_HEAD}. ${NOT_HELD_HEAD} ${TEXT_BUDGET_DESCRIPTION}${ROW_CUT_DESCRIPTION}, or rows are left out and the note gives the offset to call next.`,
|
|
529
637
|
inputSchema: z.object({
|
|
530
638
|
query: z.string().optional().describe("free text, at most 100 bytes: a product, an advisory ID, a CVE ID or a keyword, e.g. 'exchange server'; 1 or 2 characters match whole words only"),
|
|
531
639
|
vendor: z.string().optional().describe("a vendor slug, e.g. microsoft, redhat, github"),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "echelongraph-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.7.0",
|
|
4
4
|
"mcpName": "io.echelongraph/echelongraph-mcp",
|
|
5
5
|
"description": "MCP server for CVE lookups: EchelonGraph scores, CISA KEV, EPSS, CWE, exploits, version and SBOM checks, vendor advisories, and per-CVE exposure derived from Shodan data. Shodan data is owned by Shodan, which holds its copyright (© Shodan). Keyless.",
|
|
6
6
|
"type": "module",
|
|
@@ -39,6 +39,8 @@
|
|
|
39
39
|
"cwe",
|
|
40
40
|
"cvss",
|
|
41
41
|
"sbom",
|
|
42
|
+
"lockfile",
|
|
43
|
+
"sca",
|
|
42
44
|
"security-advisories",
|
|
43
45
|
"threat-intelligence",
|
|
44
46
|
"exposure",
|
package/server.json
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
]
|
|
18
18
|
}
|
|
19
19
|
],
|
|
20
|
-
"version": "2.
|
|
20
|
+
"version": "2.7.0",
|
|
21
21
|
"remotes": [
|
|
22
22
|
{
|
|
23
23
|
"type": "streamable-http",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
{
|
|
29
29
|
"registryType": "npm",
|
|
30
30
|
"identifier": "echelongraph-mcp",
|
|
31
|
-
"version": "2.
|
|
31
|
+
"version": "2.7.0",
|
|
32
32
|
"transport": {
|
|
33
33
|
"type": "stdio"
|
|
34
34
|
},
|