echelongraph-mcp 3.0.0 → 3.1.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 +695 -2
- package/README.md +85 -74
- package/dist/fixStatus.js +4 -2
- package/dist/http.js +73 -81
- package/dist/httpPolicy.js +213 -11
- package/dist/index.js +516 -52
- package/dist/logLines.js +86 -0
- package/dist/prompts.js +126 -21
- package/dist/readBody.js +140 -0
- package/dist/requiredText.js +38 -3
- package/dist/resources.js +3 -3
- package/dist/runtime.js +66 -1
- package/dist/schemaRefusal.js +138 -0
- package/dist/textBudget.js +1082 -78
- package/dist/tools/check_affected.js +23 -7
- package/dist/tools/check_sbom.js +287 -8
- package/dist/tools/cve_intel.js +163 -8
- package/dist/tools/cve_remediation.js +174 -18
- package/dist/tools/epss_history.js +45 -6
- package/dist/tools/kev_recent.js +4 -1
- package/dist/tools/match_batch.js +78 -15
- package/dist/tools/packageRecords.js +58 -0
- package/dist/tools/scan_manifest.js +26 -7
- package/dist/tools/vendor_advisories.js +40 -8
- package/docs/exposure-radar.md +4 -0
- package/docs/rate-limits.md +6 -3
- package/docs/structured-results.md +15 -3
- package/docs/tools.md +149 -12
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/tools/cve_intel.js
CHANGED
|
@@ -42,6 +42,7 @@
|
|
|
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
44
|
import { answering, FULL_ARG, TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
|
|
45
|
+
import { ONE_PACKAGE_RULE, packagesListedTwice } from "./packageRecords.js";
|
|
45
46
|
const TOOL = "cve_intel";
|
|
46
47
|
// The sections relayed, in the answer's order, and the keys each failed_sections name covers.
|
|
47
48
|
const LISTS = ["cwes", "exploits", "affected_packages", "fixed_versions", "timeline"];
|
|
@@ -73,9 +74,57 @@ const LEFT_OUT = ["vendor_advisories", "patches", "ai", "trending", "historical_
|
|
|
73
74
|
// Per-CVE row limits of the API (core-backend cve/enrichment_store.go): a list this long may be cut.
|
|
74
75
|
const API_ROW_LIMIT = { exploits: 10, affected_packages: 50, fixed_versions: 200, timeline: 100 };
|
|
75
76
|
const TIMELINE_RELAYED = 20;
|
|
76
|
-
const
|
|
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 and checker repositories, 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
|
+
// #3267: the exploit kinds this server classifies, both lists by name, as core-backend's
|
|
79
|
+
// egkev.ExploitCodeKinds lists exploit code ("the one definition of exploit code", #1213, #3038)
|
|
80
|
+
// and every other kind core-backend holds (exploits.Kind* constants) is a detection kind there.
|
|
81
|
+
// Until #3267 this server named only the detection kinds and counted every other kind as exploit
|
|
82
|
+
// code, the inverse of the backend's list: the two failed in opposite directions. core-backend fails
|
|
83
|
+
// closed (egkev/backfill_exploit_poc_available.sql raises on a kind ExploitCodeKinds does not
|
|
84
|
+
// classify); this server failed open, so a kind core-backend added after a release (another scanner
|
|
85
|
+
// or template source) would have reached every client of that release as "N public exploit
|
|
86
|
+
// references", the claim #3039 removed, with nothing erroring. github_checker did exactly that
|
|
87
|
+
// from #3040 (2026-10-12) to #3221. A kind on neither list is now counted as neither: the note
|
|
88
|
+
// names it as a kind this server does not classify (exploitSentences). test/cve_intel.test.mjs holds both
|
|
89
|
+
// lists to core-backend's source in the monorepo, and the kind field's text to the note everywhere.
|
|
90
|
+
const EXPLOIT_CODE_KINDS = ["metasploit", "exploit_db", "github_poc", "vendor_poc"];
|
|
91
|
+
const DETECTION_KINDS = ["nuclei_template", "github_checker"];
|
|
92
|
+
const DETECTION_GLOSS = {
|
|
93
|
+
nuclei_template: "a Nuclei detection template",
|
|
94
|
+
github_checker: "a GitHub repository named or described as a checker or scanner",
|
|
95
|
+
};
|
|
96
|
+
const isExploitCodeKind = (k) => EXPLOIT_CODE_KINDS.includes(k);
|
|
97
|
+
const isDetectionKind = (k) => DETECTION_KINDS.includes(k);
|
|
98
|
+
const orList = (xs) => (xs.length <= 1 ? (xs[0] ?? "") : `${xs.slice(0, -1).join(", ")} or ${xs.at(-1)}`);
|
|
99
|
+
const andList = (xs) => (xs.length <= 1 ? (xs[0] ?? "") : `${xs.slice(0, -1).join(", ")} and ${xs.at(-1)}`);
|
|
100
|
+
// #3267's review: the method and exploits_by_kind name the kinds from the same two lists, so neither
|
|
101
|
+
// can name a kind the note counts otherwise; each kind's words are a Record over its list, so a kind
|
|
102
|
+
// added to a list without them does not compile.
|
|
103
|
+
const CODE_NOUN = {
|
|
104
|
+
metasploit: "Metasploit modules",
|
|
105
|
+
exploit_db: "Exploit-DB entries",
|
|
106
|
+
github_poc: "GitHub proof-of-concept repositories",
|
|
107
|
+
vendor_poc: "vendor proofs of concept",
|
|
108
|
+
};
|
|
109
|
+
const DETECTION_NOUN_METHOD = {
|
|
110
|
+
nuclei_template: "Nuclei detection templates (nuclei_template)",
|
|
111
|
+
github_checker: "GitHub checker repositories (github_checker: GitHub repositories named or described as a checker or scanner)",
|
|
112
|
+
};
|
|
113
|
+
const UNCLASSIFIED_KIND = "A kind on neither list is one this server does not classify: the note counts it as neither.";
|
|
114
|
+
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: EchelonGraph's cve_exploits records, whose data model holds public exploit code, namely ${andList(EXPLOIT_CODE_KINDS.map((k) => `${CODE_NOUN[k]} (${k})`))}, and detection checks, namely ${andList(DETECTION_KINDS.map((k) => DETECTION_NOUN_METHOD[k]))}, which check whether a system is affected and are not necessarily exploit code, 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. ${UNCLASSIFIED_KIND} affected_packages and fixed_versions: package-ecosystem advisory data, each row naming its source. timeline: EchelonGraph's enrichment history for the CVE, newest first.`;
|
|
115
|
+
// #3221: the kinds, split as egkev.ExploitCodeKinds splits them (#3038, #3040) and /pulse shows
|
|
116
|
+
// them: exploit code, and the two detection kinds. Until #3221 the field listed nuclei_template
|
|
117
|
+
// beside the exploit-code kinds, and not github_checker, a kind core-backend's data model has held
|
|
118
|
+
// since #3040 (exploits.KindGitHubChecker). The text says what each kind is, not which rows carry
|
|
119
|
+
// it. #3267: built from the two lists the note counts by, so the field cannot name a kind the note
|
|
120
|
+
// counts otherwise, and it says what the note does with any other kind.
|
|
121
|
+
const EXPLOIT_KIND_DESCRIPTION = `Exploit code: ${orList(EXPLOIT_CODE_KINDS)}. A detection check, which checks whether a system is affected and is not necessarily exploit code: ${orList(DETECTION_KINDS.map((k) => `${k} (${DETECTION_GLOSS[k]})`))}. A kind not named here is one this server does not classify: the note counts it as neither.`;
|
|
122
|
+
const EXPLOITS_BY_KIND_DESCRIPTION = `Every reference on record counted by kind (the values of each exploit's kind), past the 10 listed; the counts under ${andList(DETECTION_KINDS)} are detection checks, not exploit code, those under ${andList(EXPLOIT_CODE_KINDS)} are exploit code, and those under any other kind are of a kind this server does not classify, counted as neither.`;
|
|
78
123
|
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.';
|
|
124
|
+
// #3039: a Nuclei template is a detection check, not necessarily exploit code (core-backend
|
|
125
|
+
// egkev.ExploitCodeKinds, #3038; /pulse words it so since #3022). The head says "exploit code,
|
|
126
|
+
// detection templates" where it said "public exploit code"; the method and the note say what a
|
|
127
|
+
// template is, and count templates apart. The room: "the newest" and "per-CVE" (14 characters).
|
|
79
128
|
// #2876: the exploit dates are named for what they are. Within the 100 characters of headroom
|
|
80
129
|
// under the 2048-character client cut (prompts-resources.test.mjs), the room came from tighter
|
|
81
130
|
// glosses and from the input hint, which inputSchema's cve_id description carries.
|
|
@@ -84,7 +133,10 @@ const FIXED_BRANCHES_DESCRIPTION = 'fixed_branches lists each affected range of
|
|
|
84
133
|
// 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
134
|
// states the walk FIXED_BRANCHES_DESCRIPTION and branchSentences state, in fewer characters than
|
|
86
135
|
// the old rule (a range's bounds folded into the list of its fields), so the headroom holds.
|
|
87
|
-
|
|
136
|
+
// #3221: "detection checks", /pulse's word for Nuclei templates and GitHub checker repositories
|
|
137
|
+
// together (ExploitWatch.tsx DETECTION_CHECKS_HEADING), where the head said "detection templates",
|
|
138
|
+
// which a checker repository is not; 3 characters shorter. The kinds are the outputSchema's.
|
|
139
|
+
export const CVE_INTEL_DESCRIPTION = `Weakness, exploit code, detection checks, 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 (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}`;
|
|
88
140
|
// ── outputSchema ──
|
|
89
141
|
const Rows = (shape) => z.array(z.looseObject(shape));
|
|
90
142
|
const CountMap = z.record(z.string(), z.number());
|
|
@@ -97,7 +149,7 @@ function schemas() {
|
|
|
97
149
|
cve_id: z.string(),
|
|
98
150
|
cwes: Rows({ cwe_id: opt(z.string()), name: opt(z.string()), source: opt(z.string()) }).optional(),
|
|
99
151
|
exploits: Rows({
|
|
100
|
-
kind: opt(z.string()).describe(
|
|
152
|
+
kind: opt(z.string()).describe(EXPLOIT_KIND_DESCRIPTION),
|
|
101
153
|
source_name: opt(z.string()),
|
|
102
154
|
source_url: opt(z.string()),
|
|
103
155
|
description: opt(z.string()),
|
|
@@ -110,7 +162,7 @@ function schemas() {
|
|
|
110
162
|
}).optional(),
|
|
111
163
|
exploits_total: z.number().int().nullable().optional().describe("Every exploit reference on record for the CVE, not only those listed in exploits."),
|
|
112
164
|
exploits_capped: z.boolean().optional().describe("true: exploits lists fewer references than exploits_total."),
|
|
113
|
-
exploits_by_kind: CountMap.optional(),
|
|
165
|
+
exploits_by_kind: CountMap.optional().describe(EXPLOITS_BY_KIND_DESCRIPTION),
|
|
114
166
|
exploits_by_status: CountMap.optional(),
|
|
115
167
|
affected_packages: Rows({
|
|
116
168
|
ecosystem: opt(z.string()),
|
|
@@ -178,6 +230,70 @@ function cweSentence(cve, cwes) {
|
|
|
178
230
|
});
|
|
179
231
|
return `${cve} is classified under ${listed(named)}.`;
|
|
180
232
|
}
|
|
233
|
+
const DETECTION_NOUN = {
|
|
234
|
+
nuclei_template: ["Nuclei detection template", "Nuclei detection templates"],
|
|
235
|
+
github_checker: ["GitHub checker repository", "GitHub checker repositories"],
|
|
236
|
+
};
|
|
237
|
+
// /pulse's DETECTION_TEMPLATE_NOTE and CHECKER_REPOSITORY_NOTE, each lower-cased and without its
|
|
238
|
+
// period, to follow a colon, a semicolon or an opening parenthesis.
|
|
239
|
+
const DETECTION_NOTE = "a detection template checks whether a system is affected; it is not necessarily exploit code";
|
|
240
|
+
const CHECKER_NOTE = "a GitHub repository named or described as a checker or scanner checks whether a system is affected; it is not necessarily exploit code";
|
|
241
|
+
// Both kinds: /pulse's two notes in sequence, as the page prints them, never one merged sentence.
|
|
242
|
+
const BOTH_DETECTION_NOTES = `${DETECTION_NOTE}. ${CHECKER_NOTE[0].toUpperCase()}${CHECKER_NOTE.slice(1)}`;
|
|
243
|
+
const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
|
244
|
+
const detectedTotal = (c) => DETECTION_KINDS.reduce((s, k) => s + c[k], 0);
|
|
245
|
+
// "1 Nuclei detection template", "3 GitHub checker repositories": each detection kind present.
|
|
246
|
+
const detectionParts = (c) => DETECTION_KINDS.filter((k) => c[k] > 0).map((k) => plural(c[k], ...DETECTION_NOUN[k]));
|
|
247
|
+
// What the detection kinds present are: the template's note, the checker's, or both in sequence.
|
|
248
|
+
const detectionNote = (c) => c.nuclei_template > 0 && c.github_checker > 0 ? BOTH_DETECTION_NOTES : c.github_checker > 0 ? CHECKER_NOTE : DETECTION_NOTE;
|
|
249
|
+
// #3267: a kind on neither list. The note names it only when it reads as a kind (a lower-case
|
|
250
|
+
// identifier, as every exploits.Kind* value is), so the note never carries other text the API sent.
|
|
251
|
+
const UNCLASSIFIED_NOUN = ["reference of a kind this server does not classify", "references of a kind this server does not classify"];
|
|
252
|
+
const KIND_TOKEN = /^[a-z][a-z0-9_]{0,63}$/;
|
|
253
|
+
const unknownKindsOf = (kinds) => [...new Set(kinds.filter((k) => k !== undefined && KIND_TOKEN.test(k) && !isExploitCodeKind(k) && !isDetectionKind(k)))].sort();
|
|
254
|
+
const unclassifiedNote = (kinds) => kinds.length === 0
|
|
255
|
+
? "a reference of no kind this server classifies is counted as neither exploit code nor a detection check"
|
|
256
|
+
: kinds.length === 1
|
|
257
|
+
? `the kind ${kinds[0]} is one this server does not classify: it is counted as neither exploit code nor a detection check, and a newer release of this server may classify it`
|
|
258
|
+
: `the kinds ${listed(kinds)} are ones this server does not classify: they are counted as neither exploit code nor a detection check, and a newer release of this server may classify them`;
|
|
259
|
+
const capitalized = (s) => `${s[0].toUpperCase()}${s.slice(1)}`;
|
|
260
|
+
// The listed rows by class.
|
|
261
|
+
function countsIn(rows) {
|
|
262
|
+
const kinds = rows.map((r) => strAt(r, "kind"));
|
|
263
|
+
const detections = Object.fromEntries(DETECTION_KINDS.map((k) => [k, kinds.filter((x) => x === k).length]));
|
|
264
|
+
const code = kinds.filter(isExploitCodeKind).length;
|
|
265
|
+
return { code, detections, unclassified: rows.length - code - detectedTotal(detections), unknownKinds: unknownKindsOf(kinds) };
|
|
266
|
+
}
|
|
267
|
+
// Every reference on record by class: exploits_by_kind's counts where the answer carries the map,
|
|
268
|
+
// the listed rows' where they are all of them, undefined otherwise. With the map, what its
|
|
269
|
+
// exploit-code and detection kinds do not account for of exploits_total is unclassified.
|
|
270
|
+
function kindCounts(d, rows, total) {
|
|
271
|
+
const byKind = field(d, "exploits_by_kind");
|
|
272
|
+
if (byKind !== null && typeof byKind === "object" && !Array.isArray(byKind)) {
|
|
273
|
+
const at = (k) => numAt(d, "exploits_by_kind", k) ?? 0;
|
|
274
|
+
const detections = Object.fromEntries(DETECTION_KINDS.map((k) => [k, at(k)]));
|
|
275
|
+
const code = EXPLOIT_CODE_KINDS.reduce((s, k) => s + at(k), 0);
|
|
276
|
+
return {
|
|
277
|
+
code,
|
|
278
|
+
detections,
|
|
279
|
+
unclassified: Math.max(0, total - code - detectedTotal(detections)),
|
|
280
|
+
unknownKinds: unknownKindsOf(Object.keys(byKind).filter((k) => at(k) > 0)),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
return rows.length === total ? countsIn(rows) : undefined;
|
|
284
|
+
}
|
|
285
|
+
// "1 public exploit reference, 1 Nuclei detection template and 1 reference of a kind this server
|
|
286
|
+
// does not classify": each class present, exploit code first.
|
|
287
|
+
const classParts = (c) => [
|
|
288
|
+
...(c.code > 0 ? [plural(c.code, "public exploit reference", "public exploit references")] : []),
|
|
289
|
+
...detectionParts(c.detections),
|
|
290
|
+
...(c.unclassified > 0 ? [plural(c.unclassified, ...UNCLASSIFIED_NOUN)] : []),
|
|
291
|
+
];
|
|
292
|
+
// What the classes present beside exploit code are: the detection note(s), then the unclassified
|
|
293
|
+
// kind's, each its own sentence.
|
|
294
|
+
const classNotes = (c) => [...(detectedTotal(c.detections) > 0 ? [detectionNote(c.detections)] : []), ...(c.unclassified > 0 ? [unclassifiedNote(c.unknownKinds)] : [])]
|
|
295
|
+
.map((n, i) => (i === 0 ? n : capitalized(n)))
|
|
296
|
+
.join(". ");
|
|
181
297
|
function exploitSentences(cve, d, rows) {
|
|
182
298
|
const total = numAt(d, "exploits_total");
|
|
183
299
|
const verified = numAt(d, "exploits_by_status", "verified");
|
|
@@ -191,7 +307,13 @@ function exploitSentences(cve, d, rows) {
|
|
|
191
307
|
`exploits is empty and the answer carries no exploits_total, so it does not count the references on record for ${cve}; an empty list is not evidence that no public exploit exists.`,
|
|
192
308
|
];
|
|
193
309
|
}
|
|
194
|
-
|
|
310
|
+
const shown = countsIn(rows);
|
|
311
|
+
const what = shown.code === rows.length
|
|
312
|
+
? `${plural(rows.length, "public exploit reference", "public exploit references")}`
|
|
313
|
+
: shown.code === 0 && shown.unclassified === 0
|
|
314
|
+
? `${listed([...detectionParts(shown.detections), "nothing else"])} (${classNotes(shown)})`
|
|
315
|
+
: `${plural(rows.length, "reference", "references")}: ${listed(classParts(shown))} (${classNotes(shown)})`;
|
|
316
|
+
return [`exploits lists ${what} for ${cve}, ${verifiedRows} with verified_status verified; the answer carries no exploits_total, so it does not say how many are on record.${cut}`];
|
|
195
317
|
}
|
|
196
318
|
if (total === 0) {
|
|
197
319
|
return [
|
|
@@ -200,7 +322,23 @@ function exploitSentences(cve, d, rows) {
|
|
|
200
322
|
}
|
|
201
323
|
const v = verified === undefined ? "" : `, ${verified} of them with verified_status verified`;
|
|
202
324
|
const cut = capped === true ? ` exploits_capped is true: exploits lists ${rows.length} of the ${total}, verified first.` : "";
|
|
203
|
-
|
|
325
|
+
const counts = kindCounts(d, rows, total);
|
|
326
|
+
if (counts === undefined) {
|
|
327
|
+
return [
|
|
328
|
+
`EchelonGraph holds ${plural(total, "reference", "references")} for ${cve} (exploits_total)${v}; the answer carries no exploits_by_kind, so it does not say how many are exploit code and how many detection checks, Nuclei detection templates or GitHub checker repositories (${BOTH_DETECTION_NOTES}).${cut}`,
|
|
329
|
+
];
|
|
330
|
+
}
|
|
331
|
+
const detected = detectedTotal(counts.detections);
|
|
332
|
+
if (detected === 0 && counts.unclassified === 0) {
|
|
333
|
+
return [`EchelonGraph holds ${plural(total, "public exploit reference", "public exploit references")} for ${cve} (exploits_total)${v}.${cut}`];
|
|
334
|
+
}
|
|
335
|
+
// Only detection checks: no exploit code, and no kind this server cannot vouch for.
|
|
336
|
+
if (counts.code === 0 && counts.unclassified === 0) {
|
|
337
|
+
return [
|
|
338
|
+
`EchelonGraph holds ${listed(detectionParts(counts.detections))} for ${cve} and nothing else in exploits (exploits_total ${total}): ${detectionNote(counts.detections)}. It holds no Metasploit module, Exploit-DB entry or proof of concept for ${cve}, which is not evidence that none exists.${cut}`,
|
|
339
|
+
];
|
|
340
|
+
}
|
|
341
|
+
return [`EchelonGraph holds ${plural(total, "reference", "references")} for ${cve} (exploits_total)${v}: ${listed(classParts(counts))}; ${classNotes(counts)}.${cut}`];
|
|
204
342
|
}
|
|
205
343
|
function listSentence(name, rows) {
|
|
206
344
|
const limit = API_ROW_LIMIT[name] ?? 0;
|
|
@@ -209,6 +347,15 @@ function listSentence(name, rows) {
|
|
|
209
347
|
const cut = rows.length >= limit ? `, the most the API returns per CVE, so there may be more` : "";
|
|
210
348
|
return `${name} lists ${rows.length} row${rows.length === 1 ? "" : "s"}${cut}.`;
|
|
211
349
|
}
|
|
350
|
+
// #3285 (3.1.0's lane P): affected_packages lists a package once per stored ecosystem string, as
|
|
351
|
+
// cve_remediation's fixed_branches does, so CVE-2025-48013 has two rows for
|
|
352
|
+
// drupal/quick_node_block, under Packagist:https://packages.drupal.org/8 ([0, 2.0.1)) and Packagist
|
|
353
|
+
// ([0, 2.0.0)), each of one range, and "no range of the row holds", read row by row, gave a
|
|
354
|
+
// Packagist 1.0.0 the fix 2.0.0, which the Drupal row still holds affected. Rows the matcher reads
|
|
355
|
+
// as one package (packageRecords.ts, core-backend registryRecordKey: Packagist or Maven, bare or
|
|
356
|
+
// with a repository URL; a release suffix never) are now said of together, with the walk over all
|
|
357
|
+
// their ranges, which gives 2.0.1, the matcher's fixed_in, and the sentence on rows of several
|
|
358
|
+
// ranges names the others.
|
|
212
359
|
// #2817: where fixed_version is not the whole answer. A row with more than one range in
|
|
213
360
|
// fixed_branches has a fix per range; a row with fixed_branches null has only fixed_version on
|
|
214
361
|
// record, and so does a row with fixed_branches [] and a fixed_version (no version range on
|
|
@@ -232,9 +379,12 @@ function branchSentences(rows) {
|
|
|
232
379
|
const multi = [];
|
|
233
380
|
const unknown = [];
|
|
234
381
|
const noRange = [];
|
|
382
|
+
// #3285: rows the matcher reads as one package (packageRecords.ts) are said of together, below.
|
|
383
|
+
const { twice } = packagesListedTwice(rows);
|
|
384
|
+
const together = new Set(twice.flatMap((g) => g.rows));
|
|
235
385
|
rows.forEach((r, i) => {
|
|
236
386
|
const fb = field(r, "fixed_branches");
|
|
237
|
-
if (Array.isArray(fb) && fb.length > 1)
|
|
387
|
+
if (Array.isArray(fb) && fb.length > 1 && !together.has(i))
|
|
238
388
|
multi.push(named(r, i));
|
|
239
389
|
if (fb === null)
|
|
240
390
|
unknown.push(named(r, i));
|
|
@@ -245,6 +395,11 @@ function branchSentences(rows) {
|
|
|
245
395
|
if (multi.length) {
|
|
246
396
|
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).`);
|
|
247
397
|
}
|
|
398
|
+
if (twice.length) {
|
|
399
|
+
const rowsOf = twice.reduce((t, g) => t + g.rows.length, 0);
|
|
400
|
+
const pkgs = twice.map((g) => `${g.name} (${listed(g.ecosystems)})`);
|
|
401
|
+
out.push(`${rowsOf} affected_packages rows are ${twice.length} package${twice.length === 1 ? "" : "s"} (${few(pkgs)}): ${ONE_PACKAGE_RULE("Rows")}, so there an installed version's fix is the first fixed above it that no range of those rows holds: the fixed of the range that holds it, or, while another range of them still holds that version, that range's fixed (none where a range on the way has no fixed); each row's fixed_version is one range's fix.`);
|
|
402
|
+
}
|
|
248
403
|
if (unknown.length) {
|
|
249
404
|
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.`);
|
|
250
405
|
}
|
|
@@ -40,6 +40,7 @@ import { ANNOTATIONS, CVE_ID, SHOWN_BASE, api, badInput, checked, crashed, envel
|
|
|
40
40
|
import { answering, FULL_ARG, TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
|
|
41
41
|
import { fixStatusSchema } from "../fixStatus.js";
|
|
42
42
|
import { MATCH_HEADERS } from "./check_affected.js";
|
|
43
|
+
import { ONE_PACKAGE_RULE, packagesListedTwice } from "./packageRecords.js";
|
|
43
44
|
const TOOL = "cve_remediation";
|
|
44
45
|
const SECTIONS = ["cisa", "fixed_branches", "vendor_remediations", "fix_references", "patches"];
|
|
45
46
|
const KINDS = ["vendor_fix", "workaround", "mitigation", "no_fix_planned", "none_available", "other"];
|
|
@@ -124,6 +125,10 @@ function schemas() {
|
|
|
124
125
|
advisory_id: opt(z.string()),
|
|
125
126
|
source: opt(z.string()),
|
|
126
127
|
}))),
|
|
128
|
+
fixed_branches_truncated: z
|
|
129
|
+
.object({ total: z.number().int(), returned: z.number().int() })
|
|
130
|
+
.optional()
|
|
131
|
+
.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."),
|
|
127
132
|
}))),
|
|
128
133
|
vendor_remediations: opt(z.array(VendorAdvisory)),
|
|
129
134
|
fix_references: opt(z.array(z.looseObject({ url: opt(z.string()), tags: opt(z.array(z.string())), source: opt(z.string()) }))),
|
|
@@ -148,24 +153,50 @@ function schemas() {
|
|
|
148
153
|
}
|
|
149
154
|
let built;
|
|
150
155
|
const schemasOnce = () => (built ??= schemas());
|
|
151
|
-
// The text cut: vendor texts go first, the kinds, states and URLs stay.
|
|
152
|
-
const CUT = {
|
|
153
|
-
rows: "vendor_remediations",
|
|
154
|
-
levels: [
|
|
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 },
|
|
156
|
-
{ keep: ["vendor", "vendor_advisory_id", "remediation_state", "remediation_kinds", ["items", ["kind", "url"]]], clip: 2048, cap: 8 },
|
|
157
|
-
],
|
|
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
156
|
// #3013: the structured result's cut. CVE-2021-44228's answer is 75,963 characters of structured
|
|
161
157
|
// result, 61,248 of them its 20 vendor_remediations, whose items carry the vendor's text (about 550
|
|
162
158
|
// characters an item, the payload) and its product_ids (about 550, the vendor's own identifiers).
|
|
163
159
|
// Before any advisory leaves, every advisory's items are thinned: product_ids first, keeping each
|
|
164
160
|
// text; then the texts, keeping each item's kind, source_category, url and fixed_build, so every
|
|
165
161
|
// advisory stays named with its state, kinds and links (get_vendor_advisory returns one whole).
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
162
|
+
// fix_status and fixed_in (#2841) are not lists, and installed is an object, so none of them is
|
|
163
|
+
// ever cut.
|
|
164
|
+
//
|
|
165
|
+
// #3139: through 3.0.0 advisories then left from the end, before patches, and fixed_branches had no
|
|
166
|
+
// step, so CVE-2023-44487 (KEV-listed) kept 2 of its 20 advisories at 29,704 characters while
|
|
167
|
+
// fixed_branches (13,581 characters, 50 rows) and patches (7,471, 30) stayed whole, and the note
|
|
168
|
+
// named no tool that lists the other 18 (measured on hosted 3.0.0, 2026-10-11; also CVE-2018-12126
|
|
169
|
+
// 6/20, CVE-2019-11135 2/3, CVE-2018-3639 16/20). Now vendor_remediations is a keepAll step: every
|
|
170
|
+
// advisory stays, thinned, while patches (each to its vendor, patch_url and published_at: the
|
|
171
|
+
// erratum, which vendor_remediations names again for Red Hat) and then fixed_branches (each to its
|
|
172
|
+
// package, range, fixed_version and its ranges' introduced, fixed and last_affected, what the
|
|
173
|
+
// sbom_review prompt walks; the per-range source, advisory_id and dependents_count go) are thinned
|
|
174
|
+
// and then lose rows from the end, and then any other list at the top of data (fix_references).
|
|
175
|
+
// Only when all of that is cut and the result is still past the budget do advisories leave, from
|
|
176
|
+
// the end; whenever one does, the note names vendor_advisories_for_cve, which lists every vendor
|
|
177
|
+
// advisory naming the CVE, as where the rest are.
|
|
178
|
+
// #3139's review: that order dropped 2 of CVE-2023-44487's 30 patches while its 50 fixed_branches
|
|
179
|
+
// rows stayed whole (13,581 characters). Decided (2026-10-11): fixed_branches is thinned before any
|
|
180
|
+
// patch row leaves. The order is now:
|
|
181
|
+
// 1. vendor_remediations thinned, as above, every advisory kept (keepAll);
|
|
182
|
+
// 2. fixed_branches thinned (each package to its ecosystem, package_name, version_range,
|
|
183
|
+
// fixed_version and its ranges' introduced, fixed and last_affected: each range's source and
|
|
184
|
+
// advisory_id go, and the package's dependents_count and source), then each package's ranges
|
|
185
|
+
// capped at one number, the most that fit (at least 1), a package with more carrying
|
|
186
|
+
// fixed_branches_truncated with its total and returned; every package kept (keepAll);
|
|
187
|
+
// 3. patches thinned (vendor, patch_url and published_at), every patch kept (keepAll);
|
|
188
|
+
// 4. any other list at the top of data (fix_references), cut from the end;
|
|
189
|
+
// 5. then patch rows leave from the end, then fixed_branches rows, and only then advisories
|
|
190
|
+
// (textBudget.ts: the last keepAll step's entries leave first);
|
|
191
|
+
// 6. the room left goes back to each package's ranges, in the order of fixed_branches (giveBack):
|
|
192
|
+
// step 2 caps them before the patches are cut, and a cap that held while every patch was
|
|
193
|
+
// whole would otherwise stay after the patches are thinned.
|
|
194
|
+
// Round 2 replayed CVE-2023-44487 at 28 of 30 patches: fix_references (2,191 characters) were cut
|
|
195
|
+
// after patch rows left, and the 30 thinned were about 240 characters past the budget beside them.
|
|
196
|
+
// Round 3 (#3139's second review, 2026-10-11): patches is a keepAll step, so fix_references are cut
|
|
197
|
+
// first and CVE-2023-44487 keeps all 20 advisories, all 50 fixed_branches and all 30 patches, each
|
|
198
|
+
// thinned (test/cve_remediation.test.mjs); round 1 (patches cut before fixed_branches was thinned)
|
|
199
|
+
// kept 11 patches without fix_references.
|
|
169
200
|
//
|
|
170
201
|
// #3013's review: the second level leaves every item's text out of data, and the text is the
|
|
171
202
|
// vendor's step itself (a workaround's or mitigation's wording), which the triage_cve and
|
|
@@ -175,6 +206,22 @@ const CUT = {
|
|
|
175
206
|
// with full: true returns this data whole; the prompts send the model there when it is cut.
|
|
176
207
|
const ADVISORY_FIELDS = ["vendor", "vendor_display_name", "vendor_advisory_id", "vendor_published_at", "withdrawn", "remediation_state", "remediation_kinds", "items_total"];
|
|
177
208
|
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.";
|
|
209
|
+
const FIXED_BRANCHES_THIN = ["ecosystem", "package_name", "version_range", "fixed_version", ["fixed_branches", ["introduced", "fixed", "last_affected"]]];
|
|
210
|
+
// A package whose ranges a cut keeps the first `cap` of: fixed_branches_truncated with its total
|
|
211
|
+
// and returned. The structured cut's step 2 marks them so, and (3.1.0's phase A review) the text's.
|
|
212
|
+
const rangesTruncated = (row, cap) => {
|
|
213
|
+
const n = Array.isArray(row.fixed_branches) ? row.fixed_branches.length : 0;
|
|
214
|
+
return n > cap ? { fixed_branches_truncated: { total: n, returned: cap } } : {};
|
|
215
|
+
};
|
|
216
|
+
// 3.1.0's phase C review: the note's fix walk ("the first fixed above it that no range of its
|
|
217
|
+
// package holds", branchSentence below), over a capped package's kept ranges alone, can name a
|
|
218
|
+
// version a range left out still holds (test/cve_remediation.test.mjs: a package whose every fix
|
|
219
|
+
// the next range holds walks to c.0.0 over its first c ranges, to N.0.0 over all N). Both notes say
|
|
220
|
+
// so of a capped package, and where its fix is read: data with full: true (the text beside a cut
|
|
221
|
+
// data, and data itself), or data as it is (the text beside a data the call asked whole).
|
|
222
|
+
const CAPPED_FIX_DATA = "its fix, walked over its kept ranges alone, can be wrong (read that package's fix with full: true)";
|
|
223
|
+
const cappedFixText = (full) => `A capped package's fix, walked over its kept ranges alone, can be wrong: read that package's fix ${full ? "with full: true" : "from structuredContent.data"}.`;
|
|
224
|
+
const PATCHES_THIN = ["vendor", "patch_url", "published_at"];
|
|
178
225
|
const CVE_REMEDIATION_DATA = {
|
|
179
226
|
steps: [
|
|
180
227
|
{
|
|
@@ -183,15 +230,96 @@ const CVE_REMEDIATION_DATA = {
|
|
|
183
230
|
[...ADVISORY_FIELDS, ["items", ["kind", "source_category", "source_subcategory", "text", "text_truncated", "text_chars", "url", "fixed_build", "product_count"]]],
|
|
184
231
|
[...ADVISORY_FIELDS, ["items", ["kind", "source_category", "url", "fixed_build"]]],
|
|
185
232
|
],
|
|
233
|
+
keepAll: true,
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
lists: ["fixed_branches"],
|
|
237
|
+
thin: [
|
|
238
|
+
FIXED_BRANCHES_THIN,
|
|
239
|
+
{
|
|
240
|
+
keep: FIXED_BRANCHES_THIN,
|
|
241
|
+
capped: "fixed_branches",
|
|
242
|
+
least: 1,
|
|
243
|
+
mark: rangesTruncated,
|
|
244
|
+
said: (cap) => `each package's fixed_branches keeping at most its first ${cap} range${cap === 1 ? "" : "s"}, a package with more carrying fixed_branches_truncated with its total and returned, and ${CAPPED_FIX_DATA}`,
|
|
245
|
+
},
|
|
246
|
+
],
|
|
247
|
+
keepAll: true,
|
|
248
|
+
giveBack: true,
|
|
186
249
|
},
|
|
187
|
-
|
|
250
|
+
// #3139 round 3: every patch row stays, thinned (keepAll), while the step after it (any other
|
|
251
|
+
// list at the top of data: fix_references) is cut; patch rows leave only after that, and
|
|
252
|
+
// before any fixed_branches row or advisory (the last keepAll step's entries leave first).
|
|
253
|
+
{ lists: ["patches"], thin: [PATCHES_THIN], keepAll: true },
|
|
188
254
|
],
|
|
189
|
-
// Said where the advisories kept are thinned to a level whose items keep no text
|
|
190
|
-
|
|
255
|
+
// Said where the advisories kept are thinned to a level whose items keep no text, and (#3139)
|
|
256
|
+
// wherever an advisory is left out.
|
|
257
|
+
page: (t, _d, at) => {
|
|
191
258
|
const items = at.keeps.vendor_remediations?.find((x) => typeof x !== "string" && x[0] === "items");
|
|
192
|
-
|
|
259
|
+
const v = t.vendor_remediations;
|
|
260
|
+
return [items && !items[1].includes("text") ? ITEM_TEXT_ELSEWHERE : "", v && v.returned < v.total ? advisoriesElsewhere(v.total - v.returned, "data") : ""].filter(Boolean).join(" ");
|
|
193
261
|
},
|
|
194
262
|
};
|
|
263
|
+
// #3139: where the advisories a cut leaves out are read. vendor_advisories_for_cve lists every one
|
|
264
|
+
// naming the CVE, with its vendor and vendor_advisory_id (CVE-2023-44487's 20 at 15,505
|
|
265
|
+
// characters on hosted 3.0.0), and get_vendor_advisory then returns any one whole. #3272: the
|
|
266
|
+
// first text block's cut says it too, of the advisories it leaves out.
|
|
267
|
+
const advisoriesElsewhere = (n, of) => `vendor_advisories_for_cve, given this CVE, lists every vendor advisory naming it, the ${n} left out of ${of} among them, each with its vendor and vendor_advisory_id.`;
|
|
268
|
+
// The text cut: vendor texts go first, the kinds, states and URLs stay.
|
|
269
|
+
// #3272: through 3.0.2 the first text block was still cut the 3.0.0 way, which #3139 fixed only in
|
|
270
|
+
// structuredContent.data: CUT named no list beside the advisories, so once its leanest level did not
|
|
271
|
+
// fit, advisories left the text from the end while fixed_branches, patches and fix_references stayed
|
|
272
|
+
// whole beside them. On the recorded CVE-2023-44487 answer (test/fixtures/cve_remediation-
|
|
273
|
+
// CVE-2023-44487.json) the text kept 19 of 20 advisories beside 50/50 fixed_branches and 30/30
|
|
274
|
+
// patches, and the same answer with 1, 5, 10 and 16 more fixed_branches packages 18, 13, 8 and 2
|
|
275
|
+
// (from 18 more, the text fell through to step 4's every-list cap and kept 20 again); the TEXT CUT
|
|
276
|
+
// note named get_vendor_advisory, which needs the advisory's ID, and never vendor_advisories_for_cve.
|
|
277
|
+
// Now the text follows the structured cut's order (beforeRows, textBudget.ts step 3): the advisories
|
|
278
|
+
// are cut level by level with every list beside them whole; once the leanest level does not fit,
|
|
279
|
+
// fixed_branches is cut to FIXED_BRANCHES_THIN's fields, then patches to PATCHES_THIN's, every row
|
|
280
|
+
// kept; then fix_references, then patches, then fixed_branches lose rows from the end, each emptied
|
|
281
|
+
// before the next loses any; only then do advisories leave, from the end, and the note names
|
|
282
|
+
// vendor_advisories_for_cve as where the rest are. The recorded answer now keeps all 20 advisories
|
|
283
|
+
// beside every fixed_branches row (thinned), patch and fix_reference.
|
|
284
|
+
// 3.1.0's phase A review: the text kept every range of each fixed_branches row it kept, capping no
|
|
285
|
+
// package's ranges as the structured cut's step 2 does, so a package too long to fit alone left the
|
|
286
|
+
// text with every package after it, and the lists emptied before it stayed empty: CVE-2023-44487's
|
|
287
|
+
// answer with one package of N ranges put first among 51 kept 11 of 20 references at 150 ranges, 51
|
|
288
|
+
// packages, 14 patches and no reference at 200, 43 packages and no patch at 250, and, from 400
|
|
289
|
+
// ranges, no package, patch or reference in 6,347 characters, where 3.0.2's every-list cap kept 32
|
|
290
|
+
// packages, 30 patches and 20 references at every size. Now, once fixed_branches and patches are
|
|
291
|
+
// thinned and the text still does not fit, each package's ranges are cut to its first c, the most
|
|
292
|
+
// that fit with patches and references as they stand (at least 1), every package kept and marked
|
|
293
|
+
// fixed_branches_truncated as data marks it; only if 1 does not fit do references, then patches,
|
|
294
|
+
// then packages leave from the end, and the room they leave goes back to c. The note names the
|
|
295
|
+
// packages so cut and says data holds every range.
|
|
296
|
+
const CUT = {
|
|
297
|
+
rows: "vendor_remediations",
|
|
298
|
+
levels: [
|
|
299
|
+
{ keep: ["vendor", "vendor_advisory_id", "withdrawn", "remediation_state", "remediation_kinds", "items_total", ["items", ["kind", "source_category", "text", "url", "fixed_build"]]], clip: 400, cap: 16 },
|
|
300
|
+
{ keep: ["vendor", "vendor_advisory_id", "remediation_state", "remediation_kinds", ["items", ["kind", "url"]]], clip: 2048, cap: 8 },
|
|
301
|
+
],
|
|
302
|
+
sides: [
|
|
303
|
+
{
|
|
304
|
+
list: "fixed_branches",
|
|
305
|
+
keep: FIXED_BRANCHES_THIN,
|
|
306
|
+
beforeRows: true,
|
|
307
|
+
capped: {
|
|
308
|
+
list: "fixed_branches",
|
|
309
|
+
least: 1,
|
|
310
|
+
mark: rangesTruncated,
|
|
311
|
+
name: (e) => `${strAt(e, "package_name") ?? "?"} (${strAt(e, "ecosystem") ?? "?"})`,
|
|
312
|
+
said: (cap, names, full) => `In it, each package's fixed_branches keeps at most its first ${cap} range${cap === 1 ? "" : "s"}: ${names.length <= 3 ? listed(names) : `${names.slice(0, 3).join(", ")} and ${names.length - 3} more`} ${names.length === 1 ? "has more, carrying" : "have more, each carrying"} fixed_branches_truncated with its total and returned, and structuredContent.data holds every range${full ? " with full: true" : ""}. ${cappedFixText(full)}`,
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
{ list: "patches", keep: PATCHES_THIN, beforeRows: true },
|
|
316
|
+
{ list: "fix_references", beforeRows: true },
|
|
317
|
+
],
|
|
318
|
+
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.",
|
|
319
|
+
page: (inText, rows) => advisoriesElsewhere(rows - inText, "the first text block"),
|
|
320
|
+
};
|
|
321
|
+
// The text cut, for the tests that replay it (test/cve_remediation.test.mjs).
|
|
322
|
+
export { CUT as CVE_REMEDIATION_TEXT_CUT };
|
|
195
323
|
const listed = (xs) => (xs.length <= 1 ? (xs[0] ?? "") : `${xs.slice(0, -1).join(", ")} and ${xs.at(-1)}`);
|
|
196
324
|
const arr = (v) => (Array.isArray(v) ? v : null);
|
|
197
325
|
const strs = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === "string") : null);
|
|
@@ -225,6 +353,21 @@ function vendorSentences(cve, rows) {
|
|
|
225
353
|
out.push(`${few(states.withdrawn)} ${states.withdrawn.length === 1 ? "was" : "were"} withdrawn by ${states.withdrawn.length === 1 ? "its" : "their"} vendor: ${states.withdrawn.length === 1 ? "its" : "their"} remediation is not relayed.`);
|
|
226
354
|
return out;
|
|
227
355
|
}
|
|
356
|
+
// #3285: the ecosystems whose ":" suffix is a repository, not a release (core-backend
|
|
357
|
+
// cve/pkgecosystem.go baseTokenMatchableEcosystems): an entry of one package_name under
|
|
358
|
+
// "Packagist:https://packages.drupal.org/8" and one under "Packagist" are one package to the
|
|
359
|
+
// matcher (cve/pkgmatch_fixedin.go registryRecordKey), which walks their ranges together; a
|
|
360
|
+
// release suffix (Debian:12, Alpine:v3.20) is never folded. CVE-2025-48013's fixed_branches lists
|
|
361
|
+
// drupal/quick_node_block under both (fixed 2.0.1 and fixed 2.0.0, each from 0): read entry by
|
|
362
|
+
// entry, the rule gave a Packagist 1.0.0 the fix 2.0.0, which the Drupal advisory still holds
|
|
363
|
+
// affected; as one package it gives 2.0.1, the matcher's fixed_in. packageRecords.ts holds the
|
|
364
|
+
// reading, which cve_intel's note states of affected_packages too.
|
|
365
|
+
// The packages fixed_branches lists under more than one such ecosystem, each as its package_name
|
|
366
|
+
// with the ecosystems it is listed under, and how many entries fold into another so.
|
|
367
|
+
function onePackageListedTwice(rows) {
|
|
368
|
+
const { twice, folded } = packagesListedTwice(rows);
|
|
369
|
+
return { named: twice.map((g) => `${g.name} (${listed(g.ecosystems)})`), folded };
|
|
370
|
+
}
|
|
228
371
|
function branchSentence(rows) {
|
|
229
372
|
if (rows.length === 0)
|
|
230
373
|
return "fixed_branches is empty: no affected package range is on record, which is not a finding that no fix exists.";
|
|
@@ -237,7 +380,14 @@ function branchSentence(rows) {
|
|
|
237
380
|
fixed++;
|
|
238
381
|
}
|
|
239
382
|
}
|
|
240
|
-
|
|
383
|
+
const twice = onePackageListedTwice(rows);
|
|
384
|
+
const packages = rows.length - twice.folded;
|
|
385
|
+
const listedAs = twice.folded === 0 ? `${rows.length} affected package${rows.length === 1 ? "" : "s"}` : `${rows.length} entries, ${packages} affected package${packages === 1 ? "" : "s"},`;
|
|
386
|
+
const rule = `fixed_branches lists ${listedAs} 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.`;
|
|
387
|
+
if (twice.named.length === 0)
|
|
388
|
+
return rule;
|
|
389
|
+
const few = twice.named.length <= 3 ? listed(twice.named) : `${twice.named.slice(0, 3).join(", ")} and ${twice.named.length - 3} more`;
|
|
390
|
+
return `${rule} ${ONE_PACKAGE_RULE("Entries")}: here ${few}.`;
|
|
241
391
|
}
|
|
242
392
|
// fix_status's sentence: what the API's rule says, relayed. Nothing about the fix is inferred here.
|
|
243
393
|
function fixStatusSentence(cve, fs) {
|
|
@@ -250,7 +400,13 @@ function fixStatusSentence(cve, fs) {
|
|
|
250
400
|
const unread = Array.isArray(field(fs, "unread_inputs")) ? field(fs, "unread_inputs").filter((x) => typeof x === "string") : [];
|
|
251
401
|
const why = state === "none_confirmed" ? ", which is not a finding that no fix exists" : "";
|
|
252
402
|
const notRead = unread.length ? `; it rests without ${listed(unread)}, which the API could not read (unread_inputs)` : "";
|
|
253
|
-
|
|
403
|
+
// #3076, third pass: what vendor_fix_released evidence is, so "available" is not read as more
|
|
404
|
+
// than it says. Relayed from the field the API set, nothing inferred.
|
|
405
|
+
const released = field(fs, "vendor_released");
|
|
406
|
+
const qualifier = released !== null && released !== undefined && typeof released === "object"
|
|
407
|
+
? `; 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`
|
|
408
|
+
: "";
|
|
409
|
+
return `fix_status for ${cve} is ${state}${basis ? ` (basis ${basis})` : ""}, the API's fix-status rule${why}${qualifier}${notRead}.`;
|
|
254
410
|
}
|
|
255
411
|
// The sentence on the installed version asked: what the API answered for it, relayed.
|
|
256
412
|
function installedSentence(asked, d) {
|