@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.
- package/CHANGELOG.md +8 -0
- package/data/_indexes/_meta.json +8 -8
- package/data/_indexes/activity-feed.json +16 -16
- package/data/_indexes/catalog-summaries.json +6 -6
- package/data/_indexes/chains.json +2 -2
- package/data/attack-techniques.json +3 -1
- package/data/cve-catalog.json +107 -1
- package/data/cwe-catalog.json +2 -1
- package/data/framework-control-gaps.json +7 -1
- package/data/zeroday-lessons.json +99 -2
- package/manifest.json +53 -53
- package/package.json +2 -2
- package/sbom.cdx.json +27 -27
- package/scripts/check-skill-catalog-facts.js +226 -8
|
@@ -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.
|
|
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
|
-
|
|
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
|
|
241
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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) {
|