@blamejs/exceptd-skills 0.19.33 → 0.19.34

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.
Files changed (119) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/bin/exceptd.js +896 -2824
  3. package/data/_indexes/_meta.json +2 -2
  4. package/lib/auto-discovery.js +56 -286
  5. package/lib/canonical-eq.js +7 -40
  6. package/lib/citation-resolve.js +22 -70
  7. package/lib/collectors/ai-api.js +20 -54
  8. package/lib/collectors/cicd-pipeline-compromise.js +40 -108
  9. package/lib/collectors/citation-hygiene.js +72 -210
  10. package/lib/collectors/containers.js +41 -130
  11. package/lib/collectors/cred-stores.js +31 -115
  12. package/lib/collectors/crypto-codebase.js +55 -138
  13. package/lib/collectors/crypto.js +24 -54
  14. package/lib/collectors/hardening.js +20 -78
  15. package/lib/collectors/kernel.js +16 -46
  16. package/lib/collectors/library-author.js +57 -206
  17. package/lib/collectors/mcp.js +24 -70
  18. package/lib/collectors/runtime.js +24 -86
  19. package/lib/collectors/sbom.js +34 -106
  20. package/lib/collectors/scan-excludes.js +31 -138
  21. package/lib/collectors/secrets.js +62 -178
  22. package/lib/cross-ref-api.js +39 -123
  23. package/lib/currency-severity.js +8 -27
  24. package/lib/cve-batch.js +13 -21
  25. package/lib/cve-cli.js +13 -20
  26. package/lib/cve-curation.js +72 -239
  27. package/lib/cve-regression-watcher.js +29 -152
  28. package/lib/cvss.js +13 -54
  29. package/lib/doctor-bucketing.js +3 -19
  30. package/lib/exit-codes.js +10 -42
  31. package/lib/flag-suggest.js +7 -25
  32. package/lib/framework-gap.js +35 -114
  33. package/lib/gap-detectors.js +37 -159
  34. package/lib/id-validation.js +9 -30
  35. package/lib/job-queue.js +13 -36
  36. package/lib/lint-skills.js +64 -232
  37. package/lib/playbook-runner.js +693 -2095
  38. package/lib/prefetch.js +100 -376
  39. package/lib/refresh-external.js +199 -627
  40. package/lib/refresh-network.js +75 -307
  41. package/lib/rfc-cli.js +23 -68
  42. package/lib/scoring.js +77 -145
  43. package/lib/sign.js +43 -229
  44. package/lib/source-advisories.js +43 -194
  45. package/lib/source-ghsa.js +37 -120
  46. package/lib/source-osv.js +94 -266
  47. package/lib/ttp-mapper.js +14 -24
  48. package/lib/upstream-check-cli.js +10 -28
  49. package/lib/upstream-check.js +19 -44
  50. package/lib/validate-catalog-meta.js +17 -61
  51. package/lib/validate-cve-catalog.js +43 -119
  52. package/lib/validate-indexes.js +25 -76
  53. package/lib/validate-package.js +16 -62
  54. package/lib/validate-playbooks.js +69 -275
  55. package/lib/validate-vendor.js +16 -49
  56. package/lib/verify.js +56 -286
  57. package/lib/version-pins.js +5 -34
  58. package/lib/worker-pool.js +11 -30
  59. package/lib/xml-tokenizer.js +47 -152
  60. package/manifest.json +53 -53
  61. package/orchestrator/dispatcher.js +17 -68
  62. package/orchestrator/event-bus.js +11 -74
  63. package/orchestrator/index.js +138 -412
  64. package/orchestrator/pipeline.js +28 -85
  65. package/orchestrator/scanner.js +34 -138
  66. package/orchestrator/scheduler.js +20 -84
  67. package/package.json +1 -1
  68. package/sbom.cdx.json +241 -241
  69. package/scripts/audit-catalog-gaps.js +9 -62
  70. package/scripts/audit-cross-skill.js +5 -31
  71. package/scripts/audit-perf.js +6 -16
  72. package/scripts/backfill-theater-test.js +7 -64
  73. package/scripts/bootstrap.js +12 -44
  74. package/scripts/build-indexes.js +40 -154
  75. package/scripts/builders/activity-feed.js +4 -14
  76. package/scripts/builders/catalog-summaries.js +3 -10
  77. package/scripts/builders/currency.js +7 -20
  78. package/scripts/builders/cwe-chains.js +7 -30
  79. package/scripts/builders/did-ladders.js +6 -13
  80. package/scripts/builders/frequency.js +5 -19
  81. package/scripts/builders/jurisdiction-clocks.js +6 -25
  82. package/scripts/builders/recipes.js +6 -14
  83. package/scripts/builders/section-offsets.js +13 -51
  84. package/scripts/builders/stale-content.js +7 -28
  85. package/scripts/builders/summary-cards.js +8 -29
  86. package/scripts/builders/theater-fingerprints.js +12 -27
  87. package/scripts/builders/token-budget.js +4 -31
  88. package/scripts/check-agents-md-collectors.js +11 -54
  89. package/scripts/check-catalog-gap-budget.js +15 -32
  90. package/scripts/check-changelog-extract.js +18 -48
  91. package/scripts/check-codebase-patterns-currency.js +6 -22
  92. package/scripts/check-codebase-patterns.js +50 -143
  93. package/scripts/check-epss-consistency.js +9 -64
  94. package/scripts/check-framework-gap-coverage.js +13 -31
  95. package/scripts/check-manifest-snapshot.js +13 -73
  96. package/scripts/check-sbom-currency.js +44 -142
  97. package/scripts/check-test-count.js +15 -52
  98. package/scripts/check-test-coverage.js +66 -197
  99. package/scripts/check-test-subjects.js +21 -62
  100. package/scripts/check-ttp-references.js +14 -38
  101. package/scripts/check-ttp-upstream.js +8 -40
  102. package/scripts/check-version-bump.js +9 -61
  103. package/scripts/check-version-tags.js +20 -121
  104. package/scripts/predeploy.js +38 -184
  105. package/scripts/refresh-manifest-snapshot.js +16 -38
  106. package/scripts/refresh-mitre-atlas.js +3 -8
  107. package/scripts/refresh-mitre-attack.js +1 -8
  108. package/scripts/refresh-mitre-d3fend.js +3 -9
  109. package/scripts/refresh-mitre-ics-attack.js +3 -8
  110. package/scripts/refresh-reverse-refs.js +27 -94
  111. package/scripts/refresh-rfc-index.js +2 -10
  112. package/scripts/refresh-sbom.js +31 -161
  113. package/scripts/refresh-upstream-catalogs.js +40 -137
  114. package/scripts/release.js +69 -232
  115. package/scripts/run-e2e-scenarios.js +24 -71
  116. package/scripts/sync-manifest-metadata.js +10 -34
  117. package/scripts/sync-package-description.js +8 -17
  118. package/scripts/validate-vendor-online.js +13 -44
  119. package/scripts/verify-shipped-tarball.js +35 -140
package/lib/rfc-cli.js CHANGED
@@ -2,21 +2,14 @@
2
2
  "use strict";
3
3
 
4
4
  /**
5
- * lib/rfc-cli.js — `exceptd rfc <number>` resolver.
6
- *
7
- * Local index (whole current RFC series, offline) -> resolved cache -> one
8
- * datatracker lookup to disambiguate obsoleted-vs-nonexistent. Resolves an RFC
9
- * number to its title + status so an agent can confirm a citation (e.g. "is
10
- * RFC 9404 the Sieve spec?") without the datatracker. Optional --check
11
- * "<claimed title>" reports whether the claimed title matches.
5
+ * `exceptd rfc <number>` — resolves an RFC number to its title and status:
6
+ * local index, then resolved cache, then one datatracker lookup to separate
7
+ * obsoleted from nonexistent.
12
8
  */
13
9
 
14
10
  const { resolveRfc } = require("./citation-resolve.js");
15
11
 
16
- // Stopwords that don't disambiguate one RFC title from another. A claimed title
17
- // run preceded by one of these in the index title is still a clean match; a run
18
- // preceded by a CONTENT word (e.g. "datagram" before "transport layer security")
19
- // is the tail of a more-specific title and must NOT be accepted as a match.
12
+ // Stopwords don't disambiguate one RFC title from another.
20
13
  const TITLE_STOPWORDS = new Set(["the", "a", "an", "of", "for", "to", "in", "on", "and", "or"]);
21
14
 
22
15
  function normTitle(s) {
@@ -24,37 +17,19 @@ function normTitle(s) {
24
17
  }
25
18
 
26
19
  /**
27
- * Decide whether a claimed RFC title matches the authoritative index title.
28
- *
29
- * Replaces the old lenient bidirectional substring test (`a.includes(b) ||
30
- * b.includes(a)`), which let "TLS" match the DTLS title (substring of "dtls")
31
- * and let "Transport Layer Security" match the DTLS title (tail-of-phrase).
32
- * The comparison is now whole-word and phrase-aware:
33
- *
34
- * 1. Every claimed token must appear as a WHOLE word in the index title
35
- * (so "tls" never matches inside "dtls").
36
- * 2. The claimed token sequence must appear as a CONTIGUOUS run in the index
37
- * title, OR the claim must cover enough of the index title (containment
38
- * ratio floor) to be unambiguous.
39
- * 3. A contiguous run that is immediately preceded by a distinguishing
40
- * CONTENT word in the index title is rejected — it is the tail of a
41
- * more-specific title (the "datagram transport layer security" trap).
42
- *
43
- * Returns true / false. Only called when both a claim and an index title exist.
20
+ * Whole-word, phrase-aware comparison of a claimed RFC title against the
21
+ * authoritative index title. Called only when both exist.
44
22
  */
45
23
  function titleMatches(claimed, indexTitle) {
46
24
  const claimTokens = normTitle(claimed).split(" ").filter(Boolean);
47
25
  const titleTokens = normTitle(indexTitle).split(" ").filter(Boolean);
48
26
  if (claimTokens.length === 0 || titleTokens.length === 0) return false;
49
27
 
50
- // (1) Whole-word containment: every claimed token must be a standalone token
51
- // in the index title. Kills the tls-inside-dtls substring false positive.
52
28
  const titleSet = new Set(titleTokens);
53
29
  for (const t of claimTokens) {
54
30
  if (!titleSet.has(t)) return false;
55
31
  }
56
32
 
57
- // Find every contiguous run of the claim inside the index title.
58
33
  const runStarts = [];
59
34
  for (let i = 0; i + claimTokens.length <= titleTokens.length; i++) {
60
35
  let hit = true;
@@ -65,16 +40,12 @@ function titleMatches(claimed, indexTitle) {
65
40
  }
66
41
 
67
42
  if (runStarts.length > 0) {
68
- // A single-token claim that is a whole word in the title is unambiguous on
69
- // its own — the whole-word check above already excluded the substring trap
70
- // (e.g. "tls" is NOT a token inside "dtls"), so "TLS" correctly matches the
71
- // 8446 title (standalone "tls" token) but not the 9147 DTLS title.
43
+ // A single-token claim that survived the whole-word check is unambiguous on
44
+ // its own — "tls" is not a token inside "dtls".
72
45
  if (claimTokens.length === 1) return true;
73
- // (3) For a MULTI-token run, accept only if at least one occurrence is NOT
74
- // preceded by a distinguishing content word — i.e. it begins the title
75
- // or is preceded only by a stopword. A run preceded solely by a content
76
- // qualifier (e.g. "datagram" before "transport layer security") is the
77
- // tail of a more-specific title and must not be accepted as a match.
46
+ // A multi-token run counts only where some occurrence begins the title or
47
+ // is preceded by a stopword. Preceded by a content qualifier ("datagram"
48
+ // before "transport layer security") it is the tail of a different title.
78
49
  for (const start of runStarts) {
79
50
  if (start === 0) return true;
80
51
  const prev = titleTokens[start - 1];
@@ -83,13 +54,9 @@ function titleMatches(claimed, indexTitle) {
83
54
  return false;
84
55
  }
85
56
 
86
- // No contiguous run, but all tokens present out of order. Accept only when the
87
- // claim covers a strong majority of the index title's tokens (containment
88
- // ratio floor) — a few scattered tokens against a long title is ambiguous,
89
- // not a match. Count DISTINCT claim tokens that appear in the title: counting
90
- // non-distinct tokens lets a repeated-token claim (e.g. "security security
91
- // security security") inflate the ratio past the floor and falsely match an
92
- // unrelated title.
57
+ // No contiguous run, all tokens present out of order: accept only when the
58
+ // claim covers a strong majority of the title's tokens. DISTINCT claim tokens
59
+ // are counted, or a repeated-token claim inflates the ratio past the floor.
93
60
  const distinct = new Set(claimTokens);
94
61
  const present = [...distinct].filter((t) => titleSet.has(t)).length;
95
62
  const ratio = present / titleTokens.length;
@@ -99,9 +66,8 @@ function titleMatches(claimed, indexTitle) {
99
66
  async function main() {
100
67
  const argv = process.argv.slice(2);
101
68
  const flags = new Set(argv.filter((a) => a.startsWith("--")));
102
- // Reject unknown flags (same contract as the in-process verbs). `--check`
103
- // consumes the following token as its value; that value is a positional, not
104
- // a flag, so it isn't checked here.
69
+ // Unknown flags are rejected, as in the in-process verbs. `--check` consumes
70
+ // the following token, which is a positional and so isn't checked here.
105
71
  const KNOWN = new Set(["--json", "--pretty", "--air-gap", "--no-network", "--check", "--help", "-h"]);
106
72
  const unknown = [...flags].filter((f) => !KNOWN.has(f));
107
73
  if (unknown.length > 0) {
@@ -112,10 +78,8 @@ async function main() {
112
78
  process.exitCode = 1;
113
79
  return;
114
80
  }
115
- // --check "<claimed title>" consumes the FOLLOWING token as its value. Exclude
116
- // that value token by INDEX from the positional pool before selecting id, so
117
- // the RFC number resolves correctly regardless of flag order
118
- // (`rfc --check "Some Title" 9404` reads id=9404, not id="Some Title").
81
+ // The `--check` value token is excluded from the positional pool by INDEX, so
82
+ // `rfc --check "Some Title" 9404` reads id=9404, not id="Some Title".
119
83
  const checkIdx = argv.indexOf("--check");
120
84
  const checkValueIdx = (checkIdx !== -1 && argv[checkIdx + 1] && !argv[checkIdx + 1].startsWith("--")) ? checkIdx + 1 : -1;
121
85
  const positionals = argv.filter((a, i) => !a.startsWith("--") && i !== checkValueIdx);
@@ -123,9 +87,7 @@ async function main() {
123
87
  const pretty = flags.has("--pretty");
124
88
  const json = flags.has("--json") || pretty;
125
89
 
126
- // The claimed title is exactly the excluded value token (kept in lockstep with
127
- // checkValueIdx so the two never diverge); a trailing `--check` with no value
128
- // leaves it null.
90
+ // Exactly the excluded value token; a trailing `--check` with no value is null.
129
91
  let claimedTitle = null;
130
92
  if (checkValueIdx !== -1) claimedTitle = argv[checkValueIdx];
131
93
 
@@ -143,10 +105,7 @@ async function main() {
143
105
  if (claimedTitle && r.title) {
144
106
  titleMatch = titleMatches(claimedTitle, r.title);
145
107
  }
146
- // Derive `ok` from the resolved status + title-check the same way the exit
147
- // code is derived below — a non-zero exit (status nonexistent OR an explicit
148
- // title mismatch) must carry ok:false, not the inverted ok:true the envelope
149
- // previously hardcoded.
108
+ // `ok` derives from the same condition as the exit code below.
150
109
  const fails = r.status === "nonexistent" || titleMatch === false;
151
110
  const body = { verb: "rfc", ...r, ...(claimedTitle ? { claimed_title: claimedTitle, title_match: titleMatch } : {}), ok: !fails };
152
111
 
@@ -171,15 +130,11 @@ async function main() {
171
130
  if (fails) process.exitCode = 2;
172
131
  }
173
132
 
174
- // Only run the CLI when invoked directly (`exceptd rfc ...`). When required by a
175
- // test the IIFE must not fire — it would read process.argv and write to stdout —
176
- // so the pure title-match helper can be exercised in-process.
133
+ // Under require it would read process.argv and write stdout; keep it testable.
177
134
  if (require.main === module) {
178
135
  main().catch((err) => {
179
- // A corrupt/unreadable RFC index (or any unexpected throw inside the async
180
- // body) becomes a rejected promise. Emit the documented {ok:false,error}
181
- // envelope rather than crashing with a raw stack trace, and signal failure
182
- // via exitCode so the event loop drains stderr before exit.
136
+ // Any unexpected throw becomes the {ok:false,error} envelope, not a raw
137
+ // stack; `process.exitCode`, not `process.exit()`, so stderr drains.
183
138
  process.stderr.write(JSON.stringify({ ok: false, verb: "rfc", error: String((err && err.message) || err) }) + "\n");
184
139
  process.exitCode = 1;
185
140
  });
package/lib/scoring.js CHANGED
@@ -4,17 +4,13 @@
4
4
  * RWEP — Real-World Exploit Priority scoring engine. Supplements CVSS with
5
5
  * exploit availability, active exploitation and operational constraints.
6
6
  *
7
- * `rwep_factors` carries two shapes at once. Every factor stores its POST-WEIGHT
8
- * contribution except `blast_radius`, which stores its RAW 0..30 magnitude —
9
- * summing the object still yields `rwep_score` only because the blast weight is
10
- * also 30. Do not feed booleans in here; `scoreCustom()` takes those, plus a
11
- * numeric blast_radius and a ladder string. `deriveRwepFromFactors()` detects
12
- * which shape it was given and routes accordingly.
7
+ * In a stored `rwep_factors` block every factor is its POST-WEIGHT contribution
8
+ * except `blast_radius`, a RAW 0..30 magnitude; the object sums to `rwep_score`
9
+ * only because the blast weight is also 30.
13
10
  */
14
11
 
15
12
  // Loaded from the schema so the two cannot drift. live_patch_tools is
16
- // deliberately absent: it is schema-optional, and the
17
- // live_patch_available => live_patch_tools implication is enforced below.
13
+ // schema-optional — validate() enforces live_patch_available => live_patch_tools.
18
14
  const CVE_SCHEMA_REQUIRED = require('./schemas/cve-catalog.schema.json').required;
19
15
 
20
16
  // reboot_required applies even when a live patch exists, because a live patch
@@ -30,12 +26,9 @@ const RWEP_WEIGHTS = {
30
26
  reboot_required: 5
31
27
  };
32
28
 
33
- // Must stay aligned with playbook-runner's _activeExploitationLadder so the
34
- // catalog scorer and the runtime evaluator agree on the same string. 'unknown'
35
- // scores a quarter rather than zero: an untriaged CVE is not a clean one.
36
- // 'theoretical' is mapped explicitly at 0 — a published PoC carries its weight
37
- // through poc_available, and an incidental `?? 0` fall-through here would be
38
- // indistinguishable from an unrecognised value.
29
+ // Must stay aligned with playbook-runner's _activeExploitationLadder. 'unknown'
30
+ // scores a quarter — an untriaged CVE is not a clean one; 'theoretical' is
31
+ // mapped explicitly so it stays distinguishable from an unrecognised value.
39
32
  const ACTIVE_EXPLOITATION_LADDER = {
40
33
  confirmed: 1.0,
41
34
  suspected: 0.5,
@@ -45,15 +38,10 @@ const ACTIVE_EXPLOITATION_LADDER = {
45
38
  };
46
39
 
47
40
  /**
48
- * Ladder multiplier for an active_exploitation value, as
49
- * { multiplier, recognised, normalised }.
50
- *
51
- * Case- and whitespace-normalised, so 'Confirmed' resolves rather than zeroing.
52
- * null and undefined are the documented 'none' default and count as recognised.
53
- * Anything else returns multiplier 0 AND emits a process warning: an
54
- * out-of-vocabulary string would otherwise drop 20 points silently, and the
55
- * no-match path has to be observable. validateFactors() carries the structured
56
- * diagnostic for callers that collect warnings.
41
+ * Ladder multiplier as { multiplier, recognised, normalised }. Case- and
42
+ * whitespace-normalised; null and undefined are the 'none' default and count as
43
+ * recognised. Anything else returns multiplier 0 with recognised false, so an
44
+ * out-of-vocabulary string cannot drop 20 points silently.
57
45
  */
58
46
  function resolveActiveExploitation(active_exploitation) {
59
47
  if (active_exploitation === undefined || active_exploitation === null) {
@@ -72,11 +60,8 @@ function resolveActiveExploitation(active_exploitation) {
72
60
  function activeExploitationMultiplier(active_exploitation) {
73
61
  const r = resolveActiveExploitation(active_exploitation);
74
62
  if (!r.recognised) {
75
- // Observable diagnostic for the bare-number call path (scoreCustom without
76
- // collectWarnings, which the production write-paths use). Routed through
77
- // process.emitWarning so it lands on the standard Node diagnostic channel
78
- // without changing the function's number return contract; deduped per
79
- // distinct offending value so a batch curation run doesn't flood stderr.
63
+ // Routed through process.emitWarning so the bare-number return contract is
64
+ // unchanged; validateFactors() carries the structured form.
80
65
  const detail = active_exploitation === undefined || active_exploitation === null
81
66
  ? String(active_exploitation)
82
67
  : (typeof active_exploitation === 'string' ? JSON.stringify(active_exploitation) : `${typeof active_exploitation} ${JSON.stringify(active_exploitation)}`);
@@ -89,8 +74,7 @@ function activeExploitationMultiplier(active_exploitation) {
89
74
  }
90
75
 
91
76
  // Boolean-input (Shape-A) keys scoreCustom recognises; validateFactors flags
92
- // anything else. The last two are the catalog's own field names, accepted as
93
- // aliases so a factor bag built straight from an entry validates.
77
+ // anything else. The last two are catalog field names accepted as aliases.
94
78
  const RECOGNISED_FACTOR_KEYS = new Set([
95
79
  'cisa_kev', 'poc_available', 'ai_assisted_weapon', 'ai_discovered',
96
80
  'active_exploitation', 'blast_radius', 'patch_available',
@@ -99,11 +83,9 @@ const RECOGNISED_FACTOR_KEYS = new Set([
99
83
  'patch_required_reboot',
100
84
  ]);
101
85
 
102
- // Post-weight (Shape-B) keys deriveRwepFromFactors may sum. `ai_factor` has to
103
- // be added back: it is the +15 weight every Shape-B entry stores, and it is
104
- // absent from the Shape-A set above, so filtering on that set alone would drop
105
- // it from every derivation. A key outside this set is excluded from the sum and
106
- // surfaced, so a typo cannot quietly change a score.
86
+ // Post-weight (Shape-B) keys deriveRwepFromFactors may sum. `ai_factor` is added
87
+ // back because it is absent from the Shape-A set above. A key outside this set is
88
+ // excluded from the sum and surfaced, so a typo cannot quietly change a score.
107
89
  const RECOGNISED_POST_WEIGHT_KEYS = new Set([...RECOGNISED_FACTOR_KEYS, 'ai_factor']);
108
90
 
109
91
  function score(cveId, catalog) {
@@ -115,9 +97,7 @@ function score(cveId, catalog) {
115
97
  /**
116
98
  * Warnings for a factor bag: missing-but-defaultable fields and out-of-range
117
99
  * values. Never throws — a caller wanting enforcement treats a non-empty return
118
- * as a failure. Booleans may be null (false, with a warning);
119
- * active_exploitation must be a ladder value; blast_radius is an integer in
120
- * [0, 30], flagged out of range because that usually means a unit error.
100
+ * as a failure.
121
101
  */
122
102
  function validateFactors(factors) {
123
103
  const warnings = [];
@@ -127,8 +107,8 @@ function validateFactors(factors) {
127
107
  const boolFields = ['cisa_kev', 'poc_available', 'ai_assisted_weapon', 'ai_discovered',
128
108
  'patch_available', 'live_patch_available', 'reboot_required'];
129
109
  for (const f of boolFields) {
130
- // Honours the same aliasing as scoreCustom, so a bag supplying only the
131
- // catalog field name is not flagged missing.
110
+ // Same aliasing as scoreCustom, so a bag supplying only the catalog field
111
+ // name is not flagged missing.
132
112
  const present = (f === 'ai_assisted_weapon')
133
113
  ? (factors.ai_assisted_weapon ?? factors.ai_assisted_weaponization)
134
114
  : (f === 'reboot_required')
@@ -146,20 +126,18 @@ function validateFactors(factors) {
146
126
  warnings.push("active_exploitation: missing (treated as 'none')");
147
127
  } else {
148
128
  // Normalised before the vocabulary check so this accepts exactly what the
149
- // scorer accepts; otherwise 'Confirmed' is flagged here and consumed there.
129
+ // scorer accepts — otherwise 'Confirmed' is flagged here and taken there.
150
130
  const aeNorm = typeof aeRaw === 'string' ? aeRaw.trim().toLowerCase() : aeRaw;
151
131
  if (!aeAllowed.includes(aeNorm)) {
152
132
  warnings.push(`active_exploitation: expected one of ${aeAllowed.join(', ')}, got ${JSON.stringify(aeRaw)}`);
153
133
  }
154
134
  }
155
- // Number.isFinite rather than a typeof check: `typeof NaN === 'number'` and
156
- // `JSON.stringify(NaN) === 'null'`, which together produce the useless
157
- // "expected number, got number (null)".
135
+ // Number.isFinite rather than typeof: `typeof NaN === 'number'` and
136
+ // `JSON.stringify(NaN) === 'null'` produce "expected number, got number (null)".
158
137
  if (factors.blast_radius === undefined || factors.blast_radius === null) {
159
138
  warnings.push('blast_radius: missing (treated as 0)');
160
139
  } else if (typeof factors.blast_radius !== 'number') {
161
- // scoreCustom coerces a numeric string via Number(), so a finite one is
162
- // noted rather than rejected — the scorer will use it either way.
140
+ // scoreCustom coerces a numeric string, so a finite one is noted, not rejected.
163
141
  if (typeof factors.blast_radius === 'string' && Number.isFinite(Number(factors.blast_radius)) && factors.blast_radius.trim() !== '') {
164
142
  warnings.push(`blast_radius: numeric string "${factors.blast_radius}" accepted (coerced to ${Number(factors.blast_radius)}); prefer a JSON number`);
165
143
  } else {
@@ -172,8 +150,8 @@ function validateFactors(factors) {
172
150
  } else if (factors.blast_radius < 0 || factors.blast_radius > 30) {
173
151
  warnings.push(`blast_radius: ${factors.blast_radius} out of expected range [0, 30] (clamped to weight ceiling, but the value usually indicates a unit-of-measure mistake)`);
174
152
  }
175
- // An unknown key is surfaced rather than ignored: `patch_avilable` would
176
- // otherwise default to false with no diagnostic.
153
+ // An unknown key is surfaced, not ignored: `patch_avilable` would otherwise
154
+ // default to false with no diagnostic.
177
155
  for (const k of Object.keys(factors)) {
178
156
  if (!RECOGNISED_FACTOR_KEYS.has(k)) {
179
157
  warnings.push(`unknown factor: ${k} (ignored — not in the recognised key set)`);
@@ -183,19 +161,16 @@ function validateFactors(factors) {
183
161
  }
184
162
 
185
163
  /**
186
- * RWEP for a factor bag, clamped to [0, 100].
187
- *
188
- * Returns a bare number, which callers depend on. With
189
- * `opts.collectWarnings` it returns `{ score, _scoring_warnings }` instead;
190
- * for validation without a score, call `validateFactors()` directly.
164
+ * RWEP for a factor bag, clamped to [0, 100]. Returns a bare number, which
165
+ * callers depend on; with `opts.collectWarnings` it returns
166
+ * `{ score, _scoring_warnings }` instead.
191
167
  */
192
168
  function scoreCustom(factors, opts) {
193
169
  const {
194
170
  cisa_kev = false,
195
171
  poc_available = false,
196
172
  ai_assisted_weapon = false,
197
- // Catalog field name, accepted as an alias so an entry-derived bag still
198
- // counts the +15 AI factor.
173
+ // Catalog field name, aliased so an entry-derived bag still counts the +15.
199
174
  ai_assisted_weaponization = false,
200
175
  ai_discovered = false,
201
176
  active_exploitation = 'none',
@@ -203,8 +178,7 @@ function scoreCustom(factors, opts) {
203
178
  patch_available = false,
204
179
  live_patch_available = false,
205
180
  reboot_required = false,
206
- // Likewise: the catalog spells this `patch_required_reboot`, so both
207
- // spellings are accepted and a direct caller cannot lose the factor.
181
+ // Likewise the catalog spelling; both are accepted so no caller loses it.
208
182
  patch_required_reboot,
209
183
  } = factors || {};
210
184
  const rebootFactor = (reboot_required === true) || (patch_required_reboot === true);
@@ -217,11 +191,8 @@ function scoreCustom(factors, opts) {
217
191
  // odd number would truncate under a `Math.floor(weight/2)` split.
218
192
  const aeMultiplier = activeExploitationMultiplier(active_exploitation);
219
193
  score += RWEP_WEIGHTS.active_exploitation * aeMultiplier;
220
- // Accepts only a finite number or a trimmed non-empty numeric string, which
221
- // is validateFactors' contract exactly. Bare Number() would turn `true` into
222
- // 1 and `[7]` into 7 — values the validator rejects — so the scorer would add
223
- // a contribution the validator calls invalid. NaN also has
224
- // `typeof === 'number'` and propagates through the final clamp.
194
+ // Only a finite number or a trimmed non-empty numeric string, matching
195
+ // validateFactors. Bare Number() turns `true` into 1 and `[7]` into 7.
225
196
  let brRaw = 0;
226
197
  if (typeof blast_radius === 'number' && Number.isFinite(blast_radius)) {
227
198
  brRaw = blast_radius;
@@ -254,23 +225,17 @@ function scoreCustom(factors, opts) {
254
225
  }
255
226
 
256
227
  /**
257
- * RWEP from a `rwep_factors` object of either shape, so the curation
258
- * apply-path and the auto-discovery builder share one derivation.
259
- *
260
- * Shape A (booleans, a ladder string, a numeric blast_radius) routes through
261
- * scoreCustom. Shape B (post-weight contributions, how the catalog stores them)
262
- * is summed and clamped to [0, 100].
228
+ * RWEP from a `rwep_factors` object of either shape. Shape A (booleans, a ladder
229
+ * string, a numeric blast_radius) routes through scoreCustom; Shape B
230
+ * (post-weight contributions, how the catalog stores them) is summed and clamped.
263
231
  */
264
232
  function deriveRwepFromFactors(factors) {
265
233
  if (!factors || typeof factors !== 'object') return 0;
266
234
  const entries = Object.entries(factors);
267
235
  if (entries.length === 0) return 0;
268
- // A boolean, or a ladder string, is Shape-A evidence. The ladder string
269
- // legitimately appears in BOTH shapes — Shape B may carry it as a readable
270
- // status beside its integers — so hasPostWeightInt below is what
271
- // disambiguates, not excluding active_exploitation from this check. Excluding
272
- // it here under-scores a ladder-only bag, which falls through to the sum and
273
- // skips the string entirely.
236
+ // A boolean or a ladder string is Shape-A evidence. The ladder string is legal
237
+ // in both shapes, so hasPostWeightInt below disambiguates; excluding it here
238
+ // sends a ladder-only bag to the sum, which skips the string entirely.
274
239
  const aeAllowed = new Set(['none', 'unknown', 'suspected', 'theoretical', 'confirmed']);
275
240
  const hasBooleanOrLadder = entries.some(
276
241
  ([, v]) => (typeof v === 'boolean' || (typeof v === 'string' && aeAllowed.has(v.trim().toLowerCase()))),
@@ -283,18 +248,14 @@ function deriveRwepFromFactors(factors) {
283
248
  if (hasBooleanOrLadder && !hasPostWeightInt) {
284
249
  return scoreCustom(factors);
285
250
  }
286
- // Shape B: sum and clamp. blast_radius is the one field needing a per-factor
287
- // clamp first — it is a raw 0..30 magnitude, not a post-weight contribution,
288
- // so a unit error like 300 would otherwise reach only the aggregate clamp and
289
- // make the two scorers disagree, which would show up as a false divergence in
290
- // validate()'s recompute-vs-stored gate.
251
+ // Shape B: sum and clamp. blast_radius needs a per-factor clamp first — a unit
252
+ // error like 300 would otherwise reach only the aggregate clamp and make the
253
+ // two scorers disagree in validate()'s recompute-vs-stored gate.
291
254
  let sum = 0;
292
255
  for (const [k, v] of Object.entries(factors)) {
293
256
  if (typeof v !== 'number' || !Number.isFinite(v)) continue;
294
- // An unrecognised key is excluded AND surfaced, matching what scoreCustom
295
- // and validateFactors do, so the three scoring surfaces agree on what a
296
- // typo means. Summing it blindly let a sub-5 typo inflate the breakdown
297
- // with no diagnostic.
257
+ // Excluded AND surfaced, as in validateFactors, so a sub-5 typo cannot
258
+ // inflate the sum with no diagnostic.
298
259
  if (!RECOGNISED_POST_WEIGHT_KEYS.has(k)) {
299
260
  process.emitWarning(
300
261
  `rwep_factors carries unrecognised key '${k}'; excluded from the derived sum`,
@@ -314,9 +275,8 @@ function deriveRwepFromFactors(factors) {
314
275
  return Math.max(0, Math.min(100, sum));
315
276
  }
316
277
 
317
- // Post-weight (Shape-B) factor object required by cve-catalog.schema.json.
318
- // Canonical home for the math auto-discovery.js/cve-enrich.js both consume,
319
- // so Σ Object.values(...) === scoreCustom(inputs) (pre-clamp) by construction.
278
+ // Post-weight (Shape-B) factor object required by cve-catalog.schema.json:
279
+ // Σ Object.values(...) === scoreCustom(inputs) pre-clamp, by construction.
320
280
  function postWeightFactors(inputs) {
321
281
  const i = inputs || {};
322
282
  const aeMultiplier = activeExploitationMultiplier(i.active_exploitation);
@@ -351,8 +311,7 @@ function compare(cveId, catalog, opts) {
351
311
  if (!entry) throw new Error(`CVE not in catalog: ${cveId}`);
352
312
 
353
313
  // `recompute` ignores the stored score and re-derives from rwep_factors,
354
- // which is how catalog drift against current weights is caught. Routed
355
- // through the shape detector so hand-edited factors in either shape work.
314
+ // which is how catalog drift against the current weights is caught.
356
315
  const recompute = !!(opts && opts.recompute);
357
316
  let rwep;
358
317
  if (recompute) {
@@ -362,8 +321,7 @@ function compare(cveId, catalog, opts) {
362
321
  rwep = entry.rwep_score;
363
322
  }
364
323
  // Absent or non-finite CVSS must not reach `cvss * 10`: NaN fails every band
365
- // below and falls through to "broadly aligned", asserting an alignment never
366
- // computed. Absent CVSS is not-comparable, not zero.
324
+ // and falls through to "broadly aligned". Absent CVSS is not-comparable, not zero.
367
325
  const cvss = (typeof entry.cvss_score === 'number' && Number.isFinite(entry.cvss_score))
368
326
  ? entry.cvss_score
369
327
  : null;
@@ -375,9 +333,7 @@ function compare(cveId, catalog, opts) {
375
333
  const delta = (cvssAbsent || !rwepValid) ? null : rwep - cvssEquivalent;
376
334
 
377
335
  // The "broadly aligned" band is ±10, the tightest that still reads ordinary
378
- // CVSS rounding as alignment. A ±20 band swallows a delta of 12, which is
379
- // exactly the case where the CVSS-calibrated SLA is the thing at issue.
380
- // A zero-vs-zero entry reports no scoring signal rather than alignment.
336
+ // CVSS rounding as alignment; ±20 swallows a delta of 12.
381
337
  let explanation = '';
382
338
  if (!rwepValid) {
383
339
  explanation = 'RWEP score absent or non-numeric for this CVE — no usable RWEP signal to compare. Backfill rwep_score / rwep_factors in the catalog.';
@@ -387,29 +343,23 @@ function compare(cveId, catalog, opts) {
387
343
  explanation = 'CVSS absent — RWEP is the only usable score for this CVE; no CVSS comparison is possible. Backfill cvss_score in the catalog to enable the comparison.';
388
344
  } else if (delta > 10) {
389
345
  explanation = `RWEP significantly higher than CVSS equivalent. Factors driving delta: `;
390
- // Lists every factor scoreCustom counts, through the same aliases and
391
- // normalization — otherwise an entry driven by ai_assisted_weaponization, a
392
- // stray-cased 'Confirmed' or the patch_required_reboot alias shows a raised
393
- // RWEP with no stated reason.
346
+ // Every factor scoreCustom counts, through the same aliases and
347
+ // normalization, or a raised RWEP is shown with no stated reason.
394
348
  const driving = [];
395
349
  if (entry.cisa_kev) driving.push('CISA KEV (+25)');
396
350
  if (entry.poc_available) driving.push('public PoC (+20)');
397
351
  if (entry.ai_discovered || entry.ai_assisted_weaponization) driving.push('AI-discovered (+15 weaponization)');
398
- // Through the same ladder, so suspected (+10) and unknown (+5) are listed
399
- // with their real contribution. A confirmed-only test leaves the enumerated
400
- // factors summing to less than the delta, or to nothing at all.
352
+ // Through the same ladder, so suspected (+10) and unknown (+5) are listed too.
401
353
  const ae = resolveActiveExploitation(entry.active_exploitation);
402
354
  if (ae.multiplier > 0) {
403
355
  driving.push(`${ae.normalised} exploitation (+${Math.round(RWEP_WEIGHTS.active_exploitation * ae.multiplier)})`);
404
356
  }
405
- // blast_radius contributes its raw 0..30 value, so a blast-driven delta
406
- // needs it listed or the raised RWEP has no stated cause.
357
+ // blast_radius contributes its raw 0..30 value, so a blast-driven delta needs it.
407
358
  const blastRaw = Number((entry.rwep_factors || {}).blast_radius);
408
359
  const blast = Number.isFinite(blastRaw) ? Math.max(0, Math.min(RWEP_WEIGHTS.blast_radius, blastRaw)) : 0;
409
360
  if (blast > 0) driving.push(`blast radius (+${Math.round(blast)})`);
410
- // Ungated by live_patch_available, mirroring scoreCustom's rebootFactor.
411
- // Gating it here hides a driver the score counted on any entry that both
412
- // needs a reboot and has a live patch.
361
+ // Ungated by live_patch_available, mirroring scoreCustom's rebootFactor:
362
+ // gating here hides a driver the score counted.
413
363
  if (entry.reboot_required || entry.patch_required_reboot) driving.push('reboot required (+5)');
414
364
  // Names the structural cause rather than trailing off after "driving delta:".
415
365
  explanation += driving.length ? driving.join(', ') : 'blast magnitude / structural RWEP factors';
@@ -443,17 +393,14 @@ function compare(cveId, catalog, opts) {
443
393
  }
444
394
 
445
395
  /**
446
- * Which shape an `rwep_factors` block uses: 'A' raw, 'B' post-weight,
447
- * 'unknown' for empty or ambiguous blocks, 'mixed' for the violating case.
448
- *
449
- * Mixing them inside one entry breaks the sum invariant silently:
450
- * `{ cisa_kev: true, blast_radius: 30 }` sums to 30 when the intended score is
451
- * 55, because a raw boolean contributes nothing to a post-weight sum.
396
+ * Which shape an `rwep_factors` block uses: 'A' raw, 'B' post-weight, 'unknown'
397
+ * for empty or ambiguous blocks, 'mixed' for the violating case. Mixing them
398
+ * breaks the sum invariant silently: `{ cisa_kev: true, blast_radius: 30 }` sums
399
+ * to 30 where the intended score is 55.
452
400
  */
453
401
  function detectFactorShape(factors) {
454
402
  if (!factors || typeof factors !== 'object') return 'unknown';
455
- // Both spellings per factor, so a post-weight integer on either canonical key
456
- // registers as Shape-B evidence rather than slipping past the detector.
403
+ // Both spellings per factor, so a post-weight integer on either registers as Shape-B.
457
404
  const boolFields = ['cisa_kev', 'poc_available', 'ai_assisted_weaponization', 'ai_discovered', 'ai_factor', 'active_exploitation', 'patch_available', 'live_patch_available', 'patch_required_reboot', 'reboot_required'];
458
405
  let sawBool = false;
459
406
  let sawWeightedInt = false;
@@ -461,8 +408,7 @@ function detectFactorShape(factors) {
461
408
  if (k === 'blast_radius') continue; // always integer in both shapes
462
409
  if (k === 'active_exploitation' && typeof v === 'string') {
463
410
  // Valid in both shapes, so it is not Shape-A evidence: counting it would
464
- // return 'mixed' on a clean Shape-B block. Its weight is resolved in the
465
- // post-weight path, not here.
411
+ // return 'mixed' on a clean Shape-B block.
466
412
  continue;
467
413
  }
468
414
  if (typeof v === 'boolean' || v === null) {
@@ -486,10 +432,8 @@ function validate(catalog) {
486
432
  for (const [cveId, entry] of Object.entries(catalog)) {
487
433
  if (cveId.startsWith('_')) continue;
488
434
  // Drafts carry a conservative-default rwep_score beside null-until-curated
489
- // factor fields, so the divergence check below would fire on every one of
490
- // them. They are reviewed through `_auto_imported_meta.curation_needed` and
491
- // the validator's draft tier instead; clearing `_auto_imported` at curation
492
- // restores full validation.
435
+ // factor fields, so the divergence check below fires on every one. They are
436
+ // reviewed through `_auto_imported_meta.curation_needed` instead.
493
437
  if (entry && entry._auto_imported === true) continue;
494
438
  for (const field of CVE_SCHEMA_REQUIRED) {
495
439
  if (!(field in entry)) {
@@ -506,10 +450,9 @@ function validate(catalog) {
506
450
  if (shape === 'mixed') {
507
451
  errors.push(`${cveId}: rwep_factors mixes Shape A (booleans) with Shape B (post-weight integers) — sum invariant cannot hold. Convert factors to a single shape.`);
508
452
  }
509
- // Per-factor coherence: every Shape-B contribution must equal the weight
510
- // its source field implies, because two compensating errors cancel inside
511
- // the ±5 aggregate tolerance below. blast_radius is exempt — it is the one
512
- // judgment-set factor with no deriving field.
453
+ // Per-factor coherence: every Shape-B contribution must equal the weight its
454
+ // source field implies, because two compensating errors cancel inside the ±5
455
+ // aggregate tolerance below. blast_radius is exempt — no deriving field.
513
456
  if (shape === 'B') {
514
457
  const f = entry.rwep_factors;
515
458
  const aeMultiplier = resolveActiveExploitation(entry.active_exploitation).multiplier;
@@ -526,8 +469,8 @@ function validate(catalog) {
526
469
  errors.push(`${cveId}: rwep_factors.${k} is ${f[k]} but the entry's source fields imply ${want}`);
527
470
  }
528
471
  }
529
- // One implied weight, two accepted spellings. Both are checked, or a
530
- // contradictory value stored under the alias passes the coherence gate.
472
+ // One implied weight, two accepted spellings: both are checked, or a
473
+ // contradictory value under the alias passes the coherence gate.
531
474
  const rebootWant = entry.patch_required_reboot === true ? RWEP_WEIGHTS.reboot_required : 0;
532
475
  for (const rebootKey of ['reboot_required', 'patch_required_reboot']) {
533
476
  if (rebootKey in f && typeof f[rebootKey] === 'number' && f[rebootKey] !== rebootWant) {
@@ -544,8 +487,7 @@ function validate(catalog) {
544
487
  blast_radius: entry.rwep_factors ? entry.rwep_factors.blast_radius : 0,
545
488
  patch_available: entry.patch_available,
546
489
  live_patch_available: entry.live_patch_available,
547
- // Both spellings, mirroring scoreCustom: passing only one drops the other
548
- // and computes a divergent expected score.
490
+ // Both spellings, mirroring scoreCustom; passing one computes a divergent score.
549
491
  reboot_required: entry.reboot_required || entry.patch_required_reboot
550
492
  });
551
493
  if (Math.abs(calculatedRwep - entry.rwep_score) > 5) {
@@ -556,15 +498,10 @@ function validate(catalog) {
556
498
  }
557
499
 
558
500
  /**
559
- * Strict CVSS 3.x vector parse, as `{ ok, version, reason? }`.
560
- *
561
- * Strict CSAF validators reject a document whose cvss_v3 block is keyed off a
562
- * malformed vector, so a permissive parse here fails downstream rather than
563
- * here. Mandatory metrics in order are AV/AC/PR/UI/S/C/I/A; E/RL/RC and the
564
- * CR..MA environmental set are optional.
565
- *
566
- * 3.0 and 3.1 share one grammar and differ only in the prefix, and CSAF 2.0
567
- * accepts both, so the version is recorded rather than rejected.
501
+ * Strict CVSS 3.x vector parse, as `{ ok, version, reason? }`. A permissive parse
502
+ * here fails downstream instead: strict CSAF validators reject a document whose
503
+ * cvss_v3 block is keyed off a malformed vector. CSAF 2.0 accepts 3.0 and 3.1
504
+ * alike, so the version is recorded rather than rejected.
568
505
  */
569
506
  const CVSS_3X_RE = /^CVSS:3\.[01]\/AV:[NALP]\/AC:[LH]\/PR:[NLH]\/UI:[NR]\/S:[UC]\/C:[NLH]\/I:[NLH]\/A:[NLH](\/E:[XUPFH])?(\/RL:[XOTWU])?(\/RC:[XURC])?(\/CR:[XLMH])?(\/IR:[XLMH])?(\/AR:[XLMH])?(\/MAV:[XNALP])?(\/MAC:[XLH])?(\/MPR:[XNLH])?(\/MUI:[XNR])?(\/MS:[XUC])?(\/MC:[XNLH])?(\/MI:[XNLH])?(\/MA:[XNLH])?$/;
570
507
 
@@ -588,16 +525,11 @@ function parseCvss31Vector(v) {
588
525
 
589
526
  /**
590
527
  * Package-Confidence Score: a supplementary 0-100 supply-chain trust signal
591
- * shown alongside RWEP, never instead of it. Returns null with no usable input.
592
- *
593
- * Its polarity is the INVERSE of RWEP — high means trustworthy provenance, low
594
- * means it behaves like malware — so the two must never be summed or compared.
595
- * It is deliberately outside the RWEP factor key set and is never called from
596
- * validate(), scoreCustom() or deriveRwepFromFactors(), so it cannot perturb a
597
- * stored rwep_score or trip the divergence gate.
528
+ * shown alongside RWEP, never instead of it. Null with no usable input.
598
529
  *
599
- * Equal-weight mean of whichever sub-signals are present; an absent dimension is
600
- * skipped rather than scored 0, so a partly-curated entry is not punished.
530
+ * Its polarity is the INVERSE of RWEP — high means trustworthy provenance — so
531
+ * the two are never summed or compared. Equal-weight mean of whichever
532
+ * sub-signals are present; an absent dimension is skipped rather than scored 0.
601
533
  */
602
534
  function packageConfidence(inputs) {
603
535
  if (!inputs || typeof inputs !== 'object') return null;