@blamejs/exceptd-skills 0.21.8 → 0.21.10
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 +18 -0
- package/data/_indexes/_meta.json +4 -4
- package/data/_indexes/activity-feed.json +2 -2
- package/data/_indexes/catalog-summaries.json +2 -2
- package/data/cve-catalog.json +241 -241
- package/data/zeroday-lessons.json +606 -606
- package/lib/cve-batch.js +31 -8
- package/lib/gap-detectors.js +438 -4
- package/manifest.json +53 -53
- package/package.json +1 -1
- package/sbom.cdx.json +24 -24
- package/scripts/check-catalog-gap-budget.js +3 -3
package/lib/cve-batch.js
CHANGED
|
@@ -155,7 +155,7 @@ async function curateBatch({ factsPath, judgmentsPath, apply, catalogRoot, today
|
|
|
155
155
|
atlas: _loadIds(catalogRoot, 'atlas-ttps.json'),
|
|
156
156
|
frameworkGaps: _loadIds(catalogRoot, 'framework-control-gaps.json'),
|
|
157
157
|
};
|
|
158
|
-
const entries = {}; const lessons = {}; const orphans = []; const errors = [];
|
|
158
|
+
const entries = {}; const lessons = {}; const orphans = []; const errors = []; const warnings = [];
|
|
159
159
|
for (const id of Object.keys(facts)) {
|
|
160
160
|
const { entry, errors: e } = enrich.assembleEntry(facts[id], judgments[id] || {}, today);
|
|
161
161
|
entry.id = id; // for orphan messages
|
|
@@ -175,11 +175,34 @@ async function curateBatch({ factsPath, judgmentsPath, apply, catalogRoot, today
|
|
|
175
175
|
}
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
-
//
|
|
179
|
-
//
|
|
178
|
+
// A catalog field name cited after "packet" stops the batch. Other
|
|
179
|
+
// curation-input wording, and a stated KEV listing date that differs from the
|
|
180
|
+
// entry's, are matched by wording that network prose can also form, so they
|
|
181
|
+
// are warnings: the dry run lists them and the batch still applies.
|
|
182
|
+
const where = (f) => `${f.id}: ${f.catalog === 'cve-catalog' ? 'entry' : 'lesson'} ${f.field}`;
|
|
180
183
|
for (const f of gaps.pipelineWordingFindings({ 'cve-catalog': entries, 'zeroday-lessons': lessons }, { includeDrafts: true })) {
|
|
181
|
-
|
|
182
|
-
|
|
184
|
+
if (f.field_citation) {
|
|
185
|
+
errors.push(`${where(f)} cites a catalog field of the curation input ("Packet fields for CVE-...", ` +
|
|
186
|
+
`"the packet's cisa_kev ..."); state the fact and its source instead.`);
|
|
187
|
+
} else {
|
|
188
|
+
warnings.push(`${where(f)} may cite the curation input ("Packet: ...", "the packet names ..."); ` +
|
|
189
|
+
`state the fact and its source instead unless the text is about a network packet.`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// A date the text gives for this entry's own CVE id is a mismatch; any other
|
|
193
|
+
// attribution is a warning.
|
|
194
|
+
for (const f of gaps.kevListingDateFindings({ 'cve-catalog': entries, 'zeroday-lessons': lessons }, { entries })) {
|
|
195
|
+
if (f.definite) errors.push(`${where(f)} ${f.reason}.`);
|
|
196
|
+
else warnings.push(`${where(f)} ${f.reason}; check that the date belongs to this CVE.`);
|
|
197
|
+
}
|
|
198
|
+
// CISA's KEV required action never names a restart. The template forms of
|
|
199
|
+
// that claim ("requires a service restart or system reboot per the KEV
|
|
200
|
+
// requiredAction") stop the batch; any other phrasing the detector matches is
|
|
201
|
+
// a warning, since prose can mention the two together without the claim.
|
|
202
|
+
for (const f of gaps.kevActionRestartFindings({ 'cve-catalog': entries, 'zeroday-lessons': lessons }, { includeDrafts: true })) {
|
|
203
|
+
const msg = `${where(f)} ${f.reason}; state the restart without that attribution, or drop it when the entry's patch_required_reboot does not support it.`;
|
|
204
|
+
if (f.definite) errors.push(msg);
|
|
205
|
+
else warnings.push(msg);
|
|
183
206
|
}
|
|
184
207
|
|
|
185
208
|
// Reported as a validation error so the DRY RUN surfaces it; insertEntries
|
|
@@ -189,7 +212,7 @@ async function curateBatch({ factsPath, judgmentsPath, apply, catalogRoot, today
|
|
|
189
212
|
errors.push(`${id}: already present in the catalog — this batch adds entries and cannot replace one. Restore the catalog and apply once.`);
|
|
190
213
|
|
|
191
214
|
const ok = orphans.length === 0 && errors.length === 0;
|
|
192
|
-
if (!apply || !ok) return { ok, entries, orphans, errors, written: false };
|
|
215
|
+
if (!apply || !ok) return { ok, entries, orphans, errors, warnings, written: false };
|
|
193
216
|
|
|
194
217
|
// WRITE
|
|
195
218
|
const catPath = path.join(catalogRoot, 'data', 'cve-catalog.json');
|
|
@@ -211,7 +234,7 @@ async function curateBatch({ factsPath, judgmentsPath, apply, catalogRoot, today
|
|
|
211
234
|
for (const id of Object.keys(entries))
|
|
212
235
|
fs.writeFileSync(path.join(catalogRoot, 'tests', `${id.toLowerCase()}.test.js`), renderTest(id));
|
|
213
236
|
|
|
214
|
-
return { ok: true, entries, orphans, errors, written: true };
|
|
237
|
+
return { ok: true, entries, orphans, errors, warnings, written: true };
|
|
215
238
|
}
|
|
216
239
|
|
|
217
240
|
async function cli(argv) {
|
|
@@ -247,7 +270,7 @@ async function cli(argv) {
|
|
|
247
270
|
return;
|
|
248
271
|
}
|
|
249
272
|
process.stdout.write(JSON.stringify({ ok: r.ok, count: Object.keys(r.entries).length,
|
|
250
|
-
orphans: r.orphans, errors: r.errors, written: r.written }, null, 2) + '\n');
|
|
273
|
+
orphans: r.orphans, errors: r.errors, warnings: r.warnings, written: r.written }, null, 2) + '\n');
|
|
251
274
|
if (!r.ok) process.exitCode = 1;
|
|
252
275
|
}
|
|
253
276
|
|
package/lib/gap-detectors.js
CHANGED
|
@@ -36,11 +36,49 @@ const PIPELINE_WORDING = [
|
|
|
36
36
|
/\bPacket fields?\s*(?::|for\s+(?:CVE-|this\b))/,
|
|
37
37
|
/\bPacket vector\b/,
|
|
38
38
|
/\bpacket(?:'s)? (?:attack_vector|live_patch_notes|cisa_kev|affected_versions|poc_description|patch_available|active_exploitation_notes|vendor_update_paths|cwe_refs|rwep_\w+)\b/i,
|
|
39
|
-
/\b[Tt]he packet (?:records|names|registers|supplies|lists|says|states|reports|marks|confirms|describes)\b/,
|
|
39
|
+
/\b[Tt]he packet (?:(?:also|actually|itself) )?(?:records|names|registers|supplies|lists|says|states|reports|marks|confirms|describes|notes)\b/,
|
|
40
|
+
// Verbs a curator used for the input bundle, each with the object that makes
|
|
41
|
+
// it curation wording rather than a statement about a network packet.
|
|
42
|
+
/\b[Tt]he packet (?:ties (?:this|the) (?:[\w/.-]+ ){0,4}(?:use-after-free|injection|overflow|flaw|defect|sink|weakness|bypass|CWE-\d+|affected|remediation|vulnerability)(?![\w-])|documents\b|locates the (?:flaw|defect|bug)|pairs that with|pairs an? (?:public (?:PoC|exploit)|pre-auth|(?:\d{4}(?:-\d{2}-\d{2})? )?(?:CVE|KEV))|attributes the (?:residual|exposure|flaw|defect)\b|establishes the (?:flaw|defect)|qualifies the (?:flaw|defect)|scores it (?:RWEP|CVSS)|dates the (?:vendor|fix|KEV)|conditions the (?:bypass|flaw|exploit|crash|trigger)\b|cites\b|pins this to|characterizes (?:the caller|the [\w -]{1,40}? as)|classifies this as|classes this CVE|labels this a|calls the (?:[\w-]+ ){0,3}(?:flaw|defect)|places (?:this|the (?:flaw|defect|bug|bypass|trigger|CVE)|execution)(?![\w-])|recording confirmed|naming a chain|chains that with|enumerates fixes|defines it:|has it wired|noting no live|measures:)/,
|
|
40
43
|
/\b(?:per|according to) the packet\b/i,
|
|
44
|
+
/\bthe remediation the packet supports\b/i,
|
|
45
|
+
// A label that opens a text or sentence: "Packet records ...", "Packet attack
|
|
46
|
+
// vector: ...", "Packet NIST-800-53-SI-2 gap: ...", "Packet CVE-2025-0282 ...".
|
|
47
|
+
/(?:^|[.;!?]\s+)Packet (?:records (?:CWE-|CVE-|CVSS|an? CISA|cisa_|patch_|poc_|live_|active_|an? [\w -]{0,30}?(?:flaw|zero-day|write|read|overflow|injection|bypass|vulnerability|use-after-free)(?![\w-]))|names the affected|gives (?:patch|live|poc|cisa)_|gap (?:NIST|NIS2|AU-|UK-CAF|ISO-27001|DORA|EU-)|attack (?:vector|path)(?: as recorded)?:|affected(?::| field\b|\/)|name(?::| and vector\b)|description:|citing gaps\b|for CVE-|entry ['"‘“]|CVE-\d{4}-\d+|(?:NIST|NIS2|AU-|UK-CAF|ISO-27001|DORA|EU-|PCI-DSS|HIPAA|SOC2|CMMC|FedRAMP)[\w.-]* gap\b)/i,
|
|
48
|
+
/\bthe packet makes [\w -]{1,40}? the (?:precondition|condition|clock)\b/i,
|
|
49
|
+
/\bthe packet for CVE-\d/i,
|
|
50
|
+
/\bCWE-\d+ in the packet\b(?![\s-]*(?:headers?|captures?|payloads?|body|trailer|data|stream|buffer|fields?|options?|structure|pars\w*|process\w*|handl\w*|engine|path|type|flow|size|length|format|layer|queue|filter\w*|inspection|socket))/,
|
|
51
|
+
/\bCorroborating packet fields\b/i,
|
|
52
|
+
/\bthis packet (?:allows only|is one of the few|carries both halves)\b/i,
|
|
53
|
+
/\bpacket's (?:[\w-]+ )?note (?:records|says|states)\b/i,
|
|
54
|
+
/\bpacket's (?:structured record says|range runs from \d+\.\d+\.\d+|history supports|point is that|opening fact|update requires a reboot|access condition includes|second path is simply|mass exploitation|actual exploitation path)\b/i,
|
|
55
|
+
/\bpacket vector (?:states|records|says)\b/,
|
|
56
|
+
// "in the packet" is curation wording only when no part of a network packet,
|
|
57
|
+
// or the code that handles one (header, capture, payload, parser ...), follows it.
|
|
58
|
+
/\bcited in the packet\b(?![\s-]*(?:headers?|captures?|payloads?|body|trailer|data|stream|buffer|fields?|options?|structure|pars\w*|process\w*|handl\w*|engine|path|type|flow|size|length|format|layer|queue|filter\w*|inspection|socket))/i,
|
|
59
|
+
/\b(?:exploit|PoC|proof of concept|proof-of-concept) recorded in the packet\b(?![\s-]*(?:headers?|captures?|payloads?|body|trailer|data|stream|buffer|fields?|options?|structure|pars\w*|process\w*|handl\w*|engine|path|type|flow|size|length|format|layer|queue|filter\w*|inspection|socket))/i,
|
|
60
|
+
/\b(?:NVD|KEV|CISA|short) description(?: of CVE-\d{4}-\d+)? (?:carried )?in the packet\b(?![\s-]*(?:headers?|captures?|payloads?|body|trailer|data|stream|buffer|fields?|options?|structure|pars\w*|process\w*|handl\w*|engine|path|type|flow|size|length|format|layer|queue|filter\w*|inspection|socket))/i,
|
|
61
|
+
/\bfollows? the packet rather than\b/i,
|
|
62
|
+
/\b(?:a|the) claim the packet makes\b/i,
|
|
63
|
+
/\bwhat this packet establishes\b/i,
|
|
64
|
+
/\bthis packet makes it an? (?:authentication|enforcement|remediation|patch|priority|containment)[\w -]{0,30}?(?:point|item)\b/i,
|
|
65
|
+
/\bThis packet carries (?:two|three|several) facts\b/,
|
|
66
|
+
/\b(?:two|three|several) packet details\b/i,
|
|
67
|
+
/\bthe packet is (?:explicit|specific|silent|clear) (?:that|about)\b/i,
|
|
68
|
+
/\bwhat the packet does (?:not )?(?:establish|supply|say)\b/i,
|
|
69
|
+
/\bfact the packet does supply\b/i,
|
|
70
|
+
/\bthe packet has an? (?:(?:pre-authenticated|unauthenticated|authenticated|remote|local|low-privileged|privileged|adjacent),? ){0,4}attacker(?![\w-])/i,
|
|
71
|
+
/\bthe packet has the (?:flaw|defect)\b/i,
|
|
72
|
+
/\bthe packet requires (?:a user to open\b|a reboot after\b|the fixed build\b)/i,
|
|
73
|
+
/\b(?:reboot|restart) the packet requires\b/i,
|
|
74
|
+
/\bthe packet identifies (?:the (?:affected|vulnerable) (?:product|component|version)|the (?:product|flaw|defect) as)\b/i,
|
|
75
|
+
/\bthe packet identifies [\w.-]+\.exe\b/i,
|
|
76
|
+
/\bwhich the packet identifies as\b/i,
|
|
77
|
+
/\bthe packet sets (?:ai_|poc_|cisa_|patch_|live_|active_)/i,
|
|
41
78
|
// A possessive followed by a curation noun, a catalog field identifier or a
|
|
42
79
|
// framework key. Words a network packet can also own (path, delivery, length,
|
|
43
|
-
// boundary)
|
|
80
|
+
// boundary) appear only with the verbs a curator used with them.
|
|
81
|
+
/\bpacket's (?:path (?:ends in (?:OS |arbitrary |remote )?(?:command|code)|hands an? (?:unauthenticated|remote|local|attacker)|(?:needs|requires) no (?:credentials?|authentication|user|account|code)|starts from an? (?:local|remote|unauthenticated)|goes from unauthenticated|requires to reach)\b|delivery (?:path|step) is (?:mail|a victim|a target)\b|request (?:arrives|is) unauthenticated\b|only (?:other )?(?:stated|remediation)\b|(?:[\w-]+ ){0,2}access requirement\b|(?!flow|congestion)[\w-]*(?:protection|management|security|remediation|boundary|patch|hardening|monitoring|control)[\w-]* gap\b|trigger is an? (?:app|user|local)\b|end state is (?:an? attacker|arbitrary|remote (?:code|command)|code execution|command execution)\b)/i,
|
|
44
82
|
/\bpacket's\s+(?:own|vector|stated|attack(?:\s+(?:vector|path))?|exploitation|remediation|live[- ]?patch|livepatch|chain|fix(?:ed)?|attacker|outcome|affected|flaw(?:-remediation)?|precondition|primitive|rwep|exploit|kev|gaps?|description|cit(?:ing|ed)|confirmed|defect|cwe\b|escalation|caf|recorded|requiredaction|product|essential(?:-eight)?|framing|cvss|reachability|timeline|chained|campaign|exposure|actor|interim|named|attribution|title|targeting|coverage|end-of-life|guidance|observed|recommended|threat|public-exploit|vendor|requirement|mitigation|documented|implant|lure|post-exploitation|patch-management|references?|notes?|framework|facts|advisory|versions?(?!\s+(?:field|number|byte|bits?)\b)|summary|deadline|(?:nist-800-53|uk-caf|au-essential-8|au-ism|iso-27001|nis2|dora|eu-ai-act|cwe-)[\w.-]*|(?:active_exploitation(?:_notes)?|affected_versions|ai_discovered|ai_discovery_\w+|attack_vector|cisa_kev\w*|cvss_\w+|cwe_refs|discovery_attribution_note|epss_\w+|framework_control_gaps|known_ransomware_use|live_patch_\w+|patch_available|patch_required_reboot|poc_available|poc_description|rwep_\w+|vendor_advisories|vendor_update_paths|verification_sources))\b/i,
|
|
45
83
|
];
|
|
46
84
|
|
|
@@ -49,6 +87,69 @@ function hasPipelineWording(str) {
|
|
|
49
87
|
return PIPELINE_WORDING.some((re) => re.test(str));
|
|
50
88
|
}
|
|
51
89
|
|
|
90
|
+
// The subset no sentence about a network packet can form: a catalog field name
|
|
91
|
+
// cited after "packet", or a "Packet fields" label naming a CVE.
|
|
92
|
+
// refresh --curate-batch refuses a batch on these; the rest of PIPELINE_WORDING
|
|
93
|
+
// is reported as a warning there and counted by the gap audit.
|
|
94
|
+
// Every snake_case field name a CVE catalog entry uses or its schema declares.
|
|
95
|
+
// tests/gap-detectors.test.js fails when either grows a name this list lacks.
|
|
96
|
+
const CATALOG_FIELD_NAMES = new Set([
|
|
97
|
+
"_attack_refs_correction_note", "_auto_imported", "_curation_note", "_draft", "_editorial_note",
|
|
98
|
+
"_editorial_note_lightning", "_editorial_promoted", "_intake_method", "_kev_short_description",
|
|
99
|
+
"active_exploitation", "active_exploitation_notes", "actor_account_note", "additional_osv_ids",
|
|
100
|
+
"affected_versions", "ai_assist_weaponization_notes", "ai_assisted_notes", "ai_assisted_weaponization",
|
|
101
|
+
"ai_discovered", "ai_discovery_date", "ai_discovery_notes", "ai_discovery_source", "ai_factor",
|
|
102
|
+
"aliases_note", "as_of", "atlas_refs", "atlas_refs_note", "attack_class", "attack_refs", "attack_refs_note",
|
|
103
|
+
"attack_vector", "blast_radius", "cisa_kev", "cisa_kev_date", "cisa_kev_date_correction_note",
|
|
104
|
+
"cisa_kev_due_date", "cisa_kev_due_date_note", "cisa_kev_pending", "cisa_kev_pending_reason",
|
|
105
|
+
"complexity_notes", "curation_note", "cvss_correction_note", "cvss_note", "cvss_score",
|
|
106
|
+
"cvss_score_alternate", "cvss_score_note", "cvss_v4_score", "cvss_v4_vector", "cvss_vector",
|
|
107
|
+
"cvss_vector_alternate", "cwe_note", "cwe_refs", "cwe_refs_note", "discovery_attribution_note",
|
|
108
|
+
"epss_correction_note", "epss_date", "epss_note", "epss_percentile", "epss_score", "epss_source",
|
|
109
|
+
"framework_control_gaps", "intake_gap_note", "kev_scope_note", "known_ransomware_use", "last_updated",
|
|
110
|
+
"last_verified", "live_patch_available", "live_patch_notes", "live_patch_tools", "package_confidence",
|
|
111
|
+
"pairing_note", "patch_available", "patch_required_reboot", "poc_available", "poc_description",
|
|
112
|
+
"primary_id", "reboot_required", "related_threats", "related_threats_note", "remediation_note",
|
|
113
|
+
"remediation_status", "remediation_status_verified_at", "rwep_correction_note", "rwep_factors",
|
|
114
|
+
"rwep_notes", "rwep_rationale", "rwep_score", "source_verified", "status_source", "status_verified",
|
|
115
|
+
"subsystem_anchors", "vendor_advisories", "vendor_advisories_note", "vendor_update_paths",
|
|
116
|
+
"verification_sources",
|
|
117
|
+
]);
|
|
118
|
+
|
|
119
|
+
// A snake_case token right after "packet", "packet's", "packet:",
|
|
120
|
+
// "Packet field(s):" or "Packet field(s) for CVE-X:". It is a field citation
|
|
121
|
+
// only when it names a catalog field, so a plain word ("vector", "IP version")
|
|
122
|
+
// or a network field ("src_ip") never counts.
|
|
123
|
+
const PACKET_FIELD_TOKEN = /\bpacket(?:'s|’s|:| fields?(?: for (?:CVE-\d{4}-\d+|this CVE|this entry))?(?:\s*[:-]|\s+(?:include|includes|are|were|list|lists|show|shows)\b)?)?\s*(?:field(?:\s+named)?\s+)?(_[a-z][a-z0-9_]*|[a-z][a-z0-9]*(?:_[a-z0-9]+)+)\b/gi;
|
|
124
|
+
|
|
125
|
+
// A label that introduces a list ("packet:", "Packet fields for this CVE:",
|
|
126
|
+
// "Packet fields include ..."); every snake_case name in the list, up to the
|
|
127
|
+
// end of the sentence, is checked. A period between digits ("cvss 9.8") and a
|
|
128
|
+
// semicolon between fields do not end it.
|
|
129
|
+
const PACKET_FIELD_LIST = /\bpacket(?: fields?(?: for (?:CVE-\d{4}-\d+|this CVE|this entry))?)?(?:\s*[:-]|\s+(?:include|includes|are|were|list|lists|show|shows)\b)/gi;
|
|
130
|
+
const SNAKE_TOKEN = /(?:^|[^\w])(_[a-z][a-z0-9_]*|[a-z][a-z0-9]*(?:_[a-z0-9]+)+)\b/gi;
|
|
131
|
+
|
|
132
|
+
function hasPipelineFieldCitation(str) {
|
|
133
|
+
if (typeof str !== "string" || str.length === 0) return false;
|
|
134
|
+
for (const m of str.matchAll(PACKET_FIELD_TOKEN)) if (CATALOG_FIELD_NAMES.has(m[1].toLowerCase())) return true;
|
|
135
|
+
for (const m of str.matchAll(PACKET_FIELD_LIST)) {
|
|
136
|
+
// The rest of the label's line, then any bulleted or indented lines that
|
|
137
|
+
// continue it ("Packet fields:\n- cisa_kev: true").
|
|
138
|
+
const [first, ...more] = str.slice(m.index + m[0].length).split("\n");
|
|
139
|
+
const lines = [first];
|
|
140
|
+
for (const line of more) {
|
|
141
|
+
if (!/^\s*(?:[-*•]|\d+[.)])\s+|^\s+\S/.test(line)) break;
|
|
142
|
+
lines.push(line);
|
|
143
|
+
}
|
|
144
|
+
for (const line of lines) {
|
|
145
|
+
// Drop a bullet or number marker ("1.") first, so its period does not end the list.
|
|
146
|
+
const list = line.replace(/^\s*(?:[-*•]|\d+[.)])\s+/, "").split(/\.(?!\d)|(?<!\d)\./)[0];
|
|
147
|
+
for (const t of list.matchAll(SNAKE_TOKEN)) if (CATALOG_FIELD_NAMES.has(t[1].toLowerCase())) return true;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
|
|
52
153
|
// Every string under a value, with its dotted path.
|
|
53
154
|
function _strings(value, pathSoFar, out) {
|
|
54
155
|
if (typeof value === "string") out.push([pathSoFar, value]);
|
|
@@ -72,8 +173,8 @@ function pipelineWordingFindings(loaded, opts = {}) {
|
|
|
72
173
|
const e = cat[id];
|
|
73
174
|
if (!e || (e._auto_imported === true && !opts.includeDrafts)) continue;
|
|
74
175
|
for (const [field, s] of _strings(e, "", [])) {
|
|
75
|
-
if (hasPipelineWording(s)) {
|
|
76
|
-
out.push({ class: "pipeline-wording", catalog, id, field,
|
|
176
|
+
if (hasPipelineWording(s) || hasPipelineFieldCitation(s)) {
|
|
177
|
+
out.push({ class: "pipeline-wording", catalog, id, field, field_citation: hasPipelineFieldCitation(s),
|
|
77
178
|
reason: "text cites the curation input or a catalog field name instead of stating the fact and its source" });
|
|
78
179
|
}
|
|
79
180
|
}
|
|
@@ -82,6 +183,326 @@ function pipelineWordingFindings(loaded, opts = {}) {
|
|
|
82
183
|
return out;
|
|
83
184
|
}
|
|
84
185
|
|
|
186
|
+
// Wording that states when CISA listed the flaw. Due dates ("KEV due date",
|
|
187
|
+
// "due 2026-06-28") are a different date and do not match.
|
|
188
|
+
const KEV_LISTING_DATE = [
|
|
189
|
+
/\bKEV[- ]listed (?:(?:it|(?:this|the) (?:CVE|flaw|vulnerability|bug|defect|issue)|CVE-\d{4}-\d+) )?(?:by CISA )?(?:on )?\(?(\d{4}-\d{2}-\d{2})/gi,
|
|
190
|
+
/\bKEV listing(?: date)?(?: for (?:CVE-\d{4}-\d+|this CVE|this flaw|this vulnerability))?(?:\s*:|\s+(?:is|was|of|on))?\s+\(?(\d{4}-\d{2}-\d{2})/gi,
|
|
191
|
+
/(\d{4}-\d{2}-\d{2}) KEV listing\b/gi,
|
|
192
|
+
// In the forms below the catalog may be owned ("CISA's", "CISA’s", "its",
|
|
193
|
+
// "the CISA", "the"), named "Known Exploited Vulnerabilities (KEV)", and
|
|
194
|
+
// followed by "catalog", "catalogue" or "list".
|
|
195
|
+
/\blisted (?:(?:it|(?:this|the) (?:CVE|flaw|vulnerability|bug|defect|issue)|CVE-\d{4}-\d+) )?(?:in|on|by) (?:(?:CISA['’]s|its|the(?: CISA)?|CISA) )?(?:KEV|Known Exploited Vulnerabilities(?: \(KEV\))?)(?: catalog(?:ue)?| list)?(?: by CISA)? (?:on )?(\d{4}-\d{2}-\d{2})/gi,
|
|
196
|
+
/\bKEV clock (?:that )?(?:opened|opens|started|starts|running from) (?:on )?(\d{4}-\d{2}-\d{2})/gi,
|
|
197
|
+
/\b(?:added|listed) (?:to|in) (?:(?:CISA['’]s|its|the(?: CISA)?|CISA) )?(?:KEV|Known Exploited Vulnerabilities(?: \(KEV\))?)(?: catalog(?:ue)?| list)?(?: by CISA)? (?:on )?(\d{4}-\d{2}-\d{2})/gi,
|
|
198
|
+
/\b(?:entered|joined|appeared in) (?:(?:CISA['’]s|its|the(?: CISA)?|CISA) )?(?:KEV|Known Exploited Vulnerabilities(?: \(KEV\))?)(?: catalog(?:ue)?| list)? on (\d{4}-\d{2}-\d{2})/gi,
|
|
199
|
+
// Transitive forms with a short object: "CISA added the vulnerability to KEV
|
|
200
|
+
// on ...", "put CVE-X on its KEV list on ...".
|
|
201
|
+
/\b(?:added|listed|put|placed) (?:[\w/,&-]+ ){0,5}(?:to|in|on) (?:(?:CISA['’]s|its|the(?: CISA)?|CISA) )?(?:KEV|Known Exploited Vulnerabilities(?: \(KEV\))?)(?: catalog(?:ue)?| list)?(?: by CISA)? on (\d{4}-\d{2}-\d{2})/gi,
|
|
202
|
+
/\bKEV (?:dateAdded|date)\s*[:=]?\s*(\d{4}-\d{2}-\d{2})/gi,
|
|
203
|
+
];
|
|
204
|
+
|
|
205
|
+
// Date-first wording: "On 2026-02-10, CISA added CVE-X to KEV". Group 1 is the
|
|
206
|
+
// date and group 2 the verb's object, where the ids that own the date appear.
|
|
207
|
+
const KEV_LISTING_DATE_FIRST = [
|
|
208
|
+
/\bOn (\d{4}-\d{2}-\d{2}),?\s+CISA (?:added|listed|put|placed) ((?:[\w/,&-]+ ){1,6}?)(?:to|in|on) (?:(?:CISA['’]s|its|the(?: CISA)?|CISA) )?(?:KEV|Known Exploited Vulnerabilities(?: \(KEV\))?)\b/gi,
|
|
209
|
+
];
|
|
210
|
+
|
|
211
|
+
// Who owns a date-first listing date, from the ids in the verb's object. It is
|
|
212
|
+
// definite only when the object is nothing but ids ("CVE-X", "CVE-X and
|
|
213
|
+
// CVE-Y"); any other word ("variants of CVE-X and CVE-Y") leaves it a warning.
|
|
214
|
+
function _dateFirstOwner(object, id) {
|
|
215
|
+
const ids = [...object.matchAll(/CVE-\d{4}-\d+/gi)];
|
|
216
|
+
if (!ids.length) return { owner: "unsure" };
|
|
217
|
+
if (!ids.some((x) => x[0].toUpperCase() === id.toUpperCase())) return { owner: "other" };
|
|
218
|
+
return { owner: "own", definite: /^\s*(?:CVE-\d{4}-\d+\s*(?:,\s*(?:and\s+|or\s+)?|and\s+|or\s+|&\s*|\/\s*)?)+$/i.test(object) };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Words that point a listing date back at the entry's own flaw. "the flaw" and
|
|
222
|
+
// "the CVE" count, so a sibling's date is exempt only when the text names the
|
|
223
|
+
// sibling without them ("CVE-2011-0611, a sibling, was KEV-listed ..."). A bare
|
|
224
|
+
// pronoun ("it was") does not count, since its antecedent can be the sibling.
|
|
225
|
+
const SELF_REFERENCE = /\b(?:this|the same|the) (?:flaw|CVE|vulnerability|bug|defect|entry|issue)\b/i;
|
|
226
|
+
|
|
227
|
+
// Who the date at `at` in `s` belongs to, judged from the nearest CVE id before
|
|
228
|
+
// it in the same sentence:
|
|
229
|
+
// "own" that id, or any id joined to it by "and", "or", "/" or a comma
|
|
230
|
+
// ("CVE-X and CVE-Y were added ..."), is `id`;
|
|
231
|
+
// "other" another CVE's id, with nothing between it and the date referring
|
|
232
|
+
// back to this entry ("Unlike CVE-Y, this flaw was ..." is not other);
|
|
233
|
+
// "unsure" no id in the sentence, or a self-reference after another id.
|
|
234
|
+
// Ids compare case-insensitively. `start` and `end` bound the joined ids.
|
|
235
|
+
function _dateOwner(s, at, id) {
|
|
236
|
+
// A sentence ends at . ? ! (with any closing quote or bracket) before
|
|
237
|
+
// whitespace, or at a line break. A semicolon joins clauses of one sentence,
|
|
238
|
+
// so "The sibling is CVE-X; it was KEV-listed ..." keeps CVE-X in scope.
|
|
239
|
+
let start = 0;
|
|
240
|
+
for (const b of s.slice(0, at).matchAll(/[.?!]['"’”)\]]*\s+|\n/g)) start = b.index + b[0].length;
|
|
241
|
+
const before = s.slice(start, at);
|
|
242
|
+
const ids = [...before.matchAll(/CVE-\d{4}-\d+/gi)];
|
|
243
|
+
if (!ids.length) return { owner: "unsure" };
|
|
244
|
+
let first = ids.length - 1;
|
|
245
|
+
while (first > 0 && /^\s*(?:,\s*(?:and\s+|or\s+)?|and\s+|or\s+|&\s*|\/\s*)$/i.test(
|
|
246
|
+
before.slice(ids[first - 1].index + ids[first - 1][0].length, ids[first].index))) first--;
|
|
247
|
+
// A singular verb after the group ("CVE-X and CVE-Y, which was KEV-listed
|
|
248
|
+
// ...") binds to the nearest id alone; a plural verb or none keeps the group.
|
|
249
|
+
const tail = before.slice(ids[ids.length - 1].index + ids[ids.length - 1][0].length);
|
|
250
|
+
if (/\b(?:was|is|has been)\b/i.test(tail) && !/\b(?:were|are|have been)\b/i.test(tail)) first = ids.length - 1;
|
|
251
|
+
const run = ids.slice(first);
|
|
252
|
+
const last = run[run.length - 1];
|
|
253
|
+
const span = { start: start + run[0].index, end: start + last.index + last[0].length };
|
|
254
|
+
if (run.some((x) => x[0].toUpperCase() === id.toUpperCase())) return { owner: "own", ...span };
|
|
255
|
+
// A date is another CVE's, and skipped, only when this entry's own id is not
|
|
256
|
+
// named earlier in the sentence; otherwise the attribution is unsure and the
|
|
257
|
+
// finding stays a warning ("CVE-X, together with CVE-Y, were added ...").
|
|
258
|
+
if (ids.some((x) => x[0].toUpperCase() === id.toUpperCase())) return { owner: "unsure" };
|
|
259
|
+
return { owner: SELF_REFERENCE.test(before.slice(last.index + last[0].length)) ? "unsure" : "other" };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Whether the listing phrase `m` explicitly assigns its date to the ids that
|
|
263
|
+
// span `start`..`end`: they are its subject ("CVE-X was KEV-listed on ...",
|
|
264
|
+
// "CVE-X, which was added to KEV on ...", "CVE-X's KEV listing date is ...")
|
|
265
|
+
// or the object of its verb ("CISA added CVE-X to KEV on ..."), and not the
|
|
266
|
+
// object of a preposition ("the predecessor of CVE-X"). The set is kept to
|
|
267
|
+
// forms whose attribution is unambiguous; anything else stays a warning.
|
|
268
|
+
function _assignsDateTo(s, m, start, end) {
|
|
269
|
+
// Topical form: the sentence opens with "For CVE-X," and a pronoun takes the
|
|
270
|
+
// listing ("For CVE-X, CISA added it to KEV on ...", "For CVE-X, it was
|
|
271
|
+
// KEV-listed on ...").
|
|
272
|
+
let sentence = 0;
|
|
273
|
+
for (const b of s.slice(0, start).matchAll(/[.?!]['"’”)\]]*\s+|\n/g)) sentence = b.index + b[0].length;
|
|
274
|
+
if (/^\s*(?:For|Regarding|As for)\s+$/i.test(s.slice(sentence, start))) {
|
|
275
|
+
const rest = s.slice(end, m.index);
|
|
276
|
+
return (/^\s*,\s*(?:CISA\s+)?$/.test(rest) && /^(?:added|listed|put|placed|KEV[- ]listed) it\b/i.test(m[0]))
|
|
277
|
+
|| /^\s*,\s*it\s+(?:was|is|has been)\s+(?:first\s+)?$/i.test(rest)
|
|
278
|
+
|| (/^\s*,\s*(?:the\s+|its\s+)?$/i.test(rest) && /^KEV (?:listing|dateAdded|date)\b/i.test(m[0]));
|
|
279
|
+
}
|
|
280
|
+
// The CVE a label names: "The KEV listing date for CVE-X is ...". Checked
|
|
281
|
+
// before the preposition guard, whose "for" it would otherwise trip.
|
|
282
|
+
if (start >= m.index && /^KEV listing(?: date)? for\b/i.test(m[0])) return true;
|
|
283
|
+
if (/\b(?:of|for|to|than|like|unlike|with|from|and|or|by|as|versus|vs\.?)\s*$/i.test(s.slice(Math.max(0, start - 12), start))) return false;
|
|
284
|
+
// The id inside the listing phrase as the object of its verb.
|
|
285
|
+
if (start >= m.index) return /^(?:added|listed|put|placed|KEV[- ]listed)\b/i.test(m[0]);
|
|
286
|
+
const between = s.slice(end, m.index);
|
|
287
|
+
if (/^\s*(?:\([^)]{0,80}\)\s*)?$/.test(between) && /^(?:entered|joined|appeared in)\b/i.test(m[0])) return true;
|
|
288
|
+
if (/^\s*(?:\([^)]{0,80}\)\s*)?,?\s*has an?\s+$/i.test(between) && /^KEV listing\b/i.test(m[0])) return true;
|
|
289
|
+
// A short appositive that names no other CVE: "CVE-X, the vulnerability, was
|
|
290
|
+
// KEV-listed on ...".
|
|
291
|
+
const appositive = between.match(/^\s*,\s*([^,]{1,60}),\s*(?:was|were|is|are|has been|have been|had been)\s+(?:first\s+)?$/i);
|
|
292
|
+
if (appositive && !/CVE-\d/i.test(appositive[1])) return true;
|
|
293
|
+
// A label: "CVE-X: KEV-listed ...", "CVE-X - KEV listing date ...".
|
|
294
|
+
if (/^\s*[:—–-]\s*$/.test(between) && /^(?:KEV[- ]listed|KEV listing|KEV (?:dateAdded|date)|added|listed)\b/i.test(m[0])) return true;
|
|
295
|
+
// An appositive id names the subject: "Foo (CVE-X) was KEV-listed on ...".
|
|
296
|
+
if (/\(\s*$/.test(s.slice(Math.max(0, start - 3), start))
|
|
297
|
+
&& /^\s*\)\s*(?:was|were|is|are|has been|have been|had been)\s+(?:first\s+)?$/i.test(between)) return true;
|
|
298
|
+
return /^\s*(?:\([^)]{0,80}\)\s*)?,?\s*(?:itself\s+|themselves\s+)?(?:(?:which|that)\s+)?(?:was|were|is|are|has been|have been|had been)\s+(?:first\s+)?$/i.test(between) || /^['’]s\s+$/.test(between);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// One finding per catalog or lesson text that states a KEV listing date other
|
|
302
|
+
// than the entry's cisa_kev_date. Such a text usually describes a different CVE.
|
|
303
|
+
// A date that another CVE named earlier in the same sentence owns is skipped.
|
|
304
|
+
// `definite` is true when the text explicitly assigns the date to the entry's
|
|
305
|
+
// own CVE id (see _assignsDateTo). `entries` supplies cisa_kev_date for
|
|
306
|
+
// lessons whose entry is not in loaded["cve-catalog"], as for a batch about to
|
|
307
|
+
// be written.
|
|
308
|
+
function kevListingDateFindings(loaded, opts = {}) {
|
|
309
|
+
const out = [];
|
|
310
|
+
const cve = { ...(loaded["cve-catalog"] || {}), ...(opts.entries || {}) };
|
|
311
|
+
for (const catalog of ["cve-catalog", "zeroday-lessons"]) {
|
|
312
|
+
const cat = loaded[catalog];
|
|
313
|
+
if (!cat) continue;
|
|
314
|
+
for (const id of Object.keys(cat)) {
|
|
315
|
+
if (id === "_meta") continue;
|
|
316
|
+
const listed = cve[id] && cve[id].cisa_kev_date;
|
|
317
|
+
if (typeof listed !== "string" || !listed) continue;
|
|
318
|
+
for (const [field, s] of _strings(cat[id], "", [])) {
|
|
319
|
+
// Two patterns can match the same stated date; it is one finding.
|
|
320
|
+
const seen = new Set();
|
|
321
|
+
for (const re of KEV_LISTING_DATE) {
|
|
322
|
+
for (const m of s.matchAll(re)) {
|
|
323
|
+
if (m[1] === listed) continue;
|
|
324
|
+
// The date's own position, so a CVE id inside the match ("added
|
|
325
|
+
// CVE-2011-0611 to its ... catalog on ...") precedes it.
|
|
326
|
+
const at = m.index + m[0].indexOf(m[1]);
|
|
327
|
+
if (seen.has(at)) continue;
|
|
328
|
+
seen.add(at);
|
|
329
|
+
const own = _dateOwner(s, at, id);
|
|
330
|
+
if (own.owner === "other") continue;
|
|
331
|
+
out.push({ class: "logical-consistency", catalog, id, field,
|
|
332
|
+
rule: "stated_kev_listing_date_matches_entry", definite: own.owner === "own" && _assignsDateTo(s, m, own.start, own.end),
|
|
333
|
+
reason: `text states a KEV listing date of ${m[1]}, but the entry's cisa_kev_date is ${listed}` });
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
for (const re of KEV_LISTING_DATE_FIRST) {
|
|
337
|
+
for (const m of s.matchAll(re)) {
|
|
338
|
+
if (m[1] === listed) continue;
|
|
339
|
+
const at = m.index + m[0].indexOf(m[1]);
|
|
340
|
+
if (seen.has(at)) continue;
|
|
341
|
+
seen.add(at);
|
|
342
|
+
const own = _dateFirstOwner(m[2], id);
|
|
343
|
+
if (own.owner === "other") continue;
|
|
344
|
+
out.push({ class: "logical-consistency", catalog, id, field,
|
|
345
|
+
rule: "stated_kev_listing_date_matches_entry", definite: own.owner === "own" && own.definite,
|
|
346
|
+
reason: `text states a KEV listing date of ${m[1]}, but the entry's cisa_kev_date is ${listed}` });
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return out;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// A restart or reboot credited to CISA's KEV required action. The required
|
|
356
|
+
// action says to apply mitigations per vendor instructions, follow BOD 22-01
|
|
357
|
+
// guidance or discontinue use; it never names a restart. Each pattern is a
|
|
358
|
+
// construction that attaches the attribution to the restart itself, so an
|
|
359
|
+
// accurate "apply mitigations per the KEV requiredAction" does not match. The
|
|
360
|
+
// restart and the attribution must sit in one clause: no comma, "then", "and",
|
|
361
|
+
// "before", "after" or "once" between them. A negation ("not", "no", "never",
|
|
362
|
+
// "nor", "without", "cannot", or a contraction such as "isn't") between them
|
|
363
|
+
// also stops the match.
|
|
364
|
+
const KEV_ACTION_RESTART_CREDIT = [
|
|
365
|
+
/\bfollows (?:the KEV requiredAction|the KEV required action|the required action in CISA(?:'s|’s) KEV entry|the required action in the KEV entry|CISA(?:'s|’s) KEV required action|CISA(?:'s|’s) KEV requiredAction|the KEV entry(?:'s|’s) required action) \(service restart or system reboot\)/i,
|
|
366
|
+
/\b(?:restart|reboot)(?:(?!\b(?:then|and|before|after|once|not|no|never|nor|without|cannot)\b|\w*n['’]t\b)[^.;,()]){0,60}?\brequir(?:es|ed|ing) per (?:the KEV requiredAction|the KEV required action|the required action in CISA(?:'s|’s) KEV entry|the required action in the KEV entry|CISA(?:'s|’s) KEV required action|CISA(?:'s|’s) KEV requiredAction|the KEV entry(?:'s|’s) required action)/i,
|
|
367
|
+
/(?:restart|reboot)(?:ing)?\b(?:(?!\b(?:then|and|before|after|once|not|no|never|nor|without|cannot)\b|\w*n['’]t\b)[^.;,()]){0,25}?,? per (?:the KEV requiredAction|the KEV required action|the required action in CISA(?:'s|’s) KEV entry|the required action in the KEV entry|CISA(?:'s|’s) KEV required action|CISA(?:'s|’s) KEV requiredAction|the KEV entry(?:'s|’s) required action)/i,
|
|
368
|
+
/\b(?:fix|patch|update)(?: that)? per (?:the KEV requiredAction|the KEV required action|the required action in CISA(?:'s|’s) KEV entry|the required action in the KEV entry|CISA(?:'s|’s) KEV required action|CISA(?:'s|’s) KEV requiredAction|the KEV entry(?:'s|’s) required action),? (?:typically |also )?requir(?:es|ing) (?:a )?(?:service )?(?:restart|reboot)/i,
|
|
369
|
+
/\bthe (?:service )?(?:restart|reboot)(?: or (?:system )?(?:restart|reboot))? (?:that )?(?:the KEV requiredAction|the KEV required action|the required action in CISA(?:'s|’s) KEV entry|the required action in the KEV entry|CISA(?:'s|’s) KEV required action|CISA(?:'s|’s) KEV requiredAction|the KEV entry(?:'s|’s) required action) (?:calls for|implies|requires|mandates|specifies|names)\b/i,
|
|
370
|
+
/\b(?:the KEV requiredAction|the KEV required action|the required action in CISA(?:'s|’s) KEV entry|the required action in the KEV entry|CISA(?:'s|’s) KEV required action|CISA(?:'s|’s) KEV requiredAction|the KEV entry(?:'s|’s) required action) (?:requires|required|calls for|mandates|specifies|names) (?:a |an )?(?:(?:service|system|host|device|appliance|server) )?(?:restart|reboot)/i,
|
|
371
|
+
/^[\s"'(]*per (?:the KEV requiredAction|the KEV required action|the required action in CISA(?:'s|’s) KEV entry|the required action in the KEV entry|CISA(?:'s|’s) KEV required action|CISA(?:'s|’s) KEV requiredAction|the KEV entry(?:'s|’s) required action),? (?:(?:an?|the)\s+)?(?:(?:service|system|host|device|appliance|server)\s+)?(?:restart|reboot)\b(?!(?:(?!\b(?:if|when|unless|because|although|though|while|since|even)\b)[^,;.]){0,80}?(?:\b(?:not|never|cannot|no longer)\b|n['’]t\b))/i,
|
|
372
|
+
/\b(?:restart|reboot)(?:(?!\b(?:then|and|before|after|once|not|no|never|nor|without|cannot)\b|\w*n['’]t\b)[^.;,()]){0,40}?\b(?:required|mandated|called for|specified) by (?:the KEV requiredAction|the KEV required action|the required action in CISA(?:'s|’s) KEV entry|the required action in the KEV entry|CISA(?:'s|’s) KEV required action|CISA(?:'s|’s) KEV requiredAction|the KEV entry(?:'s|’s) required action)/i,
|
|
373
|
+
];
|
|
374
|
+
|
|
375
|
+
// Text ending in a negator plus the noun phrase a match starts in: "No
|
|
376
|
+
// service ", "No service restart or system ", "without a ", "Neither a
|
|
377
|
+
// restart nor a ", "does not require a service restart or system ". Only that
|
|
378
|
+
// phrase is checked, so a negation elsewhere in the sentence ("No reboot is
|
|
379
|
+
// needed because the KEV requiredAction requires a service restart") does not
|
|
380
|
+
// hide a match.
|
|
381
|
+
const NEGATED_RESTART_BEFORE = /\b(?:no|not|never|nor|without|neither)\s+(?:(?:require|need|call\s+for|mandate|involve)\s+)?(?:(?:an?|any)\s+)?(?:(?:service|system|host|device|appliance|server)\s+)?(?:(?:restart|reboot)s?\s+(?:or|nor|and)\s+(?:(?:an?|any)\s+)?(?:(?:service|system|host|device|appliance|server)\s+)?)*$/i;
|
|
382
|
+
|
|
383
|
+
// The text is split at periods, semicolons and colons, so a labeled line
|
|
384
|
+
// ("Remediation: Per the KEV requiredAction, reboot ...") is read from the
|
|
385
|
+
// label on. Only a part that names a restart or reboot is checked, so "apply
|
|
386
|
+
// mitigations as required per the KEV requiredAction" does not match. A match
|
|
387
|
+
// whose restart noun phrase is negated is skipped, and the search continues
|
|
388
|
+
// past it.
|
|
389
|
+
function creditsRestartToKevAction(str) {
|
|
390
|
+
if (typeof str !== "string" || str.length === 0) return false;
|
|
391
|
+
return str.split(/(?<=[.;:])\s+/).some((sentence) => {
|
|
392
|
+
if (!/\b(?:restart|reboot)/i.test(sentence)) return false;
|
|
393
|
+
return KEV_ACTION_RESTART_CREDIT.some((re) => {
|
|
394
|
+
const anchored = re.source.startsWith("^");
|
|
395
|
+
for (let from = 0; from < sentence.length;) {
|
|
396
|
+
const m = re.exec(sentence.slice(from));
|
|
397
|
+
if (!m) return false;
|
|
398
|
+
const at = from + m.index;
|
|
399
|
+
if (!NEGATED_RESTART_BEFORE.test(sentence.slice(0, at))) return true;
|
|
400
|
+
if (anchored) return false;
|
|
401
|
+
from = at + 1;
|
|
402
|
+
}
|
|
403
|
+
return false;
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// The two forms in which the claim entered the catalogs: "requires a service
|
|
409
|
+
// restart or system reboot per the KEV requiredAction" and "follows the KEV
|
|
410
|
+
// requiredAction (service restart or system reboot)". A finding whose matching
|
|
411
|
+
// sentence takes one of these forms is marked definite.
|
|
412
|
+
const KEV_ACTION_RESTART_TEMPLATE = [
|
|
413
|
+
/(?<!\bnever\s)\brequires (?:a )?service restart or system reboot,? per (?:the KEV requiredAction|the KEV required action|the required action in CISA(?:'s|’s) KEV entry|the required action in the KEV entry|CISA(?:'s|’s) KEV required action|CISA(?:'s|’s) KEV requiredAction|the KEV entry(?:'s|’s) required action)/i,
|
|
414
|
+
/\bfollows (?:the KEV requiredAction|the KEV required action|the required action in CISA(?:'s|’s) KEV entry|the required action in the KEV entry|CISA(?:'s|’s) KEV required action|CISA(?:'s|’s) KEV requiredAction|the KEV entry(?:'s|’s) required action) \(service restart or system reboot\)/i,
|
|
415
|
+
];
|
|
416
|
+
|
|
417
|
+
// A template match is definite only when its own clause, from the last comma,
|
|
418
|
+
// semicolon, colon, parenthesis, dash, "and", "but", "so", "while", "whereas",
|
|
419
|
+
// "because", "since", "although" or "though" before it, holds no negation
|
|
420
|
+
// ("Documentation does not say that the vendor patch requires ..."), and the
|
|
421
|
+
// rest of that clause does not deny the statement ("... is incorrect"), and the
|
|
422
|
+
// clause text in front of it is the claim's subject (TEMPLATE_SUBJECT). Every
|
|
423
|
+
// occurrence in the sentence is checked.
|
|
424
|
+
const TEMPLATE_CLAUSE_BREAK = /[,;:(—–]|\b(?:and|but|so|while|whereas|because|since|although|though)\b/gi;
|
|
425
|
+
const TEMPLATE_NEGATION_BEFORE = /\b(?:not|never|no|nor|neither|nothing|nowhere|none|cannot|without|false|untrue|incorrect|wrong|whether|if)\b|n['’]t\b/i;
|
|
426
|
+
const TEMPLATE_DENIAL_AFTER = /^(?:(?!\b(?:and|but|so|because|since|while|even|when|if|unless|where)\b)[^,;.()—–]){0,40}?\b(?:not|incorrect|wrong|false|untrue|mistaken|unsupported|unverified|disputed|unfounded|inaccurate|erroneous|misleading|a misreading|a myth)\b/i;
|
|
427
|
+
// A denial attached after a comma or dash: "..., which is incorrect", "... —
|
|
428
|
+
// a claim that is unsupported", including after a closing quote.
|
|
429
|
+
const TEMPLATE_DENIAL_APPOSITIVE = /^["'’”]?\s*[,—–]\s*(?:which|this|that|a\s+(?:claim|reading|statement|view|belief|misreading))\b[^.;]{0,40}?\b(?:not|incorrect|wrong|false|untrue|mistaken|unsupported|unverified|disputed|unfounded|inaccurate|erroneous|misleading|a misreading|a myth)\b/i;
|
|
430
|
+
// The clause text in front of a definite match: the claim's subject ("the
|
|
431
|
+
// vendor patch typically", "the fix", "applying the patch", "remediation",
|
|
432
|
+
// "it"), optionally behind a record frame ("live_patch_notes state that",
|
|
433
|
+
// "the entry states", "with the recorded note '"). "The claim that ...",
|
|
434
|
+
// "Documentation does not say that ..." and "Saying ..." are not subjects.
|
|
435
|
+
const TEMPLATE_SUBJECT = /^(?:with\s+)?(?:(?:the|a)\s+)?(?:(?:recorded\s+)?note|live[_-]patch[_ ]notes?|entry)?\s*(?:states?|stating|records?|recording|reads?)?\s*(?:that\s+)?["'‘“]?\s*(?:applying\s+)?(?:(?:(?:the|a|an|its)\s+)?(?:vendor\s+|fixed\s+|security\s+)?(?:patch|fix|update|release|build|firmware|remediation|it)(?:\s+that)?)?\s*(?:(?:typically|also|usually|generally|often)\s*)?$/i;
|
|
436
|
+
|
|
437
|
+
// A label in front of a colon that holds a directive or a verb of saying ("The
|
|
438
|
+
// control must not state: the vendor patch requires ...", "Do not write:")
|
|
439
|
+
// keeps the clause after it from being definite. A plain label ("Remediation:")
|
|
440
|
+
// or an explanatory one ("there is no live-patch tool:") does not.
|
|
441
|
+
const TEMPLATE_LABEL_DIRECTIVE = /\b(?:must|should|shall|avoid|state|states|stating|say|says|saying|claim|claims|write|writes|assert|asserts|imply|implies|suggest|suggests|describe|describes|false|incorrect|wrong|myth)\b/i;
|
|
442
|
+
// A verb of saying or believing anywhere earlier in the sentence ("Do not
|
|
443
|
+
// claim, without evidence, the vendor patch requires ...") keeps a template
|
|
444
|
+
// match from being definite, whatever commas lie in between. Negation alone
|
|
445
|
+
// ("Live patches do not exist, and the vendor patch requires ..."), the
|
|
446
|
+
// record verbs the catalog frames use ("states", "records") and field values
|
|
447
|
+
// ("live_patch_available is false") do not.
|
|
448
|
+
const TEMPLATE_PREFIX_DIRECTIVE = /\b(?:avoid|say|says|saying|said|claim|claims|claiming|write|writes|written|assert|asserts|assertion|assertions|imply|implies|suggest|suggests|describe|describes|assume|assumes|assumed|believe|believes|allege|alleges|alleged|allegation|allegations)\b/i;
|
|
449
|
+
|
|
450
|
+
// Sentences end at a period or semicolon only. A line break is read as a space,
|
|
451
|
+
// so wrapped prose keeps its framing ("Documentation does not say that\nthe
|
|
452
|
+
// vendor patch requires ...").
|
|
453
|
+
function isTemplateRestartCredit(str) {
|
|
454
|
+
if (typeof str !== "string" || str.length === 0) return false;
|
|
455
|
+
return str.split(/(?<=[.;])\s+/).some((sentence) => {
|
|
456
|
+
if (!creditsRestartToKevAction(sentence)) return false;
|
|
457
|
+
return KEV_ACTION_RESTART_TEMPLATE.some((re) => {
|
|
458
|
+
for (let from = 0; from < sentence.length;) {
|
|
459
|
+
const m = re.exec(sentence.slice(from));
|
|
460
|
+
if (!m) return false;
|
|
461
|
+
const at = from + m.index;
|
|
462
|
+
let start = 0;
|
|
463
|
+
let prev = 0;
|
|
464
|
+
let colon = false;
|
|
465
|
+
for (const b of sentence.slice(0, at).matchAll(TEMPLATE_CLAUSE_BREAK)) {
|
|
466
|
+
prev = start;
|
|
467
|
+
start = b.index + b[0].length;
|
|
468
|
+
colon = b[0] === ":";
|
|
469
|
+
}
|
|
470
|
+
const subject = sentence.slice(start, at).trim();
|
|
471
|
+
const label = colon ? sentence.slice(prev, start - 1) : "";
|
|
472
|
+
if (TEMPLATE_SUBJECT.test(subject) && !TEMPLATE_NEGATION_BEFORE.test(subject) &&
|
|
473
|
+
!TEMPLATE_LABEL_DIRECTIVE.test(label) && !TEMPLATE_PREFIX_DIRECTIVE.test(sentence.slice(0, at)) &&
|
|
474
|
+
!TEMPLATE_DENIAL_AFTER.test(sentence.slice(at + m[0].length)) &&
|
|
475
|
+
!TEMPLATE_DENIAL_APPOSITIVE.test(sentence.slice(at + m[0].length))) return true;
|
|
476
|
+
from = at + 1;
|
|
477
|
+
}
|
|
478
|
+
return false;
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// One finding per catalog or lesson text that credits a restart or reboot to
|
|
484
|
+
// the KEV required action, marked definite when it takes a template form.
|
|
485
|
+
// Drafts are skipped unless opts.includeDrafts is set.
|
|
486
|
+
function kevActionRestartFindings(loaded, opts = {}) {
|
|
487
|
+
const out = [];
|
|
488
|
+
for (const catalog of ["cve-catalog", "zeroday-lessons"]) {
|
|
489
|
+
const cat = loaded[catalog];
|
|
490
|
+
if (!cat) continue;
|
|
491
|
+
for (const id of Object.keys(cat)) {
|
|
492
|
+
if (id === "_meta") continue;
|
|
493
|
+
const e = cat[id];
|
|
494
|
+
if (!e || (e._auto_imported === true && !opts.includeDrafts)) continue;
|
|
495
|
+
for (const [field, s] of _strings(e, "", [])) {
|
|
496
|
+
if (!creditsRestartToKevAction(s)) continue;
|
|
497
|
+
out.push({ class: "logical-consistency", catalog, id, field,
|
|
498
|
+
rule: "restart_not_credited_to_kev_required_action", definite: isTemplateRestartCredit(s),
|
|
499
|
+
reason: "text credits a restart or reboot to CISA's KEV required action, which names none" });
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
return out;
|
|
504
|
+
}
|
|
505
|
+
|
|
85
506
|
// Fields present but weak; what counts as weak is per catalog and field.
|
|
86
507
|
function contentQualityFindings(loaded) {
|
|
87
508
|
const out = [];
|
|
@@ -223,6 +644,8 @@ function logicalConsistencyFindings(loaded) {
|
|
|
223
644
|
reason: "rwep_score declared but rwep_factors is empty — score is unjustified" });
|
|
224
645
|
}
|
|
225
646
|
}
|
|
647
|
+
out.push(...kevListingDateFindings(loaded));
|
|
648
|
+
out.push(...kevActionRestartFindings(loaded));
|
|
226
649
|
return out;
|
|
227
650
|
}
|
|
228
651
|
|
|
@@ -481,8 +904,19 @@ const DETECTOR_CLASSES = [
|
|
|
481
904
|
module.exports = {
|
|
482
905
|
hasPlaceholderLanguage,
|
|
483
906
|
hasPipelineWording,
|
|
907
|
+
hasPipelineFieldCitation,
|
|
484
908
|
pipelineWordingFindings,
|
|
485
909
|
PIPELINE_WORDING,
|
|
910
|
+
PACKET_FIELD_TOKEN,
|
|
911
|
+
CATALOG_FIELD_NAMES,
|
|
912
|
+
kevListingDateFindings,
|
|
913
|
+
kevActionRestartFindings,
|
|
914
|
+
creditsRestartToKevAction,
|
|
915
|
+
KEV_ACTION_RESTART_CREDIT,
|
|
916
|
+
KEV_ACTION_RESTART_TEMPLATE,
|
|
917
|
+
isTemplateRestartCredit,
|
|
918
|
+
KEV_LISTING_DATE,
|
|
919
|
+
KEV_LISTING_DATE_FIRST,
|
|
486
920
|
daysSince,
|
|
487
921
|
contentQualityFindings,
|
|
488
922
|
temporalStalenessFindings,
|