echelongraph-mcp 2.7.2 → 3.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +219 -0
- package/README.md +163 -433
- package/dist/fixStatus.js +30 -0
- package/dist/httpPolicy.js +16 -1
- package/dist/index.js +287 -34
- package/dist/prompts.js +63 -33
- package/dist/requiredText.js +16 -1
- package/dist/resources.js +32 -6
- package/dist/textBudget.js +588 -22
- package/dist/tools/check_affected.js +45 -12
- package/dist/tools/check_sbom.js +158 -9
- package/dist/tools/cve_intel.js +63 -9
- package/dist/tools/cve_remediation.js +230 -16
- package/dist/tools/epss_history.js +10 -4
- package/dist/tools/get_cwe.js +7 -2
- package/dist/tools/kev_recent.js +22 -3
- package/dist/tools/match_batch.js +27 -7
- package/dist/tools/scan_manifest.js +35 -10
- package/dist/tools/vendor_advisories.js +47 -11
- package/docs/exposure-radar.md +181 -0
- package/docs/rate-limits.md +50 -0
- package/docs/structured-results.md +43 -0
- package/docs/tools.md +167 -0
- package/package.json +2 -1
- package/server.json +2 -2
|
@@ -7,13 +7,15 @@
|
|
|
7
7
|
// What the answer holds:
|
|
8
8
|
// cisa CISA KEV's required_action, due_date, short_description and notes_urls
|
|
9
9
|
// for a KEV-listed CVE. CISA directs the action at US federal civilian
|
|
10
|
-
// agencies (BOD
|
|
10
|
+
// agencies (under BOD 26-04, which replaced BOD 22-01 on
|
|
11
|
+
// 10 June 2026); it is not EchelonGraph's advice.
|
|
11
12
|
// fixed_branches each affected package with every affected range and its fix.
|
|
12
13
|
// vendor_remediations each vendor advisory naming the CVE (at most 20, newest first), with its
|
|
13
14
|
// remediation_state and the items it lists for the CVE, each with the
|
|
14
15
|
// vendor's own category (kind): vendor_fix, workaround, mitigation,
|
|
15
16
|
// no_fix_planned, none_available or other.
|
|
16
|
-
// fix_references the record's references tagged Patch or Mitigation
|
|
17
|
+
// fix_references the record's references tagged Patch or Mitigation, by NVD, or patch or
|
|
18
|
+
// mitigation, by the CNA (#2841: the API matches the tag in either case).
|
|
17
19
|
// patches cve_patches rows.
|
|
18
20
|
// coverage parsed_vendors, vendors_not_parsed and the caps.
|
|
19
21
|
// failed_sections the sections the API could not read.
|
|
@@ -21,19 +23,40 @@
|
|
|
21
23
|
// The rules the note keeps: not_parsed is not "no remediation"; none_in_source is what the
|
|
22
24
|
// advisory lists, not a finding that no fix exists; an empty section is "none on record", never
|
|
23
25
|
// "no fix"; a failed section is named, never relayed as empty. A REJECTED CVE is not_assessed.
|
|
26
|
+
//
|
|
27
|
+
// fix_status (#2841, #2518 item 3) is the API's fix-status rule (core-backend cve/fixstatus.go,
|
|
28
|
+
// the rule /pulse pages render), relayed: this file derives nothing from it.
|
|
29
|
+
//
|
|
30
|
+
// The installed version (#2841): ecosystem, package and version, all three or none. They travel
|
|
31
|
+
// in the X-EG-Ecosystem, X-EG-Package and X-EG-Version request headers, percent-encoded as
|
|
32
|
+
// check_affected sends them, and never in the URL (#1983: Cloud Run's request log records the URL
|
|
33
|
+
// and no request header). The API answers installed (what was asked and its verdict) and fixed_in,
|
|
34
|
+
// the first version above it that no affected range on record holds (the fixed bound of the range
|
|
35
|
+
// holding it, moved past any overlapping range, with fixed_in_detail naming it, #3074), or null
|
|
36
|
+
// with fixed_in_reason, from the registry matcher check_affected's registry path runs; that answer
|
|
37
|
+
// is private, no-store.
|
|
24
38
|
import * as z from "zod";
|
|
25
39
|
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";
|
|
40
|
+
import { answering, FULL_ARG, TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
|
|
41
|
+
import { fixStatusSchema } from "../fixStatus.js";
|
|
42
|
+
import { MATCH_HEADERS } from "./check_affected.js";
|
|
27
43
|
const TOOL = "cve_remediation";
|
|
28
44
|
const SECTIONS = ["cisa", "fixed_branches", "vendor_remediations", "fix_references", "patches"];
|
|
29
45
|
const KINDS = ["vendor_fix", "workaround", "mitigation", "no_fix_planned", "none_available", "other"];
|
|
30
46
|
const STATES = ["parsed", "none_in_source", "not_parsed", "withdrawn"];
|
|
47
|
+
// core-backend cve/pkgmatch_fixedin.go InstalledFix's verdicts.
|
|
48
|
+
const VERDICTS = ["affected", "not_affected", "undetermined", "not_assessed"];
|
|
49
|
+
// The installed version's arguments, in the order the headers are named.
|
|
50
|
+
const INSTALLED = ["ecosystem", "package", "version"];
|
|
31
51
|
// Required in the description's head (src/requiredText.ts REQUIRED_IN_HEAD.cve_remediation).
|
|
32
52
|
export const REMEDIATION_UNTESTED = "Every text is relayed as stated by its source; EchelonGraph has not tested it.";
|
|
33
53
|
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
|
|
36
|
-
|
|
54
|
+
export const REMEDIATION_CISA = "CISA's required_action is directed at US federal civilian agencies (under BOD 26-04, which replaced BOD 22-01 on 10 June 2026) and is not EchelonGraph's advice.";
|
|
55
|
+
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 tagged Patch or Mitigation by NVD, or patch or mitigation by the CNA; and cve_patches. Nothing is generated or tested.";
|
|
56
|
+
// #3084 (merged from 24dad5eb): REMEDIATION_CISA names BOD 26-04, which replaced BOD 22-01, 48 characters
|
|
57
|
+
// longer, which left this description 71 under the 2048-character cut (#2924's floor is 100); the
|
|
58
|
+
// remediation_state parentheses and the failed_sections sentence are said shorter, every claim kept.
|
|
59
|
+
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 fix_status (the API's fix-status rule, as CVE Pulse shows it: state and basis), cisa (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, with remediation_state, remediation_kinds and items, each with kind, source_category, text and url), fix_references (references NVD or the CNA tagged Patch or Mitigation), patches, coverage 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. remediation_state is parsed, none_in_source (none listed for this CVE), not_parsed (that vendor's remediation is not read) or withdrawn. ecosystem, package and version (all three or none, sent in request headers, never in the URL) add installed, the verdict at that version, and fixed_in, the lowest unaffected version above it, or null with fixed_in_reason. A section the API could not read is named in failed_sections, not relayed as empty. A REJECTED CVE answers not_assessed. ${TEXT_BUDGET_DESCRIPTION} Cut, vendor texts are shortened first; kinds, states and URLs are kept.`;
|
|
37
60
|
// ── outputSchema ──
|
|
38
61
|
// Built on first use: index.ts imports this file, so its exports are not initialised while this
|
|
39
62
|
// module's top level runs.
|
|
@@ -67,6 +90,19 @@ function schemas() {
|
|
|
67
90
|
cve_id: opt(z.string()),
|
|
68
91
|
state: opt(z.string()).describe("assessed, or not_assessed for a CVE record rejected by its numbering authority."),
|
|
69
92
|
not_assessed_reason: opt(z.string()),
|
|
93
|
+
fix_status: fixStatusSchema(),
|
|
94
|
+
installed: opt(z.looseObject({
|
|
95
|
+
ecosystem: opt(z.string()).describe("The ecosystem asked for, in its canonical OSV spelling."),
|
|
96
|
+
ecosystem_recognised: opt(z.boolean()),
|
|
97
|
+
package: opt(z.string()),
|
|
98
|
+
version: opt(z.string()),
|
|
99
|
+
verdict: opt(z.union([z.enum(VERDICTS), z.string()])).describe("affected (a range on record holds the version), not_affected (no range on record holds it), undetermined (a range could not be decided at it) or not_assessed (the record names no such package for this CVE, or the lookup did not run)."),
|
|
100
|
+
version_evidence: opt(z.string()),
|
|
101
|
+
interval: opt(z.looseObject({ introduced: opt(z.string()), fixed: opt(z.string()), last_affected: opt(z.string()) })).describe("The affected range holding the version (fixed is exclusive, last_affected inclusive); absent when none holds it."),
|
|
102
|
+
})).describe("The installed version asked for, as the API read it from the request headers, and its verdict against this CVE; absent when none was asked."),
|
|
103
|
+
fixed_in: opt(z.string()).describe("With an installed version: the first version above it that no affected range on record holds, always above it, as the advisory states it: the fixed bound of the range holding it, moved past any overlapping range that still holds that bound (fixed_in_detail then names it). null when no range on record holds it, no fix is on record past the ranges holding it, or the version was not assessed, with fixed_in_reason saying which. Absent when no installed version was asked."),
|
|
104
|
+
fixed_in_reason: opt(z.string()).describe("Why fixed_in is null; absent when fixed_in is a version."),
|
|
105
|
+
fixed_in_detail: opt(z.string()).describe("Set when fixed_in is not the fixed bound of the range holding the installed version: the overlapping ranges of the same record it moved past, which still hold that bound."),
|
|
70
106
|
cisa: z
|
|
71
107
|
.looseObject({
|
|
72
108
|
kev_listed: opt(z.boolean()),
|
|
@@ -88,6 +124,10 @@ function schemas() {
|
|
|
88
124
|
advisory_id: opt(z.string()),
|
|
89
125
|
source: opt(z.string()),
|
|
90
126
|
}))),
|
|
127
|
+
fixed_branches_truncated: z
|
|
128
|
+
.object({ total: z.number().int(), returned: z.number().int() })
|
|
129
|
+
.optional()
|
|
130
|
+
.describe("Present only where the structured result's cut (DATA CUT) kept this package's first ranges, not all: total, its ranges in the answer; returned, the ones data keeps. The same call with full: true returns every range."),
|
|
91
131
|
}))),
|
|
92
132
|
vendor_remediations: opt(z.array(VendorAdvisory)),
|
|
93
133
|
fix_references: opt(z.array(z.looseObject({ url: opt(z.string()), tags: opt(z.array(z.string())), source: opt(z.string()) }))),
|
|
@@ -103,6 +143,10 @@ function schemas() {
|
|
|
103
143
|
vendor_advisory_limit: z.number().nullable(),
|
|
104
144
|
item_text_cap: z.number().nullable(),
|
|
105
145
|
items_per_advisory_cap: z.number().nullable(),
|
|
146
|
+
installed_asked: z
|
|
147
|
+
.strictObject({ ecosystem: z.string(), package: z.string(), version: z.string() })
|
|
148
|
+
.nullable()
|
|
149
|
+
.describe("The installed version this call sent (in request headers), as given; null when none."),
|
|
106
150
|
});
|
|
107
151
|
return { output: envelopeSchema({ data, coverage, freshness: null }) };
|
|
108
152
|
}
|
|
@@ -115,8 +159,107 @@ const CUT = {
|
|
|
115
159
|
{ 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
160
|
{ keep: ["vendor", "vendor_advisory_id", "remediation_state", "remediation_kinds", ["items", ["kind", "url"]]], clip: 2048, cap: 8 },
|
|
117
161
|
],
|
|
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.",
|
|
162
|
+
whole: "get_vendor_advisory returns any one of these advisories by its vendor and vendor_advisory_id, whole in its structuredContent.data with full: true, with every remediation item it lists.",
|
|
163
|
+
};
|
|
164
|
+
// #3013: the structured result's cut. CVE-2021-44228's answer is 75,963 characters of structured
|
|
165
|
+
// result, 61,248 of them its 20 vendor_remediations, whose items carry the vendor's text (about 550
|
|
166
|
+
// characters an item, the payload) and its product_ids (about 550, the vendor's own identifiers).
|
|
167
|
+
// Before any advisory leaves, every advisory's items are thinned: product_ids first, keeping each
|
|
168
|
+
// text; then the texts, keeping each item's kind, source_category, url and fixed_build, so every
|
|
169
|
+
// advisory stays named with its state, kinds and links (get_vendor_advisory returns one whole).
|
|
170
|
+
// fix_status and fixed_in (#2841) are not lists, and installed is an object, so none of them is
|
|
171
|
+
// ever cut.
|
|
172
|
+
//
|
|
173
|
+
// #3139: through 3.0.0 advisories then left from the end, before patches, and fixed_branches had no
|
|
174
|
+
// step, so CVE-2023-44487 (KEV-listed) kept 2 of its 20 advisories at 29,704 characters while
|
|
175
|
+
// fixed_branches (13,581 characters, 50 rows) and patches (7,471, 30) stayed whole, and the note
|
|
176
|
+
// named no tool that lists the other 18 (measured on hosted 3.0.0, 2026-10-11; also CVE-2018-12126
|
|
177
|
+
// 6/20, CVE-2019-11135 2/3, CVE-2018-3639 16/20). Now vendor_remediations is a keepAll step: every
|
|
178
|
+
// advisory stays, thinned, while patches (each to its vendor, patch_url and published_at: the
|
|
179
|
+
// erratum, which vendor_remediations names again for Red Hat) and then fixed_branches (each to its
|
|
180
|
+
// package, range, fixed_version and its ranges' introduced, fixed and last_affected, what the
|
|
181
|
+
// sbom_review prompt walks; the per-range source, advisory_id and dependents_count go) are thinned
|
|
182
|
+
// and then lose rows from the end, and then any other list at the top of data (fix_references).
|
|
183
|
+
// Only when all of that is cut and the result is still past the budget do advisories leave, from
|
|
184
|
+
// the end; whenever one does, the note names vendor_advisories_for_cve, which lists every vendor
|
|
185
|
+
// advisory naming the CVE, as where the rest are.
|
|
186
|
+
// #3139's review: that order dropped 2 of CVE-2023-44487's 30 patches while its 50 fixed_branches
|
|
187
|
+
// rows stayed whole (13,581 characters). Decided (2026-10-11): fixed_branches is thinned before any
|
|
188
|
+
// patch row leaves. The order is now:
|
|
189
|
+
// 1. vendor_remediations thinned, as above, every advisory kept (keepAll);
|
|
190
|
+
// 2. fixed_branches thinned (each package to its ecosystem, package_name, version_range,
|
|
191
|
+
// fixed_version and its ranges' introduced, fixed and last_affected: each range's source and
|
|
192
|
+
// advisory_id go, and the package's dependents_count and source), then each package's ranges
|
|
193
|
+
// capped at one number, the most that fit (at least 1), a package with more carrying
|
|
194
|
+
// fixed_branches_truncated with its total and returned; every package kept (keepAll);
|
|
195
|
+
// 3. patches thinned (vendor, patch_url and published_at), every patch kept (keepAll);
|
|
196
|
+
// 4. any other list at the top of data (fix_references), cut from the end;
|
|
197
|
+
// 5. then patch rows leave from the end, then fixed_branches rows, and only then advisories
|
|
198
|
+
// (textBudget.ts: the last keepAll step's entries leave first);
|
|
199
|
+
// 6. the room left goes back to each package's ranges, in the order of fixed_branches (giveBack):
|
|
200
|
+
// step 2 caps them before the patches are cut, and a cap that held while every patch was
|
|
201
|
+
// whole would otherwise stay after the patches are thinned.
|
|
202
|
+
// Round 2 replayed CVE-2023-44487 at 28 of 30 patches: fix_references (2,191 characters) were cut
|
|
203
|
+
// after patch rows left, and the 30 thinned were about 240 characters past the budget beside them.
|
|
204
|
+
// Round 3 (#3139's second review, 2026-10-11): patches is a keepAll step, so fix_references are cut
|
|
205
|
+
// first and CVE-2023-44487 keeps all 20 advisories, all 50 fixed_branches and all 30 patches, each
|
|
206
|
+
// thinned (test/cve_remediation.test.mjs); round 1 (patches cut before fixed_branches was thinned)
|
|
207
|
+
// kept 11 patches without fix_references.
|
|
208
|
+
//
|
|
209
|
+
// #3013's review: the second level leaves every item's text out of data, and the text is the
|
|
210
|
+
// vendor's step itself (a workaround's or mitigation's wording), which the triage_cve and
|
|
211
|
+
// sbom_review prompts have the model quote. No string is shortened (the cut's rule), so the note
|
|
212
|
+
// says the text is gone and where each one is: get_vendor_advisory, by the advisory's vendor and
|
|
213
|
+
// vendor_advisory_id, returns that advisory's remediations with their texts, and the same call
|
|
214
|
+
// with full: true returns this data whole; the prompts send the model there when it is cut.
|
|
215
|
+
const ADVISORY_FIELDS = ["vendor", "vendor_display_name", "vendor_advisory_id", "vendor_published_at", "withdrawn", "remediation_state", "remediation_kinds", "items_total"];
|
|
216
|
+
const ITEM_TEXT_ELSEWHERE = "No item in data keeps its text, the vendor's own wording of the step (a workaround or mitigation among them): get_vendor_advisory, given an advisory's vendor and its vendor_advisory_id as advisory_id, returns it among that advisory's remediations, whole in its structuredContent.data with full: true.";
|
|
217
|
+
const FIXED_BRANCHES_THIN = ["ecosystem", "package_name", "version_range", "fixed_version", ["fixed_branches", ["introduced", "fixed", "last_affected"]]];
|
|
218
|
+
const CVE_REMEDIATION_DATA = {
|
|
219
|
+
steps: [
|
|
220
|
+
{
|
|
221
|
+
lists: ["vendor_remediations"],
|
|
222
|
+
thin: [
|
|
223
|
+
[...ADVISORY_FIELDS, ["items", ["kind", "source_category", "source_subcategory", "text", "text_truncated", "text_chars", "url", "fixed_build", "product_count"]]],
|
|
224
|
+
[...ADVISORY_FIELDS, ["items", ["kind", "source_category", "url", "fixed_build"]]],
|
|
225
|
+
],
|
|
226
|
+
keepAll: true,
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
lists: ["fixed_branches"],
|
|
230
|
+
thin: [
|
|
231
|
+
FIXED_BRANCHES_THIN,
|
|
232
|
+
{
|
|
233
|
+
keep: FIXED_BRANCHES_THIN,
|
|
234
|
+
capped: "fixed_branches",
|
|
235
|
+
least: 1,
|
|
236
|
+
mark: (row, cap) => {
|
|
237
|
+
const n = Array.isArray(row.fixed_branches) ? row.fixed_branches.length : 0;
|
|
238
|
+
return n > cap ? { fixed_branches_truncated: { total: n, returned: cap } } : {};
|
|
239
|
+
},
|
|
240
|
+
said: (cap) => `each package's fixed_branches keeping at most its first ${cap} ranges, a package with more carrying fixed_branches_truncated with its total and returned`,
|
|
241
|
+
},
|
|
242
|
+
],
|
|
243
|
+
keepAll: true,
|
|
244
|
+
giveBack: true,
|
|
245
|
+
},
|
|
246
|
+
// #3139 round 3: every patch row stays, thinned (keepAll), while the step after it (any other
|
|
247
|
+
// list at the top of data: fix_references) is cut; patch rows leave only after that, and
|
|
248
|
+
// before any fixed_branches row or advisory (the last keepAll step's entries leave first).
|
|
249
|
+
{ lists: ["patches"], thin: [["vendor", "patch_url", "published_at"]], keepAll: true },
|
|
250
|
+
],
|
|
251
|
+
// Said where the advisories kept are thinned to a level whose items keep no text, and (#3139)
|
|
252
|
+
// wherever an advisory is left out.
|
|
253
|
+
page: (t, _d, at) => {
|
|
254
|
+
const items = at.keeps.vendor_remediations?.find((x) => typeof x !== "string" && x[0] === "items");
|
|
255
|
+
const v = t.vendor_remediations;
|
|
256
|
+
return [items && !items[1].includes("text") ? ITEM_TEXT_ELSEWHERE : "", v && v.returned < v.total ? advisoriesElsewhere(v.total - v.returned) : ""].filter(Boolean).join(" ");
|
|
257
|
+
},
|
|
119
258
|
};
|
|
259
|
+
// #3139: where the advisories a cut leaves out are read. vendor_advisories_for_cve lists every one
|
|
260
|
+
// naming the CVE, with its vendor and vendor_advisory_id (CVE-2023-44487's 20 at 15,505
|
|
261
|
+
// characters on hosted 3.0.0), and get_vendor_advisory then returns any one whole.
|
|
262
|
+
const advisoriesElsewhere = (n) => `vendor_advisories_for_cve, given this CVE, lists every vendor advisory naming it, the ${n} left out of data among them, each with its vendor and vendor_advisory_id.`;
|
|
120
263
|
const listed = (xs) => (xs.length <= 1 ? (xs[0] ?? "") : `${xs.slice(0, -1).join(", ")} and ${xs.at(-1)}`);
|
|
121
264
|
const arr = (v) => (Array.isArray(v) ? v : null);
|
|
122
265
|
const strs = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === "string") : null);
|
|
@@ -162,18 +305,70 @@ function branchSentence(rows) {
|
|
|
162
305
|
fixed++;
|
|
163
306
|
}
|
|
164
307
|
}
|
|
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.`;
|
|
308
|
+
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 first fixed above it that no range of its package holds (the fixed of the range that holds it, or, while another range still holds that version, that range's fixed), compared in the ecosystem's version order.`;
|
|
166
309
|
}
|
|
167
|
-
|
|
168
|
-
|
|
310
|
+
// fix_status's sentence: what the API's rule says, relayed. Nothing about the fix is inferred here.
|
|
311
|
+
function fixStatusSentence(cve, fs) {
|
|
312
|
+
if (fs === null || fs === undefined)
|
|
313
|
+
return null;
|
|
314
|
+
const state = strAt(fs, "state");
|
|
315
|
+
if (!state)
|
|
316
|
+
return null;
|
|
317
|
+
const basis = strAt(fs, "basis");
|
|
318
|
+
const unread = Array.isArray(field(fs, "unread_inputs")) ? field(fs, "unread_inputs").filter((x) => typeof x === "string") : [];
|
|
319
|
+
const why = state === "none_confirmed" ? ", which is not a finding that no fix exists" : "";
|
|
320
|
+
const notRead = unread.length ? `; it rests without ${listed(unread)}, which the API could not read (unread_inputs)` : "";
|
|
321
|
+
// #3076, third pass: what vendor_fix_released evidence is, so "available" is not read as more
|
|
322
|
+
// than it says. Relayed from the field the API set, nothing inferred.
|
|
323
|
+
const released = field(fs, "vendor_released");
|
|
324
|
+
const qualifier = released !== null && released !== undefined && typeof released === "object"
|
|
325
|
+
? `; the vendor states a fix is released, but the CVE record names no fixed version (vendor_released), so the fixed release is the vendor advisory's to name`
|
|
326
|
+
: "";
|
|
327
|
+
return `fix_status for ${cve} is ${state}${basis ? ` (basis ${basis})` : ""}, the API's fix-status rule${why}${qualifier}${notRead}.`;
|
|
328
|
+
}
|
|
329
|
+
// The sentence on the installed version asked: what the API answered for it, relayed.
|
|
330
|
+
function installedSentence(asked, d) {
|
|
331
|
+
const who = `${asked.ecosystem} ${asked.package} ${asked.version}`;
|
|
332
|
+
if (!("fixed_in" in d)) {
|
|
333
|
+
return `The answer carries no fixed_in for the installed version asked (${who}): the API did not assess it, which is not a finding either way.`;
|
|
334
|
+
}
|
|
335
|
+
const inst = field(d, "installed");
|
|
336
|
+
const verdict = strAt(inst, "verdict");
|
|
337
|
+
const fixedIn = strAt(d, "fixed_in");
|
|
338
|
+
if (fixedIn) {
|
|
339
|
+
// #3074: fixed_in moved past an overlapping range, so it is not the holding range's bound.
|
|
340
|
+
const detail = strAt(d, "fixed_in_detail");
|
|
341
|
+
if (detail) {
|
|
342
|
+
return `For the installed version asked (${who}), installed.verdict is ${verdict ?? "not given"} and fixed_in is ${fixedIn}: the first version above it that no affected range on record holds (${detail}), as the advisory states it; EchelonGraph has not tested it.`;
|
|
343
|
+
}
|
|
344
|
+
const lo = strAt(field(inst, "interval"), "introduced");
|
|
345
|
+
const range = lo ? ` [${lo}, ${fixedIn})` : "";
|
|
346
|
+
return `For the installed version asked (${who}), installed.verdict is ${verdict ?? "not given"} and fixed_in is ${fixedIn}: the fixed bound of the affected range${range} that holds it, as the advisory states it; EchelonGraph has not tested it.`;
|
|
347
|
+
}
|
|
348
|
+
const reason = strAt(d, "fixed_in_reason") ?? "the answer gives no fixed_in_reason";
|
|
349
|
+
return `For the installed version asked (${who}), installed.verdict is ${verdict ?? "not given"} and fixed_in is null: ${reason}.`;
|
|
350
|
+
}
|
|
351
|
+
export async function cveRemediation(args) {
|
|
352
|
+
const id = (args.cve_id ?? "").trim();
|
|
169
353
|
if (!id)
|
|
170
354
|
return badInput(TOOL, "cve_id is required");
|
|
171
355
|
if (!CVE_ID.test(id))
|
|
172
356
|
return badInput(TOOL, `cve_id ${JSON.stringify(id.slice(0, 32))} is not a CVE id (expected the form CVE-2021-44228)`);
|
|
357
|
+
// The installed version: all three or none.
|
|
358
|
+
const given = { ecosystem: (args.ecosystem ?? "").trim(), package: (args.package ?? "").trim(), version: (args.version ?? "").trim() };
|
|
359
|
+
const present = INSTALLED.filter((k) => given[k] !== "");
|
|
360
|
+
const missing = INSTALLED.filter((k) => given[k] === "");
|
|
361
|
+
if (present.length > 0 && missing.length > 0) {
|
|
362
|
+
return badInput(TOOL, `ecosystem, package and version go together, all three or none: ${listed([...missing])} ${missing.length === 1 ? "is" : "are"} missing (given: ${listed([...present])})`);
|
|
363
|
+
}
|
|
364
|
+
const asked = present.length === INSTALLED.length ? given : null;
|
|
173
365
|
const cve = id.toUpperCase();
|
|
174
366
|
const path = `/api/v1/public/cves/${encodeURIComponent(cve)}/remediation`;
|
|
367
|
+
// #1983: percent-encoded, as the backend's matchHeaderValue decodes them; never in the URL.
|
|
368
|
+
const enc = encodeURIComponent;
|
|
369
|
+
const headers = asked ? { [MATCH_HEADERS.ecosystem]: enc(asked.ecosystem), [MATCH_HEADERS.package]: enc(asked.package), [MATCH_HEADERS.version]: enc(asked.version) } : undefined;
|
|
175
370
|
try {
|
|
176
|
-
const r = await api(path);
|
|
371
|
+
const r = await api(path, headers ? { headers } : undefined);
|
|
177
372
|
if (!r.ok)
|
|
178
373
|
return failed(TOOL, r);
|
|
179
374
|
const d = r.data;
|
|
@@ -197,11 +392,13 @@ export async function cveRemediation(cve_id) {
|
|
|
197
392
|
vendor_advisory_limit: typeof field(cov, "vendor_advisory_limit") === "number" ? field(cov, "vendor_advisory_limit") : null,
|
|
198
393
|
item_text_cap: typeof field(cov, "item_text_cap") === "number" ? field(cov, "item_text_cap") : null,
|
|
199
394
|
items_per_advisory_cap: typeof field(cov, "items_per_advisory_cap") === "number" ? field(cov, "items_per_advisory_cap") : null,
|
|
395
|
+
installed_asked: asked,
|
|
200
396
|
};
|
|
201
397
|
const head = okHead(TOOL, r.status);
|
|
202
398
|
if (state === "not_assessed") {
|
|
203
399
|
const reason = strAt(d, "not_assessed_reason") ?? "the API gives no reason";
|
|
204
|
-
|
|
400
|
+
const inst = asked ? ` ${installedSentence(asked, d)}` : "";
|
|
401
|
+
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.${inst}`, {
|
|
205
402
|
state: "not_assessed",
|
|
206
403
|
measured_at: null,
|
|
207
404
|
method: METHOD,
|
|
@@ -211,6 +408,11 @@ export async function cveRemediation(cve_id) {
|
|
|
211
408
|
});
|
|
212
409
|
}
|
|
213
410
|
const said = [];
|
|
411
|
+
const fixSaid = fixStatusSentence(cve, field(d, "fix_status"));
|
|
412
|
+
if (fixSaid)
|
|
413
|
+
said.push(fixSaid);
|
|
414
|
+
if (asked)
|
|
415
|
+
said.push(installedSentence(asked, d));
|
|
214
416
|
const cisa = field(d, "cisa");
|
|
215
417
|
const action = strAt(cisa, "required_action");
|
|
216
418
|
if (field(cisa, "kev_listed") === true) {
|
|
@@ -225,8 +427,12 @@ export async function cveRemediation(cve_id) {
|
|
|
225
427
|
if (vendors && !(reported ?? []).includes("vendor_remediations"))
|
|
226
428
|
said.push(...vendorSentences(cve, vendors));
|
|
227
429
|
const refs = arr(field(d, "fix_references"));
|
|
228
|
-
|
|
229
|
-
|
|
430
|
+
// #2841: the API keeps both spellings, NVD's (Patch, Mitigation) and a CNA's (patch, mitigation).
|
|
431
|
+
if (refs) {
|
|
432
|
+
said.push(refs.length
|
|
433
|
+
? `fix_references lists ${refs.length} reference${refs.length === 1 ? "" : "s"} the CVE record tags as a patch or mitigation (NVD's Patch or Mitigation, or the CNA's patch or mitigation), each with the tags as written.`
|
|
434
|
+
: "No reference of the CVE record is tagged as a patch or mitigation, in NVD's spelling (Patch, Mitigation) or the CNA's (patch, mitigation).");
|
|
435
|
+
}
|
|
230
436
|
if (reported === null)
|
|
231
437
|
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
438
|
else if (reported.length)
|
|
@@ -250,8 +456,16 @@ export function registerCveRemediation(server) {
|
|
|
250
456
|
server.registerTool(TOOL, {
|
|
251
457
|
title: "How one CVE is fixed",
|
|
252
458
|
description: CVE_REMEDIATION_DESCRIPTION,
|
|
253
|
-
inputSchema:
|
|
459
|
+
inputSchema: CVE_REMEDIATION_INPUT,
|
|
254
460
|
outputSchema: output,
|
|
255
461
|
annotations: ANNOTATIONS,
|
|
256
|
-
}, async (
|
|
462
|
+
}, async (args) => checked(TOOL, output, await answering(args.full, CVE_REMEDIATION_DATA, () => cveRemediation(args))));
|
|
257
463
|
}
|
|
464
|
+
// The installed version is optional and all-or-none (cveRemediation refuses a partial set).
|
|
465
|
+
export const CVE_REMEDIATION_INPUT = z.object({
|
|
466
|
+
cve_id: z.string().describe("a CVE ID, e.g. CVE-2021-44228"),
|
|
467
|
+
ecosystem: z.string().optional().describe("Installed version, with package and version: the package's ecosystem, such as npm, PyPI or Maven. Sent in a request header."),
|
|
468
|
+
package: z.string().optional().describe("Installed version, with ecosystem and version: the package name in that ecosystem, such as org.apache.logging.log4j:log4j-core. Sent in a request header."),
|
|
469
|
+
version: z.string().optional().describe("Installed version, with ecosystem and package: the version installed, such as 2.14.1. Sent in a request header."),
|
|
470
|
+
full: FULL_ARG,
|
|
471
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as z from "zod";
|
|
2
|
-
import { TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
|
|
2
|
+
import { answering, FULL_ARG, NO_DATA_CUT, TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
|
|
3
3
|
const TOOL = "epss_history";
|
|
4
4
|
const CVE_ID = /^CVE-\d{4}-\d{4,}$/i;
|
|
5
5
|
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.";
|
|
@@ -208,13 +208,19 @@ export async function epssHistory(kit, cve_id) {
|
|
|
208
208
|
return kit.crashed(TOOL, e);
|
|
209
209
|
}
|
|
210
210
|
}
|
|
211
|
+
// One output schema per process, not per request (3.0.2; index.ts envelopeSchema says why).
|
|
212
|
+
const OUTPUTS = new WeakMap();
|
|
211
213
|
export function registerEPSSHistory(server, kit) {
|
|
212
|
-
const output = epssHistoryOutput(kit);
|
|
214
|
+
const output = OUTPUTS.get(kit) ?? epssHistoryOutput(kit);
|
|
215
|
+
OUTPUTS.set(kit, output);
|
|
213
216
|
server.registerTool(TOOL, {
|
|
214
217
|
title: "EPSS change history for one CVE",
|
|
215
218
|
description: EPSS_HISTORY_DESCRIPTION,
|
|
216
|
-
inputSchema: z.object({ cve_id: kit.cveIdArg }),
|
|
219
|
+
inputSchema: z.object({ cve_id: kit.cveIdArg, full: FULL_ARG }),
|
|
217
220
|
outputSchema: output,
|
|
218
221
|
annotations: kit.annotations,
|
|
219
|
-
},
|
|
222
|
+
},
|
|
223
|
+
// #3013: a few points a CVE, the API's own cap on them, 2,508 characters for CVE-2021-44228:
|
|
224
|
+
// no list is named, so only a list at the top level of data would be cut past the budget.
|
|
225
|
+
async ({ cve_id, full }) => kit.checked(TOOL, output, await answering(full, NO_DATA_CUT, () => epssHistory(kit, cve_id))));
|
|
220
226
|
}
|
package/dist/tools/get_cwe.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// the note says it can lag the feed.
|
|
14
14
|
import * as z from "zod";
|
|
15
15
|
import { ANNOTATIONS, api, badInput, checked, crashed, envelopeSchema, failed, field, lenAt, numAt, okHead, opt, rowsScoreNote, strAt, succeeded, } from "../index.js";
|
|
16
|
-
import { GET_CVE_WHOLE, TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
|
|
16
|
+
import { answering, FULL_ARG, GET_CVE_WHOLE, TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
|
|
17
17
|
const TOOL = "get_cwe";
|
|
18
18
|
const CWE_ARG = /^(?:CWE-)?0*(\d{1,6})$/i;
|
|
19
19
|
const MAX_PAGE = 200;
|
|
@@ -27,6 +27,10 @@ const GET_CWE_TEXT = {
|
|
|
27
27
|
levels: [{ keep: ["cve_id", "severity", "cvss_v3_score", "echelongraph_score", "echelongraph_severity", "score_assessed", "score_unassessed_reason", "kev_listed", "published", "description"], clip: 120 }],
|
|
28
28
|
whole: `${GET_CVE_WHOLE}.`,
|
|
29
29
|
};
|
|
30
|
+
// #3013: the structured result's cut. CWE-79's page of 50 is 24,276 characters of structured result,
|
|
31
|
+
// within the budget; rows a few times longer leave data from the end. A page is always 50 rows, so
|
|
32
|
+
// full: true is the only way to read the rest of one.
|
|
33
|
+
const GET_CWE_DATA = { steps: [{ lists: ["cves"] }] };
|
|
30
34
|
function schemas() {
|
|
31
35
|
const row = z.looseObject({
|
|
32
36
|
cve_id: opt(z.string()),
|
|
@@ -150,8 +154,9 @@ export function registerGetCwe(server) {
|
|
|
150
154
|
inputSchema: z.object({
|
|
151
155
|
cwe_id: z.string().describe("a CWE ID, e.g. CWE-79 (or 79)"),
|
|
152
156
|
page: z.number().int().min(1).max(MAX_PAGE).optional().describe(`page of 50 CVEs (default 1, max ${MAX_PAGE})`),
|
|
157
|
+
full: FULL_ARG,
|
|
153
158
|
}),
|
|
154
159
|
outputSchema: output,
|
|
155
160
|
annotations: ANNOTATIONS,
|
|
156
|
-
}, async ({ cwe_id, page }) => checked(TOOL, output, await getCwe(cwe_id, page)));
|
|
161
|
+
}, async ({ cwe_id, page, full }) => checked(TOOL, output, await answering(full, GET_CWE_DATA, () => getCwe(cwe_id, page))));
|
|
157
162
|
}
|
package/dist/tools/kev_recent.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as z from "zod";
|
|
2
|
-
import { GET_CVE_WHOLE, TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
|
|
2
|
+
import { answering, FULL_ARG, GET_CVE_WHOLE, TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
|
|
3
3
|
const TOOL = "kev_recent";
|
|
4
4
|
const PATH = "/api/v1/public/kev/recent";
|
|
5
5
|
export const KEV_RECENT_MAX_LIMIT = 200;
|
|
@@ -145,6 +145,21 @@ function kevText(a) {
|
|
|
145
145
|
page: (shown) => `next_cursor continues after the last row of this page, not after the last row in this text: to read the rows left out in the text, call kev_recent again with the same filters${a.cursor?.trim() ? " and cursor" : ""} and a limit of ${shown}, whose rows are the ones in this text, then with that page's next_cursor and a limit of ${shown} or fewer.`,
|
|
146
146
|
};
|
|
147
147
|
}
|
|
148
|
+
// #3013: the structured result's cut. A production row is about 400 characters of compact JSON, so
|
|
149
|
+
// the default page of 50 fits (23,234 characters of structured result) and the largest, 200, was
|
|
150
|
+
// 84,380: rows leave data from the end, and, as for the text, the note says how to read them
|
|
151
|
+
// without skipping one, since next_cursor continues after the page, not after data's last row.
|
|
152
|
+
function kevData(a) {
|
|
153
|
+
return {
|
|
154
|
+
steps: [{ lists: ["kev"] }],
|
|
155
|
+
page: (t) => {
|
|
156
|
+
const k = t.kev?.returned;
|
|
157
|
+
if (!k)
|
|
158
|
+
return "";
|
|
159
|
+
return `next_cursor continues after the last row of this page, not after the last row in data: to read the rows data leaves out, call kev_recent again with the same filters${a.cursor?.trim() ? " and cursor" : ""} and a limit of ${k}, whose rows are the ones in data, then with that page's next_cursor and a limit of ${k} or fewer.`;
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
148
163
|
async function kevRecent(d, a) {
|
|
149
164
|
try {
|
|
150
165
|
const h = headersFor(a);
|
|
@@ -208,9 +223,12 @@ async function kevRecent(d, a) {
|
|
|
208
223
|
return d.crashed(TOOL, e);
|
|
209
224
|
}
|
|
210
225
|
}
|
|
226
|
+
// One output schema per process, not per request (3.0.2; index.ts envelopeSchema says why).
|
|
227
|
+
const OUTPUTS = new WeakMap();
|
|
211
228
|
// Registers the tool on a server built by index.ts's createServer.
|
|
212
229
|
export function registerKevRecent(server, d) {
|
|
213
|
-
const output = d.envelopeSchema({ data: Data, coverage: Coverage, freshness: Freshness });
|
|
230
|
+
const output = OUTPUTS.get(d.envelopeSchema) ?? d.envelopeSchema({ data: Data, coverage: Coverage, freshness: Freshness });
|
|
231
|
+
OUTPUTS.set(d.envelopeSchema, output);
|
|
214
232
|
server.registerTool("kev_recent", {
|
|
215
233
|
title: "Recent CISA KEV additions",
|
|
216
234
|
description: KEV_RECENT_DESCRIPTION,
|
|
@@ -221,8 +239,9 @@ export function registerKevRecent(server, d) {
|
|
|
221
239
|
vendor: z.string().optional().describe("CISA vendorProject, an exact case-insensitive match, e.g. 'Microsoft'"),
|
|
222
240
|
limit: z.number().int().min(1).max(KEV_RECENT_MAX_LIMIT).optional().describe("page size (default 50, max 200)"),
|
|
223
241
|
cursor: z.string().optional().describe("next_cursor from the previous page"),
|
|
242
|
+
full: FULL_ARG,
|
|
224
243
|
}),
|
|
225
244
|
outputSchema: output,
|
|
226
245
|
annotations: d.annotations,
|
|
227
|
-
}, async (a) => d.checked(TOOL, output, await kevRecent(d, a)));
|
|
246
|
+
}, async (a) => d.checked(TOOL, output, await answering(a.full, kevData(a), () => kevRecent(d, a))));
|
|
228
247
|
}
|
|
@@ -256,9 +256,17 @@ export async function sendBatches(deps, tool, purls) {
|
|
|
256
256
|
// sent are always the input's distinct purls from position sent + 1 on
|
|
257
257
|
// (purls.slice(answers.length * MAX_COMPONENTS)), which it can rebuild from its own input, given
|
|
258
258
|
// the order they are counted in (`readOrder`), which the sentence says whenever it gives the
|
|
259
|
-
// position, not only when the text cuts the list.
|
|
260
|
-
//
|
|
261
|
-
|
|
259
|
+
// position, not only when the text cuts the list. #3013: the structured result's cut empties
|
|
260
|
+
// data.not_sent_purls before any row leaves (it is rebuilt from the position). #3132's review:
|
|
261
|
+
// round 1 then gave it back the first entries the room the rows left held (3 of the rate-limited
|
|
262
|
+
// case's 800), a list a client would retry as though it were the whole; the cut now keeps it whole
|
|
263
|
+
// or not at all (check_sbom.ts sbomData), and the sentence gives the positions as coverage holds
|
|
264
|
+
// them. `retry` is the tool and argument to send them to ("check_sbom again with purls set to").
|
|
265
|
+
// #3132's second review: those positions index the purls this tool read, which a check_sbom
|
|
266
|
+
// client rebuilds from its own purl list (or SBOM), but a scan_manifest client cannot without
|
|
267
|
+
// reading the files as this tool does; with `positions` false (scan_manifest), the sentence sends
|
|
268
|
+
// it to the same call with full: true, which always returns the list, instead.
|
|
269
|
+
export function sendingSentences(g, distinct, readOrder, retry, positions = true) {
|
|
262
270
|
const out = [];
|
|
263
271
|
if (g.batches > 1) {
|
|
264
272
|
out.push(`The ${distinct} distinct purls make ${g.batches} batches of at most ${MAX_COMPONENTS}, sent one after another; the API answered ${g.batches_sent} of them, and data.summary sums those answers.`);
|
|
@@ -270,7 +278,7 @@ export function sendingSentences(g, distinct, readOrder, retry) {
|
|
|
270
278
|
if (notSent > 0) {
|
|
271
279
|
const one = notSent === 1;
|
|
272
280
|
const where = one ? `it is the input's distinct purl at position ${g.sent + 1}` : `they are the input's distinct purls from position ${g.sent + 1} on`;
|
|
273
|
-
out.push(`${plural(notSent, "purl was", "purls were")} NOT sent (not_sent_reason ${g.not_sent_reason}: ${g.not_sent_detail}), so ${one ? "it is" : "they are"} not checked and not clean; ${where}, ${readOrder}, and data.not_sent_purls lists ${one ? "it" : "them"}: call ${retry} ${one ? "it" : "them"}${g.not_sent_reason === "request_failed" ? "" : " after a minute"}.`);
|
|
281
|
+
out.push(`${plural(notSent, "purl was", "purls were")} NOT sent (not_sent_reason ${g.not_sent_reason}: ${g.not_sent_detail}), so ${one ? "it is" : "they are"} not checked and not clean; ${where}, ${readOrder}, and data.not_sent_purls lists ${one ? "it" : "them"} (past the structured result's budget it is left out whole by default, never cut short${positions ? `, and ${one ? "that position is" : "those positions are"} coverage.sent + 1 to coverage.distinct_purls; with full: true it is always there` : ": the same call with full: true always returns it"}): call ${retry} ${one ? "it" : "them"}${g.not_sent_reason === "request_failed" ? "" : " after a minute"}.`);
|
|
274
282
|
}
|
|
275
283
|
return out;
|
|
276
284
|
}
|
|
@@ -312,7 +320,7 @@ export function verdictSentences(d, g) {
|
|
|
312
320
|
out.push(`Affected: ${named.join("; ")}${hits.length > NOTE_AFFECTED_MAX ? `; and ${hits.length - NOTE_AFFECTED_MAX} more affected components in data.results` : ""}.`);
|
|
313
321
|
// Said only when the answer carries fixed_in (an API before #2834 sends none).
|
|
314
322
|
if (hits.some((r) => (Array.isArray(field(r, "matches")) ? field(r, "matches") : []).some((m) => isObj(m) && "fixed_in" in m))) {
|
|
315
|
-
out.push("Each match's fixed_in is the
|
|
323
|
+
out.push("Each match's fixed_in is the first version above the component's that no affected interval of the advisory holds; null, with fixed_in_reason, where the advisory records no fix for that version.");
|
|
316
324
|
}
|
|
317
325
|
}
|
|
318
326
|
// #2836: rows matched by the source package their purl's upstream qualifier names.
|
|
@@ -364,10 +372,11 @@ export const Row = z.looseObject({
|
|
|
364
372
|
cve_id: opt(z.string()),
|
|
365
373
|
kev_listed: opt(z.boolean()).describe("Whether the CVE is in CISA's Known Exploited Vulnerabilities catalog, as EchelonGraph's record holds it."),
|
|
366
374
|
epss_score: opt(z.number()).describe("The CVE's EPSS score (FIRST), as EchelonGraph's record holds it; null when it has none."),
|
|
367
|
-
match_reason: opt(z.string()).describe("Why the advisory applies, with the advisory interval the version falls inside: \"[A, B)\" names
|
|
375
|
+
match_reason: opt(z.string()).describe("Why the advisory applies, with the advisory interval the version falls inside: \"[A, B)\" names that interval's fixed bound B (another interval of the advisory can still hold B; fixed_in is the version past them all), \"[A, B]\" the last affected version B. On an upstream row it ends by naming the source package and the purl name."),
|
|
368
376
|
interval: opt(z.looseObject({ introduced: opt(z.string()), fixed: opt(z.string()), last_affected: opt(z.string()) })).describe("The advisory interval that holds this component's version (fixed is exclusive, last_affected inclusive); absent when no interval holds it."),
|
|
369
|
-
fixed_in: opt(z.string()).describe("The fixed bound of the
|
|
377
|
+
fixed_in: opt(z.string()).describe("The lowest version above this component's that no affected interval of the CVE's record for the package holds, always above it: the fixed bound of the interval that holds the component's version, moved past any overlapping interval that still holds that bound (fixed_in_detail then names it). null when no such version is on record (an interval on the way records no fix: it ends at last_affected, or has no end) or no interval holds the version, with fixed_in_reason saying which."),
|
|
370
378
|
fixed_in_reason: opt(z.string()).describe("Why fixed_in is null; absent when fixed_in is a version."),
|
|
379
|
+
fixed_in_detail: opt(z.string()).describe("Set when fixed_in is not the fixed bound of the interval that holds the component's version: the overlapping intervals of the same record it moved past, which still hold that bound."),
|
|
371
380
|
}))),
|
|
372
381
|
count: opt(z.number()),
|
|
373
382
|
advisories_considered: opt(z.number()),
|
|
@@ -376,6 +385,17 @@ export const Row = z.looseObject({
|
|
|
376
385
|
undetermined: opt(z.array(z.looseObject({ cve_id: opt(z.string()) }))),
|
|
377
386
|
capped: opt(z.boolean()),
|
|
378
387
|
candidates_capped: opt(z.boolean()),
|
|
388
|
+
// #3132: set by this server's structured cut (check_sbom.ts SBOM_ROW_LEVELS), never by the API.
|
|
389
|
+
matches_truncated: z
|
|
390
|
+
.strictObject({
|
|
391
|
+
total: z.number().int().min(0),
|
|
392
|
+
returned: z.number().int().min(0),
|
|
393
|
+
kev_listed_cve_ids: z.array(z.string()).optional(),
|
|
394
|
+
ransomware_cve_ids: z.array(z.string()).optional(),
|
|
395
|
+
fixed_in_versions: z.array(z.string()).optional(),
|
|
396
|
+
})
|
|
397
|
+
.optional()
|
|
398
|
+
.describe("Present only on a row whose matches the structured result's cut (DATA CUT) shortened or left out: total, the row's matches in the answer; returned, the ones data keeps (its first, highest effective_score first); and, while they fit, of all its matches, the CVE IDs KEV-listed (kev_listed_cve_ids) or used in ransomware (ransomware_cve_ids), and the distinct fixed_in versions (fixed_in_versions). The same call with full: true returns every match."),
|
|
379
399
|
});
|
|
380
400
|
// The batch route's answer, as data carries it: its fields, merged over the batches.
|
|
381
401
|
export const batchDataShape = {
|