@blamejs/exceptd-skills 0.21.7 → 0.21.9

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,25 @@ 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.`);
183
197
  }
184
198
 
185
199
  // Reported as a validation error so the DRY RUN surfaces it; insertEntries
@@ -189,7 +203,7 @@ async function curateBatch({ factsPath, judgmentsPath, apply, catalogRoot, today
189
203
  errors.push(`${id}: already present in the catalog — this batch adds entries and cannot replace one. Restore the catalog and apply once.`);
190
204
 
191
205
  const ok = orphans.length === 0 && errors.length === 0;
192
- if (!apply || !ok) return { ok, entries, orphans, errors, written: false };
206
+ if (!apply || !ok) return { ok, entries, orphans, errors, warnings, written: false };
193
207
 
194
208
  // WRITE
195
209
  const catPath = path.join(catalogRoot, 'data', 'cve-catalog.json');
@@ -211,7 +225,7 @@ async function curateBatch({ factsPath, judgmentsPath, apply, catalogRoot, today
211
225
  for (const id of Object.keys(entries))
212
226
  fs.writeFileSync(path.join(catalogRoot, 'tests', `${id.toLowerCase()}.test.js`), renderTest(id));
213
227
 
214
- return { ok: true, entries, orphans, errors, written: true };
228
+ return { ok: true, entries, orphans, errors, warnings, written: true };
215
229
  }
216
230
 
217
231
  async function cli(argv) {
@@ -247,7 +261,7 @@ async function cli(argv) {
247
261
  return;
248
262
  }
249
263
  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');
264
+ orphans: r.orphans, errors: r.errors, warnings: r.warnings, written: r.written }, null, 2) + '\n');
251
265
  if (!r.ok) process.exitCode = 1;
252
266
  }
253
267
 
@@ -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,175 @@ 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
+
85
355
  // Fields present but weak; what counts as weak is per catalog and field.
86
356
  function contentQualityFindings(loaded) {
87
357
  const out = [];
@@ -223,6 +493,7 @@ function logicalConsistencyFindings(loaded) {
223
493
  reason: "rwep_score declared but rwep_factors is empty — score is unjustified" });
224
494
  }
225
495
  }
496
+ out.push(...kevListingDateFindings(loaded));
226
497
  return out;
227
498
  }
228
499
 
@@ -481,8 +752,14 @@ const DETECTOR_CLASSES = [
481
752
  module.exports = {
482
753
  hasPlaceholderLanguage,
483
754
  hasPipelineWording,
755
+ hasPipelineFieldCitation,
484
756
  pipelineWordingFindings,
485
757
  PIPELINE_WORDING,
758
+ PACKET_FIELD_TOKEN,
759
+ CATALOG_FIELD_NAMES,
760
+ kevListingDateFindings,
761
+ KEV_LISTING_DATE,
762
+ KEV_LISTING_DATE_FIRST,
486
763
  daysSince,
487
764
  contentQualityFindings,
488
765
  temporalStalenessFindings,