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.
@@ -0,0 +1,125 @@
1
+ import * as z from "zod";
2
+ const TOOL = "epss_history";
3
+ const CVE_ID = /^CVE-\d{4}-\d{4,}$/i;
4
+ export const EPSS_HISTORY_METHOD = "EchelonGraph's record of FIRST's EPSS scores: the nightly EPSS fetcher overwrites each CVE's epss_score and epss_percentile from FIRST's published file and, for every CVE whose value changed, records the new values with the time it wrote them; this series is those records, oldest first, with repeated records of one change collapsed into the first.";
5
+ const CHANGE_ONLY = "series_kind is change_only: each point is a change EchelonGraph recorded, at the time it recorded it, and a day with no point is not a recorded value, so the series must not be read or redrawn as a daily series; no point here is interpolated.";
6
+ const BEFORE_FIRST = "The value in force before a CVE's first point is not in the series.";
7
+ const NO_FRESHNESS = "freshness is null: the answer does not carry the time of the EPSS fetcher's last completed run.";
8
+ export const EPSS_HISTORY_DESCRIPTION = `How one CVE's EPSS score (FIRST's exploit-prediction probability, 0 to 1, with its percentile) has changed, as EchelonGraph recorded it: points, oldest first, each with at, epss_score and epss_percentile; current, the record's value now (epss_score, epss_percentile, epss_updated_at); series_kind, always change_only; series_starts_at, when EchelonGraph began recording EPSS changes for any CVE; history_rows, points_truncated and latest_point_matches_current. Pass a CVE ID like CVE-2023-44487. The series is change-only: a point is a recorded change, and a day without a point is not a recorded value. Never interpolate it into a daily series. Before series_starts_at nothing was recorded, so a missing point there means not recorded, not unchanged, and the value in force before a CVE's first point is not in the series. latest_point_matches_current false means a change is missing from the series. Its structured result carries state (measured), measured_at (current.epss_updated_at: when EchelonGraph last wrote a changed value, not FIRST's score date), method, coverage (series_kind, series_starts_at, points, points_truncated), freshness (null) and notes, with data equal to the API's JSON; the result's last text block repeats it without data (the first text block) and without the note's sentences (the text block before it), with which notes ends.`;
9
+ // The API's JSON. points, series_kind and current are required: an answer without them is not
10
+ // this series, and a series_kind other than change_only would make every note above wrong, so
11
+ // either is a failure (checked), never relayed.
12
+ const opt = (t) => t.nullable().optional();
13
+ const Point = (instant) => z.looseObject({
14
+ at: instant.describe("When EchelonGraph recorded this change."),
15
+ epss_score: z.number().describe("The EPSS probability (0 to 1) from that change on."),
16
+ epss_percentile: opt(z.number()).describe("Its percentile (0 to 1), null where the record held none."),
17
+ });
18
+ const data = (instant) => z.looseObject({
19
+ cve_id: z.string(),
20
+ series_kind: z.enum(["change_only"]).describe("Always change_only: one point per recorded change, none for a day without one."),
21
+ series_starts_at: instant.nullable().describe("The earliest EPSS change EchelonGraph recorded for any CVE: when recording began. Null when nothing has been recorded."),
22
+ current: z.looseObject({
23
+ epss_score: opt(z.number()).describe("The record's EPSS probability now; null when it holds none."),
24
+ epss_percentile: opt(z.number()),
25
+ epss_updated_at: opt(instant).describe("When EchelonGraph last wrote a changed EPSS value for this CVE."),
26
+ }),
27
+ points: z.array(Point(instant)).describe("Recorded changes, oldest first. Never a daily series."),
28
+ history_rows: opt(z.number().int().min(0)).describe("Raw history rows read, before repeated records of one change were collapsed."),
29
+ points_truncated: opt(z.boolean()).describe("True when only the newest rows were read, so the earliest points are missing."),
30
+ latest_point_matches_current: opt(z.boolean()).describe("Whether the newest point equals current; false means a change is missing from the series; null when there is no point or no current value."),
31
+ });
32
+ export function epssHistoryOutput(kit) {
33
+ return kit.envelopeSchema({
34
+ data: data(kit.instant),
35
+ coverage: z.strictObject({
36
+ series_kind: z.enum(["change_only"]),
37
+ series_starts_at: kit.instant.nullable(),
38
+ points: z.number().int().min(0).describe("How many points the series holds."),
39
+ points_truncated: z.boolean(),
40
+ }),
41
+ freshness: null,
42
+ });
43
+ }
44
+ const isNum = (v) => typeof v === "number" && Number.isFinite(v);
45
+ const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
46
+ const realInstant = (v) => typeof v === "string" && Date.parse(v) > 0;
47
+ // The note: what came back, in words, and what it does not say.
48
+ function note(head, id, d) {
49
+ const points = Array.isArray(d.points) ? d.points.filter(isObj) : [];
50
+ const cur = isObj(d.current) ? d.current : {};
51
+ const curScore = isNum(cur.epss_score) ? cur.epss_score : undefined;
52
+ const start = realInstant(d.series_starts_at) ? d.series_starts_at : undefined;
53
+ const s = [head];
54
+ s.push(curScore === undefined
55
+ ? `EchelonGraph's record holds no EPSS score for ${id} (current.epss_score is null).`
56
+ : `${id}'s EPSS score in EchelonGraph's record is ${curScore}${isNum(cur.epss_percentile) ? ` (percentile ${cur.epss_percentile})` : ""}.`);
57
+ if (points.length === 0) {
58
+ s.push(start
59
+ ? `No EPSS change is recorded for ${id} since recording began at ${start}: a measured empty series, not a lookup failure.`
60
+ : "No EPSS change has been recorded for any CVE yet (series_starts_at is null), so this empty series says nothing about how the score moved.");
61
+ }
62
+ else {
63
+ const first = points[0];
64
+ const last = points[points.length - 1];
65
+ s.push(`${points.length} recorded change${points.length === 1 ? "" : "s"}, from ${first.epss_score} at ${first.at} to ${last.epss_score} at ${last.at}.`);
66
+ }
67
+ if (start)
68
+ s.push(`Recording began at ${start} (series_starts_at): before it a missing point means not recorded, not unchanged.`);
69
+ if (d.points_truncated === true)
70
+ s.push("points_truncated is true: only the newest records were read, so the earliest changes are missing from this series.");
71
+ if (d.latest_point_matches_current === false) {
72
+ s.push("latest_point_matches_current is false: the newest recorded point is not the record's current value, so at least one change is missing from the series.");
73
+ }
74
+ return { note: s.join(" "), own: [CHANGE_ONLY, BEFORE_FIRST] };
75
+ }
76
+ export async function epssHistory(kit, cve_id) {
77
+ const id = cve_id.trim();
78
+ if (!id)
79
+ return kit.badInput(TOOL, "cve_id is required");
80
+ if (!CVE_ID.test(id))
81
+ return kit.badInput(TOOL, `cve_id ${JSON.stringify(id.slice(0, 32))} is not a CVE id (expected the form CVE-2023-44487)`);
82
+ const cve = id.toUpperCase();
83
+ try {
84
+ const r = await kit.api(`/api/v1/public/cves/${encodeURIComponent(cve)}/epss-history`);
85
+ if (!r.ok)
86
+ return kit.failed(TOOL, r);
87
+ const d = r.data;
88
+ const name = typeof d.cve_id === "string" && d.cve_id ? d.cve_id : cve;
89
+ const { note: n, own } = note(kit.okHead(TOOL, r.status), name, d);
90
+ const cur = isObj(d.current) ? d.current : {};
91
+ const written = realInstant(cur.epss_updated_at) ? cur.epss_updated_at : null;
92
+ return kit.succeeded(r.data, n, {
93
+ state: "measured",
94
+ measured_at: written,
95
+ method: EPSS_HISTORY_METHOD,
96
+ coverage: {
97
+ series_kind: d.series_kind,
98
+ series_starts_at: d.series_starts_at ?? null,
99
+ points: Array.isArray(d.points) ? d.points.length : 0,
100
+ points_truncated: d.points_truncated === true,
101
+ },
102
+ freshness: null,
103
+ notes: [
104
+ ...own,
105
+ written
106
+ ? "measured_at is current.epss_updated_at: when EchelonGraph last wrote a changed EPSS value for this CVE; values are written only on change, so it is not when the value was last checked, and it is not FIRST's score date."
107
+ : "measured_at is null: the record carries no epss_updated_at.",
108
+ NO_FRESHNESS,
109
+ ],
110
+ });
111
+ }
112
+ catch (e) {
113
+ return kit.crashed(TOOL, e);
114
+ }
115
+ }
116
+ export function registerEPSSHistory(server, kit) {
117
+ const output = epssHistoryOutput(kit);
118
+ server.registerTool(TOOL, {
119
+ title: "EPSS change history for one CVE",
120
+ description: EPSS_HISTORY_DESCRIPTION,
121
+ inputSchema: z.object({ cve_id: kit.cveIdArg }),
122
+ outputSchema: output,
123
+ annotations: kit.annotations,
124
+ }, async ({ cve_id }) => kit.checked(TOOL, output, await epssHistory(kit, cve_id)));
125
+ }
@@ -0,0 +1,148 @@
1
+ // get_cwe (#2720): one CWE's name and description, and a page of the active CVEs classified
2
+ // under it, from GET /api/v1/public/cwes/:cwe_id?page=N (core-backend cve/handler_cwe.go
3
+ // GetCWELanding, cwe_landing_store.go). The JSON is relayed as the API sent it.
4
+ //
5
+ // The order is the API's to state. Since #2720 the answer carries `order` (KEV-listed first);
6
+ // before it, a DISTINCT ON put cve_id first in the sort, so page 1 was the oldest CVE ids. So the
7
+ // note repeats `order` when the answer has it, and otherwise says the answer does not state its
8
+ // order, rather than calling the rows KEV-first.
9
+ //
10
+ // The API answers a page past max_page as max_page, and an id it does not know with total 0, so
11
+ // the note compares the page asked for with the page served, and says when total counts CVEs no
12
+ // page lists. Each page is cached by the API for up to an hour (cweLandingTTL) behind a CDN, so
13
+ // the note says it can lag the feed.
14
+ import * as z from "zod";
15
+ import { ANNOTATIONS, api, badInput, checked, crashed, envelopeSchema, failed, field, lenAt, numAt, okHead, opt, rowsScoreNote, strAt, succeeded, } from "../index.js";
16
+ const TOOL = "get_cwe";
17
+ const CWE_ARG = /^(?:CWE-)?0*(\d{1,6})$/i;
18
+ const MAX_PAGE = 200;
19
+ const GET_CWE_METHOD = "EchelonGraph's per-CWE listing (GET /api/v1/public/cwes/:cwe_id): the CWE's name and description from the MITRE CWE catalog EchelonGraph embeds (catalog_version), and the active CVEs in EchelonGraph's CVE feed that an NVD, GitHub (GHSA) or CVE.org record classifies under it, rejected and reserved records left out, 50 to a page.";
20
+ export const GET_CWE_DESCRIPTION = `One CWE (weakness class) and the CVEs classified under it. Returns cwe_id, name and description (from the MITRE CWE catalog EchelonGraph embeds, version catalog_version), total (the active CVEs in EchelonGraph's feed that an NVD, GitHub or CVE.org record classifies under it, rejected and reserved records left out), and cves, one page of 50: each with cve_id, severity, cvss_v3_score, echelongraph_score, echelongraph_severity, score_assessed, kev_listed, published and a shortened description. order says how the rows are sorted (CISA-KEV-listed first, then EchelonGraph score); an answer without order does not state its order, and the note says so. page is the page served, page_size its size and max_page the last page the API serves: a later page is answered as max_page, so past max_page pages total counts CVEs no page lists. echelongraph_score is EchelonGraph's score only when score_assessed is true; the note labels each row that is NOT YET SCORED. A total of 0 says that no CVE in EchelonGraph's feed is classified under that CWE, not that none exists. Pass cwe_id like CWE-79 (or 79) and an optional page (1-${MAX_PAGE}). Its structured result carries state (measured), measured_at (null), method, coverage (total, returned, page, page_requested, page_size, max_page), freshness (null) and notes, with data equal to the API's JSON; the result's last text block repeats it without data (the first text block) and without the note's sentences (the text block before it), with which notes ends.`;
21
+ function schemas() {
22
+ const row = z.looseObject({
23
+ cve_id: opt(z.string()),
24
+ severity: opt(z.string()),
25
+ cvss_v3_score: opt(z.number()),
26
+ cvss_v2_score: opt(z.number()),
27
+ cvss_v2_severity: opt(z.string()),
28
+ echelongraph_score: opt(z.number()).describe("EchelonGraph's 0-10 score: a score only when score_assessed is true."),
29
+ echelongraph_severity: opt(z.string()),
30
+ score_assessed: opt(z.boolean()).describe("Whether EchelonGraph has scored the CVE; false: NOT YET SCORED, not scored 0."),
31
+ kev_listed: opt(z.boolean()),
32
+ published: opt(z.string()),
33
+ description: opt(z.string()),
34
+ });
35
+ const data = z.looseObject({
36
+ cwe_id: opt(z.string()),
37
+ name: opt(z.string()),
38
+ description: opt(z.string()),
39
+ catalog_version: opt(z.string()).describe("The version of the MITRE CWE catalog name and description come from."),
40
+ total: opt(z.number()).describe("The active CVEs classified under the CWE, rejected and reserved records left out."),
41
+ cves: z.array(row).optional(),
42
+ page: opt(z.number()),
43
+ page_size: opt(z.number()),
44
+ max_page: opt(z.number()).describe("The last page the API serves; a later page is answered as this one."),
45
+ order: opt(z.string()).describe("How cves is sorted, as the API states it; absent from an API older than the field, which sorted by CVE id."),
46
+ });
47
+ const coverage = z.strictObject({
48
+ total: z.number().nullable(),
49
+ returned: z.number().nullable(),
50
+ page: z.number().nullable().describe("The page served."),
51
+ page_requested: z.number().describe("The page asked for."),
52
+ page_size: z.number().nullable(),
53
+ max_page: z.number().nullable(),
54
+ });
55
+ return { output: envelopeSchema({ data, coverage, freshness: null }) };
56
+ }
57
+ let built;
58
+ // Built on first use: index.ts imports this file, so its exports are not initialised while this
59
+ // module's top level runs.
60
+ const schemasOnce = () => (built ??= schemas());
61
+ export async function getCwe(cwe_id, page) {
62
+ const raw = cwe_id.trim();
63
+ if (!raw)
64
+ return badInput(TOOL, "cwe_id is required");
65
+ const m = CWE_ARG.exec(raw);
66
+ if (!m)
67
+ return badInput(TOOL, `cwe_id ${JSON.stringify(raw.slice(0, 32))} is not a CWE id (expected the form CWE-79)`);
68
+ const asked = page ?? 1;
69
+ if (!Number.isInteger(asked) || asked < 1 || asked > MAX_PAGE)
70
+ return badInput(TOOL, `page must be a whole number from 1 to ${MAX_PAGE}`);
71
+ const cwe = `CWE-${Number(m[1])}`;
72
+ try {
73
+ const r = await api(`/api/v1/public/cwes/${encodeURIComponent(cwe)}${asked > 1 ? `?page=${asked}` : ""}`);
74
+ if (!r.ok)
75
+ return failed(TOOL, r);
76
+ const d = r.data;
77
+ const id = strAt(d, "cwe_id") ?? cwe;
78
+ const total = numAt(d, "total");
79
+ const returned = lenAt(d, "cves");
80
+ const served = numAt(d, "page");
81
+ const size = numAt(d, "page_size");
82
+ const maxPage = numAt(d, "max_page");
83
+ const order = strAt(d, "order");
84
+ const name = strAt(d, "name");
85
+ const version = strAt(d, "catalog_version");
86
+ const said = [];
87
+ said.push(name
88
+ ? `${id} is ${name} in the MITRE CWE catalog${version ? ` (version ${version})` : ""}.`
89
+ : `The answer carries no name for ${id}: the MITRE CWE catalog EchelonGraph embeds has no entry for it, or the API sent none.`);
90
+ if (total === 0) {
91
+ said.push(`No active CVE in EchelonGraph's feed is classified under ${id} (total 0): a measured empty result (we looked and found nothing), which says only that no NVD, GitHub or CVE.org record EchelonGraph holds puts a CVE in this class, not that none exists.`);
92
+ }
93
+ else if (total !== undefined) {
94
+ const of = returned === undefined ? "" : `; this page lists ${returned}`;
95
+ const pg = served === undefined ? "" : ` (page ${served}${maxPage === undefined ? "" : ` of at most ${maxPage}`})`;
96
+ said.push(`EchelonGraph's feed holds ${total} active CVEs classified under ${id} (total, rejected and reserved records left out)${of}${pg}.`);
97
+ if (maxPage !== undefined && size !== undefined && total > maxPage * size) {
98
+ said.push(`Only the first ${maxPage * size} of them can be paged to: the API serves no page past max_page ${maxPage}.`);
99
+ }
100
+ }
101
+ if (served !== undefined && served !== asked) {
102
+ said.push(`Page ${asked} was asked for and the API served page ${served}.`);
103
+ }
104
+ if (returned) {
105
+ const rows = field(d, "cves");
106
+ const kev = Array.isArray(rows) ? rows.filter((c) => field(c, "kev_listed") === true).length : 0;
107
+ said.push(order
108
+ ? `The rows are sorted by ${order}, as the answer states (order); ${kev} of the ${returned} on this page are CISA-KEV-listed (kev_listed).`
109
+ : `The answer does not state how its rows are sorted (no order field; an API older than it sorted them by CVE id), so the first rows are not necessarily the CISA-KEV-listed or highest-scored ones; ${kev} of the ${returned} on this page are CISA-KEV-listed (kev_listed).`);
110
+ }
111
+ said.push("The API serves each page from a cache refreshed at most hourly, behind a CDN, so it can lag the feed by an hour or more.");
112
+ const scores = rowsScoreNote(d);
113
+ return succeeded(d, `${okHead(TOOL, r.status)} ${said.join(" ")}${scores}`, {
114
+ state: "measured",
115
+ measured_at: null,
116
+ method: GET_CWE_METHOD,
117
+ coverage: {
118
+ total: total ?? null,
119
+ returned: returned ?? null,
120
+ page: served ?? null,
121
+ page_requested: asked,
122
+ page_size: size ?? null,
123
+ max_page: maxPage ?? null,
124
+ },
125
+ freshness: null,
126
+ notes: [
127
+ "measured_at is null: a page of CVEs has no single observation time; each row carries its own published date.",
128
+ "freshness is null: the answer does not say when its cached page was computed.",
129
+ ],
130
+ });
131
+ }
132
+ catch (e) {
133
+ return crashed(TOOL, e);
134
+ }
135
+ }
136
+ export function registerGetCwe(server) {
137
+ const { output } = schemasOnce();
138
+ server.registerTool("get_cwe", {
139
+ title: "CWE and its CVEs",
140
+ description: GET_CWE_DESCRIPTION,
141
+ inputSchema: z.object({
142
+ cwe_id: z.string().describe("a CWE ID, e.g. CWE-79 (or 79)"),
143
+ page: z.number().int().min(1).max(MAX_PAGE).optional().describe(`page of 50 CVEs (default 1, max ${MAX_PAGE})`),
144
+ }),
145
+ outputSchema: output,
146
+ annotations: ANNOTATIONS,
147
+ }, async ({ cwe_id, page }) => checked(TOOL, output, await getCwe(cwe_id, page)));
148
+ }
@@ -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
+ }