@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/CHANGELOG.md +16 -0
- package/data/_indexes/_meta.json +4 -4
- package/data/cve-catalog.json +3 -3
- package/data/zeroday-lessons.json +2206 -2206
- package/lib/cve-batch.js +22 -8
- package/lib/gap-detectors.js +281 -4
- package/manifest.json +53 -53
- package/package.json +1 -1
- package/sbom.cdx.json +23 -23
- 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,25 @@ 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.`);
|
|
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
|
|
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,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,
|