@blamejs/exceptd-skills 0.21.14 → 0.21.16

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.
@@ -10,7 +10,23 @@
10
10
  * cell gives one), a public-exploit column (Yes/No), and an AI column. An
11
11
  * "AI-Discovered" column is compared with ai_discovered; an AI column that also
12
12
  * covers acceleration, enablement or weaponization is compared with
13
- * ai_discovered OR ai_assisted_weaponization. A prose line that names exactly one
13
+ * ai_discovered OR ai_assisted_weaponization. The Active Exploitation, Patch and
14
+ * Live Patch columns (the headers columnKind lists) are compared only when no
15
+ * cell in the row names another CVE, the row's CVE is under an identifier
16
+ * header (ID_HEADER_PART, with no ROLE_HEADER word) or, in a table with no
17
+ * identifier column, in the first column under a header that names no CVE and
18
+ * no role, and no other row in the same section
19
+ * (from one "#" or "##" heading outside a fenced code block to the next) has
20
+ * that CVE as its own row CVE (a row that names no CVE and leaves blank the
21
+ * cell under the row above's CVE continues that row): Active Exploitation in the
22
+ * STATE_FORMS,
23
+ * NONE_FORMS or NOT_CONFIRMED_FORMS wording ("No confirmed exploitation"
24
+ * differs only from Confirmed, and None agrees with Theoretical); Patch in one
25
+ * of the PATCH_FORMS wordings, or as a Yes alone or followed by a PATCH_FORMS
26
+ * patch statement (a bare Yes only in a plain Patch column); Live Patch as a Yes
27
+ * or No that ends the cell or is followed by a comma, semicolon, period, colon,
28
+ * dash or parenthesis, with no qualifying word or second answer after it. A
29
+ * prose line that names exactly one
14
30
  * catalog CVE is compared on "RWEP <n>", "CVSS <n.n>", "KEV-listed <date>" and
15
31
  * "not KEV-listed". On any line, the parentheses that follow a CVE id are read
16
32
  * as that CVE's values, so a list such as "CVE-A (90), CVE-B (Dirty Frag, 38,
@@ -63,12 +79,137 @@ function columnKind(header) {
63
79
  if (/\bpoc\b|public exploit/.test(h)) return "poc";
64
80
  if (/^ai[- ]discovered$/.test(h)) return "ai_discovered";
65
81
  if (/^ai\b/.test(h)) return "ai_any";
82
+ // The exploitation, patch and live-patch columns are read only under these
83
+ // headers. Any other header ("Patch Reboot Required?", "Patch Downtime",
84
+ // "Active Exploitation Actor", "Patch / Live Patch") is not compared.
85
+ if (/^active exploitation$/.test(h)) return "active";
86
+ // "Live Patch" or "Live-Patch"; a bare "Livepatch" column names Canonical
87
+ // Livepatch alone in a per-vendor matrix.
88
+ if (/^live[- ]patch(?:able|es)?(?:\s+availab(?:le|ility))?$/.test(h)) return "live_patch";
89
+ if (/^patch(?:es)?(?:\s+availab(?:le|ility))?$/.test(h)) return "patch";
90
+ if (/^patch(?:es)?\s*\/\s*(?:mitigation|remediation|workaround)s?(?:\s+availab(?:le|ility))?$/.test(h)) return "patch_or_mitigation";
66
91
  return null;
67
92
  }
68
93
 
94
+ /**
95
+ * The vendor-patch phrase: "Vendor patch", "Vendor updates", "Vendor IDE update".
96
+ * The one qualifier word is a closed list, so a status word in that slot
97
+ * ("Vendor declined fix", "Vendor never patches") never forms the phrase.
98
+ */
99
+ const PHRASE = "vendor(?: (?:ide|saas|firmware|security|product|os|kernel|browser|app|agent|server|client|cloud|library|platform))? (?:patch|update|fix)(?:e?s)?";
100
+ // A word that can qualify or reverse the statement it follows ("Yes (kpatch
101
+ // pending)", "Vendor patch + hardening still pending"). A live-patch cell whose text after
102
+ // its leading Yes or No holds one, and a cell in a qualifiable PATCH_FORMS
103
+ // wording that holds one, is not compared.
104
+ const QUALIFYING = /\b(?:not|no|none|never|n\/a|tbd|tba|eta|pending|planned|expected|unavailable|unreleased|awaiting|awaited|outstanding|delayed|forthcoming|upcoming|coming|unknown|soon|later|next|progress|development|scheduled|future|until|needed|required|necessary|still|yet|q[1-4]|if|unless|except|but|although|however|withdrawn|revoked|reverted|pulled|superseded)\b/i;
105
+
106
+ /**
107
+ * The patch cells the gate reads, each matched against the whole cell, with the
108
+ * value it reads and whether a qualifying word leaves it not compared. Any other
109
+ * wording is not compared.
110
+ */
111
+ const PATCH_FORMS = [
112
+ // "Vendor patch", "Vendor updates available", "Vendor patches shipped 2099-01-01".
113
+ [new RegExp(`^${PHRASE}(?:\\s+(?:is\\s+|are\\s+)?(?:available|released|shipped|published|issued|applied)(?:\\s+\\d{4}-\\d{2}-\\d{2})?)?\\s*\\.?$`, "i"), true, false], // allow:dynamic-regex — built from the static PHRASE literal
114
+ // "Vendor patch + config hardening", "Vendor IDE update + manifest signing".
115
+ // Each "+" item starts and ends on a non-space character and stops at "+", ";",
116
+ // "," or a parenthesis, so the spaces around a "+" can only match `\s*` and a
117
+ // failing match backtracks at most linearly.
118
+ [new RegExp(`^${PHRASE}(?:\\s*\\+\\s*[^+;,()\\s](?:[^+;,()]*[^+;,()\\s])?)+$`, "i"), true, true], // allow:dynamic-regex — static PHRASE
119
+ // "Mitigation + vendor patch".
120
+ [new RegExp(`^[a-z][a-z -]{0,40}\\+\\s*${PHRASE}\\s*\\.?$`, "i"), true, true], // allow:dynamic-regex — static PHRASE
121
+ // "Vendor patch pending", "Vendor fix unavailable", "Vendor update not yet available".
122
+ [new RegExp(`^${PHRASE}\\s+(?:is\\s+|are\\s+)?(?:unavailable|unreleased|pending|planned|not (?:yet )?(?:available|released|shipped|published|issued))\\s*\\.?$`, "i"), false, false], // allow:dynamic-regex — static PHRASE
123
+ // "No", "None", "No patch", "No vendor fix available", "No vendor patch (product EoL)".
124
+ [new RegExp(`^(?:no|none)(?:\\s+(?:${PHRASE}|patch(?:es)?|fix(?:es)?|updates?)(?:\\s+(?:is\\s+|are\\s+)?available)?)?(?:\\s+\\((?:product\\s+)?(?:eol|end[- ]of[- ]life|unsupported)\\))?\\s*\\.?$`, "i"), false, false], // allow:dynamic-regex — static PHRASE
125
+ // "Mitigation only; no vendor patch", "Workaround only, no vendor fix available".
126
+ [new RegExp(`^(?:mitigations?|workarounds?|compensating controls?)(?:\\s+only)?\\s*[;,]\\s*no\\s+${PHRASE}(?:\\s+available)?\\s*\\.?$`, "i"), false, false], // allow:dynamic-regex — static PHRASE
127
+ ];
128
+
129
+ /**
130
+ * The value of the first PATCH_FORMS entry that matches the whole cell, or null
131
+ * when no entry matches or when that entry is qualifiable and the cell holds a
132
+ * QUALIFYING word.
133
+ */
134
+ function patchForm(cell) {
135
+ for (const [re, value, qualifiable] of PATCH_FORMS) {
136
+ if (re.test(cell)) return qualifiable && QUALIFYING.test(cell) ? null : value;
137
+ }
138
+ return null;
139
+ }
140
+
141
+ /**
142
+ * A leading Yes or No that ends the cell or is followed by a comma, semicolon,
143
+ * period, colon, dash or parenthesis ("Yes (kpatch/livepatch)", "No — kpatch
144
+ * RHEL-only"), or null when another word or a hyphen follows it ("No-reboot
145
+ * hotpatch", "No reboot"), when a qualifying word comes after it ("Yes (kpatch
146
+ * pending)"), or when a later Yes follows it ("No (Ubuntu); Yes (RHEL
147
+ * kpatch)").
148
+ */
149
+ function plainAnswer(cell) {
150
+ const m = /^(yes|no)(?=\s*(?:$|[,;.:(—–]|-\s))/i.exec(cell);
151
+ if (!m) return null;
152
+ const rest = cell.slice(m[0].length);
153
+ if (QUALIFYING.test(rest) || /\byes\b/i.test(rest)) return null;
154
+ return m[1].toLowerCase() === "yes";
155
+ }
156
+
157
+ /**
158
+ * A Patch column cell's claim. A Yes reads as a patch when a PATCH_FORMS patch
159
+ * statement follows it ("Yes, vendor patch"), or when it is the whole cell in a
160
+ * plain Patch column; in a "Patch / Mitigation", "Patch / Remediation" or "Patch
161
+ * / Workaround" column a bare Yes can answer for the mitigation and is not
162
+ * compared. A Yes with any other text after it ("Yes (workaround)", "Yes, if on
163
+ * 2.x") is not compared. Any other cell is read only when the whole cell is one
164
+ * of PATCH_FORMS.
165
+ */
166
+ function patchCellValue(cell, kind) {
167
+ if (/^yes\b/i.test(cell)) {
168
+ // "Yes (vendor IDE update)" is read on the text inside the parentheses.
169
+ const wrapped = /^yes\s*\(([^()]*)\)\s*\.?$/i.exec(cell);
170
+ const rest = wrapped ? wrapped[1].trim() : cell.replace(/^yes\b[\s,;:—–-]*/i, "");
171
+ if (/^\.?$/.test(rest)) return kind === "patch" ? true : null;
172
+ return patchForm(rest) === true ? true : null;
173
+ }
174
+ return patchForm(cell);
175
+ }
176
+
177
+ /** The cells that read as no exploitation: "None", "None observed", "No exploitation observed in the wild". */
178
+ const NONE_FORMS = /^(?:none|no)(?:\s+(?:in[- ]the[- ]wild\s+)?exploitation)?(?:\s+(?:observed|recorded|reported|seen|detected))?(?:\s+in[- ]the[- ]wild)?\s*\.?$/i;
179
+ /** The cells that deny confirmation: "No confirmed exploitation", "No exploitation confirmed", "None known", "No known in-the-wild use". */
180
+ const NOT_CONFIRMED_FORMS = /^(?:(?:no|none)\s+(?:yet\s+)?(?:publicly\s+)?(?:confirmed|known)(?:\s+(?:in[- ]the[- ]wild\s+)?(?:exploitation|use|attacks?))?(?:\s+in[- ]the[- ]wild)?|no\s+(?:in[- ]the[- ]wild\s+)?exploitation\s+(?:yet\s+)?(?:publicly\s+)?(?:confirmed|known))\s*\.?$/i;
181
+ /** The cells that name a state: "Confirmed", "Confirmed mass exploitation", "Confirmed exploitation 2024", "Theoretical only". */
182
+ const STATE_FORMS = /^(confirmed|suspected|unknown|theoretical)(?:\s+(?:mass\s+|active\s+)?exploitation)?(?:\s+\d{4}(?:\s*[-–]\s*\d{4})?)?(?:\s+only)?\s*\.?$/i;
183
+
184
+ /**
185
+ * Active-exploitation cell, read only when the whole cell is one of STATE_FORMS,
186
+ * NONE_FORMS or NOT_CONFIRMED_FORMS. "No confirmed exploitation" (or "None
187
+ * known") denies confirmation, not exploitation: it is true of every state but
188
+ * confirmed, so it returns "not_confirmed", which differs only from a confirmed
189
+ * entry. Any other wording ("Confirmed PoC", "No confirmed mass exploitation",
190
+ * "Suspected (supply-chain)", "No data") is not compared.
191
+ */
192
+ function exploitationValue(cell) {
193
+ if (NOT_CONFIRMED_FORMS.test(cell)) return "not_confirmed";
194
+ if (NONE_FORMS.test(cell)) return "none";
195
+ const m = STATE_FORMS.exec(cell);
196
+ return m ? m[1].toLowerCase() : null;
197
+ }
198
+
69
199
  /** A cell's text with Markdown emphasis and code marks removed. */
70
200
  const plain = (cell) => cell.replace(/[*_`]/g, "").trim();
71
201
 
202
+ /** The column kinds compared only on a row that names a single CVE. */
203
+ const ROW_CVE_KINDS = new Set(["active", "patch", "patch_or_mitigation", "live_patch"]);
204
+ /**
205
+ * One "/" part of an identifier column's header, with parenthetical notes
206
+ * removed: CVE or CVEs, optionally preceded by Evidence and followed by ID or
207
+ * Class ("CVE", "CVE ID", "Evidence CVE", "CVE Class", "CVE (if any)").
208
+ */
209
+ const ID_HEADER_PART = /^(?:evidence\s+)?cves?(?:\s+(?:id|class))?$/i;
210
+ /** A header word that marks a column as describing or citing a case, not naming the row. */
211
+ const ROLE_HEADER = /\b(?:related|siblings?|similar|see[- ]also|examples?|e\.g|analog(?:ue)?s?|pocs?|demos?|exploits?|notes?|comments?|rationales?|reasons?|descriptions?|details?)\b/i;
212
+
72
213
  function compareCell(kind, raw, e, say) {
73
214
  const cell = plain(raw);
74
215
  if (kind === "cvss") {
@@ -97,6 +238,20 @@ function compareCell(kind, raw, e, say) {
97
238
  const v = aiValue(cell, /^ai[- ](?:discovered|assisted|accelerated|enabled|weaponi[sz]ed)\b/i);
98
239
  const expected = Boolean(e.ai_discovered) || Boolean(e.ai_assisted_weaponization);
99
240
  if (v !== null && v !== expected) say(`AI "${cell.slice(0, 40)}", catalog ai_discovered ${e.ai_discovered} / ai_assisted_weaponization ${e.ai_assisted_weaponization}`);
241
+ } else if (kind === "active") {
242
+ const v = exploitationValue(cell);
243
+ const actual = String(e.active_exploitation || "").toLowerCase();
244
+ // The catalog's theoretical state is a public PoC with no exploitation seen,
245
+ // so a None cell agrees with it, and a Theoretical cell with a none entry.
246
+ const quiet = (s) => s === "none" || s === "theoretical";
247
+ const differs = v === "not_confirmed" ? actual === "confirmed" : v !== actual && !(quiet(v) && quiet(actual));
248
+ if (v && actual && differs) say(`active exploitation "${cell.slice(0, 40)}", catalog ${actual}`);
249
+ } else if (kind === "patch" || kind === "patch_or_mitigation") {
250
+ const v = patchCellValue(cell, kind);
251
+ if (v !== null && v !== Boolean(e.patch_available)) say(`patch "${cell.slice(0, 40)}", catalog patch_available ${e.patch_available}`);
252
+ } else if (kind === "live_patch") {
253
+ const v = plainAnswer(cell);
254
+ if (v !== null && v !== Boolean(e.live_patch_available)) say(`live patch "${cell.slice(0, 40)}", catalog live_patch_available ${e.live_patch_available}`);
100
255
  }
101
256
  }
102
257
 
@@ -216,7 +371,34 @@ function compareFactorRow(cells, rawHeader, e, say) {
216
371
  function checkSkill(file, catalog) {
217
372
  const seen = new Set();
218
373
  const failures = [];
219
- const push = (msg) => { if (!seen.has(msg)) { seen.add(msg); failures.push(msg); } };
374
+ // Each failure carries the order in which it was found, so failures held back
375
+ // until the section ends are still reported in line order.
376
+ let seq = 0;
377
+ const push = (msg, at = seq++) => { if (!seen.has(msg)) { seen.add(msg); failures.push([at, msg]); } };
378
+ // Exploitation, patch and live-patch failures are held until the section ends
379
+ // (the next "#" or "##" heading, or the end of the file) and kept only for a
380
+ // CVE that is the row CVE of exactly one row in that section. A per-distribution
381
+ // or per-version table ("RHEL 9 | Yes", "Ubuntu 24.04 | No"), one table or one
382
+ // "###" sub-heading per distribution, or a continuation row that leaves the CVE
383
+ // cell blank answers for one row at a time, not for the CVE.
384
+ let held = [];
385
+ let rowCves = new Map();
386
+ let lastRowCve = null;
387
+ let lastRowCol = -1;
388
+ const flush = () => {
389
+ for (const [at, cve, msg] of held) if (rowCves.get(cve) === 1) push(msg, at);
390
+ held = [];
391
+ rowCves = new Map();
392
+ lastRowCve = null;
393
+ };
394
+ // A "#" line inside a fenced code block does not end the section for the
395
+ // exploitation, patch and live-patch row count. A fence closes only on a line
396
+ // of its own character, at least as long as the opening run, with nothing
397
+ // after it. A nested code block that uses the other character or a shorter run
398
+ // leaves the outer fence open; a bare run of the same character at least as
399
+ // long as the outer one closes it.
400
+ let fence = null;
401
+ const countRow = (cve) => rowCves.set(cve, (rowCves.get(cve) || 0) + 1);
220
402
  let compared = 0;
221
403
  let header = null;
222
404
  let rawHeader = null;
@@ -224,33 +406,68 @@ function checkSkill(file, catalog) {
224
406
  const lines = fs.readFileSync(file, "utf8").split(/\r?\n/);
225
407
  lines.forEach((line, i) => {
226
408
  const where = `${path.relative(ROOT, file).replace(/\\/g, "/")}:${i + 1}`;
409
+ const fenceLine = /^\s*(`{3,}|~{3,})(.*)$/s.exec(line);
410
+ if (fenceLine && !fence) fence = fenceLine[1];
411
+ else if (fenceLine && fenceLine[1][0] === fence[0] && fenceLine[1].length >= fence.length && !fenceLine[2].trim()) fence = null;
227
412
  if (/^#{1,6}\s/.test(line)) {
413
+ // A "###" or deeper heading ("### RHEL 9", "### Ubuntu 24.04") stays in its
414
+ // parent section for the row count.
415
+ if (!fence && /^#{1,2}\s/.test(line)) flush();
228
416
  const headingIds = uniqueCves(line);
229
417
  sectionCve = headingIds.length === 1 && catalog[headingIds[0]] ? headingIds[0] : null;
230
418
  }
231
419
  compared += compareParentheticals(line, catalog, (id, msg) => push(`${where} ${id}: ${msg}`));
232
420
  if (/^\s*\|/.test(line)) {
233
421
  const cells = splitRow(line);
234
- if (header === null) { rawHeader = cells; header = cells.map(columnKind); return; }
422
+ if (header === null) { rawHeader = cells; header = cells.map(columnKind); lastRowCve = null; return; }
235
423
  if (isDivider(cells)) return;
424
+ // A row that names no CVE and leaves blank the cell under the row above's
425
+ // CVE continues that row.
426
+ const continues = lastRowCve && !plain(cells[lastRowCol] || "") && uniqueCves(line).length === 0;
236
427
  if (sectionCve && uniqueCves(line).length === 0) {
428
+ if (continues) countRow(lastRowCve);
429
+ else lastRowCve = null;
237
430
  if (compareFactorRow(cells, rawHeader, catalog[sectionCve], (msg) => push(`${where} ${sectionCve}: ${msg}`))) compared++;
238
431
  return;
239
432
  }
240
- const idCell = cells.find((c) => uniqueCves(c).length > 0) || "";
241
- if (/^(?:no|n\/a)\b/i.test(idCell) || /\bsee\s+CVE-/i.test(idCell)) return;
433
+ const idCol = cells.findIndex((c) => uniqueCves(c).length > 0);
434
+ const idCell = cells[idCol] || "";
435
+ if (/^(?:no|n\/a)\b/i.test(idCell) || /\bsee\s+CVE-/i.test(idCell)) { lastRowCve = null; return; }
242
436
  const ids = uniqueCves(idCell);
243
437
  if (ids.length > 1) {
438
+ lastRowCve = null;
244
439
  const known = ids.filter((id) => catalog[id]);
245
440
  const scored = header.some((kind, k) => (kind === "cvss" || kind === "rwep") && /^\d/.test(plain(cells[k] || "")));
246
441
  if (known.length && scored) push(`${where} ${ids.join(", ")}: ${UNATTRIBUTED}`);
247
442
  return;
248
443
  }
249
- if (ids.length !== 1 || !catalog[ids[0]]) return;
444
+ if (continues) { countRow(lastRowCve); return; }
445
+ if (ids.length !== 1 || !catalog[ids[0]]) { lastRowCve = null; return; }
250
446
  const e = catalog[ids[0]];
251
447
  compared++;
448
+ countRow(ids[0]);
449
+ lastRowCve = ids[0];
450
+ lastRowCol = idCol;
451
+ // The exploitation, patch and live-patch cells are read against the row's
452
+ // CVE only when no cell names another CVE and the CVE sits under an
453
+ // identifier header (ID_HEADER_PART), or, in a table with no identifier
454
+ // column, in the first column under a header that names no CVE and no role
455
+ // (ROLE_HEADER). A row whose CVE is a related
456
+ // case, cited in a PoC, notes, "Related CVE", "CVE (example)" or similar
457
+ // column, does not have them compared with that entry.
458
+ const single = cells.every((c) => uniqueCves(c).every((id) => id === ids[0]));
459
+ const norm = (h) => (h || "").replace(/[?*`_]/g, " ");
460
+ const idHeader = (h) => !ROLE_HEADER.test(h) && h.replace(/\([^)]*\)/g, " ").split("/").some((part) => ID_HEADER_PART.test(part.trim()));
461
+ const first = norm(rawHeader[0]);
462
+ const idColumn = rawHeader.some((h) => idHeader(norm(h)));
463
+ const named = idHeader(norm(rawHeader[idCol])) || (idCol === 0 && !idColumn && !/\bcves?\b/i.test(first) && !ROLE_HEADER.test(first));
252
464
  header.forEach((kind, k) => {
253
- if (kind && cells[k] !== undefined) compareCell(kind, cells[k], e, (msg) => push(`${where} ${ids[0]}: ${msg}`));
465
+ if (!kind || cells[k] === undefined) return;
466
+ if (!ROW_CVE_KINDS.has(kind)) {
467
+ compareCell(kind, cells[k], e, (msg) => push(`${where} ${ids[0]}: ${msg}`));
468
+ } else if (single && named) {
469
+ compareCell(kind, cells[k], e, (msg) => held.push([seq++, ids[0], `${where} ${ids[0]}: ${msg}`]));
470
+ }
254
471
  });
255
472
  return;
256
473
  }
@@ -266,7 +483,8 @@ function checkSkill(file, catalog) {
266
483
  compared++;
267
484
  compareProse(line, catalog[ids[0]], (msg) => push(`${where} ${ids[0]}: ${msg}`));
268
485
  });
269
- return { failures, compared };
486
+ flush();
487
+ return { failures: failures.sort((a, b) => a[0] - b[0]).map(([, msg]) => msg), compared };
270
488
  }
271
489
 
272
490
  function check(skillsDir, catalog) {