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.
@@ -0,0 +1,257 @@
1
+ // cve_remediation (#2841): how one CVE is fixed, as its sources state it, from GET
2
+ // /api/v1/public/cves/:id/remediation (core-backend cve/remediation.go). Nothing here is
3
+ // generated: every text and URL is relayed as the API sends it, and the API's own texts are its
4
+ // sources' (CISA's KEV catalog, the OSV ranges #2817 loads, the vendors' advisories #2840 parses,
5
+ // the CVE record's references as NVD tagged them).
6
+ //
7
+ // What the answer holds:
8
+ // cisa CISA KEV's required_action, due_date, short_description and notes_urls
9
+ // for a KEV-listed CVE. CISA directs the action at US federal civilian
10
+ // agencies (BOD 22-01); it is not EchelonGraph's advice.
11
+ // fixed_branches each affected package with every affected range and its fix.
12
+ // vendor_remediations each vendor advisory naming the CVE (at most 20, newest first), with its
13
+ // remediation_state and the items it lists for the CVE, each with the
14
+ // vendor's own category (kind): vendor_fix, workaround, mitigation,
15
+ // no_fix_planned, none_available or other.
16
+ // fix_references the record's references tagged Patch or Mitigation.
17
+ // patches cve_patches rows.
18
+ // coverage parsed_vendors, vendors_not_parsed and the caps.
19
+ // failed_sections the sections the API could not read.
20
+ //
21
+ // The rules the note keeps: not_parsed is not "no remediation"; none_in_source is what the
22
+ // advisory lists, not a finding that no fix exists; an empty section is "none on record", never
23
+ // "no fix"; a failed section is named, never relayed as empty. A REJECTED CVE is not_assessed.
24
+ import * as z from "zod";
25
+ import { ANNOTATIONS, CVE_ID, SHOWN_BASE, api, badInput, checked, crashed, envelopeSchema, failed, failure, field, NOT_A_FINDING, okHead, opt, strAt, succeeded } from "../index.js";
26
+ import { TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
27
+ const TOOL = "cve_remediation";
28
+ const SECTIONS = ["cisa", "fixed_branches", "vendor_remediations", "fix_references", "patches"];
29
+ const KINDS = ["vendor_fix", "workaround", "mitigation", "no_fix_planned", "none_available", "other"];
30
+ const STATES = ["parsed", "none_in_source", "not_parsed", "withdrawn"];
31
+ // Required in the description's head (src/requiredText.ts REQUIRED_IN_HEAD.cve_remediation).
32
+ export const REMEDIATION_UNTESTED = "Every text is relayed as stated by its source; EchelonGraph has not tested it.";
33
+ export const REMEDIATION_NOT_A_ZERO = "An empty list or a remediation_state of none_in_source or not_parsed is not a finding that no fix exists.";
34
+ export const REMEDIATION_CISA = "CISA's required_action is directed at US federal civilian agencies (BOD 22-01) and is not EchelonGraph's advice.";
35
+ const METHOD = "EchelonGraph's stored sources for one CVE (GET /api/v1/public/cves/:id/remediation): CISA's Known Exploited Vulnerabilities catalog text; the affected ranges and fixes of OSV advisories; the remediation each vendor advisory lists, categorised by the vendor's own category (Red Hat CSAF remediations, MSRC CVRF Workaround, Mitigation and Vendor Fix rows, Palo Alto's solution and work_around sections); the CVE record's references NVD tagged Patch or Mitigation; and cve_patches. Nothing is generated or tested.";
36
+ export const CVE_REMEDIATION_DESCRIPTION = `How one CVE is fixed, as its sources state it, in one answer. ${REMEDIATION_UNTESTED} ${REMEDIATION_NOT_A_ZERO} Returns cisa (kev_listed, and for a KEV-listed CVE CISA's required_action, due_date, short_description and notes_urls, verbatim; ${REMEDIATION_CISA}), fixed_branches (each affected package with fixed_branches: every affected range, introduced and fixed or last_affected, and fixed_version, one range's fix), vendor_remediations (each vendor advisory naming the CVE, newest first, at most 20: vendor, vendor_advisory_id, remediation_state, remediation_kinds and items, each item with kind, source_category, text, url, fixed_build, product_ids and product_count), fix_references (the record's references NVD tagged Patch or Mitigation), patches, coverage (parsed_vendors, vendors_not_parsed and the caps) and failed_sections. kind is the vendor's own category, never read from the text: vendor_fix, workaround, mitigation, no_fix_planned, none_available, or other with the vendor's name for it in source_category. remediation_state is parsed, none_in_source (parsed; the advisory lists none for this CVE), not_parsed (EchelonGraph does not read that vendor's remediation) or withdrawn. A section the API could not read is named in failed_sections and coverage.sections_failed, never relayed as empty. A REJECTED CVE answers not_assessed. Pass a CVE ID like CVE-2021-44228. ${TEXT_BUDGET_DESCRIPTION} Cut, vendor texts are shortened first; kinds, states and URLs are kept.`;
37
+ // ── outputSchema ──
38
+ // Built on first use: index.ts imports this file, so its exports are not initialised while this
39
+ // module's top level runs.
40
+ function schemas() {
41
+ const Kind = z.union([z.enum(KINDS), z.string()]);
42
+ const State = z.union([z.enum(STATES), z.string()]);
43
+ const Item = z.looseObject({
44
+ kind: opt(Kind).describe("The vendor's own category: vendor_fix, workaround, mitigation, no_fix_planned, none_available or other."),
45
+ source_category: opt(z.string()).describe("The vendor's own name for the category (CSAF vendor_fix, MSRC Vendor Fix, Palo Alto work_around)."),
46
+ source_subcategory: opt(z.string()).describe("MSRC's SubType, e.g. Security Update; null elsewhere."),
47
+ text: opt(z.string()).describe("The vendor's text, verbatim but for HTML markup removed, cut at coverage.item_text_cap characters (text_truncated)."),
48
+ text_truncated: opt(z.boolean()),
49
+ text_chars: opt(z.number()).describe("The length of the vendor's text in characters."),
50
+ url: opt(z.string()),
51
+ fixed_build: opt(z.string()).describe("MSRC's FixedBuild; null elsewhere."),
52
+ product_ids: opt(z.array(z.string())).describe("The vendor's own product identifiers the item applies to, the first 16."),
53
+ product_count: opt(z.number()),
54
+ });
55
+ const VendorAdvisory = z.looseObject({
56
+ vendor: opt(z.string()),
57
+ vendor_display_name: opt(z.string()),
58
+ vendor_advisory_id: opt(z.string()),
59
+ vendor_published_at: opt(z.string()),
60
+ withdrawn: opt(z.boolean()),
61
+ remediation_state: opt(State).describe("parsed; none_in_source (parsed, and the advisory lists no remediation 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); withdrawn (the vendor rescinded it)."),
62
+ remediation_kinds: opt(z.array(Kind)),
63
+ items: opt(z.array(Item)),
64
+ items_total: opt(z.number()),
65
+ });
66
+ const data = z.looseObject({
67
+ cve_id: opt(z.string()),
68
+ state: opt(z.string()).describe("assessed, or not_assessed for a CVE record rejected by its numbering authority."),
69
+ not_assessed_reason: opt(z.string()),
70
+ cisa: z
71
+ .looseObject({
72
+ kev_listed: opt(z.boolean()),
73
+ required_action: opt(z.string()).describe(`CISA's requiredAction, verbatim. ${REMEDIATION_CISA}`),
74
+ due_date: opt(z.string()),
75
+ short_description: opt(z.string()),
76
+ notes_urls: opt(z.array(z.string())),
77
+ })
78
+ .nullable()
79
+ .optional(),
80
+ fixed_branches: opt(z.array(z.looseObject({
81
+ ecosystem: opt(z.string()),
82
+ package_name: opt(z.string()),
83
+ fixed_version: opt(z.string()).describe("One range's fix, kept for compatibility; not the fix for every affected range."),
84
+ fixed_branches: opt(z.array(z.looseObject({
85
+ introduced: opt(z.string()),
86
+ fixed: opt(z.string()),
87
+ last_affected: opt(z.string()).describe("The range's last affected version, where no fix is on record; never a fix."),
88
+ advisory_id: opt(z.string()),
89
+ source: opt(z.string()),
90
+ }))),
91
+ }))),
92
+ vendor_remediations: opt(z.array(VendorAdvisory)),
93
+ fix_references: opt(z.array(z.looseObject({ url: opt(z.string()), tags: opt(z.array(z.string())), source: opt(z.string()) }))),
94
+ patches: opt(z.array(z.unknown())),
95
+ coverage: z.unknown().optional(),
96
+ failed_sections: opt(z.array(z.string())),
97
+ });
98
+ const coverage = z.strictObject({
99
+ sections_relayed: z.array(z.string()),
100
+ sections_failed: z.array(z.string()).nullable().describe("The sections the API could not read (its failed_sections); null when the answer does not say."),
101
+ parsed_vendors: z.array(z.string()).nullable().describe("The vendors whose remediation EchelonGraph parses."),
102
+ vendors_not_parsed: z.array(z.string()).nullable().describe("The vendors in this answer whose remediation EchelonGraph does not parse."),
103
+ vendor_advisory_limit: z.number().nullable(),
104
+ item_text_cap: z.number().nullable(),
105
+ items_per_advisory_cap: z.number().nullable(),
106
+ });
107
+ return { output: envelopeSchema({ data, coverage, freshness: null }) };
108
+ }
109
+ let built;
110
+ const schemasOnce = () => (built ??= schemas());
111
+ // The text cut: vendor texts go first, the kinds, states and URLs stay.
112
+ const CUT = {
113
+ rows: "vendor_remediations",
114
+ levels: [
115
+ { keep: ["vendor", "vendor_advisory_id", "withdrawn", "remediation_state", "remediation_kinds", "items_total", ["items", ["kind", "source_category", "text", "url", "fixed_build"]]], clip: 400, cap: 16 },
116
+ { keep: ["vendor", "vendor_advisory_id", "remediation_state", "remediation_kinds", ["items", ["kind", "url"]]], clip: 2048, cap: 8 },
117
+ ],
118
+ whole: "get_vendor_advisory returns any one of these advisories by its vendor and vendor_advisory_id, whole in its structuredContent.data, with every remediation item it lists.",
119
+ };
120
+ const listed = (xs) => (xs.length <= 1 ? (xs[0] ?? "") : `${xs.slice(0, -1).join(", ")} and ${xs.at(-1)}`);
121
+ const arr = (v) => (Array.isArray(v) ? v : null);
122
+ const strs = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === "string") : null);
123
+ function vendorSentences(cve, rows) {
124
+ if (rows.length === 0) {
125
+ return [`No vendor advisory on record names ${cve}, which is not a finding that no vendor published a fix.`];
126
+ }
127
+ const out = [];
128
+ const byKind = new Map();
129
+ const states = {};
130
+ for (const r of rows) {
131
+ const who = `${strAt(r, "vendor") ?? "?"} ${strAt(r, "vendor_advisory_id") ?? "?"}`;
132
+ const st = strAt(r, "remediation_state") ?? "unknown";
133
+ (states[st] ??= []).push(who);
134
+ for (const k of strs(field(r, "remediation_kinds")) ?? []) {
135
+ if (!byKind.has(k))
136
+ byKind.set(k, []);
137
+ byKind.get(k).push(who);
138
+ }
139
+ }
140
+ const few = (xs) => (xs.length <= 4 ? listed(xs) : `${xs.slice(0, 4).join(", ")} and ${xs.length - 4} more`);
141
+ const kinds = [...KINDS.filter((k) => byKind.has(k)), ...[...byKind.keys()].filter((k) => !KINDS.includes(k))];
142
+ if (kinds.length) {
143
+ out.push(`Vendor remediation for ${cve}, by the vendor's own category: ${kinds.map((k) => `${k} from ${few(byKind.get(k))}`).join("; ")}. Each is the vendor's text as stated; EchelonGraph has not tested it.`);
144
+ }
145
+ if (states.none_in_source)
146
+ out.push(`${few(states.none_in_source)} ${states.none_in_source.length === 1 ? "lists" : "list"} no remediation for ${cve} (none_in_source), which is not a finding that no fix exists.`);
147
+ if (states.not_parsed)
148
+ out.push(`EchelonGraph does not read the remediation of ${few(states.not_parsed)} (not_parsed): read ${states.not_parsed.length === 1 ? "it" : "each"} with get_vendor_advisory; not_parsed is not "no remediation".`);
149
+ if (states.withdrawn)
150
+ out.push(`${few(states.withdrawn)} ${states.withdrawn.length === 1 ? "was" : "were"} withdrawn by ${states.withdrawn.length === 1 ? "its" : "their"} vendor: ${states.withdrawn.length === 1 ? "its" : "their"} remediation is not relayed.`);
151
+ return out;
152
+ }
153
+ function branchSentence(rows) {
154
+ if (rows.length === 0)
155
+ return "fixed_branches is empty: no affected package range is on record, which is not a finding that no fix exists.";
156
+ let ranges = 0;
157
+ let fixed = 0;
158
+ for (const r of rows) {
159
+ for (const b of arr(field(r, "fixed_branches")) ?? []) {
160
+ ranges++;
161
+ if (strAt(b, "fixed"))
162
+ fixed++;
163
+ }
164
+ }
165
+ return `fixed_branches lists ${rows.length} affected package${rows.length === 1 ? "" : "s"} with ${ranges} affected range${ranges === 1 ? "" : "s"}, ${fixed} with a fix on record; an installed version's fix is the fixed of the range that holds it, compared in the ecosystem's version order.`;
166
+ }
167
+ export async function cveRemediation(cve_id) {
168
+ const id = cve_id.trim();
169
+ if (!id)
170
+ return badInput(TOOL, "cve_id is required");
171
+ if (!CVE_ID.test(id))
172
+ return badInput(TOOL, `cve_id ${JSON.stringify(id.slice(0, 32))} is not a CVE id (expected the form CVE-2021-44228)`);
173
+ const cve = id.toUpperCase();
174
+ const path = `/api/v1/public/cves/${encodeURIComponent(cve)}/remediation`;
175
+ try {
176
+ const r = await api(path);
177
+ if (!r.ok)
178
+ return failed(TOOL, r);
179
+ const d = r.data;
180
+ const state = strAt(d, "state");
181
+ if (state !== "assessed" && state !== "not_assessed") {
182
+ const why = "the answer carries no state (assessed or not_assessed)";
183
+ return failure("failed", `${TOOL} FAILED: EchelonGraph answered HTTP ${r.status} from ${SHOWN_BASE} for GET ${path}, but ${why}. ${NOT_A_FINDING}`, {
184
+ kind: "unexpected_shape",
185
+ path,
186
+ status: r.status,
187
+ message: why,
188
+ });
189
+ }
190
+ const reported = strs(field(d, "failed_sections"));
191
+ const cov = field(d, "coverage");
192
+ const coverage = {
193
+ sections_relayed: SECTIONS.filter((k) => field(d, k) !== undefined && !(reported ?? []).includes(k)),
194
+ sections_failed: reported,
195
+ parsed_vendors: strs(field(cov, "parsed_vendors")),
196
+ vendors_not_parsed: strs(field(cov, "vendors_not_parsed")),
197
+ vendor_advisory_limit: typeof field(cov, "vendor_advisory_limit") === "number" ? field(cov, "vendor_advisory_limit") : null,
198
+ item_text_cap: typeof field(cov, "item_text_cap") === "number" ? field(cov, "item_text_cap") : null,
199
+ items_per_advisory_cap: typeof field(cov, "items_per_advisory_cap") === "number" ? field(cov, "items_per_advisory_cap") : null,
200
+ };
201
+ const head = okHead(TOOL, r.status);
202
+ if (state === "not_assessed") {
203
+ const reason = strAt(d, "not_assessed_reason") ?? "the API gives no reason";
204
+ return succeeded(d, `${head} NOT ASSESSED: ${cve}'s remediation is not assessed (${reason}). A rejected CVE record is not an active vulnerability, so no fix is relayed for it.`, {
205
+ state: "not_assessed",
206
+ measured_at: null,
207
+ method: METHOD,
208
+ coverage,
209
+ freshness: null,
210
+ notes: ["measured_at is null: the answer dates no observation.", "freshness is null: the answer carries no poll-completion time."],
211
+ });
212
+ }
213
+ const said = [];
214
+ const cisa = field(d, "cisa");
215
+ const action = strAt(cisa, "required_action");
216
+ if (field(cisa, "kev_listed") === true) {
217
+ said.push(action
218
+ ? `${cve} is in CISA's KEV catalog; cisa.required_action is CISA's text, verbatim${strAt(cisa, "due_date") ? `, due ${strAt(cisa, "due_date")}` : ""}: ${REMEDIATION_CISA}`
219
+ : `${cve} is in CISA's KEV catalog, and no required_action is on record for it.`);
220
+ }
221
+ const pkgs = arr(field(d, "fixed_branches"));
222
+ if (pkgs && !(reported ?? []).includes("fixed_branches"))
223
+ said.push(branchSentence(pkgs));
224
+ const vendors = arr(field(d, "vendor_remediations"));
225
+ if (vendors && !(reported ?? []).includes("vendor_remediations"))
226
+ said.push(...vendorSentences(cve, vendors));
227
+ const refs = arr(field(d, "fix_references"));
228
+ if (refs)
229
+ said.push(refs.length ? `fix_references lists ${refs.length} reference${refs.length === 1 ? "" : "s"} the CVE record tags Patch or Mitigation.` : "No reference of the CVE record is tagged Patch or Mitigation.");
230
+ if (reported === null)
231
+ said.push("The answer carries no failed_sections, so an empty section may be one the API could not read, not a finding of none.");
232
+ else if (reported.length)
233
+ said.push(`The API could not read ${listed(reported)} (failed_sections): an empty value there is not a finding of none.`);
234
+ said.push(REMEDIATION_UNTESTED);
235
+ return succeeded(d, `${head} ${said.join(" ")}`, {
236
+ state: "measured",
237
+ measured_at: null,
238
+ method: METHOD,
239
+ coverage,
240
+ freshness: null,
241
+ notes: ["measured_at is null: the answer has no single observation time.", "freshness is null: the answer carries no poll-completion time."],
242
+ }, CUT);
243
+ }
244
+ catch (e) {
245
+ return crashed(TOOL, e);
246
+ }
247
+ }
248
+ export function registerCveRemediation(server) {
249
+ const { output } = schemasOnce();
250
+ server.registerTool(TOOL, {
251
+ title: "How one CVE is fixed",
252
+ description: CVE_REMEDIATION_DESCRIPTION,
253
+ inputSchema: z.object({ cve_id: z.string().describe("a CVE ID, e.g. CVE-2021-44228") }),
254
+ outputSchema: output,
255
+ annotations: ANNOTATIONS,
256
+ }, async ({ cve_id }) => checked(TOOL, output, await cveRemediation(cve_id)));
257
+ }
@@ -6,7 +6,7 @@ export const EPSS_HISTORY_METHOD = "EchelonGraph's record of FIRST's EPSS scores
6
6
  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.";
7
7
  const BEFORE_FIRST = "The value in force before a CVE's first point is not in the series.";
8
8
  const NO_FRESHNESS = "freshness is null: the answer does not carry the time of the EPSS fetcher's last completed run.";
9
- 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. A daily series interpolated from it holds values EchelonGraph never recorded, so the series is never 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. ${TEXT_BUDGET_DESCRIPTION}`;
9
+ 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, epss_percentile and score_date (FIRST's score date, null where the record does not hold it); 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; complete_since, from when the record holds every change; coverage, which FIRST score dates the record holds; 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. A daily series interpolated from it holds values EchelonGraph never recorded, so the series is never 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. Between series_starts_at and complete_since the record misses changes, so a missing point there does not mean unchanged either; only from complete_since on is every change a point. coverage.days_missing and coverage.days_partial name the FIRST score dates the record does not hold, or holds for some CVEs only. latest_point_matches_current false means a change is missing from the series. Its structured result's measured_at is current.epss_updated_at (when EchelonGraph last wrote a changed value, not FIRST's score date), its coverage says which part of the record is complete and which score dates it holds, and its freshness is null. ${TEXT_BUDGET_DESCRIPTION}`;
10
10
  // The API's JSON. points, series_kind and current are required: an answer without them is not
11
11
  // this series, and a series_kind other than change_only would make every note above wrong, so
12
12
  // either is a failure (checked), never relayed.
@@ -15,11 +15,29 @@ const Point = (instant) => z.looseObject({
15
15
  at: instant.describe("When EchelonGraph recorded this change."),
16
16
  epss_score: z.number().describe("The EPSS probability (0 to 1) from that change on."),
17
17
  epss_percentile: opt(z.number()).describe("Its percentile (0 to 1), null where the record held none."),
18
+ score_date: opt(z.string()).describe("FIRST's score date (YYYY-MM-DD) of the logged refresh that wrote this point; null where no logged refresh covers it."),
19
+ });
20
+ const Day = z.string().describe("A FIRST score date, YYYY-MM-DD.");
21
+ const ApiCoverage = (instant) => z.looseObject({
22
+ recorded_from: opt(instant).describe("series_starts_at: when recording began."),
23
+ complete_since: opt(instant).describe("From here on every change a refresh applied is a point; before it changes are missing."),
24
+ series_complete: opt(z.boolean()).describe("True only when nothing in the record predates complete_since."),
25
+ points_before_complete_since: opt(z.number().int().min(0)).describe("This CVE's points in the part of the record that misses changes."),
26
+ run_log_since: opt(instant).describe("The first logged refresh: score dates before it are not listed, held or missing."),
27
+ first_day: opt(Day).describe("The earliest score date in the run log."),
28
+ last_day: opt(Day).describe("The latest score date in the run log."),
29
+ days_held: opt(z.number().int().min(0)).describe("Score dates from first_day to last_day that a refresh applied completely."),
30
+ days_partial: opt(z.array(Day)).describe("Score dates whose refreshes all failed part-way: some CVEs hold that day, some do not."),
31
+ days_missing: opt(z.array(Day)).describe("Score dates from first_day to last_day that no refresh applied."),
32
+ runs_undated: opt(z.number().int().min(0)),
33
+ refresh_runs_truncated: opt(z.boolean()),
18
34
  });
19
35
  const data = (instant) => z.looseObject({
20
36
  cve_id: z.string(),
21
37
  series_kind: z.enum(["change_only"]).describe("Always change_only: one point per recorded change, none for a day without one."),
22
38
  series_starts_at: instant.nullable().describe("The earliest EPSS change EchelonGraph recorded for any CVE: when recording began. Null when nothing has been recorded."),
39
+ complete_since: opt(instant).describe("From when the record holds every change a refresh applied. Between series_starts_at and complete_since changes are missing, so a missing point there does not mean unchanged. Absent from an API older than #2867."),
40
+ coverage: opt(ApiCoverage(instant)).describe("Which part of the record is complete and which FIRST score dates it holds."),
23
41
  current: z.looseObject({
24
42
  epss_score: opt(z.number()).describe("The record's EPSS probability now; null when it holds none."),
25
43
  epss_percentile: opt(z.number()),
@@ -36,8 +54,16 @@ export function epssHistoryOutput(kit) {
36
54
  coverage: z.strictObject({
37
55
  series_kind: z.enum(["change_only"]),
38
56
  series_starts_at: kit.instant.nullable(),
57
+ complete_since: kit.instant.nullable().describe("From when every change is a point; null when the API did not say, and then no part of the series is known complete."),
58
+ series_complete: z.boolean().describe("True only when the API says nothing in the record predates complete_since."),
39
59
  points: z.number().int().min(0).describe("How many points the series holds."),
60
+ points_before_complete_since: z.number().int().min(0).nullable().describe("How many of them fall where changes are missing; null when the API did not say."),
40
61
  points_truncated: z.boolean(),
62
+ first_day: z.string().nullable().describe("The earliest FIRST score date the run log lists; null when it lists none."),
63
+ last_day: z.string().nullable(),
64
+ days_held: z.number().int().min(0).nullable().describe("Score dates from first_day to last_day the record holds; null when none are listed."),
65
+ days_partial: z.array(z.string()).describe("Score dates held for some CVEs only."),
66
+ days_missing: z.array(z.string()).describe("Score dates from first_day to last_day the record does not hold."),
41
67
  }),
42
68
  freshness: null,
43
69
  });
@@ -45,8 +71,37 @@ export function epssHistoryOutput(kit) {
45
71
  const isNum = (v) => typeof v === "number" && Number.isFinite(v);
46
72
  const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
47
73
  const realInstant = (v) => typeof v === "string" && Date.parse(v) > 0;
74
+ const isDay = (v) => typeof v === "string" && /^\d{4}-\d{2}-\d{2}$/.test(v);
75
+ const dayList = (v) => (Array.isArray(v) ? v.filter(isDay) : []);
76
+ // A day list in the note: the first DAYS_IN_NOTE, then how many more coverage holds.
77
+ const DAYS_IN_NOTE = 10;
78
+ const sayDays = (days, field) => days.length <= DAYS_IN_NOTE ? days.join(", ") : `${days.slice(0, DAYS_IN_NOTE).join(", ")} and ${days.length - DAYS_IN_NOTE} more (coverage.${field})`;
79
+ function completeness(d, points) {
80
+ const cov = isObj(d.coverage) ? d.coverage : {};
81
+ const completeSince = realInstant(d.complete_since) ? d.complete_since : realInstant(cov.complete_since) ? cov.complete_since : undefined;
82
+ let pointsBefore;
83
+ if (isNum(cov.points_before_complete_since))
84
+ pointsBefore = cov.points_before_complete_since;
85
+ else if (completeSince)
86
+ pointsBefore = points.filter((p) => realInstant(p.at) && Date.parse(p.at) < Date.parse(completeSince)).length;
87
+ const firstDay = isDay(cov.first_day) ? cov.first_day : undefined;
88
+ const lastDay = isDay(cov.last_day) ? cov.last_day : undefined;
89
+ return {
90
+ completeSince,
91
+ // Complete only on the API's word AND with a complete_since to be complete from.
92
+ seriesComplete: completeSince !== undefined && cov.series_complete === true,
93
+ pointsBefore,
94
+ firstDay,
95
+ lastDay,
96
+ daysHeld: firstDay && isNum(cov.days_held) ? cov.days_held : undefined,
97
+ daysPartial: dayList(cov.days_partial),
98
+ daysMissing: dayList(cov.days_missing),
99
+ runLogSince: realInstant(cov.run_log_since) ? cov.run_log_since : undefined,
100
+ hasCoverage: isObj(d.coverage),
101
+ };
102
+ }
48
103
  // The note: what came back, in words, and what it does not say.
49
- function note(head, id, d) {
104
+ function note(head, id, d, c) {
50
105
  const points = Array.isArray(d.points) ? d.points.filter(isObj) : [];
51
106
  const cur = isObj(d.current) ? d.current : {};
52
107
  const curScore = isNum(cur.epss_score) ? cur.epss_score : undefined;
@@ -56,9 +111,19 @@ function note(head, id, d) {
56
111
  ? `EchelonGraph's record holds no EPSS score for ${id} (current.epss_score is null).`
57
112
  : `${id}'s EPSS score in EchelonGraph's record is ${curScore}${isNum(cur.epss_percentile) ? ` (percentile ${cur.epss_percentile})` : ""}.`);
58
113
  if (points.length === 0) {
59
- s.push(start
60
- ? `No EPSS change is recorded for ${id} since recording began at ${start}: a measured empty series, not a lookup failure.`
61
- : "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.");
114
+ if (!start) {
115
+ s.push("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.");
116
+ }
117
+ else if (c.seriesComplete) {
118
+ s.push(`No EPSS change is recorded for ${id} since recording began at ${start}: a measured empty series, not a lookup failure.`);
119
+ }
120
+ else if (c.completeSince) {
121
+ // "Measured" only for the complete part (#2867).
122
+ s.push(`No EPSS change is recorded for ${id} since recording began at ${start}. From complete_since (${c.completeSince}) that is measured: no refresh since then changed it. Before complete_since the record misses changes, so the empty series is not evidence that the score held still from ${start} to ${c.completeSince}.`);
123
+ }
124
+ else {
125
+ s.push(`No EPSS change is recorded for ${id} since recording began at ${start}. The answer does not say from when the record is complete, so the empty series is not evidence that the score held still.`);
126
+ }
62
127
  }
63
128
  else {
64
129
  const first = points[0];
@@ -67,6 +132,26 @@ function note(head, id, d) {
67
132
  }
68
133
  if (start)
69
134
  s.push(`Recording began at ${start} (series_starts_at): before it a missing point means not recorded, not unchanged.`);
135
+ if (c.seriesComplete) {
136
+ s.push(`The record is complete (complete_since ${c.completeSince}): every change a refresh applied since recording began is a point.`);
137
+ }
138
+ else if (c.completeSince) {
139
+ const where = c.pointsBefore ? ` ${c.pointsBefore} of this series' ${points.length} point${points.length === 1 ? "" : "s"} fall there.` : "";
140
+ s.push(`The record is complete only from ${c.completeSince} (complete_since): from then on every change a refresh applied is a point. Between ${start ?? "series_starts_at"} and complete_since EchelonGraph's earlier write path missed changes, so there a missing point does not mean unchanged, and this series is not complete.${where}`);
141
+ }
142
+ else if (start) {
143
+ s.push("The answer does not say from when the record is complete (complete_since is absent), so a missing point anywhere in this series may be a missed change, not an unchanged score.");
144
+ }
145
+ if (c.firstDay && c.lastDay) {
146
+ const held = c.daysHeld === undefined ? "" : `, ${c.daysHeld} held`;
147
+ const missing = c.daysMissing.length ? `; not held: ${sayDays(c.daysMissing, "days_missing")}` : "; none missing";
148
+ const partial = c.daysPartial.length ? `; held for some CVEs only (a refresh failed part-way): ${sayDays(c.daysPartial, "days_partial")}` : "";
149
+ const since = c.runLogSince ? ` (run log since ${c.runLogSince}; earlier dates are not listed)` : "";
150
+ s.push(`FIRST score dates in EchelonGraph's run log${since}: ${c.firstDay} to ${c.lastDay}${held}${missing}${partial}.`);
151
+ }
152
+ else if (c.hasCoverage) {
153
+ s.push("The run log lists no FIRST score date yet, so this answer does not say which days the record holds, and no point carries a score_date.");
154
+ }
70
155
  if (d.points_truncated === true)
71
156
  s.push("points_truncated is true: only the newest records were read, so the earliest changes are missing from this series.");
72
157
  if (d.latest_point_matches_current === false) {
@@ -87,7 +172,8 @@ export async function epssHistory(kit, cve_id) {
87
172
  return kit.failed(TOOL, r);
88
173
  const d = r.data;
89
174
  const name = typeof d.cve_id === "string" && d.cve_id ? d.cve_id : cve;
90
- const { note: n, own } = note(kit.okHead(TOOL, r.status), name, d);
175
+ const c = completeness(d, Array.isArray(d.points) ? d.points.filter(isObj) : []);
176
+ const { note: n, own } = note(kit.okHead(TOOL, r.status), name, d, c);
91
177
  const cur = isObj(d.current) ? d.current : {};
92
178
  const written = realInstant(cur.epss_updated_at) ? cur.epss_updated_at : null;
93
179
  return kit.succeeded(r.data, n, {
@@ -97,8 +183,16 @@ export async function epssHistory(kit, cve_id) {
97
183
  coverage: {
98
184
  series_kind: d.series_kind,
99
185
  series_starts_at: d.series_starts_at ?? null,
186
+ complete_since: c.completeSince ?? null,
187
+ series_complete: c.seriesComplete,
100
188
  points: Array.isArray(d.points) ? d.points.length : 0,
189
+ points_before_complete_since: c.pointsBefore ?? null,
101
190
  points_truncated: d.points_truncated === true,
191
+ first_day: c.firstDay ?? null,
192
+ last_day: c.lastDay ?? null,
193
+ days_held: c.daysHeld ?? null,
194
+ days_partial: c.daysPartial,
195
+ days_missing: c.daysMissing,
102
196
  },
103
197
  freshness: null,
104
198
  notes: [
@@ -7,7 +7,7 @@ const MAX_VENDOR = 128;
7
7
  const MAX_CURSOR = 128;
8
8
  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.";
9
9
  const KEV_RECENT_COVERAGE = "CISA KEV catalog";
10
- 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; an RFC 3339 timestamp, such as the kev_added_date 2024-04-12T00:00:00Z that CVE records carry, is read as the date written in it, with the time and offset dropped, and anything else is refused), 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 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. 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, shortDescription and notes are not in these rows: each CVE's own record carries them, verbatim and labelled as CISA's. Its structured result's measured_at is our last successful fetch of CISA's feed (null when the API does not give it), coverage counts the CISA KEV catalog (total, returned, kev_listed_total, catalog_count, limit, has_more) and freshness gives last_successful_fetch_at, catalog_version and date_released." +
11
11
  " " +
12
12
  TEXT_BUDGET_DESCRIPTION +
13
13
  " Cut, each row keeps fewer fields, cve_id, kev_added_date, kev_vendor and kev_ransomware at least, or rows are left out, and next_cursor still continues after the last row of the page, not of the text.";
@@ -28,7 +28,7 @@ const KEVRow = z.looseObject({
28
28
  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."),
29
29
  });
30
30
  const Data = z.looseObject({
31
- kev: z.array(KEVRow).optional(),
31
+ kev: z.array(KEVRow).optional().describe("Newest kev_added_date first; rows within one date are ordered by cve_id."),
32
32
  count: opt(z.number()),
33
33
  total: opt(z.number()),
34
34
  kev_listed_total: opt(z.number()),
@@ -215,8 +215,8 @@ export function registerKevRecent(server, d) {
215
215
  title: "Recent CISA KEV additions",
216
216
  description: KEV_RECENT_DESCRIPTION,
217
217
  inputSchema: z.object({
218
- since: z.string().optional().describe("earliest kev_added_date to include, YYYY-MM-DD (an RFC 3339 timestamp is read as the date written in it)"),
219
- until: z.string().optional().describe("latest kev_added_date to include, YYYY-MM-DD (an RFC 3339 timestamp is read as the date written in it)"),
218
+ since: z.string().optional().describe("earliest kev_added_date to include, YYYY-MM-DD (an RFC 3339 timestamp is read as the date written in it, with the time and offset dropped; anything else is refused)"),
219
+ until: z.string().optional().describe("latest kev_added_date to include, YYYY-MM-DD (an RFC 3339 timestamp is read as the date written in it, with the time and offset dropped; anything else is refused)"),
220
220
  ransomware: z.boolean().optional().describe("true: only CVEs with known ransomware-campaign use; false: only those without"),
221
221
  vendor: z.string().optional().describe("CISA vendorProject, an exact case-insensitive match, e.g. 'Microsoft'"),
222
222
  limit: z.number().int().min(1).max(KEV_RECENT_MAX_LIMIT).optional().describe("page size (default 50, max 200)"),
@@ -0,0 +1,48 @@
1
+ // What every manifest reader (#2835) returns, and the purl builders they share.
2
+ //
3
+ // A reader turns one file's text into the purls it pins (one per dependency entry it can resolve
4
+ // to a single registry version) and the entries it cannot check, each with a reason. Nothing is
5
+ // guessed: a range is not narrowed to a version, a branch is not resolved to a tag, and an entry
6
+ // that names a local path or a VCS checkout is not matched against registry advisories, whose
7
+ // versions it need not share. Each such entry is listed, never dropped.
8
+ //
9
+ // purls follow the purl spec (github.com/package-url/purl-spec, PURL-TYPES.rst), and the shape
10
+ // tier1-cloud-scanner/sca/scanner.go purlFor emits: the type, each name segment percent-encoded
11
+ // (a scope's '@' as %40), '@', the version as the file states it (Go keeps its leading v). The
12
+ // backend reads them with url.PathUnescape (core-backend internal/cve/matchbatch.go
13
+ // purlNameVersion) and normalises names on lookup (pkgmatch.go), so the case a file uses is kept.
14
+ // Why an entry was not sent to the API. Each is not clean: the entry was not checked at all.
15
+ // version_unpinned the entry gives a range or a wildcard, not one version (django>=4, ==1.4.*);
16
+ // version_unresolved the entry names no single registry version: none is given, a branch is
17
+ // named (composer dev-main), or the version the build selects cannot be read
18
+ // from this file (a go.mod require of an excluded version);
19
+ // local_path the dependency is a directory, file or workspace member, not a registry
20
+ // release (a go.mod replace to ./x, an npm link, a Cargo path crate);
21
+ // vcs_source the dependency is a VCS checkout (git+, a GIT section), whose code need not
22
+ // match the registry release of the same version;
23
+ // unsupported_line a line or entry this reader does not take: another file it includes
24
+ // (-r, -c), a direct URL, an option, a registry other than the ecosystem's
25
+ // public one, or text it cannot read.
26
+ export const NOT_CHECKED_REASONS = ["version_unpinned", "version_unresolved", "local_path", "vcs_source", "unsupported_line"];
27
+ // A file the reader cannot read at all (not JSON, not TOML): the reason, said to the caller.
28
+ export class Unreadable extends Error {
29
+ }
30
+ // The most characters of a line or value a not_checked entry repeats.
31
+ const SPEC_MAX = 200;
32
+ export const specOf = (s) => {
33
+ const t = s.trim();
34
+ return t.length > SPEC_MAX ? `${t.slice(0, SPEC_MAX - 1)}…` : t;
35
+ };
36
+ // One purl segment, percent-encoded as the purl spec asks; '+' is left as it is (Go's
37
+ // +incompatible, a PyPI local version), which url.PathUnescape reads back unchanged.
38
+ export const seg = (s) => encodeURIComponent(s).replace(/%2B/g, "+");
39
+ // A hierarchical name (a Go module path, an npm @scope/name, a Packagist vendor/name): each
40
+ // '/'-separated segment encoded, the separators kept.
41
+ export const segPath = (s) => s.split("/").map(seg).join("/");
42
+ export const purlOf = (type, name, version) => `pkg:${type}/${segPath(name)}@${seg(version)}`;
43
+ // PEP 503: a PyPI name lowercased, each run of '-', '_' and '.' one '-' (the purl spec's pypi rule).
44
+ export const pep503 = (name) => name.toLowerCase().replace(/[-_.]+/g, "-");
45
+ // A version a purl can carry: non-empty, no whitespace.
46
+ export const usableVersion = (v) => typeof v === "string" && v.trim() !== "" && !/\s/.test(v.trim());
47
+ export const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
48
+ export const emptyParsed = () => ({ pinned: [], not_checked: [], skipped: 0, notes: [] });