@blamejs/exceptd-skills 0.19.25 → 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.
@@ -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
- const cweRefs = (f.cwe_nvd || []).filter((c) => /^CWE-\d+$/.test(c));
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 };
@@ -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
- for (const d of r.discrepancies || []) {
420
- if (d.field === "epss_score" || d.field === "epss_percentile") {
421
- diffs.push({ id: r.cve_id, field: d.field, before: d.local, after: d.fetched, severity: d.severity });
422
- }
423
- }
424
- // epss_date refreshes when score does.
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: filtered,
502
+ diffs,
436
503
  errors,
437
- summary: `${filtered.length} EPSS diffs; ${errors} unreachable / ${report.total} total`,
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 = 0.05;
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 score = row.epss != null ? Number(row.epss) : null;
1035
- const pct = row.percentile != null ? Number(row.percentile) : null;
1036
- const local = ctx.cveCatalog[id];
1037
- if (score != null && local.epss_score != null && Math.abs(score - local.epss_score) > drift) {
1038
- diffs.push({ id, field: "epss_score", before: local.epss_score, after: score, severity: "medium" });
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 };