echelongraph-mcp 2.3.3 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +79 -4
- package/dist/http.js +240 -0
- package/dist/httpPolicy.js +244 -0
- package/dist/index.js +179 -50
- package/dist/runtime.js +65 -0
- package/dist/tools/check_affected.js +269 -0
- package/dist/tools/check_sbom.js +329 -0
- package/dist/tools/cve_intel.js +276 -0
- package/dist/tools/epss_history.js +125 -0
- package/dist/tools/get_cwe.js +148 -0
- package/dist/tools/kev_recent.js +190 -0
- package/dist/tools/vendor_advisories.js +316 -0
- package/package.json +4 -2
- package/server.json +8 -2
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
const TOOL = "kev_recent";
|
|
3
|
+
const PATH = "/api/v1/public/kev/recent";
|
|
4
|
+
export const KEV_RECENT_MAX_LIMIT = 200;
|
|
5
|
+
const MAX_VENDOR = 128;
|
|
6
|
+
const MAX_CURSOR = 128;
|
|
7
|
+
const KEV_RECENT_METHOD = "EchelonGraph's copy of the CISA KEV catalog: CISA's feed is polled every 5 minutes and each entry is written onto its CVE's record; rows are read from those records, newest kev_added_date first.";
|
|
8
|
+
const KEV_RECENT_COVERAGE = "CISA KEV catalog";
|
|
9
|
+
const KEV_RECENT_DESCRIPTION = "The CVEs CISA has added to its Known Exploited Vulnerabilities (KEV) catalog, newest first, from EchelonGraph's copy of that catalog, which polls CISA's feed every 5 minutes. Each row in kev gives cve_id, kev_added_date (CISA's dateAdded), kev_due_date, kev_vendor, kev_product, kev_vuln_name and kev_ransomware (known ransomware-campaign use), EchelonGraph's severity, cvss_v3_score, epss_score, epss_percentile and eg_kev_tier for the CVE, and our_first_seen_kev, when EchelonGraph's poller first recorded the CVE entering the catalog (null where no such record exists). Filter by since and until (YYYY-MM-DD, inclusive, on kev_added_date), ransomware, and vendor (an exact, case-insensitive match on kev_vendor); page with limit (1 to 200, default 50) and cursor, passing back the previous page's next_cursor with the same filters. Rows within one date are ordered by cve_id. total counts the CVEs matching the filters; kev_listed_total counts every CVE EchelonGraph holds as KEV-listed, and catalog.catalog_count is CISA's own count in the catalog last fetched, so a gap between the two is entries not yet in EchelonGraph's CVE table, which no page returns. CISA's requiredAction and shortDescription are not returned. Its structured result carries state (measured), measured_at (our last successful fetch of CISA's feed, null when the API does not give it), method, coverage (the CISA KEV catalog: total, returned, kev_listed_total, catalog_count, limit, has_more), freshness (last_successful_fetch_at, catalog_version, date_released) 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.";
|
|
10
|
+
const opt = (t) => t.nullable().optional();
|
|
11
|
+
const KEVRow = z.looseObject({
|
|
12
|
+
cve_id: opt(z.string()),
|
|
13
|
+
kev_added_date: opt(z.string()),
|
|
14
|
+
kev_due_date: opt(z.string()),
|
|
15
|
+
kev_vendor: opt(z.string()),
|
|
16
|
+
kev_product: opt(z.string()),
|
|
17
|
+
kev_vuln_name: opt(z.string()),
|
|
18
|
+
kev_ransomware: opt(z.boolean()),
|
|
19
|
+
severity: opt(z.string()),
|
|
20
|
+
cvss_v3_score: opt(z.number()),
|
|
21
|
+
epss_score: opt(z.number()),
|
|
22
|
+
epss_percentile: opt(z.number()),
|
|
23
|
+
eg_kev_tier: opt(z.number()),
|
|
24
|
+
our_first_seen_kev: opt(z.string()).describe("When EchelonGraph's poller first recorded the CVE entering the catalog; null where no such record exists."),
|
|
25
|
+
});
|
|
26
|
+
const Data = z.looseObject({
|
|
27
|
+
kev: z.array(KEVRow).optional(),
|
|
28
|
+
count: opt(z.number()),
|
|
29
|
+
total: opt(z.number()),
|
|
30
|
+
kev_listed_total: opt(z.number()),
|
|
31
|
+
limit: opt(z.number()),
|
|
32
|
+
next_cursor: opt(z.string()),
|
|
33
|
+
order: opt(z.string()),
|
|
34
|
+
filters: opt(z.looseObject({})),
|
|
35
|
+
catalog: opt(z.looseObject({
|
|
36
|
+
source: opt(z.string()),
|
|
37
|
+
feed_url: opt(z.string()),
|
|
38
|
+
last_successful_fetch_at: opt(z.string()),
|
|
39
|
+
catalog_version: opt(z.string()),
|
|
40
|
+
date_released: opt(z.string()),
|
|
41
|
+
catalog_count: opt(z.number()),
|
|
42
|
+
})),
|
|
43
|
+
method: opt(z.string()),
|
|
44
|
+
notes: opt(z.array(z.string())),
|
|
45
|
+
generated_at: opt(z.string()),
|
|
46
|
+
});
|
|
47
|
+
const Coverage = z.strictObject({
|
|
48
|
+
catalog: z.literal(KEV_RECENT_COVERAGE),
|
|
49
|
+
total: z.number().nullable().describe("CVEs matching the filters."),
|
|
50
|
+
returned: z.number().nullable().describe("Rows in this page."),
|
|
51
|
+
kev_listed_total: z.number().nullable().describe("Every CVE EchelonGraph holds as KEV-listed."),
|
|
52
|
+
catalog_count: z.number().nullable().describe("CISA's own count, in the catalog last fetched."),
|
|
53
|
+
limit: z.number().nullable(),
|
|
54
|
+
has_more: z.boolean().nullable().describe("Whether next_cursor names a further page."),
|
|
55
|
+
});
|
|
56
|
+
const Freshness = z.strictObject({
|
|
57
|
+
last_successful_fetch_at: z.string().nullable().describe("When CISA last answered our poll of its feed: a 200 we applied, or a 304 Not Modified."),
|
|
58
|
+
catalog_version: z.string().nullable(),
|
|
59
|
+
date_released: z.string().nullable(),
|
|
60
|
+
});
|
|
61
|
+
// Inputs this side refuses before any request, with the reason.
|
|
62
|
+
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
63
|
+
const isDate = (s) => DATE.test(s) && new Date(`${s}T00:00:00Z`).toISOString().slice(0, 10) === s;
|
|
64
|
+
// A header value must be visible ASCII: fetch refuses others, and that refusal would read as an outage.
|
|
65
|
+
const PRINTABLE = /^[\x20-\x7e]+$/;
|
|
66
|
+
function headersFor(a) {
|
|
67
|
+
const h = {};
|
|
68
|
+
const since = a.since?.trim();
|
|
69
|
+
const until = a.until?.trim();
|
|
70
|
+
if (since) {
|
|
71
|
+
if (!isDate(since))
|
|
72
|
+
return { why: "since must be a date, YYYY-MM-DD" };
|
|
73
|
+
h["X-EG-Since"] = since;
|
|
74
|
+
}
|
|
75
|
+
if (until) {
|
|
76
|
+
if (!isDate(until))
|
|
77
|
+
return { why: "until must be a date, YYYY-MM-DD" };
|
|
78
|
+
h["X-EG-Until"] = until;
|
|
79
|
+
}
|
|
80
|
+
if (since && until && since > until)
|
|
81
|
+
return { why: "since must not be after until" };
|
|
82
|
+
if (a.ransomware !== undefined)
|
|
83
|
+
h["X-EG-Ransomware"] = String(a.ransomware);
|
|
84
|
+
const vendor = a.vendor?.trim();
|
|
85
|
+
if (vendor) {
|
|
86
|
+
if (vendor.length > MAX_VENDOR || !PRINTABLE.test(vendor))
|
|
87
|
+
return { why: `vendor must be at most ${MAX_VENDOR} printable ASCII characters` };
|
|
88
|
+
h["X-EG-Vendor"] = vendor;
|
|
89
|
+
}
|
|
90
|
+
if (a.limit !== undefined)
|
|
91
|
+
h["X-EG-Limit"] = String(a.limit);
|
|
92
|
+
const cursor = a.cursor?.trim();
|
|
93
|
+
if (cursor) {
|
|
94
|
+
if (cursor.length > MAX_CURSOR || !/^[A-Za-z0-9_-]+$/.test(cursor))
|
|
95
|
+
return { why: "cursor must be a next_cursor this tool returned" };
|
|
96
|
+
h["X-EG-Cursor"] = cursor;
|
|
97
|
+
}
|
|
98
|
+
return { headers: h };
|
|
99
|
+
}
|
|
100
|
+
const at = (o, ...keys) => keys.reduce((v, k) => (v !== null && typeof v === "object" ? v[k] : undefined), o);
|
|
101
|
+
const num = (o, ...k) => {
|
|
102
|
+
const v = at(o, ...k);
|
|
103
|
+
return typeof v === "number" && Number.isFinite(v) ? v : null;
|
|
104
|
+
};
|
|
105
|
+
const str = (o, ...k) => {
|
|
106
|
+
const v = at(o, ...k);
|
|
107
|
+
return typeof v === "string" && v ? v : null;
|
|
108
|
+
};
|
|
109
|
+
const realInstant = (s) => s !== null && Date.parse(s) > 0;
|
|
110
|
+
async function kevRecent(d, a) {
|
|
111
|
+
try {
|
|
112
|
+
const h = headersFor(a);
|
|
113
|
+
if ("why" in h)
|
|
114
|
+
return d.badInput(TOOL, h.why);
|
|
115
|
+
const r = await d.api(PATH, { headers: h.headers });
|
|
116
|
+
if (!r.ok)
|
|
117
|
+
return d.failed(TOOL, r);
|
|
118
|
+
const rowsV = at(r.data, "kev");
|
|
119
|
+
const rows = Array.isArray(rowsV) ? rowsV : null;
|
|
120
|
+
const total = num(r.data, "total");
|
|
121
|
+
const listed = num(r.data, "kev_listed_total");
|
|
122
|
+
const catCount = num(r.data, "catalog", "catalog_count");
|
|
123
|
+
const fetchedRaw = str(r.data, "catalog", "last_successful_fetch_at");
|
|
124
|
+
const fetched = realInstant(fetchedRaw) ? fetchedRaw : null;
|
|
125
|
+
const version = str(r.data, "catalog", "catalog_version");
|
|
126
|
+
const released = str(r.data, "catalog", "date_released");
|
|
127
|
+
const next = str(r.data, "next_cursor");
|
|
128
|
+
const said = [d.okHead(TOOL, r.status)];
|
|
129
|
+
if (total === 0) {
|
|
130
|
+
said.push("No CVE in EchelonGraph's copy of the CISA KEV catalog matched these filters: a measured empty result, not a lookup failure.");
|
|
131
|
+
}
|
|
132
|
+
else if (rows && rows.length === 0) {
|
|
133
|
+
said.push(`This page is empty: the cursor is past the last of the ${total ?? "matching"} rows.`);
|
|
134
|
+
}
|
|
135
|
+
else if (rows) {
|
|
136
|
+
const first = rows[0];
|
|
137
|
+
const newest = typeof first?.cve_id === "string" && typeof first?.kev_added_date === "string" ? ` The first is ${first.cve_id}, added by CISA on ${first.kev_added_date}.` : "";
|
|
138
|
+
said.push(`${total === null ? "The API did not say how many CVEs match" : `${total} CVEs in EchelonGraph's copy of the CISA KEV catalog match`}; ${rows.length} are in this page, newest kev_added_date first.${newest}`);
|
|
139
|
+
}
|
|
140
|
+
if (rows && rows.length > 0)
|
|
141
|
+
said.push(next ? "More rows follow: call again with cursor set to next_cursor and the same filters." : "This is the last page.");
|
|
142
|
+
said.push(fetched
|
|
143
|
+
? `EchelonGraph last fetched CISA's feed successfully at ${fetched}${version ? `, catalog version ${version}` : ""}.`
|
|
144
|
+
: "The API gave no time for EchelonGraph's last successful fetch of CISA's feed, so how fresh this list is is unknown, not stale.");
|
|
145
|
+
if (catCount !== null && listed !== null && catCount > listed) {
|
|
146
|
+
said.push(`CISA's catalog as last fetched counts ${catCount} entries and EchelonGraph holds ${listed} CVEs as KEV-listed, so ${catCount - listed} are not in its CVE table yet and no page returns them.`);
|
|
147
|
+
}
|
|
148
|
+
return d.succeeded(r.data, said.join(" "), {
|
|
149
|
+
state: "measured",
|
|
150
|
+
measured_at: fetched,
|
|
151
|
+
method: KEV_RECENT_METHOD,
|
|
152
|
+
coverage: {
|
|
153
|
+
catalog: KEV_RECENT_COVERAGE,
|
|
154
|
+
total,
|
|
155
|
+
returned: rows ? rows.length : null,
|
|
156
|
+
kev_listed_total: listed,
|
|
157
|
+
catalog_count: catCount,
|
|
158
|
+
limit: num(r.data, "limit"),
|
|
159
|
+
has_more: rows ? next !== null : null,
|
|
160
|
+
},
|
|
161
|
+
freshness: { last_successful_fetch_at: fetched, catalog_version: version, date_released: released },
|
|
162
|
+
notes: [
|
|
163
|
+
fetched
|
|
164
|
+
? "measured_at is EchelonGraph's last successful fetch of CISA's feed, the time this copy of the catalog was last confirmed."
|
|
165
|
+
: "measured_at is null: the answer does not give EchelonGraph's last successful fetch of CISA's feed.",
|
|
166
|
+
],
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
catch (e) {
|
|
170
|
+
return d.crashed(TOOL, e);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// Registers the tool on a server built by index.ts's createServer.
|
|
174
|
+
export function registerKevRecent(server, d) {
|
|
175
|
+
const output = d.envelopeSchema({ data: Data, coverage: Coverage, freshness: Freshness });
|
|
176
|
+
server.registerTool("kev_recent", {
|
|
177
|
+
title: "Recent CISA KEV additions",
|
|
178
|
+
description: KEV_RECENT_DESCRIPTION,
|
|
179
|
+
inputSchema: z.object({
|
|
180
|
+
since: z.string().optional().describe("earliest kev_added_date to include, YYYY-MM-DD"),
|
|
181
|
+
until: z.string().optional().describe("latest kev_added_date to include, YYYY-MM-DD"),
|
|
182
|
+
ransomware: z.boolean().optional().describe("true: only CVEs with known ransomware-campaign use; false: only those without"),
|
|
183
|
+
vendor: z.string().optional().describe("CISA vendorProject, an exact case-insensitive match, e.g. 'Microsoft'"),
|
|
184
|
+
limit: z.number().int().min(1).max(KEV_RECENT_MAX_LIMIT).optional().describe("page size (default 50, max 200)"),
|
|
185
|
+
cursor: z.string().optional().describe("next_cursor from the previous page"),
|
|
186
|
+
}),
|
|
187
|
+
outputSchema: output,
|
|
188
|
+
annotations: d.annotations,
|
|
189
|
+
}, async (a) => d.checked(TOOL, output, await kevRecent(d, a)));
|
|
190
|
+
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
// The vendor-advisory tools (#2719): vendor_advisories_for_cve, get_vendor_advisory and
|
|
2
|
+
// search_vendor_advisories, over core-backend's /api/v1/public/vendor-advisories routes
|
|
3
|
+
// (internal/cve/vendoradv/handler.go: ByCVE, Detail, List).
|
|
4
|
+
//
|
|
5
|
+
// What these answers are. EchelonGraph polls vendors' own advisory feeds on a schedule and
|
|
6
|
+
// stores each advisory as the vendor published it (vendor_advisories, migration 063). A row
|
|
7
|
+
// carries two dates that mean different things, and every tool relays both:
|
|
8
|
+
// vendor_published_at is the date the vendor gives, our_first_seen_at is when EchelonGraph
|
|
9
|
+
// first recorded the advisory. withdrawn is the vendor rescinding its own advisory; the list
|
|
10
|
+
// and the search leave such advisories out, the per-CVE lookup and the detail keep them, and
|
|
11
|
+
// the note names each one. Advisories of a vendor feed EchelonGraph has disabled are left out
|
|
12
|
+
// of all three. None of the answers carries a time at which the pollers last completed a poll,
|
|
13
|
+
// so freshness is null.
|
|
14
|
+
//
|
|
15
|
+
// The search term never travels in a URL (#1983). search_vendor_advisories sends it in the
|
|
16
|
+
// X-EG-Advisory-Search header, percent-encoded, which the API reads first (handler.go
|
|
17
|
+
// listSearch); the URL carries only the filters. An API that does not read that header answers
|
|
18
|
+
// the UNSEARCHED list, so the tool requires the answer's search_applied to be true whenever it
|
|
19
|
+
// sent a term, and fails otherwise rather than relay the whole list as the search's matches.
|
|
20
|
+
// The term is never written into a note, an error or a log either.
|
|
21
|
+
//
|
|
22
|
+
// The other two put a CVE ID, a vendor slug and a vendor advisory ID in the path: identifiers
|
|
23
|
+
// the routes are keyed on, refused locally unless they have an identifier's shape, so no free
|
|
24
|
+
// text reaches a URL through them.
|
|
25
|
+
//
|
|
26
|
+
// Logic only: the envelope, the failure contract and the API call are index.ts's, handed in as
|
|
27
|
+
// a VendorAdvisoryKit so this file cannot drift from them.
|
|
28
|
+
import * as z from "zod";
|
|
29
|
+
// core-backend vendoradv.SearchHeader.
|
|
30
|
+
export const VENDOR_ADVISORY_SEARCH_HEADER = "X-EG-Advisory-Search";
|
|
31
|
+
// handler.go List caps the search at 100 bytes; a longer term would be cut, possibly inside a
|
|
32
|
+
// character, so it is refused here instead.
|
|
33
|
+
const SEARCH_MAX_BYTES = 100;
|
|
34
|
+
// store.go ListAdvisoriesByCVE: LIMIT 20, newest first.
|
|
35
|
+
const BY_CVE_CAP = 20;
|
|
36
|
+
const CVE_ID = /^CVE-\d{4}-\d{4,}$/i;
|
|
37
|
+
// vendors.slug's CHECK constraint (migration 063).
|
|
38
|
+
const VENDOR_SLUG = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
39
|
+
// No control characters (a header or log line could carry them), at most 200 characters.
|
|
40
|
+
const ADVISORY_ID = /^[^\x00-\x1f\x7f]{1,200}$/;
|
|
41
|
+
const METHOD = "EchelonGraph's vendor-advisory feed: advisories as each vendor published them in its own feed (for example Microsoft MSRC, Red Hat, Cisco, Palo Alto Networks and GitHub GHSA), each feed polled on a schedule; advisories of a feed EchelonGraph has disabled are left out.";
|
|
42
|
+
const NO_FRESHNESS = "freshness is null: these answers carry no time at which the vendor-advisory pollers last completed a poll.";
|
|
43
|
+
const DATES = "vendor_published_at is the date the vendor gives for the advisory; our_first_seen_at is when EchelonGraph first recorded it, which is not the vendor's date.";
|
|
44
|
+
const LIST_MEASURED_AT = "measured_at is null: a list of advisories has no single observation time; each row carries vendor_published_at and our_first_seen_at.";
|
|
45
|
+
const ONLY_POLLED = "Only the vendor feeds EchelonGraph polls are covered, so an advisory a vendor published elsewhere is not in this answer.";
|
|
46
|
+
const realInstant = (s) => typeof s === "string" && Date.parse(s) > 0;
|
|
47
|
+
const field = (o, k) => (o !== null && typeof o === "object" ? o[k] : undefined);
|
|
48
|
+
const num = (o, k) => {
|
|
49
|
+
const v = field(o, k);
|
|
50
|
+
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
|
51
|
+
};
|
|
52
|
+
const str = (o, k) => {
|
|
53
|
+
const v = field(o, k);
|
|
54
|
+
return typeof v === "string" && v ? v : undefined;
|
|
55
|
+
};
|
|
56
|
+
const rowsOf = (o) => {
|
|
57
|
+
const v = field(o, "advisories");
|
|
58
|
+
return Array.isArray(v) ? v : undefined;
|
|
59
|
+
};
|
|
60
|
+
const plural = (n, one, many) => (n === 1 ? one : many);
|
|
61
|
+
// Each withdrawn row named by vendor and id: report it as withdrawn, never as a current advisory.
|
|
62
|
+
function withdrawnNote(rows) {
|
|
63
|
+
const w = rows.flatMap((r, i) => (field(r, "withdrawn") === true ? [`${str(r, "vendor") ?? "?"}/${str(r, "vendor_advisory_id") ?? `advisories[${i}]`}`] : []));
|
|
64
|
+
if (!w.length)
|
|
65
|
+
return "";
|
|
66
|
+
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.`;
|
|
67
|
+
}
|
|
68
|
+
// ── Output schemas ──
|
|
69
|
+
const opt = (t) => t.nullable().optional();
|
|
70
|
+
const DATE_FIELDS = {
|
|
71
|
+
vendor_published_at: opt(z.string()).describe("The date the vendor gives for the advisory."),
|
|
72
|
+
our_first_seen_at: opt(z.string()).describe("When EchelonGraph first recorded the advisory; not the vendor's date."),
|
|
73
|
+
withdrawn: opt(z.boolean()).describe("true: the vendor withdrew (rescinded) this advisory; report it as withdrawn."),
|
|
74
|
+
};
|
|
75
|
+
const AdvisoryRow = z.looseObject({
|
|
76
|
+
advisory_id: opt(z.string()),
|
|
77
|
+
vendor: opt(z.string()),
|
|
78
|
+
vendor_display_name: opt(z.string()),
|
|
79
|
+
vendor_advisory_id: opt(z.string()),
|
|
80
|
+
cve_ids: opt(z.array(z.string())),
|
|
81
|
+
title: opt(z.string()),
|
|
82
|
+
severity: opt(z.string()),
|
|
83
|
+
cvss_v3_score: opt(z.number()),
|
|
84
|
+
summary: opt(z.string()),
|
|
85
|
+
affected_products: opt(z.array(z.string())),
|
|
86
|
+
...DATE_FIELDS,
|
|
87
|
+
});
|
|
88
|
+
// Shared by the three descriptions. Top-level consts, so the site's tool-claims check
|
|
89
|
+
// (marketing-site/lib/mcpToolClaims.test.ts) can fold the descriptions to their text.
|
|
90
|
+
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; the note names each such advisory).";
|
|
91
|
+
const ENVELOPE_DESCRIPTION = "Its structured result carries state (measured), measured_at, method, coverage, freshness (null: the answer carries 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.";
|
|
92
|
+
export function registerVendorAdvisoryTools(server, kit) {
|
|
93
|
+
const FOR_CVE_OUTPUT = kit.envelopeSchema({
|
|
94
|
+
data: z.looseObject({ cve_id: opt(z.string()), advisories: opt(z.array(AdvisoryRow)), total: opt(z.number()) }),
|
|
95
|
+
coverage: z.strictObject({
|
|
96
|
+
returned: z.number().nullable().describe("The advisories in this answer."),
|
|
97
|
+
cap: z.number().describe("The most the API returns for one CVE, newest first."),
|
|
98
|
+
at_cap: z.boolean().nullable().describe("true: the answer is full, so the vendor feeds may hold more advisories for this CVE than it lists."),
|
|
99
|
+
}),
|
|
100
|
+
freshness: null,
|
|
101
|
+
});
|
|
102
|
+
const DETAIL_OUTPUT = kit.envelopeSchema({
|
|
103
|
+
data: AdvisoryRow.extend({
|
|
104
|
+
description: opt(z.string()),
|
|
105
|
+
known_cve_ids: opt(z.array(z.string())).describe("The CVE IDs of cve_ids that have a record in EchelonGraph's CVE feed."),
|
|
106
|
+
remediation: opt(z.string()),
|
|
107
|
+
references: z.unknown().optional(),
|
|
108
|
+
vendor_modified_at: opt(z.string()),
|
|
109
|
+
withdrawn_at: opt(z.string()),
|
|
110
|
+
withdrawn_reason: opt(z.string()),
|
|
111
|
+
}),
|
|
112
|
+
coverage: null,
|
|
113
|
+
freshness: null,
|
|
114
|
+
});
|
|
115
|
+
const SEARCH_OUTPUT = kit.envelopeSchema({
|
|
116
|
+
data: z.looseObject({
|
|
117
|
+
advisories: opt(z.array(AdvisoryRow)),
|
|
118
|
+
total: opt(z.number()),
|
|
119
|
+
limit: opt(z.number()),
|
|
120
|
+
offset: opt(z.number()),
|
|
121
|
+
search_applied: opt(z.boolean()).describe("Whether a free-text search filtered this answer."),
|
|
122
|
+
}),
|
|
123
|
+
coverage: z.strictObject({
|
|
124
|
+
total: z.number().nullable(),
|
|
125
|
+
returned: z.number().nullable(),
|
|
126
|
+
limit: z.number().nullable(),
|
|
127
|
+
offset: z.number().nullable(),
|
|
128
|
+
search_applied: z.boolean().nullable(),
|
|
129
|
+
}),
|
|
130
|
+
freshness: null,
|
|
131
|
+
});
|
|
132
|
+
// ── vendor_advisories_for_cve ──
|
|
133
|
+
async function forCVE(cveArg) {
|
|
134
|
+
const tool = "vendor_advisories_for_cve";
|
|
135
|
+
const raw = cveArg.trim();
|
|
136
|
+
if (!raw)
|
|
137
|
+
return kit.badInput(tool, "cve_id is required");
|
|
138
|
+
if (!CVE_ID.test(raw))
|
|
139
|
+
return kit.badInput(tool, "cve_id must be a CVE ID such as CVE-2024-21412");
|
|
140
|
+
const cve = raw.toUpperCase();
|
|
141
|
+
try {
|
|
142
|
+
const r = await kit.api(`/api/v1/public/vendor-advisories/by-cve/${encodeURIComponent(cve)}`);
|
|
143
|
+
if (!r.ok)
|
|
144
|
+
return kit.failed(tool, r);
|
|
145
|
+
const rows = rowsOf(r.data);
|
|
146
|
+
const returned = rows?.length ?? null;
|
|
147
|
+
const atCap = returned === null ? null : returned >= BY_CVE_CAP;
|
|
148
|
+
const env = {
|
|
149
|
+
state: "measured",
|
|
150
|
+
measured_at: null,
|
|
151
|
+
method: METHOD,
|
|
152
|
+
coverage: { returned, cap: BY_CVE_CAP, at_cap: atCap },
|
|
153
|
+
freshness: null,
|
|
154
|
+
notes: [LIST_MEASURED_AT, NO_FRESHNESS, DATES],
|
|
155
|
+
};
|
|
156
|
+
const head = kit.okHead(tool, r.status);
|
|
157
|
+
if (returned === 0) {
|
|
158
|
+
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}`, env);
|
|
159
|
+
}
|
|
160
|
+
if (rows === undefined)
|
|
161
|
+
return kit.succeeded(r.data, head, env);
|
|
162
|
+
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.` : "";
|
|
163
|
+
return kit.succeeded(r.data, `${head} ${rows.length} vendor ${plural(rows.length, "advisory names", "advisories name")} ${cve}.${cap}${withdrawnNote(rows)} ${ONLY_POLLED}`, env);
|
|
164
|
+
}
|
|
165
|
+
catch (e) {
|
|
166
|
+
return kit.crashed(tool, e);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// ── get_vendor_advisory ──
|
|
170
|
+
async function detail(vendorArg, idArg) {
|
|
171
|
+
const tool = "get_vendor_advisory";
|
|
172
|
+
const vendor = vendorArg.trim();
|
|
173
|
+
const id = idArg.trim();
|
|
174
|
+
if (!vendor)
|
|
175
|
+
return kit.badInput(tool, "vendor is required");
|
|
176
|
+
if (!VENDOR_SLUG.test(vendor) || vendor.length > 64)
|
|
177
|
+
return kit.badInput(tool, "vendor must be a vendor slug such as microsoft, redhat or github, as the advisory's vendor field gives it");
|
|
178
|
+
if (!id)
|
|
179
|
+
return kit.badInput(tool, "advisory_id is required");
|
|
180
|
+
if (!ADVISORY_ID.test(id))
|
|
181
|
+
return kit.badInput(tool, "advisory_id must be the vendor's advisory ID (vendor_advisory_id), at most 200 characters, with no control characters");
|
|
182
|
+
try {
|
|
183
|
+
const r = await kit.api(`/api/v1/public/vendor-advisories/${encodeURIComponent(vendor)}/${encodeURIComponent(id)}`);
|
|
184
|
+
if (!r.ok)
|
|
185
|
+
return kit.failed(tool, r);
|
|
186
|
+
const firstSeen = field(r.data, "our_first_seen_at");
|
|
187
|
+
const cveIDs = field(r.data, "cve_ids");
|
|
188
|
+
const known = field(r.data, "known_cve_ids");
|
|
189
|
+
const env = {
|
|
190
|
+
state: "measured",
|
|
191
|
+
measured_at: realInstant(firstSeen) ? firstSeen : null,
|
|
192
|
+
method: METHOD,
|
|
193
|
+
coverage: null,
|
|
194
|
+
freshness: null,
|
|
195
|
+
notes: [
|
|
196
|
+
realInstant(firstSeen)
|
|
197
|
+
? "measured_at is our_first_seen_at: when EchelonGraph first recorded this advisory."
|
|
198
|
+
: "measured_at is null: the answer carries no real our_first_seen_at.",
|
|
199
|
+
NO_FRESHNESS,
|
|
200
|
+
DATES,
|
|
201
|
+
],
|
|
202
|
+
};
|
|
203
|
+
const shown = `${str(r.data, "vendor") ?? vendor}/${str(r.data, "vendor_advisory_id") ?? id}`;
|
|
204
|
+
const withdrawn = field(r.data, "withdrawn") === true
|
|
205
|
+
? ` WITHDRAWN: the vendor withdrew (rescinded) ${shown} (withdrawn true${realInstant(field(r.data, "withdrawn_at")) ? `, withdrawn_at ${field(r.data, "withdrawn_at")}` : ""}). Report it as withdrawn, not as a current advisory.`
|
|
206
|
+
: "";
|
|
207
|
+
const cves = Array.isArray(cveIDs) && Array.isArray(known) && cveIDs.length > known.length
|
|
208
|
+
? ` 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.`
|
|
209
|
+
: "";
|
|
210
|
+
return kit.succeeded(r.data, `${kit.okHead(tool, r.status)} Returned the advisory ${shown}.${withdrawn}${cves}`, env);
|
|
211
|
+
}
|
|
212
|
+
catch (e) {
|
|
213
|
+
return kit.crashed(tool, e);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
async function search(a) {
|
|
217
|
+
const tool = "search_vendor_advisories";
|
|
218
|
+
const query = (a.query ?? "").trim();
|
|
219
|
+
if (Buffer.byteLength(query, "utf8") > SEARCH_MAX_BYTES)
|
|
220
|
+
return kit.badInput(tool, `query is longer than the API's ${SEARCH_MAX_BYTES}-byte search limit`);
|
|
221
|
+
const vendor = (a.vendor ?? "").trim();
|
|
222
|
+
if (vendor && (!VENDOR_SLUG.test(vendor) || vendor.length > 64))
|
|
223
|
+
return kit.badInput(tool, "vendor must be a vendor slug such as microsoft, redhat or github");
|
|
224
|
+
try {
|
|
225
|
+
const q = new URLSearchParams();
|
|
226
|
+
if (vendor)
|
|
227
|
+
q.set("vendor", vendor);
|
|
228
|
+
if (a.severity)
|
|
229
|
+
q.set("severity", a.severity);
|
|
230
|
+
if (a.has_cve !== undefined)
|
|
231
|
+
q.set("has_cve", String(a.has_cve));
|
|
232
|
+
q.set("limit", String(a.limit ?? 20));
|
|
233
|
+
q.set("offset", String(a.offset ?? 0));
|
|
234
|
+
// The term in the header only, never the URL (#1983).
|
|
235
|
+
const headers = query ? { [VENDOR_ADVISORY_SEARCH_HEADER]: encodeURIComponent(query) } : {};
|
|
236
|
+
const path = `/api/v1/public/vendor-advisories?${q.toString()}`;
|
|
237
|
+
const r = await kit.api(path, { headers });
|
|
238
|
+
if (!r.ok)
|
|
239
|
+
return kit.failed(tool, r);
|
|
240
|
+
const applied = field(r.data, "search_applied");
|
|
241
|
+
if (query && applied !== true) {
|
|
242
|
+
return kit.failed(tool, {
|
|
243
|
+
ok: false,
|
|
244
|
+
kind: "unexpected_shape",
|
|
245
|
+
path,
|
|
246
|
+
status: r.status,
|
|
247
|
+
detail: `the answer does not say the search was applied (search_applied is ${applied === undefined ? "absent" : JSON.stringify(applied)}: an API that does not read the ${VENDOR_ADVISORY_SEARCH_HEADER} header answers the unsearched list), so it is not relayed as the search's matches`,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
const rows = rowsOf(r.data);
|
|
251
|
+
const total = num(r.data, "total");
|
|
252
|
+
const env = {
|
|
253
|
+
state: "measured",
|
|
254
|
+
measured_at: null,
|
|
255
|
+
method: METHOD,
|
|
256
|
+
coverage: {
|
|
257
|
+
total: total ?? null,
|
|
258
|
+
returned: rows?.length ?? null,
|
|
259
|
+
limit: num(r.data, "limit") ?? null,
|
|
260
|
+
offset: num(r.data, "offset") ?? null,
|
|
261
|
+
search_applied: typeof applied === "boolean" ? applied : null,
|
|
262
|
+
},
|
|
263
|
+
freshness: null,
|
|
264
|
+
notes: [
|
|
265
|
+
LIST_MEASURED_AT,
|
|
266
|
+
NO_FRESHNESS,
|
|
267
|
+
DATES,
|
|
268
|
+
"Advisories their vendor withdrew are left out of this list (withdrawn advisories stay reachable through get_vendor_advisory and vendor_advisories_for_cve).",
|
|
269
|
+
],
|
|
270
|
+
};
|
|
271
|
+
const head = kit.okHead(tool, r.status);
|
|
272
|
+
const what = query ? "The search matched" : "The list holds";
|
|
273
|
+
if (total === 0) {
|
|
274
|
+
return kit.succeeded(r.data, `${head} ${what} 0 vendor advisories: a measured empty result, EchelonGraph was queried successfully and nothing matched these filters (we looked and found nothing). This is not a lookup failure. ${ONLY_POLLED}`, env);
|
|
275
|
+
}
|
|
276
|
+
if (total === undefined)
|
|
277
|
+
return kit.succeeded(r.data, head, env);
|
|
278
|
+
const page = rows === undefined ? "" : `; ${rows.length} returned in this page (offset ${num(r.data, "offset") ?? a.offset ?? 0})`;
|
|
279
|
+
return kit.succeeded(r.data, `${head} ${what} ${total} vendor ${plural(total, "advisory", "advisories")}${page}.`, env);
|
|
280
|
+
}
|
|
281
|
+
catch (e) {
|
|
282
|
+
return kit.crashed(tool, e);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
server.registerTool("vendor_advisories_for_cve", {
|
|
286
|
+
title: "Vendor advisories for one CVE",
|
|
287
|
+
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 none of those names the CVE. ${DATES_DESCRIPTION} Pass a CVE ID like CVE-2024-21412. coverage gives returned, cap and at_cap (true: the answer is full, so there may be more). measured_at is null. ${ENVELOPE_DESCRIPTION}`,
|
|
288
|
+
inputSchema: z.object({ cve_id: z.string().describe("a CVE ID, e.g. CVE-2024-21412") }),
|
|
289
|
+
outputSchema: FOR_CVE_OUTPUT,
|
|
290
|
+
annotations: kit.annotations,
|
|
291
|
+
}, async ({ cve_id }) => kit.checked("vendor_advisories_for_cve", FOR_CVE_OUTPUT, await forCVE(cve_id)));
|
|
292
|
+
server.registerTool("get_vendor_advisory", {
|
|
293
|
+
title: "Vendor advisory detail",
|
|
294
|
+
description: `One vendor advisory in full, by vendor and the vendor's advisory ID (the vendor and vendor_advisory_id fields of a row from search_vendor_advisories or vendor_advisories_for_cve): 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} measured_at is our_first_seen_at. ${ENVELOPE_DESCRIPTION}`,
|
|
295
|
+
inputSchema: z.object({
|
|
296
|
+
vendor: z.string().describe("the vendor slug, e.g. microsoft, redhat, github"),
|
|
297
|
+
advisory_id: z.string().describe("the vendor's advisory ID, e.g. RHSA-2024:1234 or GHSA-xxxx-xxxx-xxxx"),
|
|
298
|
+
}),
|
|
299
|
+
outputSchema: DETAIL_OUTPUT,
|
|
300
|
+
annotations: kit.annotations,
|
|
301
|
+
}, async ({ vendor, advisory_id }) => kit.checked("get_vendor_advisory", DETAIL_OUTPUT, await detail(vendor, advisory_id)));
|
|
302
|
+
server.registerTool("search_vendor_advisories", {
|
|
303
|
+
title: "Search vendor advisories",
|
|
304
|
+
description: `Search or list vendor-published advisories, newest first. query is matched case-insensitively as a substring of each advisory's title, description, vendor name, vendor_advisory_id, affected_products and cve_ids; filter by vendor (slug), by severity band, and by 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 the answer's total, limit, offset and search_applied. ${DATES_DESCRIPTION} The query is sent in a request header, never in the URL. coverage repeats total, limit, offset and search_applied, and gives returned, the rows in this page. measured_at is null. ${ENVELOPE_DESCRIPTION}`,
|
|
305
|
+
inputSchema: z.object({
|
|
306
|
+
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'"),
|
|
307
|
+
vendor: z.string().optional().describe("a vendor slug, e.g. microsoft, redhat, github"),
|
|
308
|
+
severity: z.enum(["Critical", "High", "Medium", "Low"]).optional().describe("one severity band"),
|
|
309
|
+
has_cve: z.boolean().optional().describe("true: only advisories naming a CVE; false: only those naming none"),
|
|
310
|
+
limit: z.number().int().min(1).max(50).optional().describe("page size (default 20, max 50)"),
|
|
311
|
+
offset: z.number().int().min(0).max(10000).optional().describe("rows to skip (default 0)"),
|
|
312
|
+
}),
|
|
313
|
+
outputSchema: SEARCH_OUTPUT,
|
|
314
|
+
annotations: kit.annotations,
|
|
315
|
+
}, async (a) => kit.checked("search_vendor_advisories", SEARCH_OUTPUT, await search(a)));
|
|
316
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "echelongraph-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"mcpName": "io.echelongraph/echelongraph-mcp",
|
|
5
5
|
"description": "EchelonGraph CVE and internet-exposure data as an MCP server: CVEs with EchelonGraph multi-source scores, CISA-KEV and EPSS, plus a per-CVE internet-exposure footprint derived from Shodan data. Shodan data is owned by Shodan, which holds its copyright (© Shodan). Free and keyless, for Claude, Cursor and any MCP client.",
|
|
6
6
|
"type": "module",
|
|
@@ -15,8 +15,10 @@
|
|
|
15
15
|
"scripts": {
|
|
16
16
|
"build": "tsc",
|
|
17
17
|
"start": "node dist/index.js",
|
|
18
|
+
"start:http": "node dist/http.js",
|
|
18
19
|
"smoke": "node test/smoke.mjs",
|
|
19
|
-
"test": "npm run build && node --test test/*.test.mjs"
|
|
20
|
+
"test": "npm run build && node --test test/*.test.mjs synthetic/*.test.mjs",
|
|
21
|
+
"synthetic": "node synthetic/run.mjs"
|
|
20
22
|
},
|
|
21
23
|
"keywords": [
|
|
22
24
|
"mcp",
|
package/server.json
CHANGED
|
@@ -8,12 +8,18 @@
|
|
|
8
8
|
"url": "https://github.com/echelongraph/echelongraph-mcp",
|
|
9
9
|
"source": "github"
|
|
10
10
|
},
|
|
11
|
-
"version": "2.
|
|
11
|
+
"version": "2.4.0",
|
|
12
|
+
"remotes": [
|
|
13
|
+
{
|
|
14
|
+
"type": "streamable-http",
|
|
15
|
+
"url": "https://mcp.echelongraph.io/mcp"
|
|
16
|
+
}
|
|
17
|
+
],
|
|
12
18
|
"packages": [
|
|
13
19
|
{
|
|
14
20
|
"registryType": "npm",
|
|
15
21
|
"identifier": "echelongraph-mcp",
|
|
16
|
-
"version": "2.
|
|
22
|
+
"version": "2.4.0",
|
|
17
23
|
"transport": {
|
|
18
24
|
"type": "stdio"
|
|
19
25
|
},
|