@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/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
- // New entries and lessons may not cite the curation input; the gap-detector
179
- // budget covers only texts already in the catalog.
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
- errors.push(`${f.id}: ${f.catalog === 'cve-catalog' ? 'entry' : 'lesson'} ${f.field} cites the curation ` +
182
- `input or a field name ("Packet: ...", "the packet names ..."); state the fact and its source instead.`);
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
 
@@ -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) are left out.
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,