@blamejs/exceptd-skills 0.19.24 → 0.19.26
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/AGENTS.md +15 -15
- package/ARCHITECTURE.md +7 -7
- package/CHANGELOG.md +20 -0
- package/CONTEXT.md +4 -4
- package/README.md +7 -7
- package/data/_indexes/_meta.json +10 -10
- package/data/_indexes/activity-feed.json +29 -29
- package/data/_indexes/catalog-summaries.json +13 -13
- package/data/_indexes/chains.json +76206 -0
- package/data/_indexes/frequency.json +1 -0
- package/data/attack-techniques.json +332 -14
- package/data/cve-catalog.json +11271 -4
- package/data/cwe-catalog.json +138 -4
- package/data/framework-control-gaps.json +767 -7
- package/data/zeroday-lessons.json +10075 -2
- package/lib/collectors/citation-hygiene.js +12 -0
- package/lib/cve-enrich.js +47 -3
- package/lib/refresh-external.js +119 -31
- package/manifest.json +53 -53
- package/package.json +2 -2
- package/sbom.cdx.json +43 -43
- package/scripts/check-epss-consistency.js +6 -10
- package/scripts/release.js +30 -5
|
@@ -131,6 +131,15 @@ function recordRejectedOrDisputed(note, selfId) {
|
|
|
131
131
|
// one, so a different cve appearing as that replacement must NOT suppress the
|
|
132
132
|
// flag (e.g. "this record was rejected as a duplicate of CVE-Y").
|
|
133
133
|
const REPLACEMENT_OF = /\b(?:duplicate|dup)\b[\s\w-]*\bof\b|\b(?:supersed\w+|replaced|merged)\b[\s\w-]*\b(?:by|into)\b|\bin\s+favou?r\s+of\b/i;
|
|
134
|
+
// "Reject" is also an ordinary verb for what code does to input, and a vector
|
|
135
|
+
// description is full of it: a parser rejects an invalid DTD, a validator
|
|
136
|
+
// rejects a traversal sequence. Nothing about the CVE record is being claimed
|
|
137
|
+
// there. The record sense always names what was rejected — a record, an
|
|
138
|
+
// entry, an identifier — or who rejected it (MITRE, NVD, the CNA), so require
|
|
139
|
+
// one of those nearby before treating a reject-word as record-level. The
|
|
140
|
+
// alternative is rewording accurate mechanism prose to dodge a regex, which
|
|
141
|
+
// costs the catalog more than the false positive does.
|
|
142
|
+
const RECORD_CONTEXT = /\b(record|entry|identifier|assignment|advisory|CVE|MITRE|NVD|CNA)\b/i;
|
|
134
143
|
const otherCve = (s) => (s.match(/CVE-\d{4}-\d{4,}/gi) || []).some((c) => c.toUpperCase() !== self);
|
|
135
144
|
let m;
|
|
136
145
|
while ((m = re.exec(note)) !== null) {
|
|
@@ -150,6 +159,9 @@ function recordRejectedOrDisputed(note, selfId) {
|
|
|
150
159
|
const lastTokens = before.trim().split(/[\s-]+/).slice(-3).join(" ");
|
|
151
160
|
if (QUALIFIER.test(lastTokens)) continue;
|
|
152
161
|
}
|
|
162
|
+
// (d) A 'reject' with nothing record-shaped around it is the ordinary verb
|
|
163
|
+
// — code rejecting input — not a statement about this CVE's record.
|
|
164
|
+
if (word.startsWith("reject") && !RECORD_CONTEXT.test(before + " " + after)) continue;
|
|
153
165
|
return true;
|
|
154
166
|
}
|
|
155
167
|
return false;
|
package/lib/cve-enrich.js
CHANGED
|
@@ -29,8 +29,17 @@ function parseCvssVector(vectorString) {
|
|
|
29
29
|
function extractVendorAdvisories(references, kevVendor, cveId) {
|
|
30
30
|
void cveId;
|
|
31
31
|
const out = [];
|
|
32
|
+
// Deduplicate by URL. Upstream reference lists repeat the same advisory —
|
|
33
|
+
// NVD carries a link once per tag combination, and a draft that has already
|
|
34
|
+
// been through one enrichment pass can carry its own copy alongside the
|
|
35
|
+
// original. Repeating it produces an advisory list that says the same thing
|
|
36
|
+
// several times in every rendered output, and duplicated relationships in the
|
|
37
|
+
// generated indexes.
|
|
38
|
+
const seen = new Set();
|
|
32
39
|
for (const r of references || []) {
|
|
33
40
|
if (Array.isArray(r.tags) && r.tags.includes('Vendor Advisory')) {
|
|
41
|
+
if (seen.has(r.url)) continue;
|
|
42
|
+
seen.add(r.url);
|
|
34
43
|
out.push({ vendor: kevVendor || 'Vendor', advisory_id: r.url.split('/').pop() || r.url,
|
|
35
44
|
url: r.url, severity: 'unknown', published_date: null });
|
|
36
45
|
}
|
|
@@ -40,12 +49,32 @@ function extractVendorAdvisories(references, kevVendor, cveId) {
|
|
|
40
49
|
|
|
41
50
|
function _num(x) { const n = Number(x); return Number.isFinite(n) ? n : null; }
|
|
42
51
|
|
|
52
|
+
function _ordinal(n) {
|
|
53
|
+
const suffixes = ['th', 'st', 'nd', 'rd'];
|
|
54
|
+
const v = n % 100;
|
|
55
|
+
return `${n}${suffixes[(v - 20) % 10] || suffixes[v] || suffixes[0]}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The operator-readable restatement of an entry's EPSS fields. It is DERIVED
|
|
59
|
+
// text, not authored, so it has exactly one definition — here — which both the
|
|
60
|
+
// writer (lib/refresh-external.js, when a refresh moves the numbers) and the
|
|
61
|
+
// gate (scripts/check-epss-consistency.js, which compares an entry's stored
|
|
62
|
+
// note against the fields) call. Two copies of this renderer would drift, and
|
|
63
|
+
// the drift would surface as a gate failure on correctly-refreshed data.
|
|
64
|
+
function renderEpssNote(entry) {
|
|
65
|
+
const pct = Math.round(entry.epss_percentile * 100);
|
|
66
|
+
return `FIRST EPSS ${entry.epss_score} (${_ordinal(pct)} percentile) as of ${entry.epss_date}.`;
|
|
67
|
+
}
|
|
68
|
+
|
|
43
69
|
function deriveMechanicalFields(facts, today) {
|
|
44
70
|
const f = facts || {};
|
|
45
71
|
const kev = f.kev || {};
|
|
46
72
|
const cvss = f.cvss || null;
|
|
47
73
|
const parsed = parseCvssVector(cvss && cvss.vector);
|
|
48
|
-
|
|
74
|
+
// Deduplicate: an upstream weakness list can name the same CWE twice (one per
|
|
75
|
+
// assigning source), and a repeated id becomes a repeated relationship
|
|
76
|
+
// wherever the catalog is joined against the CWE catalog.
|
|
77
|
+
const cweRefs = [...new Set((f.cwe_nvd || []).filter((c) => /^CWE-\d+$/.test(c)))];
|
|
49
78
|
const vendorAdv = extractVendorAdvisories(f.references, kev.vendor, f.id);
|
|
50
79
|
const verification = [...new Set([
|
|
51
80
|
`https://nvd.nist.gov/vuln/detail/${f.id}`,
|
|
@@ -98,7 +127,22 @@ function assembleEntry(facts, judgment, today) {
|
|
|
98
127
|
// supplied list (e.g. when NVD only returns NVD-CWE-noinfo). Kept out of the
|
|
99
128
|
// blanket JUDGMENT_KEYS loop so facts take precedence over judgment here.
|
|
100
129
|
if ((!entry.cwe_refs || entry.cwe_refs.length === 0) && Array.isArray(j.cwe_refs))
|
|
101
|
-
entry.cwe_refs = j.cwe_refs;
|
|
130
|
+
entry.cwe_refs = [...new Set(j.cwe_refs)];
|
|
131
|
+
|
|
132
|
+
// The same guard on the judgment-supplied reference arrays. These are written
|
|
133
|
+
// by hand, so a repeat is a transcription slip rather than an upstream quirk,
|
|
134
|
+
// but it lands in exactly the same joined output.
|
|
135
|
+
for (const k of ['attack_refs', 'atlas_refs', 'verification_sources', 'vendor_update_paths'])
|
|
136
|
+
if (Array.isArray(entry[k])) entry[k] = [...new Set(entry[k])];
|
|
137
|
+
if (Array.isArray(entry.vendor_advisories)) {
|
|
138
|
+
const seenUrl = new Set();
|
|
139
|
+
entry.vendor_advisories = entry.vendor_advisories.filter((a) => {
|
|
140
|
+
const u = a && a.url;
|
|
141
|
+
if (!u || seenUrl.has(u)) return !u;
|
|
142
|
+
seenUrl.add(u);
|
|
143
|
+
return true;
|
|
144
|
+
});
|
|
145
|
+
}
|
|
102
146
|
|
|
103
147
|
// Defaults + intake markers.
|
|
104
148
|
if (entry.ai_discovery_source === undefined)
|
|
@@ -169,4 +213,4 @@ function findOrphans(entry, catalogs) {
|
|
|
169
213
|
return out;
|
|
170
214
|
}
|
|
171
215
|
|
|
172
|
-
module.exports = { parseCvssVector, extractVendorAdvisories, deriveMechanicalFields, assembleEntry, findOrphans };
|
|
216
|
+
module.exports = { parseCvssVector, extractVendorAdvisories, deriveMechanicalFields, assembleEntry, findOrphans, renderEpssNote };
|
package/lib/refresh-external.js
CHANGED
|
@@ -39,6 +39,7 @@ const fs = require("fs");
|
|
|
39
39
|
const path = require("path");
|
|
40
40
|
const { execFileSync } = require("child_process");
|
|
41
41
|
const { selectNvdCvss, cvssVersionOf } = require("./cvss");
|
|
42
|
+
const { renderEpssNote } = require("./cve-enrich");
|
|
42
43
|
|
|
43
44
|
const ROOT = path.join(__dirname, "..");
|
|
44
45
|
const ABS = (p) => path.join(ROOT, p);
|
|
@@ -403,6 +404,79 @@ function kevDiffWithDiscoveryFromCache(ctx) {
|
|
|
403
404
|
};
|
|
404
405
|
}
|
|
405
406
|
|
|
407
|
+
/**
|
|
408
|
+
* EPSS publishes score, percentile and date as ONE row, and the percentile is
|
|
409
|
+
* the score's rank within that day's publication. Writing one field without the
|
|
410
|
+
* others leaves a pair drawn from two different days: both numbers in range,
|
|
411
|
+
* both shaped like EPSS values, and nothing downstream able to tell them apart
|
|
412
|
+
* from a coherent pair. That is the corruption scripts/check-epss-consistency.js
|
|
413
|
+
* detects, and a detector cannot repair it — as that gate's own header says, the
|
|
414
|
+
* provenance guarantee has to come from the write side.
|
|
415
|
+
*
|
|
416
|
+
* The bug this replaces applied the drift threshold to score and percentile
|
|
417
|
+
* INDEPENDENTLY: a score that moved past the threshold was written while its
|
|
418
|
+
* percentile, having moved less, was left at yesterday's value. So the rule is
|
|
419
|
+
* now that the threshold decides WHETHER an entry is refreshed, never WHICH of
|
|
420
|
+
* its fields is. Once either number has moved, every field comes from that same
|
|
421
|
+
* row — including the one whose own delta is under the threshold, and including
|
|
422
|
+
* a half of the pair the entry was missing entirely.
|
|
423
|
+
*
|
|
424
|
+
* `local` is the catalog entry; `fetched` is {score, percentile, date} read from
|
|
425
|
+
* one EPSS row.
|
|
426
|
+
*/
|
|
427
|
+
const EPSS_DRIFT = 0.05;
|
|
428
|
+
|
|
429
|
+
function epssTripleDiffs(id, local, fetched, drift) {
|
|
430
|
+
const { score, percentile, date } = fetched;
|
|
431
|
+
// Only a COMPLETE row can produce a coherent triple, so a partial one is
|
|
432
|
+
// refused before anything else is considered. Guarding this per-branch was
|
|
433
|
+
// not enough: a row carrying just one number can still cross the drift
|
|
434
|
+
// threshold on that number, and writing it beside the other half from the
|
|
435
|
+
// previous publication is precisely the mixed-publication state this function
|
|
436
|
+
// exists to prevent. At the top, the guard covers a complete local entry and
|
|
437
|
+
// a half-populated one alike.
|
|
438
|
+
//
|
|
439
|
+
// The date is the third member, not decoration. A row with both numbers and
|
|
440
|
+
// no date would write the new numbers while the entry kept its old
|
|
441
|
+
// epss_date — and the regenerated note would then state those numbers "as of"
|
|
442
|
+
// a publication they did not come from, which is the same defect wearing
|
|
443
|
+
// different clothes.
|
|
444
|
+
// Finite, not merely present: the callers build these with Number(), which
|
|
445
|
+
// turns a malformed cached value into NaN rather than null. A NaN would pass
|
|
446
|
+
// a nullish check, ride the repair path, and land in the catalog serialised
|
|
447
|
+
// as null — reintroducing the half-populated entry the repair exists to fix.
|
|
448
|
+
if (!Number.isFinite(score) || !Number.isFinite(percentile) || !date) return [];
|
|
449
|
+
|
|
450
|
+
const scoreMoved = local.epss_score != null && Math.abs(score - local.epss_score) > drift;
|
|
451
|
+
const pctMoved = local.epss_percentile != null && Math.abs(percentile - local.epss_percentile) > drift;
|
|
452
|
+
|
|
453
|
+
// An entry holding one half of the pair is already inconsistent, and the
|
|
454
|
+
// drift threshold cannot reach it: the absent side has nothing to compare
|
|
455
|
+
// against, so its "moved" test is false by construction, and if the present
|
|
456
|
+
// side happens to be stable the entry stays half-populated forever while the
|
|
457
|
+
// consistency gate keeps failing on it. A complete row repairs it regardless
|
|
458
|
+
// of how far anything moved. An entry carrying NO EPSS at all is left alone —
|
|
459
|
+
// that is an absent field, not an incoherent pair.
|
|
460
|
+
const localIncomplete = (local.epss_score == null) !== (local.epss_percentile == null);
|
|
461
|
+
if (!scoreMoved && !pctMoved && !localIncomplete) return [];
|
|
462
|
+
|
|
463
|
+
// `coherent` records PROVENANCE: these diffs were derived from a single
|
|
464
|
+
// complete, validated row, so whatever subset of fields actually differs, the
|
|
465
|
+
// entry is left describing one publication. The consumer cannot infer that
|
|
466
|
+
// from the fields alone — a complete row whose percentile rounds to the same
|
|
467
|
+
// value legitimately emits the score by itself, which is indistinguishable
|
|
468
|
+
// from a hand-built partial diff set unless the emitter says so.
|
|
469
|
+
const out = [];
|
|
470
|
+
const emit = (field, before, after, severity) =>
|
|
471
|
+
out.push({ id, field, before, after, severity, coherent: true });
|
|
472
|
+
|
|
473
|
+
if (score !== local.epss_score) emit("epss_score", local.epss_score ?? null, score, "medium");
|
|
474
|
+
if (percentile !== local.epss_percentile) emit("epss_percentile", local.epss_percentile ?? null, percentile, "medium");
|
|
475
|
+
// `date` is non-empty by the guard at the top of this function.
|
|
476
|
+
if (date !== local.epss_date) emit("epss_date", local.epss_date ?? null, date, "low");
|
|
477
|
+
return out;
|
|
478
|
+
}
|
|
479
|
+
|
|
406
480
|
const EPSS_SOURCE = {
|
|
407
481
|
name: "epss",
|
|
408
482
|
description: "FIRST.org EPSS scores",
|
|
@@ -416,25 +490,18 @@ const EPSS_SOURCE = {
|
|
|
416
490
|
let errors = 0;
|
|
417
491
|
for (const r of report.results) {
|
|
418
492
|
if (r.status === "unreachable") errors++;
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
if (r.fetched?.epss?.date && r.local) {
|
|
426
|
-
diffs.push({ id: r.cve_id, field: "epss_date", before: r.local.epss_date, after: r.fetched.epss.date, severity: "low" });
|
|
427
|
-
}
|
|
493
|
+
// Diff off the FETCHED ROW rather than off r.discrepancies. The
|
|
494
|
+
// discrepancy list names only the fields that differ enough to report,
|
|
495
|
+
// which is precisely the input that used to let score and percentile be
|
|
496
|
+
// written from different publications — see epssTripleDiffs.
|
|
497
|
+
if (!r.fetched?.epss || !r.local) continue;
|
|
498
|
+
diffs.push(...epssTripleDiffs(r.cve_id, r.local, r.fetched.epss, EPSS_DRIFT));
|
|
428
499
|
}
|
|
429
|
-
// Collapse duplicates: epss_date should appear once per CVE only when
|
|
430
|
-
// an epss field actually moved.
|
|
431
|
-
const epssCves = new Set(diffs.filter((d) => d.field === "epss_score" || d.field === "epss_percentile").map((d) => d.id));
|
|
432
|
-
const filtered = diffs.filter((d) => d.field !== "epss_date" || epssCves.has(d.id));
|
|
433
500
|
return {
|
|
434
501
|
status: errors === 0 ? "ok" : errors === report.results.length ? "unreachable" : "partial",
|
|
435
|
-
diffs
|
|
502
|
+
diffs,
|
|
436
503
|
errors,
|
|
437
|
-
summary: `${
|
|
504
|
+
summary: `${diffs.length} EPSS diffs; ${errors} unreachable / ${report.total} total`,
|
|
438
505
|
};
|
|
439
506
|
},
|
|
440
507
|
async applyDiff(ctx, diffs) {
|
|
@@ -442,6 +509,7 @@ const EPSS_SOURCE = {
|
|
|
442
509
|
const errors = [];
|
|
443
510
|
const catalogPath = ctx.cvePath || ABS("data/cve-catalog.json");
|
|
444
511
|
await withCatalogLock(catalogPath, (catalog) => {
|
|
512
|
+
const touchedFields = new Map();
|
|
445
513
|
for (const d of diffs) {
|
|
446
514
|
if (!catalog[d.id]) {
|
|
447
515
|
errors.push(`EPSS: no local entry for ${d.id}`);
|
|
@@ -449,8 +517,36 @@ const EPSS_SOURCE = {
|
|
|
449
517
|
}
|
|
450
518
|
catalog[d.id][d.field] = d.after;
|
|
451
519
|
catalog[d.id].last_verified = TODAY;
|
|
520
|
+
// An id is coherent only if EVERY diff applied to it declares that
|
|
521
|
+
// provenance; one unmarked diff in the set is enough to withhold the
|
|
522
|
+
// note rewrite.
|
|
523
|
+
touchedFields.set(d.id, (touchedFields.get(d.id) !== false) && d.coherent === true);
|
|
452
524
|
updated++;
|
|
453
525
|
}
|
|
526
|
+
// `epss_note` restates the three fields in prose. It is derived, so a
|
|
527
|
+
// refresh that moves the numbers and leaves the sentence behind makes the
|
|
528
|
+
// entry state two different scores as of two different dates — which the
|
|
529
|
+
// consistency gate reports as a failure on data that was otherwise
|
|
530
|
+
// refreshed correctly. Rebuild it from the same renderer the gate checks
|
|
531
|
+
// against, and only for entries that already carry one: adding the note
|
|
532
|
+
// to an entry that never had it is a content change, not a refresh.
|
|
533
|
+
for (const [id, coherent] of touchedFields) {
|
|
534
|
+
const e = catalog[id];
|
|
535
|
+
if (typeof e.epss_note !== "string") continue;
|
|
536
|
+
if (typeof e.epss_score !== "number" || typeof e.epss_percentile !== "number") continue;
|
|
537
|
+
// Regenerate only for diffs whose emitter vouched that they came from
|
|
538
|
+
// one complete publication. applyDiff is exported and is also driven by
|
|
539
|
+
// fixtures carrying a score and a date without a percentile, so a diff
|
|
540
|
+
// set can move half the pair on its own; rewriting the note from the
|
|
541
|
+
// surviving stale percentile would make a mixed-publication entry read
|
|
542
|
+
// as current and suppress the only signal the consistency gate has for
|
|
543
|
+
// it. Deciding this from WHICH fields changed does not work — a
|
|
544
|
+
// complete row whose percentile rounds to the same value emits the
|
|
545
|
+
// score alone, and skipping it there would strand the note stale
|
|
546
|
+
// forever, because the next refresh sees no diff left to repair it.
|
|
547
|
+
if (!coherent) continue;
|
|
548
|
+
e.epss_note = renderEpssNote(e);
|
|
549
|
+
}
|
|
454
550
|
catalog._meta = catalog._meta || {};
|
|
455
551
|
catalog._meta.last_updated = TODAY;
|
|
456
552
|
ctx.cveCatalog = catalog;
|
|
@@ -1019,7 +1115,7 @@ function epssDiffFromCache(ctx) {
|
|
|
1019
1115
|
const cves = Object.keys(ctx.cveCatalog).filter((k) => /^CVE-\d{4}-\d{4,7}$/.test(k));
|
|
1020
1116
|
const diffs = [];
|
|
1021
1117
|
let errors = 0;
|
|
1022
|
-
const drift =
|
|
1118
|
+
const drift = EPSS_DRIFT;
|
|
1023
1119
|
for (const id of cves) {
|
|
1024
1120
|
const payload = readCachedJson(ctx.cacheDir, "epss", id, { forceStale: ctx.forceStale });
|
|
1025
1121
|
if (!payload) { errors++; continue; }
|
|
@@ -1031,20 +1127,12 @@ function epssDiffFromCache(ctx) {
|
|
|
1031
1127
|
let row = (payload.data || []).find((r) => r?.cve === id);
|
|
1032
1128
|
if (!row && (payload.data || []).length === 1 && (payload.data || [])[0]?.cve == null) row = (payload.data || [])[0];
|
|
1033
1129
|
if (!row) continue;
|
|
1034
|
-
const
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
if (pct != null && local.epss_percentile != null && Math.abs(pct - local.epss_percentile) > drift) {
|
|
1041
|
-
diffs.push({ id, field: "epss_percentile", before: local.epss_percentile, after: pct, severity: "medium" });
|
|
1042
|
-
}
|
|
1043
|
-
if (row.date && local.epss_date && row.date !== local.epss_date) {
|
|
1044
|
-
// Only emit a date diff when we also emitted a score/percentile diff for this CVE.
|
|
1045
|
-
const moved = diffs.some((d) => d.id === id && (d.field === "epss_score" || d.field === "epss_percentile"));
|
|
1046
|
-
if (moved) diffs.push({ id, field: "epss_date", before: local.epss_date, after: row.date, severity: "low" });
|
|
1047
|
-
}
|
|
1130
|
+
const fetched = {
|
|
1131
|
+
score: row.epss != null ? Number(row.epss) : null,
|
|
1132
|
+
percentile: row.percentile != null ? Number(row.percentile) : null,
|
|
1133
|
+
date: row.date || null,
|
|
1134
|
+
};
|
|
1135
|
+
diffs.push(...epssTripleDiffs(id, ctx.cveCatalog[id], fetched, drift));
|
|
1048
1136
|
}
|
|
1049
1137
|
const status = errors === 0 ? "ok" : errors === cves.length ? "unreachable" : "partial";
|
|
1050
1138
|
return { status, diffs, errors, summary: `${diffs.length} EPSS diffs (from cache); ${errors} missing entries` };
|
|
@@ -1966,4 +2054,4 @@ if (require.main === module) {
|
|
|
1966
2054
|
});
|
|
1967
2055
|
}
|
|
1968
2056
|
|
|
1969
|
-
module.exports = { ALL_SOURCES, loadCtx, parseArgs, seedSingleAdvisory, withCatalogLock, writeJsonAtomic, nvdDiffFromCache, kevDiffFromCache };
|
|
2057
|
+
module.exports = { ALL_SOURCES, loadCtx, parseArgs, seedSingleAdvisory, withCatalogLock, writeJsonAtomic, nvdDiffFromCache, kevDiffFromCache, epssDiffFromCache, epssTripleDiffs, EPSS_DRIFT };
|