echelongraph-mcp 2.7.2 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +124 -0
- package/README.md +146 -432
- package/dist/fixStatus.js +28 -0
- package/dist/index.js +225 -29
- package/dist/prompts.js +55 -31
- package/dist/requiredText.js +1 -1
- package/dist/resources.js +32 -6
- package/dist/textBudget.js +328 -16
- package/dist/tools/check_affected.js +41 -11
- package/dist/tools/check_sbom.js +47 -8
- package/dist/tools/cve_intel.js +15 -6
- package/dist/tools/cve_remediation.js +156 -16
- package/dist/tools/epss_history.js +6 -3
- package/dist/tools/get_cwe.js +7 -2
- package/dist/tools/kev_recent.js +18 -2
- package/dist/tools/match_batch.js +10 -6
- package/dist/tools/scan_manifest.js +20 -7
- package/dist/tools/vendor_advisories.js +34 -7
- 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 +163 -0
- package/package.json +2 -1
- package/server.json +2 -2
package/dist/tools/cve_intel.js
CHANGED
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
// the rows where fixed_version is not the whole answer.
|
|
42
42
|
import * as z from "zod";
|
|
43
43
|
import { ANNOTATIONS, CVE_ID, NOT_A_FINDING, SHOWN_BASE, api, badInput, boolAt, checked, crashed, envelopeSchema, failed, failure, field, numAt, okHead, opt, strAt, succeeded, } from "../index.js";
|
|
44
|
-
import { TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
|
|
44
|
+
import { answering, FULL_ARG, TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
|
|
45
45
|
const TOOL = "cve_intel";
|
|
46
46
|
// The sections relayed, in the answer's order, and the keys each failed_sections name covers.
|
|
47
47
|
const LISTS = ["cwes", "exploits", "affected_packages", "fixed_versions", "timeline"];
|
|
@@ -75,11 +75,16 @@ const API_ROW_LIMIT = { exploits: 10, affected_packages: 50, fixed_versions: 200
|
|
|
75
75
|
const TIMELINE_RELAYED = 20;
|
|
76
76
|
const CVE_INTEL_METHOD = "EchelonGraph's per-CVE enrichment (GET /api/v1/public/cves/:id/enrichment). cwes: CWE classifications stored from NVD, GitHub (GHSA) and CVE.org records, each with the source that gave it, named from the MITRE CWE catalog EchelonGraph embeds. exploits: public exploit references from EchelonGraph's cve_exploits records, whose data model holds Metasploit modules (metasploit), Exploit-DB entries (exploit_db), nuclei templates (nuclei_template), GitHub proof-of-concept repositories (github_poc) and vendor proofs of concept (vendor_poc), filled by a curated seed and by one poller per automated source, each run only where it is enabled, so which sources are current depends on the deployment. affected_packages and fixed_versions: package-ecosystem advisory data, each row naming its source. timeline: EchelonGraph's enrichment history for the CVE, newest first.";
|
|
77
77
|
const VERIFIED_STATUS_DESCRIPTION = "verified_status is the label stored with each reference: verified for a Metasploit module, for an Exploit-DB entry Exploit-DB marks verified, and for curated seed rows marked so; reported for a public artefact nothing has confirmed works (nuclei templates, GitHub proofs of concept, unverified Exploit-DB entries); unconfirmed where a curated row says so. It is a label from the source, not a guarantee that the exploit works against a given system.";
|
|
78
|
-
const FIXED_BRANCHES_DESCRIPTION = 'fixed_branches lists each affected range of the package on record: introduced, and fixed (the range\'s fix) or last_affected (its last affected version, where no fix is on record), each null when absent, with advisory_id (the OSV record that published the range) and source (the loader\'s label for that record), both null on a row loaded before they were stored. Ranges with an advisory_id are in the order their record lists them (records in id order); ranges without one are oldest first. To pick the range for an installed version, compare in the ecosystem\'s version order (release numbers part by part, as numbers: 2.4 is below 2.13.0): the version is in a range when it is at or above introduced ("0" is the first version) and below fixed, or at or below last_affected; that range\'s fixed
|
|
78
|
+
const FIXED_BRANCHES_DESCRIPTION = 'fixed_branches lists each affected range of the package on record: introduced, and fixed (the range\'s fix) or last_affected (its last affected version, where no fix is on record), each null when absent, with advisory_id (the OSV record that published the range) and source (the loader\'s label for that record), both null on a row loaded before they were stored. Ranges with an advisory_id are in the order their record lists them (records in id order); ranges without one are oldest first. To pick the range for an installed version, compare in the ecosystem\'s version order (release numbers part by part, as numbers: 2.4 is below 2.13.0): the version is in a range when it is at or above introduced ("0" is the first version) and below fixed, or at or below last_affected; its fix is that range\'s fixed, or, while another range still holds that, the other range\'s fixed (none past a range without one); a version in no range is outside every range on record. fixed_branches is null where the package\'s ranges were never loaded, and [] where no version range is on record (the advisory gives none, only commit ranges, or more than are stored), which is not a finding that no fix exists: fixed_version can still name one. fixed_version is one range\'s fix, kept for compatibility (one per package: the range the loader kept, the advisory\'s last as a rule), not the fix for every affected range: for CVE-2021-44228, its advisory gives log4j-core three ranges, and fixed_version 2.12.2 is the fix for the one from 2.4, while the range holding 2.14.1 (from 2.13.0) is fixed in 2.15.0.';
|
|
79
79
|
// #2876: the exploit dates are named for what they are. Within the 100 characters of headroom
|
|
80
80
|
// under the 2048-character client cut (prompts-resources.test.mjs), the room came from tighter
|
|
81
81
|
// glosses and from the input hint, which inputSchema's cve_id description carries.
|
|
82
|
-
|
|
82
|
+
// #3013's review: in #3074's first cut the fixed_branches sentence still said a range's fixed is
|
|
83
|
+
// the fix for a version it holds, the rule that gave lodash 4.17.15 under CVE-2026-4800 the
|
|
84
|
+
// still-affected 4.17.21 (ranges [0, 4.17.21) and [4.0.0, 4.18.0); the fix is 4.18.0). It now
|
|
85
|
+
// states the walk FIXED_BRANCHES_DESCRIPTION and branchSentences state, in fewer characters than
|
|
86
|
+
// the old rule (a range's bounds folded into the list of its fields), so the headroom holds.
|
|
87
|
+
export const CVE_INTEL_DESCRIPTION = `Weakness, public exploit code, affected packages and fixed versions for one CVE, from EchelonGraph's per-CVE enrichment. Returns cwes (each cwe_id with name and source), exploits (each with kind, source_name, source_url, source_published_at (the source's date, null if none), eg_first_seen_at (when EchelonGraph recorded it) and verified_status; at most 10, verified first) with exploits_total, exploits_capped, exploits_by_kind and exploits_by_status, affected_packages (ecosystem, package_name, version_range, fixed_version, fixed_branches), fixed_versions (each fixed version with the vulnerable_range it fixes) and timeline (the newest enrichment-history rows, with timeline_total). fixed_branches lists each affected range on record (from introduced ("0" is the first version) to below fixed, or through last_affected, with advisory_id (its OSV record) and source); in the ecosystem's version order, an affected version's fix is the first fixed above it that no range of its row holds (none past a range with no fixed). fixed_version is one range's fix, kept for compatibility, not the fix for every affected range. fixed_branches [] is not a finding that no fix exists. verified_status is the source's label, not a guarantee the exploit works on a given system. An empty exploits list is not evidence that no public exploit exists: it covers only the sources EchelonGraph ingests, and which are polled depends on the deployment. A section the API could not read is named in coverage.sections_failed and left out of data, never relayed as an empty list. Its structured result carries notes, with data, which the first text block holds whole up to 30,000 characters; its coverage names the sections relayed, failed and left out. ${TEXT_BUDGET_DESCRIPTION}`;
|
|
83
88
|
// ── outputSchema ──
|
|
84
89
|
const Rows = (shape) => z.array(z.looseObject(shape));
|
|
85
90
|
const CountMap = z.record(z.string(), z.number());
|
|
@@ -238,7 +243,7 @@ function branchSentences(rows) {
|
|
|
238
243
|
});
|
|
239
244
|
const few = (xs) => (xs.length <= 3 ? listed(xs) : `${xs.slice(0, 3).join(", ")} and ${xs.length - 3} more`);
|
|
240
245
|
if (multi.length) {
|
|
241
|
-
out.push(`${multi.length} affected_packages row${multi.length === 1 ? " lists" : "s list"} more than one affected range in fixed_branches (${few(multi)}): there, fixed_version is one range's fix, and an installed version's fix is the fixed of the range that holds it.`);
|
|
246
|
+
out.push(`${multi.length} affected_packages row${multi.length === 1 ? " lists" : "s list"} more than one affected range in fixed_branches (${few(multi)}): there, fixed_version is one range's fix, and an installed version's fix is the first fixed above it that no range of the row holds: the fixed of the range that holds it, or, while another range still holds that version, that range's fixed (none where a range on the way has no fixed).`);
|
|
242
247
|
}
|
|
243
248
|
if (unknown.length) {
|
|
244
249
|
out.push(`${unknown.length} affected_packages row${unknown.length === 1 ? " carries" : "s carry"} fixed_branches null (${few(unknown)}): ${unknown.length === 1 ? "its" : "their"} ranges are not on record, so fixed_version, one range's fix, is all the answer holds, and it need not be the fix for a given installed version.`);
|
|
@@ -345,13 +350,17 @@ export async function cveIntel(cve_id) {
|
|
|
345
350
|
return crashed(TOOL, e);
|
|
346
351
|
}
|
|
347
352
|
}
|
|
353
|
+
// #3013: the structured result's cut. CVE-2021-44228's is 12,877 characters, within the budget; past
|
|
354
|
+
// it the newest enrichment-history rows go first, then the affected packages and fixed versions,
|
|
355
|
+
// each to at most the same number of its first entries, then any other list at the top of data.
|
|
356
|
+
const CVE_INTEL_DATA = { steps: [{ lists: ["timeline"] }, { lists: ["affected_packages", "fixed_versions"] }] };
|
|
348
357
|
export function registerCveIntel(server) {
|
|
349
358
|
const { output } = schemasOnce();
|
|
350
359
|
server.registerTool("cve_intel", {
|
|
351
360
|
title: "CVE weakness, exploits and packages",
|
|
352
361
|
description: CVE_INTEL_DESCRIPTION,
|
|
353
|
-
inputSchema: z.object({ cve_id: z.string().describe("a CVE ID, e.g. CVE-2021-44228") }),
|
|
362
|
+
inputSchema: z.object({ cve_id: z.string().describe("a CVE ID, e.g. CVE-2021-44228"), full: FULL_ARG }),
|
|
354
363
|
outputSchema: output,
|
|
355
364
|
annotations: ANNOTATIONS,
|
|
356
|
-
}, async ({ cve_id }) => checked(TOOL, output, await cveIntel(cve_id)));
|
|
365
|
+
}, async ({ cve_id, full }) => checked(TOOL, output, await answering(full, CVE_INTEL_DATA, () => cveIntel(cve_id))));
|
|
357
366
|
}
|
|
@@ -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()),
|
|
@@ -103,6 +139,10 @@ function schemas() {
|
|
|
103
139
|
vendor_advisory_limit: z.number().nullable(),
|
|
104
140
|
item_text_cap: z.number().nullable(),
|
|
105
141
|
items_per_advisory_cap: z.number().nullable(),
|
|
142
|
+
installed_asked: z
|
|
143
|
+
.strictObject({ ecosystem: z.string(), package: z.string(), version: z.string() })
|
|
144
|
+
.nullable()
|
|
145
|
+
.describe("The installed version this call sent (in request headers), as given; null when none."),
|
|
106
146
|
});
|
|
107
147
|
return { output: envelopeSchema({ data, coverage, freshness: null }) };
|
|
108
148
|
}
|
|
@@ -115,7 +155,42 @@ const CUT = {
|
|
|
115
155
|
{ 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
156
|
{ keep: ["vendor", "vendor_advisory_id", "remediation_state", "remediation_kinds", ["items", ["kind", "url"]]], clip: 2048, cap: 8 },
|
|
117
157
|
],
|
|
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.",
|
|
158
|
+
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.",
|
|
159
|
+
};
|
|
160
|
+
// #3013: the structured result's cut. CVE-2021-44228's answer is 75,963 characters of structured
|
|
161
|
+
// result, 61,248 of them its 20 vendor_remediations, whose items carry the vendor's text (about 550
|
|
162
|
+
// characters an item, the payload) and its product_ids (about 550, the vendor's own identifiers).
|
|
163
|
+
// Before any advisory leaves, every advisory's items are thinned: product_ids first, keeping each
|
|
164
|
+
// text; then the texts, keeping each item's kind, source_category, url and fixed_build, so every
|
|
165
|
+
// advisory stays named with its state, kinds and links (get_vendor_advisory returns one whole).
|
|
166
|
+
// Only then do advisories leave from the end, then the patches, then any other list at the top of
|
|
167
|
+
// data. fix_status and fixed_in (#2841) are not lists, and installed is an object, so none of them
|
|
168
|
+
// is ever cut.
|
|
169
|
+
//
|
|
170
|
+
// #3013's review: the second level leaves every item's text out of data, and the text is the
|
|
171
|
+
// vendor's step itself (a workaround's or mitigation's wording), which the triage_cve and
|
|
172
|
+
// sbom_review prompts have the model quote. No string is shortened (the cut's rule), so the note
|
|
173
|
+
// says the text is gone and where each one is: get_vendor_advisory, by the advisory's vendor and
|
|
174
|
+
// vendor_advisory_id, returns that advisory's remediations with their texts, and the same call
|
|
175
|
+
// with full: true returns this data whole; the prompts send the model there when it is cut.
|
|
176
|
+
const ADVISORY_FIELDS = ["vendor", "vendor_display_name", "vendor_advisory_id", "vendor_published_at", "withdrawn", "remediation_state", "remediation_kinds", "items_total"];
|
|
177
|
+
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.";
|
|
178
|
+
const CVE_REMEDIATION_DATA = {
|
|
179
|
+
steps: [
|
|
180
|
+
{
|
|
181
|
+
lists: ["vendor_remediations"],
|
|
182
|
+
thin: [
|
|
183
|
+
[...ADVISORY_FIELDS, ["items", ["kind", "source_category", "source_subcategory", "text", "text_truncated", "text_chars", "url", "fixed_build", "product_count"]]],
|
|
184
|
+
[...ADVISORY_FIELDS, ["items", ["kind", "source_category", "url", "fixed_build"]]],
|
|
185
|
+
],
|
|
186
|
+
},
|
|
187
|
+
{ lists: ["patches"] },
|
|
188
|
+
],
|
|
189
|
+
// Said where the advisories kept are thinned to a level whose items keep no text.
|
|
190
|
+
page: (_t, _d, at) => {
|
|
191
|
+
const items = at.keeps.vendor_remediations?.find((x) => typeof x !== "string" && x[0] === "items");
|
|
192
|
+
return items && !items[1].includes("text") ? ITEM_TEXT_ELSEWHERE : "";
|
|
193
|
+
},
|
|
119
194
|
};
|
|
120
195
|
const listed = (xs) => (xs.length <= 1 ? (xs[0] ?? "") : `${xs.slice(0, -1).join(", ")} and ${xs.at(-1)}`);
|
|
121
196
|
const arr = (v) => (Array.isArray(v) ? v : null);
|
|
@@ -162,18 +237,64 @@ function branchSentence(rows) {
|
|
|
162
237
|
fixed++;
|
|
163
238
|
}
|
|
164
239
|
}
|
|
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.`;
|
|
240
|
+
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.`;
|
|
241
|
+
}
|
|
242
|
+
// fix_status's sentence: what the API's rule says, relayed. Nothing about the fix is inferred here.
|
|
243
|
+
function fixStatusSentence(cve, fs) {
|
|
244
|
+
if (fs === null || fs === undefined)
|
|
245
|
+
return null;
|
|
246
|
+
const state = strAt(fs, "state");
|
|
247
|
+
if (!state)
|
|
248
|
+
return null;
|
|
249
|
+
const basis = strAt(fs, "basis");
|
|
250
|
+
const unread = Array.isArray(field(fs, "unread_inputs")) ? field(fs, "unread_inputs").filter((x) => typeof x === "string") : [];
|
|
251
|
+
const why = state === "none_confirmed" ? ", which is not a finding that no fix exists" : "";
|
|
252
|
+
const notRead = unread.length ? `; it rests without ${listed(unread)}, which the API could not read (unread_inputs)` : "";
|
|
253
|
+
return `fix_status for ${cve} is ${state}${basis ? ` (basis ${basis})` : ""}, the API's fix-status rule${why}${notRead}.`;
|
|
166
254
|
}
|
|
167
|
-
|
|
168
|
-
|
|
255
|
+
// The sentence on the installed version asked: what the API answered for it, relayed.
|
|
256
|
+
function installedSentence(asked, d) {
|
|
257
|
+
const who = `${asked.ecosystem} ${asked.package} ${asked.version}`;
|
|
258
|
+
if (!("fixed_in" in d)) {
|
|
259
|
+
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.`;
|
|
260
|
+
}
|
|
261
|
+
const inst = field(d, "installed");
|
|
262
|
+
const verdict = strAt(inst, "verdict");
|
|
263
|
+
const fixedIn = strAt(d, "fixed_in");
|
|
264
|
+
if (fixedIn) {
|
|
265
|
+
// #3074: fixed_in moved past an overlapping range, so it is not the holding range's bound.
|
|
266
|
+
const detail = strAt(d, "fixed_in_detail");
|
|
267
|
+
if (detail) {
|
|
268
|
+
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.`;
|
|
269
|
+
}
|
|
270
|
+
const lo = strAt(field(inst, "interval"), "introduced");
|
|
271
|
+
const range = lo ? ` [${lo}, ${fixedIn})` : "";
|
|
272
|
+
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.`;
|
|
273
|
+
}
|
|
274
|
+
const reason = strAt(d, "fixed_in_reason") ?? "the answer gives no fixed_in_reason";
|
|
275
|
+
return `For the installed version asked (${who}), installed.verdict is ${verdict ?? "not given"} and fixed_in is null: ${reason}.`;
|
|
276
|
+
}
|
|
277
|
+
export async function cveRemediation(args) {
|
|
278
|
+
const id = (args.cve_id ?? "").trim();
|
|
169
279
|
if (!id)
|
|
170
280
|
return badInput(TOOL, "cve_id is required");
|
|
171
281
|
if (!CVE_ID.test(id))
|
|
172
282
|
return badInput(TOOL, `cve_id ${JSON.stringify(id.slice(0, 32))} is not a CVE id (expected the form CVE-2021-44228)`);
|
|
283
|
+
// The installed version: all three or none.
|
|
284
|
+
const given = { ecosystem: (args.ecosystem ?? "").trim(), package: (args.package ?? "").trim(), version: (args.version ?? "").trim() };
|
|
285
|
+
const present = INSTALLED.filter((k) => given[k] !== "");
|
|
286
|
+
const missing = INSTALLED.filter((k) => given[k] === "");
|
|
287
|
+
if (present.length > 0 && missing.length > 0) {
|
|
288
|
+
return badInput(TOOL, `ecosystem, package and version go together, all three or none: ${listed([...missing])} ${missing.length === 1 ? "is" : "are"} missing (given: ${listed([...present])})`);
|
|
289
|
+
}
|
|
290
|
+
const asked = present.length === INSTALLED.length ? given : null;
|
|
173
291
|
const cve = id.toUpperCase();
|
|
174
292
|
const path = `/api/v1/public/cves/${encodeURIComponent(cve)}/remediation`;
|
|
293
|
+
// #1983: percent-encoded, as the backend's matchHeaderValue decodes them; never in the URL.
|
|
294
|
+
const enc = encodeURIComponent;
|
|
295
|
+
const headers = asked ? { [MATCH_HEADERS.ecosystem]: enc(asked.ecosystem), [MATCH_HEADERS.package]: enc(asked.package), [MATCH_HEADERS.version]: enc(asked.version) } : undefined;
|
|
175
296
|
try {
|
|
176
|
-
const r = await api(path);
|
|
297
|
+
const r = await api(path, headers ? { headers } : undefined);
|
|
177
298
|
if (!r.ok)
|
|
178
299
|
return failed(TOOL, r);
|
|
179
300
|
const d = r.data;
|
|
@@ -197,11 +318,13 @@ export async function cveRemediation(cve_id) {
|
|
|
197
318
|
vendor_advisory_limit: typeof field(cov, "vendor_advisory_limit") === "number" ? field(cov, "vendor_advisory_limit") : null,
|
|
198
319
|
item_text_cap: typeof field(cov, "item_text_cap") === "number" ? field(cov, "item_text_cap") : null,
|
|
199
320
|
items_per_advisory_cap: typeof field(cov, "items_per_advisory_cap") === "number" ? field(cov, "items_per_advisory_cap") : null,
|
|
321
|
+
installed_asked: asked,
|
|
200
322
|
};
|
|
201
323
|
const head = okHead(TOOL, r.status);
|
|
202
324
|
if (state === "not_assessed") {
|
|
203
325
|
const reason = strAt(d, "not_assessed_reason") ?? "the API gives no reason";
|
|
204
|
-
|
|
326
|
+
const inst = asked ? ` ${installedSentence(asked, d)}` : "";
|
|
327
|
+
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
328
|
state: "not_assessed",
|
|
206
329
|
measured_at: null,
|
|
207
330
|
method: METHOD,
|
|
@@ -211,6 +334,11 @@ export async function cveRemediation(cve_id) {
|
|
|
211
334
|
});
|
|
212
335
|
}
|
|
213
336
|
const said = [];
|
|
337
|
+
const fixSaid = fixStatusSentence(cve, field(d, "fix_status"));
|
|
338
|
+
if (fixSaid)
|
|
339
|
+
said.push(fixSaid);
|
|
340
|
+
if (asked)
|
|
341
|
+
said.push(installedSentence(asked, d));
|
|
214
342
|
const cisa = field(d, "cisa");
|
|
215
343
|
const action = strAt(cisa, "required_action");
|
|
216
344
|
if (field(cisa, "kev_listed") === true) {
|
|
@@ -225,8 +353,12 @@ export async function cveRemediation(cve_id) {
|
|
|
225
353
|
if (vendors && !(reported ?? []).includes("vendor_remediations"))
|
|
226
354
|
said.push(...vendorSentences(cve, vendors));
|
|
227
355
|
const refs = arr(field(d, "fix_references"));
|
|
228
|
-
|
|
229
|
-
|
|
356
|
+
// #2841: the API keeps both spellings, NVD's (Patch, Mitigation) and a CNA's (patch, mitigation).
|
|
357
|
+
if (refs) {
|
|
358
|
+
said.push(refs.length
|
|
359
|
+
? `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.`
|
|
360
|
+
: "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).");
|
|
361
|
+
}
|
|
230
362
|
if (reported === null)
|
|
231
363
|
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
364
|
else if (reported.length)
|
|
@@ -250,8 +382,16 @@ export function registerCveRemediation(server) {
|
|
|
250
382
|
server.registerTool(TOOL, {
|
|
251
383
|
title: "How one CVE is fixed",
|
|
252
384
|
description: CVE_REMEDIATION_DESCRIPTION,
|
|
253
|
-
inputSchema:
|
|
385
|
+
inputSchema: CVE_REMEDIATION_INPUT,
|
|
254
386
|
outputSchema: output,
|
|
255
387
|
annotations: ANNOTATIONS,
|
|
256
|
-
}, async (
|
|
388
|
+
}, async (args) => checked(TOOL, output, await answering(args.full, CVE_REMEDIATION_DATA, () => cveRemediation(args))));
|
|
257
389
|
}
|
|
390
|
+
// The installed version is optional and all-or-none (cveRemediation refuses a partial set).
|
|
391
|
+
export const CVE_REMEDIATION_INPUT = z.object({
|
|
392
|
+
cve_id: z.string().describe("a CVE ID, e.g. CVE-2021-44228"),
|
|
393
|
+
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."),
|
|
394
|
+
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."),
|
|
395
|
+
version: z.string().optional().describe("Installed version, with ecosystem and package: the version installed, such as 2.14.1. Sent in a request header."),
|
|
396
|
+
full: FULL_ARG,
|
|
397
|
+
});
|
|
@@ -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.";
|
|
@@ -213,8 +213,11 @@ export function registerEPSSHistory(server, kit) {
|
|
|
213
213
|
server.registerTool(TOOL, {
|
|
214
214
|
title: "EPSS change history for one CVE",
|
|
215
215
|
description: EPSS_HISTORY_DESCRIPTION,
|
|
216
|
-
inputSchema: z.object({ cve_id: kit.cveIdArg }),
|
|
216
|
+
inputSchema: z.object({ cve_id: kit.cveIdArg, full: FULL_ARG }),
|
|
217
217
|
outputSchema: output,
|
|
218
218
|
annotations: kit.annotations,
|
|
219
|
-
},
|
|
219
|
+
},
|
|
220
|
+
// #3013: a few points a CVE, the API's own cap on them, 2,508 characters for CVE-2021-44228:
|
|
221
|
+
// no list is named, so only a list at the top level of data would be cut past the budget.
|
|
222
|
+
async ({ cve_id, full }) => kit.checked(TOOL, output, await answering(full, NO_DATA_CUT, () => epssHistory(kit, cve_id))));
|
|
220
223
|
}
|
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);
|
|
@@ -221,8 +236,9 @@ export function registerKevRecent(server, d) {
|
|
|
221
236
|
vendor: z.string().optional().describe("CISA vendorProject, an exact case-insensitive match, e.g. 'Microsoft'"),
|
|
222
237
|
limit: z.number().int().min(1).max(KEV_RECENT_MAX_LIMIT).optional().describe("page size (default 50, max 200)"),
|
|
223
238
|
cursor: z.string().optional().describe("next_cursor from the previous page"),
|
|
239
|
+
full: FULL_ARG,
|
|
224
240
|
}),
|
|
225
241
|
outputSchema: output,
|
|
226
242
|
annotations: d.annotations,
|
|
227
|
-
}, async (a) => d.checked(TOOL, output, await kevRecent(d, a)));
|
|
243
|
+
}, async (a) => d.checked(TOOL, output, await answering(a.full, kevData(a), () => kevRecent(d, a))));
|
|
228
244
|
}
|
|
@@ -256,8 +256,11 @@ 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
|
-
//
|
|
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), and gives it back
|
|
261
|
+
// only the first entries the room the rows leave holds, so the sentence says it lists them whole
|
|
262
|
+
// with full: true. `retry` is the tool and argument to send them to ("check_sbom
|
|
263
|
+
// again with purls set to").
|
|
261
264
|
export function sendingSentences(g, distinct, readOrder, retry) {
|
|
262
265
|
const out = [];
|
|
263
266
|
if (g.batches > 1) {
|
|
@@ -270,7 +273,7 @@ export function sendingSentences(g, distinct, readOrder, retry) {
|
|
|
270
273
|
if (notSent > 0) {
|
|
271
274
|
const one = notSent === 1;
|
|
272
275
|
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"}.`);
|
|
276
|
+
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"} whole with full: true (past the structured result's budget, that list can be cut by default): call ${retry} ${one ? "it" : "them"}${g.not_sent_reason === "request_failed" ? "" : " after a minute"}.`);
|
|
274
277
|
}
|
|
275
278
|
return out;
|
|
276
279
|
}
|
|
@@ -312,7 +315,7 @@ export function verdictSentences(d, g) {
|
|
|
312
315
|
out.push(`Affected: ${named.join("; ")}${hits.length > NOTE_AFFECTED_MAX ? `; and ${hits.length - NOTE_AFFECTED_MAX} more affected components in data.results` : ""}.`);
|
|
313
316
|
// Said only when the answer carries fixed_in (an API before #2834 sends none).
|
|
314
317
|
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
|
|
318
|
+
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
319
|
}
|
|
317
320
|
}
|
|
318
321
|
// #2836: rows matched by the source package their purl's upstream qualifier names.
|
|
@@ -364,10 +367,11 @@ export const Row = z.looseObject({
|
|
|
364
367
|
cve_id: opt(z.string()),
|
|
365
368
|
kev_listed: opt(z.boolean()).describe("Whether the CVE is in CISA's Known Exploited Vulnerabilities catalog, as EchelonGraph's record holds it."),
|
|
366
369
|
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
|
|
370
|
+
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
371
|
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
|
|
372
|
+
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
373
|
fixed_in_reason: opt(z.string()).describe("Why fixed_in is null; absent when fixed_in is a version."),
|
|
374
|
+
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
375
|
}))),
|
|
372
376
|
count: opt(z.number()),
|
|
373
377
|
advisories_considered: opt(z.number()),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as z from "zod";
|
|
2
|
-
import { TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
|
|
3
|
-
import { sbomText } from "./check_sbom.js";
|
|
2
|
+
import { answering, FULL_ARG, TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
|
|
3
|
+
import { sbomData, sbomText } from "./check_sbom.js";
|
|
4
4
|
import { FORMAT_NAMES, FORMATS, NOT_CHECKED_REASONS, detect, readFile } from "./manifests/index.js";
|
|
5
5
|
import { CLEAN_NOTE, DISTRO_NOTE, FRESHNESS_NOTE, MAX_PURLS, MEASURED_AT_NOTE, METHOD, batchDataShape, dedupe, field, plural, purlsRefusal, sendBatches, sendingCoverage, sendingCoverageShape, sendingSentences, verdictSentences, } from "./match_batch.js";
|
|
6
6
|
export const SCAN_MANIFEST = "scan_manifest";
|
|
@@ -37,6 +37,9 @@ export function manifestText(sent) {
|
|
|
37
37
|
page: (shown) => `To read every row in the text, pass files that hold ${shown} or fewer distinct purls per call.`,
|
|
38
38
|
};
|
|
39
39
|
}
|
|
40
|
+
// #3013: the structured result's cut, check_sbom's (sbomData: the lists beside the rows, then every
|
|
41
|
+
// row thinned, then rows leaving, clean ones first) with not_checked beside not_sent_purls.
|
|
42
|
+
const MANIFEST_DATA = sbomData(["not_sent_purls", "not_checked"], (k) => `To read every row in data, pass files that hold ${k} or fewer distinct purls per call.`);
|
|
40
43
|
export function readFiles(a) {
|
|
41
44
|
if (!Array.isArray(a.files))
|
|
42
45
|
return "files must be a list of {filename, content}";
|
|
@@ -79,8 +82,13 @@ export function readFiles(a) {
|
|
|
79
82
|
out.skipped = r.skipped;
|
|
80
83
|
out.notes = r.notes;
|
|
81
84
|
out.replaced = r.replaced ?? [];
|
|
82
|
-
|
|
83
|
-
|
|
85
|
+
// One by one, never push(...list): a 5,000,000-character requirements.txt of unpinned lines
|
|
86
|
+
// is some 170,000 entries, and, spread into push() as that many arguments, they overflowed
|
|
87
|
+
// V8's stack ("Maximum call stack size exceeded", #3013's review): the call crashed.
|
|
88
|
+
for (const p of r.pinned)
|
|
89
|
+
all.push(p.purl);
|
|
90
|
+
for (const n of r.not_checked)
|
|
91
|
+
notChecked.push({ file: f.filename, ...n });
|
|
84
92
|
}
|
|
85
93
|
if (files.every((f) => !f.read))
|
|
86
94
|
return `no file could be read: ${files.map((f) => `${f.filename}: ${f.refused}`).join("; ")}`;
|
|
@@ -112,7 +120,11 @@ function noteFor(head, x, d, g) {
|
|
|
112
120
|
const nc = x.not_checked.length;
|
|
113
121
|
if (nc) {
|
|
114
122
|
const parts = NOT_CHECKED_REASONS.filter((r) => by[r] > 0).map((r) => `${r} ${by[r]}`);
|
|
115
|
-
|
|
123
|
+
// #3013's review: the note is written before the structured result is cut, and a DATA CUT can
|
|
124
|
+
// cut data.not_checked (it goes before any row), so this says the list is whole with full:
|
|
125
|
+
// true, as the sentence on data.not_sent_purls does (match_batch.ts sendingSentences); the
|
|
126
|
+
// counts above are coverage.not_checked_by_reason's, which no cut touches.
|
|
127
|
+
out.push(`${plural(nc, "entry was", "entries were")} NOT checked and ${nc === 1 ? "is" : "are"} not clean (${parts.join(", ")}): data.not_checked lists each with its file, line and reason, whole with full: true (past the structured result's budget, that list can be cut by default), and none was sent to the API.`);
|
|
116
128
|
}
|
|
117
129
|
out.push(...sendingSentences(g, x.purls.length, READ_ORDER, "check_sbom with purls set to"));
|
|
118
130
|
if (d === null)
|
|
@@ -247,11 +259,12 @@ export function registerScanManifest(server, deps) {
|
|
|
247
259
|
format: formatEnum.optional().describe("the format to read the file as, when its name does not say"),
|
|
248
260
|
}))
|
|
249
261
|
.describe("1 to 20 files, up to 5,000,000 characters of content in all; read by this MCP server into purls, and only the purls are sent to the API; over the hosted endpoint (mcp.echelongraph.io) the files are the request body"),
|
|
262
|
+
full: FULL_ARG,
|
|
250
263
|
}),
|
|
251
264
|
outputSchema: output,
|
|
252
265
|
annotations: deps.annotations,
|
|
253
|
-
}, async (a, ctx) => {
|
|
266
|
+
}, async (a, ctx) => answering(a.full, MANIFEST_DATA, async () => {
|
|
254
267
|
const run = scanManifest({ ...deps, signal: ctx.mcpReq.signal }, a);
|
|
255
268
|
return deps.checked(SCAN_MANIFEST, output, await (deps.hold ? deps.hold(run) : run));
|
|
256
|
-
});
|
|
269
|
+
}));
|
|
257
270
|
}
|