echelongraph-mcp 3.0.2 → 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 +603 -5
- package/README.md +77 -82
- package/dist/http.js +73 -81
- package/dist/httpPolicy.js +197 -10
- package/dist/index.js +451 -44
- package/dist/logLines.js +86 -0
- package/dist/prompts.js +121 -22
- package/dist/readBody.js +140 -0
- package/dist/requiredText.js +23 -3
- package/dist/resources.js +3 -3
- package/dist/runtime.js +66 -1
- package/dist/schemaRefusal.js +138 -0
- package/dist/textBudget.js +826 -76
- package/dist/tools/check_affected.js +19 -6
- package/dist/tools/check_sbom.js +191 -22
- package/dist/tools/cve_intel.js +145 -35
- package/dist/tools/cve_remediation.js +101 -19
- package/dist/tools/epss_history.js +41 -5
- package/dist/tools/match_batch.js +57 -10
- package/dist/tools/packageRecords.js +58 -0
- package/dist/tools/scan_manifest.js +11 -4
- package/dist/tools/vendor_advisories.js +27 -4
- 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 +139 -6
- 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,8 +74,52 @@ 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.';
|
|
79
124
|
// #3039: a Nuclei template is a detection check, not necessarily exploit code (core-backend
|
|
80
125
|
// egkev.ExploitCodeKinds, #3038; /pulse words it so since #3022). The head says "exploit code,
|
|
@@ -88,7 +133,10 @@ const FIXED_BRANCHES_DESCRIPTION = 'fixed_branches lists each affected range of
|
|
|
88
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
|
|
89
134
|
// states the walk FIXED_BRANCHES_DESCRIPTION and branchSentences state, in fewer characters than
|
|
90
135
|
// the old rule (a range's bounds folded into the list of its fields), so the headroom holds.
|
|
91
|
-
|
|
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}`;
|
|
92
140
|
// ── outputSchema ──
|
|
93
141
|
const Rows = (shape) => z.array(z.looseObject(shape));
|
|
94
142
|
const CountMap = z.record(z.string(), z.number());
|
|
@@ -101,7 +149,7 @@ function schemas() {
|
|
|
101
149
|
cve_id: z.string(),
|
|
102
150
|
cwes: Rows({ cwe_id: opt(z.string()), name: opt(z.string()), source: opt(z.string()) }).optional(),
|
|
103
151
|
exploits: Rows({
|
|
104
|
-
kind: opt(z.string()).describe(
|
|
152
|
+
kind: opt(z.string()).describe(EXPLOIT_KIND_DESCRIPTION),
|
|
105
153
|
source_name: opt(z.string()),
|
|
106
154
|
source_url: opt(z.string()),
|
|
107
155
|
description: opt(z.string()),
|
|
@@ -114,7 +162,7 @@ function schemas() {
|
|
|
114
162
|
}).optional(),
|
|
115
163
|
exploits_total: z.number().int().nullable().optional().describe("Every exploit reference on record for the CVE, not only those listed in exploits."),
|
|
116
164
|
exploits_capped: z.boolean().optional().describe("true: exploits lists fewer references than exploits_total."),
|
|
117
|
-
exploits_by_kind: CountMap.optional(),
|
|
165
|
+
exploits_by_kind: CountMap.optional().describe(EXPLOITS_BY_KIND_DESCRIPTION),
|
|
118
166
|
exploits_by_status: CountMap.optional(),
|
|
119
167
|
affected_packages: Rows({
|
|
120
168
|
ecosystem: opt(z.string()),
|
|
@@ -182,24 +230,70 @@ function cweSentence(cve, cwes) {
|
|
|
182
230
|
});
|
|
183
231
|
return `${cve} is classified under ${listed(named)}.`;
|
|
184
232
|
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
const DETECTION_KIND = "nuclei_template";
|
|
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.
|
|
192
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)}`;
|
|
193
243
|
const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
|
194
|
-
|
|
195
|
-
//
|
|
196
|
-
|
|
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) {
|
|
197
271
|
const byKind = field(d, "exploits_by_kind");
|
|
198
|
-
if (byKind !== null && typeof byKind === "object" && !Array.isArray(byKind))
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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;
|
|
202
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(". ");
|
|
203
297
|
function exploitSentences(cve, d, rows) {
|
|
204
298
|
const total = numAt(d, "exploits_total");
|
|
205
299
|
const verified = numAt(d, "exploits_by_status", "verified");
|
|
@@ -213,13 +307,12 @@ function exploitSentences(cve, d, rows) {
|
|
|
213
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.`,
|
|
214
308
|
];
|
|
215
309
|
}
|
|
216
|
-
const
|
|
217
|
-
const
|
|
218
|
-
const what = listedTemplates === 0
|
|
310
|
+
const shown = countsIn(rows);
|
|
311
|
+
const what = shown.code === rows.length
|
|
219
312
|
? `${plural(rows.length, "public exploit reference", "public exploit references")}`
|
|
220
|
-
: code === 0
|
|
221
|
-
? `${
|
|
222
|
-
: `${plural(rows.length, "reference", "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)})`;
|
|
223
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}`];
|
|
224
317
|
}
|
|
225
318
|
if (total === 0) {
|
|
@@ -229,23 +322,23 @@ function exploitSentences(cve, d, rows) {
|
|
|
229
322
|
}
|
|
230
323
|
const v = verified === undefined ? "" : `, ${verified} of them with verified_status verified`;
|
|
231
324
|
const cut = capped === true ? ` exploits_capped is true: exploits lists ${rows.length} of the ${total}, verified first.` : "";
|
|
232
|
-
const
|
|
233
|
-
if (
|
|
325
|
+
const counts = kindCounts(d, rows, total);
|
|
326
|
+
if (counts === undefined) {
|
|
234
327
|
return [
|
|
235
|
-
`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 Nuclei detection templates (${
|
|
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}`,
|
|
236
329
|
];
|
|
237
330
|
}
|
|
238
|
-
|
|
331
|
+
const detected = detectedTotal(counts.detections);
|
|
332
|
+
if (detected === 0 && counts.unclassified === 0) {
|
|
239
333
|
return [`EchelonGraph holds ${plural(total, "public exploit reference", "public exploit references")} for ${cve} (exploits_total)${v}.${cut}`];
|
|
240
334
|
}
|
|
241
|
-
|
|
335
|
+
// Only detection checks: no exploit code, and no kind this server cannot vouch for.
|
|
336
|
+
if (counts.code === 0 && counts.unclassified === 0) {
|
|
242
337
|
return [
|
|
243
|
-
`EchelonGraph holds ${
|
|
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}`,
|
|
244
339
|
];
|
|
245
340
|
}
|
|
246
|
-
return [
|
|
247
|
-
`EchelonGraph holds ${plural(total, "reference", "references")} for ${cve} (exploits_total)${v}: ${plural(total - templates, "public exploit reference", "public exploit references")} and ${plural(templates, "Nuclei detection template", "Nuclei detection templates")}; ${DETECTION_NOTE}.${cut}`,
|
|
248
|
-
];
|
|
341
|
+
return [`EchelonGraph holds ${plural(total, "reference", "references")} for ${cve} (exploits_total)${v}: ${listed(classParts(counts))}; ${classNotes(counts)}.${cut}`];
|
|
249
342
|
}
|
|
250
343
|
function listSentence(name, rows) {
|
|
251
344
|
const limit = API_ROW_LIMIT[name] ?? 0;
|
|
@@ -254,6 +347,15 @@ function listSentence(name, rows) {
|
|
|
254
347
|
const cut = rows.length >= limit ? `, the most the API returns per CVE, so there may be more` : "";
|
|
255
348
|
return `${name} lists ${rows.length} row${rows.length === 1 ? "" : "s"}${cut}.`;
|
|
256
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.
|
|
257
359
|
// #2817: where fixed_version is not the whole answer. A row with more than one range in
|
|
258
360
|
// fixed_branches has a fix per range; a row with fixed_branches null has only fixed_version on
|
|
259
361
|
// record, and so does a row with fixed_branches [] and a fixed_version (no version range on
|
|
@@ -277,9 +379,12 @@ function branchSentences(rows) {
|
|
|
277
379
|
const multi = [];
|
|
278
380
|
const unknown = [];
|
|
279
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));
|
|
280
385
|
rows.forEach((r, i) => {
|
|
281
386
|
const fb = field(r, "fixed_branches");
|
|
282
|
-
if (Array.isArray(fb) && fb.length > 1)
|
|
387
|
+
if (Array.isArray(fb) && fb.length > 1 && !together.has(i))
|
|
283
388
|
multi.push(named(r, i));
|
|
284
389
|
if (fb === null)
|
|
285
390
|
unknown.push(named(r, i));
|
|
@@ -290,6 +395,11 @@ function branchSentences(rows) {
|
|
|
290
395
|
if (multi.length) {
|
|
291
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).`);
|
|
292
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
|
+
}
|
|
293
403
|
if (unknown.length) {
|
|
294
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.`);
|
|
295
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"];
|
|
@@ -152,15 +153,6 @@ function schemas() {
|
|
|
152
153
|
}
|
|
153
154
|
let built;
|
|
154
155
|
const schemasOnce = () => (built ??= schemas());
|
|
155
|
-
// The text cut: vendor texts go first, the kinds, states and URLs stay.
|
|
156
|
-
const CUT = {
|
|
157
|
-
rows: "vendor_remediations",
|
|
158
|
-
levels: [
|
|
159
|
-
{ keep: ["vendor", "vendor_advisory_id", "withdrawn", "remediation_state", "remediation_kinds", "items_total", ["items", ["kind", "source_category", "text", "url", "fixed_build"]]], clip: 400, cap: 16 },
|
|
160
|
-
{ keep: ["vendor", "vendor_advisory_id", "remediation_state", "remediation_kinds", ["items", ["kind", "url"]]], clip: 2048, cap: 8 },
|
|
161
|
-
],
|
|
162
|
-
whole: "get_vendor_advisory returns any one of these advisories by its vendor and vendor_advisory_id, whole in its structuredContent.data with full: true, with every remediation item it lists.",
|
|
163
|
-
};
|
|
164
156
|
// #3013: the structured result's cut. CVE-2021-44228's answer is 75,963 characters of structured
|
|
165
157
|
// result, 61,248 of them its 20 vendor_remediations, whose items carry the vendor's text (about 550
|
|
166
158
|
// characters an item, the payload) and its product_ids (about 550, the vendor's own identifiers).
|
|
@@ -215,6 +207,21 @@ const CUT = {
|
|
|
215
207
|
const ADVISORY_FIELDS = ["vendor", "vendor_display_name", "vendor_advisory_id", "vendor_published_at", "withdrawn", "remediation_state", "remediation_kinds", "items_total"];
|
|
216
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.";
|
|
217
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"];
|
|
218
225
|
const CVE_REMEDIATION_DATA = {
|
|
219
226
|
steps: [
|
|
220
227
|
{
|
|
@@ -233,11 +240,8 @@ const CVE_REMEDIATION_DATA = {
|
|
|
233
240
|
keep: FIXED_BRANCHES_THIN,
|
|
234
241
|
capped: "fixed_branches",
|
|
235
242
|
least: 1,
|
|
236
|
-
mark:
|
|
237
|
-
|
|
238
|
-
return n > cap ? { fixed_branches_truncated: { total: n, returned: cap } } : {};
|
|
239
|
-
},
|
|
240
|
-
said: (cap) => `each package's fixed_branches keeping at most its first ${cap} ranges, a package with more carrying fixed_branches_truncated with its total and returned`,
|
|
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}`,
|
|
241
245
|
},
|
|
242
246
|
],
|
|
243
247
|
keepAll: true,
|
|
@@ -246,20 +250,76 @@ const CVE_REMEDIATION_DATA = {
|
|
|
246
250
|
// #3139 round 3: every patch row stays, thinned (keepAll), while the step after it (any other
|
|
247
251
|
// list at the top of data: fix_references) is cut; patch rows leave only after that, and
|
|
248
252
|
// before any fixed_branches row or advisory (the last keepAll step's entries leave first).
|
|
249
|
-
{ lists: ["patches"], thin: [
|
|
253
|
+
{ lists: ["patches"], thin: [PATCHES_THIN], keepAll: true },
|
|
250
254
|
],
|
|
251
255
|
// Said where the advisories kept are thinned to a level whose items keep no text, and (#3139)
|
|
252
256
|
// wherever an advisory is left out.
|
|
253
257
|
page: (t, _d, at) => {
|
|
254
258
|
const items = at.keeps.vendor_remediations?.find((x) => typeof x !== "string" && x[0] === "items");
|
|
255
259
|
const v = t.vendor_remediations;
|
|
256
|
-
return [items && !items[1].includes("text") ? ITEM_TEXT_ELSEWHERE : "", v && v.returned < v.total ? advisoriesElsewhere(v.total - v.returned) : ""].filter(Boolean).join(" ");
|
|
260
|
+
return [items && !items[1].includes("text") ? ITEM_TEXT_ELSEWHERE : "", v && v.returned < v.total ? advisoriesElsewhere(v.total - v.returned, "data") : ""].filter(Boolean).join(" ");
|
|
257
261
|
},
|
|
258
262
|
};
|
|
259
263
|
// #3139: where the advisories a cut leaves out are read. vendor_advisories_for_cve lists every one
|
|
260
264
|
// naming the CVE, with its vendor and vendor_advisory_id (CVE-2023-44487's 20 at 15,505
|
|
261
|
-
// characters on hosted 3.0.0), and get_vendor_advisory then returns any one whole.
|
|
262
|
-
|
|
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 };
|
|
263
323
|
const listed = (xs) => (xs.length <= 1 ? (xs[0] ?? "") : `${xs.slice(0, -1).join(", ")} and ${xs.at(-1)}`);
|
|
264
324
|
const arr = (v) => (Array.isArray(v) ? v : null);
|
|
265
325
|
const strs = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === "string") : null);
|
|
@@ -293,6 +353,21 @@ function vendorSentences(cve, rows) {
|
|
|
293
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.`);
|
|
294
354
|
return out;
|
|
295
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
|
+
}
|
|
296
371
|
function branchSentence(rows) {
|
|
297
372
|
if (rows.length === 0)
|
|
298
373
|
return "fixed_branches is empty: no affected package range is on record, which is not a finding that no fix exists.";
|
|
@@ -305,7 +380,14 @@ function branchSentence(rows) {
|
|
|
305
380
|
fixed++;
|
|
306
381
|
}
|
|
307
382
|
}
|
|
308
|
-
|
|
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}.`;
|
|
309
391
|
}
|
|
310
392
|
// fix_status's sentence: what the API's rule says, relayed. Nothing about the fix is inferred here.
|
|
311
393
|
function fixStatusSentence(cve, fs) {
|
|
@@ -27,10 +27,11 @@ const ApiCoverage = (instant) => z.looseObject({
|
|
|
27
27
|
first_day: opt(Day).describe("The earliest score date in the run log."),
|
|
28
28
|
last_day: opt(Day).describe("The latest score date in the run log."),
|
|
29
29
|
days_held: opt(z.number().int().min(0)).describe("Score dates from first_day to last_day that a refresh applied completely."),
|
|
30
|
-
days_partial: opt(z.array(Day)).describe("Score dates
|
|
30
|
+
days_partial: opt(z.array(Day)).describe("Score dates no refresh completed and one failed part-way: some CVEs hold that day, some do not. Also in days_in_progress while a retry of it has not finished."),
|
|
31
31
|
days_missing: opt(z.array(Day)).describe("Score dates from first_day to last_day that no refresh applied."),
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
days_in_progress: opt(z.array(Day)).describe("Score dates no refresh completed and one that has not finished is applying: some CVEs hold that day so far. Not counted held or missing; also in days_partial when an earlier refresh of it failed part-way."),
|
|
33
|
+
runs_undated: opt(z.number().int().min(0)).describe("Logged refreshes with no score date: the days they applied are not counted held, partial or in progress, and their points carry no score_date."),
|
|
34
|
+
refresh_runs_truncated: opt(z.boolean()).describe("True when only the newest refreshes of the run log were read, so the earliest score dates are not listed."),
|
|
34
35
|
});
|
|
35
36
|
const data = (instant) => z.looseObject({
|
|
36
37
|
cve_id: z.string(),
|
|
@@ -62,13 +63,28 @@ export function epssHistoryOutput(kit) {
|
|
|
62
63
|
first_day: z.string().nullable().describe("The earliest FIRST score date the run log lists; null when it lists none."),
|
|
63
64
|
last_day: z.string().nullable(),
|
|
64
65
|
days_held: z.number().int().min(0).nullable().describe("Score dates from first_day to last_day the record holds; null when none are listed."),
|
|
65
|
-
days_partial: z.array(z.string()).describe("Score dates held for some CVEs only."),
|
|
66
|
+
days_partial: z.array(z.string()).describe("Score dates held for some CVEs only, because their refreshes failed part-way."),
|
|
66
67
|
days_missing: z.array(z.string()).describe("Score dates from first_day to last_day the record does not hold."),
|
|
68
|
+
days_in_progress: z
|
|
69
|
+
.array(z.string())
|
|
70
|
+
.nullable()
|
|
71
|
+
.describe("Score dates a refresh that has not finished is applying (#3143): held for some CVEs so far. A date an earlier refresh failed part-way on is in days_partial too. Null when the API did not say."),
|
|
72
|
+
runs_undated: z
|
|
73
|
+
.number()
|
|
74
|
+
.int()
|
|
75
|
+
.min(0)
|
|
76
|
+
.nullable()
|
|
77
|
+
.describe("Logged refreshes with no score date (#3143): the days they applied are not counted, so a day in days_missing may be held for the CVEs they changed, and their points carry no score_date. Null when the API did not say."),
|
|
78
|
+
refresh_runs_truncated: z
|
|
79
|
+
.boolean()
|
|
80
|
+
.nullable()
|
|
81
|
+
.describe("True when only the newest refreshes of the run log were read, so the earliest score dates are not listed; null when the API did not say."),
|
|
67
82
|
}),
|
|
68
83
|
freshness: null,
|
|
69
84
|
});
|
|
70
85
|
}
|
|
71
86
|
const isNum = (v) => typeof v === "number" && Number.isFinite(v);
|
|
87
|
+
const isCount = (v) => Number.isInteger(v) && v >= 0;
|
|
72
88
|
const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
73
89
|
const realInstant = (v) => typeof v === "string" && Date.parse(v) > 0;
|
|
74
90
|
const isDay = (v) => typeof v === "string" && /^\d{4}-\d{2}-\d{2}$/.test(v);
|
|
@@ -96,7 +112,10 @@ function completeness(d, points) {
|
|
|
96
112
|
daysHeld: firstDay && isNum(cov.days_held) ? cov.days_held : undefined,
|
|
97
113
|
daysPartial: dayList(cov.days_partial),
|
|
98
114
|
daysMissing: dayList(cov.days_missing),
|
|
115
|
+
daysInProgress: Array.isArray(cov.days_in_progress) ? dayList(cov.days_in_progress) : undefined,
|
|
99
116
|
runLogSince: realInstant(cov.run_log_since) ? cov.run_log_since : undefined,
|
|
117
|
+
runsUndated: isCount(cov.runs_undated) ? cov.runs_undated : undefined,
|
|
118
|
+
runsTruncated: typeof cov.refresh_runs_truncated === "boolean" ? cov.refresh_runs_truncated : undefined,
|
|
100
119
|
hasCoverage: isObj(d.coverage),
|
|
101
120
|
};
|
|
102
121
|
}
|
|
@@ -146,12 +165,26 @@ function note(head, id, d, c) {
|
|
|
146
165
|
const held = c.daysHeld === undefined ? "" : `, ${c.daysHeld} held`;
|
|
147
166
|
const missing = c.daysMissing.length ? `; not held: ${sayDays(c.daysMissing, "days_missing")}` : "; none missing";
|
|
148
167
|
const partial = c.daysPartial.length ? `; held for some CVEs only (a refresh failed part-way): ${sayDays(c.daysPartial, "days_partial")}` : "";
|
|
168
|
+
// #3143: never "nothing failed". A retry of a date whose earlier refresh failed part-way is in
|
|
169
|
+
// days_partial too, so it is named in both clauses, each true of it.
|
|
170
|
+
const applying = c.daysInProgress?.length
|
|
171
|
+
? `; being applied by a refresh that has not finished, so held for some CVEs so far: ${sayDays(c.daysInProgress, "days_in_progress")}`
|
|
172
|
+
: "";
|
|
149
173
|
const since = c.runLogSince ? ` (run log since ${c.runLogSince}; earlier dates are not listed)` : "";
|
|
150
|
-
s.push(`FIRST score dates in EchelonGraph's run log${since}: ${c.firstDay} to ${c.lastDay}${held}${missing}${partial}.`);
|
|
174
|
+
s.push(`FIRST score dates in EchelonGraph's run log${since}: ${c.firstDay} to ${c.lastDay}${held}${missing}${partial}${applying}.`);
|
|
151
175
|
}
|
|
152
176
|
else if (c.hasCoverage) {
|
|
153
177
|
s.push("The run log lists no FIRST score date yet, so this answer does not say which days the record holds, and no point carries a score_date.");
|
|
154
178
|
}
|
|
179
|
+
// #3143: said only when non-zero; coverage carries the value either way. "Not counted": in no
|
|
180
|
+
// list, held, partial or in progress.
|
|
181
|
+
if (c.runsUndated) {
|
|
182
|
+
const one = c.runsUndated === 1;
|
|
183
|
+
s.push(`${c.runsUndated} logged refresh${one ? " has" : "es have"} no FIRST score date (coverage.runs_undated: ${one ? "its file named none, or it" : "their files named none, or they"} stopped before recording it). The day${one ? " it" : "s they"} applied ${one ? "is" : "are"} not counted, so a day listed missing, or not listed, may be held for the CVEs ${one ? "it" : "they"} changed, and the points ${one ? "it" : "they"} wrote carry no score_date.`);
|
|
184
|
+
}
|
|
185
|
+
if (c.runsTruncated) {
|
|
186
|
+
s.push("refresh_runs_truncated is true: only the newest refreshes of the run log were read, so first_day is not where the run log starts, and earlier score dates are not listed.");
|
|
187
|
+
}
|
|
155
188
|
if (d.points_truncated === true)
|
|
156
189
|
s.push("points_truncated is true: only the newest records were read, so the earliest changes are missing from this series.");
|
|
157
190
|
if (d.latest_point_matches_current === false) {
|
|
@@ -193,6 +226,9 @@ export async function epssHistory(kit, cve_id) {
|
|
|
193
226
|
days_held: c.daysHeld ?? null,
|
|
194
227
|
days_partial: c.daysPartial,
|
|
195
228
|
days_missing: c.daysMissing,
|
|
229
|
+
days_in_progress: c.daysInProgress ?? null,
|
|
230
|
+
runs_undated: c.runsUndated ?? null,
|
|
231
|
+
refresh_runs_truncated: c.runsTruncated ?? null,
|
|
196
232
|
},
|
|
197
233
|
freshness: null,
|
|
198
234
|
notes: [
|