@blamejs/exceptd-skills 0.19.30 → 0.19.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/scoring.js CHANGED
@@ -1,55 +1,24 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * RWEP — Real-World Exploit Priority scoring engine
5
- * Supplements CVSS with exploit availability, active exploitation, and operational constraints.
4
+ * RWEP — Real-World Exploit Priority scoring engine. Supplements CVSS with
5
+ * exploit availability, active exploitation and operational constraints.
6
6
  *
7
- * ----------------------------------------------------------------------------
8
- * `rwep_factors` dual-semantics
9
- * ----------------------------------------------------------------------------
10
- * Catalog entries (data/cve-catalog.json) store `rwep_factors` as an object
11
- * whose values are POST-WEIGHT CONTRIBUTIONS for boolean / ladder factors
12
- * but the RAW BLAST RADIUS for `blast_radius`. The two shapes coexist because
13
- * each surface has different requirements:
14
- *
15
- * cisa_kev: 0 OR +25 (post-weight contribution)
16
- * poc_available: 0 OR +20 (post-weight contribution)
17
- * ai_factor: 0 OR +15 (post-weight contribution)
18
- * active_exploitation: 0 / 10 / 5 / 20 (post-weight contribution from ladder)
19
- * blast_radius: 0..30 RAW (intentionally NOT post-weight —
20
- * mirrors the weight ceiling so it
21
- * reads as raw blast magnitude)
22
- * patch_available: 0 OR -15 (post-weight contribution)
23
- * live_patch_available: 0 OR -10 (post-weight contribution)
24
- * reboot_required: 0 OR +5 (post-weight contribution)
25
- *
26
- * Operator-facing implication: summing `Object.values(rwep_factors)` produces
27
- * the stored `rwep_score` for catalog entries because the blast weight is 30
28
- * (matches the raw cap). This dual-shape is intentional but easy to misuse;
29
- * direct boolean inputs should go through `scoreCustom()` instead.
30
- *
31
- * scoreCustom() input shape is DIFFERENT — it accepts BOOLEAN factors plus
32
- * a numeric blast_radius and a string active_exploitation ladder value.
33
- * `deriveRwepFromFactors()` is the shape-detecting bridge: if values look
34
- * numeric (post-weighted), it sums; if values look boolean / string-ladder,
35
- * it routes through scoreCustom.
36
- *
37
- * The semantic ambiguity is grandfathered. A clean rename (raw_factors vs
38
- * weighted_contributions) is a minor-bump change and is deferred.
39
- * ----------------------------------------------------------------------------
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.
40
13
  */
41
14
 
42
- // Required-field list is loaded from the catalog schema's entry-level
43
- // `required` array so the two can never drift. (live_patch_tools is
44
- // deliberately NOT hard-required here — it is schema-optional, and the
45
- // live_patch_available => live_patch_tools implication is enforced
46
- // separately below.)
15
+ // 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.
47
18
  const CVE_SCHEMA_REQUIRED = require('./schemas/cve-catalog.schema.json').required;
48
19
 
49
- // blast_radius range is 0-30; represents breadth of affected population.
50
- // AI-discovered and AI-assisted-weaponization both contribute the ai_factor (+15).
51
- // reboot_required applies whenever patch requires reboot, regardless of live-patch availability,
52
- // because live-patch is a temporary workaround — full remediation window is extended.
20
+ // reboot_required applies even when a live patch exists, because a live patch
21
+ // is a workaround and the full remediation window stays open.
53
22
  const RWEP_WEIGHTS = {
54
23
  cisa_kev: 25,
55
24
  poc_available: 20,
@@ -61,52 +30,33 @@ const RWEP_WEIGHTS = {
61
30
  reboot_required: 5
62
31
  };
63
32
 
64
- // active_exploitation ladder. Aligned with playbook-runner's
65
- // _activeExploitationLadder so the catalog scorer and the runtime evaluator
66
- // produce identical results for the same string value. 'unknown' contributes
67
- // a quarter of the confirmed weight (5 points) — operationally "we have not
68
- // confirmed, but absence of evidence is not evidence of absence; do not
69
- // score zero on a fresh CVE that hasn't been triaged yet".
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.
70
39
  const ACTIVE_EXPLOITATION_LADDER = {
71
40
  confirmed: 1.0,
72
41
  suspected: 0.5,
73
42
  unknown: 0.25,
74
- // "theoretical" = a working PoC is published but no in-the-wild exploitation
75
- // is observed (per the catalog's active_exploitation_vocabulary). The PoC
76
- // itself carries weight via the separate poc_available factor; the
77
- // active-exploitation dimension is 0 (no observed exploitation). Mapped
78
- // explicitly so it's intentional, not an incidental `?? 0` fall-through —
79
- // and so it does not perturb the stored RWEP of theoretical-status entries.
80
43
  theoretical: 0,
81
44
  none: 0,
82
45
  };
83
46
 
84
47
  /**
85
- * Resolve the active_exploitation ladder multiplier for a factor value.
48
+ * Ladder multiplier for an active_exploitation value, as
49
+ * { multiplier, recognised, normalised }.
86
50
  *
87
- * The bare `ACTIVE_EXPLOITATION_LADDER[v] ?? 0` lookup silently mapped any
88
- * out-of-vocabulary string ('exploited', 'in-the-wild', a future vocabulary
89
- * value) AND any case/whitespace variant ('Confirmed', ' CONFIRMED ') to 0 —
90
- * dropping up to the full active_exploitation weight (20 pts) from the RWEP
91
- * with no diagnostic, while validateFactors() flagged the same string. This
92
- * is the recurring "out-of-vocab token -> silent zero" class: the no-match
93
- * path must surface an error, not a silent default (same remedy as the
94
- * playbook-runner condition-evaluator hyphen fix).
95
- *
96
- * - Case-normalises the lookup so 'Confirmed' / ' CONFIRMED ' resolve to the
97
- * canonical ladder entry instead of zeroing.
98
- * - null / undefined are the documented "treated as 'none'" default (mult 0,
99
- * recognised) — these are not typos.
100
- * - A non-empty string NOT in the ladder, or a non-string non-nullish value,
101
- * is UNRECOGNISED: returns multiplier 0 AND emits a process warning so the
102
- * zeroed factor is observable in the bare-number call path. The structured
103
- * diagnostic for the collectWarnings path is produced by validateFactors().
104
- *
105
- * Returns { multiplier, recognised, normalised }.
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.
106
57
  */
107
58
  function resolveActiveExploitation(active_exploitation) {
108
59
  if (active_exploitation === undefined || active_exploitation === null) {
109
- // documented default: absent active_exploitation is scored as 'none'.
110
60
  return { multiplier: ACTIVE_EXPLOITATION_LADDER.none, recognised: true, normalised: 'none' };
111
61
  }
112
62
  if (typeof active_exploitation === 'string') {
@@ -138,29 +88,22 @@ function activeExploitationMultiplier(active_exploitation) {
138
88
  return r.multiplier;
139
89
  }
140
90
 
141
- // The canonical set of factor keys scoreCustom recognises. Used by
142
- // validateFactors to flag unknown keys.
91
+ // 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.
143
94
  const RECOGNISED_FACTOR_KEYS = new Set([
144
95
  'cisa_kev', 'poc_available', 'ai_assisted_weapon', 'ai_discovered',
145
96
  'active_exploitation', 'blast_radius', 'patch_available',
146
97
  'live_patch_available', 'reboot_required',
147
- // accepted aliases for the catalog field names: a factor bag built straight
148
- // from a catalog entry carries `ai_assisted_weaponization` (the field the
149
- // catalog declares) and `patch_required_reboot`, not the legacy short forms.
150
98
  'ai_assisted_weaponization',
151
99
  'patch_required_reboot',
152
100
  ]);
153
101
 
154
- // Shape-B (catalog post-weight) keys deriveRwepFromFactors is allowed to sum.
155
- // The post-weight summation operates on the catalog field names — which include
156
- // `ai_factor`, the +15 AI weight every Shape-B catalog entry stores. `ai_factor`
157
- // is deliberately ABSENT from RECOGNISED_FACTOR_KEYS (that set carries the
158
- // Shape-A boolean inputs `ai_assisted_weapon` / `ai_discovered` /
159
- // `ai_assisted_weaponization`), so the Shape-B allowlist must add it back — a
160
- // plain `RECOGNISED_FACTOR_KEYS.has(k)` filter would silently drop the AI weight
161
- // from every derivation. Any key NOT in this set is a typo or unknown field; it
162
- // is excluded from the sum AND surfaced (see the Shape-B loop) rather than blindly
163
- // added, so a sub-5 typo can't corrupt the derived score with no diagnostic.
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.
164
107
  const RECOGNISED_POST_WEIGHT_KEYS = new Set([...RECOGNISED_FACTOR_KEYS, 'ai_factor']);
165
108
 
166
109
  function score(cveId, catalog) {
@@ -170,18 +113,11 @@ function score(cveId, catalog) {
170
113
  }
171
114
 
172
115
  /**
173
- * Validate an RWEP factor bag. Returns an array of warning strings
174
- * for missing-but-defaultable fields and out-of-range values. Does NOT
175
- * throw — operators wanting hard enforcement should treat a non-empty
176
- * return as a failure themselves.
177
- *
178
- * Range expectations:
179
- * - cisa_kev, poc_available, ai_assisted_weapon, ai_discovered,
180
- * patch_available, live_patch_available, reboot_required: boolean
181
- * (or null, treated as false with a missing-field warning).
182
- * - active_exploitation: 'none' | 'unknown' | 'suspected' | 'theoretical' | 'confirmed'.
183
- * - blast_radius: integer in [0, 30] (clamped at the weight ceiling but
184
- * flagged when out-of-range — out-of-range usually means a unit error).
116
+ * Warnings for a factor bag: missing-but-defaultable fields and out-of-range
117
+ * 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.
185
121
  */
186
122
  function validateFactors(factors) {
187
123
  const warnings = [];
@@ -191,10 +127,8 @@ function validateFactors(factors) {
191
127
  const boolFields = ['cisa_kev', 'poc_available', 'ai_assisted_weapon', 'ai_discovered',
192
128
  'patch_available', 'live_patch_available', 'reboot_required'];
193
129
  for (const f of boolFields) {
194
- // The catalog field `ai_assisted_weaponization` satisfies `ai_assisted_weapon`,
195
- // and `patch_required_reboot` satisfies `reboot_required` — the same aliasing
196
- // scoreCustom/deriveRwepFromFactors honor, so validateFactors must accept a
197
- // block that supplies only the alias instead of flagging it "missing".
130
+ // Honours the same aliasing as scoreCustom, so a bag supplying only the
131
+ // catalog field name is not flagged missing.
198
132
  const present = (f === 'ai_assisted_weapon')
199
133
  ? (factors.ai_assisted_weapon ?? factors.ai_assisted_weaponization)
200
134
  : (f === 'reboot_required')
@@ -211,25 +145,21 @@ function validateFactors(factors) {
211
145
  if (aeRaw === undefined || aeRaw === null) {
212
146
  warnings.push("active_exploitation: missing (treated as 'none')");
213
147
  } else {
214
- // Normalize (trim + lowercase) before the vocab check so validateFactors
215
- // accepts exactly what scoreCustom/resolveActiveExploitation accept — a
216
- // stray-cased 'Confirmed' / ' confirmed ' must not be flagged here while the
217
- // scorer consumes it, or the two surfaces disagree.
148
+ // Normalised before the vocabulary check so this accepts exactly what the
149
+ // scorer accepts; otherwise 'Confirmed' is flagged here and consumed there.
218
150
  const aeNorm = typeof aeRaw === 'string' ? aeRaw.trim().toLowerCase() : aeRaw;
219
151
  if (!aeAllowed.includes(aeNorm)) {
220
152
  warnings.push(`active_exploitation: expected one of ${aeAllowed.join(', ')}, got ${JSON.stringify(aeRaw)}`);
221
153
  }
222
154
  }
223
- // NaN diagnostics. The prior message read "expected number,
224
- // got number (null)" because `JSON.stringify(NaN) === 'null'` and `typeof
225
- // NaN === 'number'`. Number.isFinite catches NaN + Infinity + -Infinity
226
- // and emits a useful message.
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)".
227
158
  if (factors.blast_radius === undefined || factors.blast_radius === null) {
228
159
  warnings.push('blast_radius: missing (treated as 0)');
229
160
  } else if (typeof factors.blast_radius !== 'number') {
230
- // scoreCustom coerces a numeric string (e.g. "30") via Number(); keep the
231
- // two surfaces consistent — accept a finite numeric string with a soft note
232
- // rather than rejecting what the scorer will happily use.
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.
233
163
  if (typeof factors.blast_radius === 'string' && Number.isFinite(Number(factors.blast_radius)) && factors.blast_radius.trim() !== '') {
234
164
  warnings.push(`blast_radius: numeric string "${factors.blast_radius}" accepted (coerced to ${Number(factors.blast_radius)}); prefer a JSON number`);
235
165
  } else {
@@ -242,9 +172,8 @@ function validateFactors(factors) {
242
172
  } else if (factors.blast_radius < 0 || factors.blast_radius > 30) {
243
173
  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)`);
244
174
  }
245
- // surface unknown factor keys so a typo'd answer file
246
- // (`patch_avilable`, `cisa-kev`, etc.) doesn't silently default to false
247
- // with no diagnostic.
175
+ // An unknown key is surfaced rather than ignored: `patch_avilable` would
176
+ // otherwise default to false with no diagnostic.
248
177
  for (const k of Object.keys(factors)) {
249
178
  if (!RECOGNISED_FACTOR_KEYS.has(k)) {
250
179
  warnings.push(`unknown factor: ${k} (ignored — not in the recognised key set)`);
@@ -254,24 +183,19 @@ function validateFactors(factors) {
254
183
  }
255
184
 
256
185
  /**
257
- * scoreCustom — compute the RWEP for a factor bag. Returns a number
258
- * (clamped to [0, 100]).
186
+ * RWEP for a factor bag, clamped to [0, 100].
259
187
  *
260
- * Backward-compat note: this function has always returned a number;
261
- * callers in lib/auto-discovery.js etc. rely on that. E10 surfaces
262
- * warnings via the optional `opts.collectWarnings` flag — when true,
263
- * scoreCustom returns `{ score, _scoring_warnings }` instead of a bare
264
- * number. Operators wanting validation without the score can call
265
- * `validateFactors(factors)` directly.
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.
266
191
  */
267
192
  function scoreCustom(factors, opts) {
268
193
  const {
269
194
  cisa_kev = false,
270
195
  poc_available = false,
271
196
  ai_assisted_weapon = false,
272
- // The catalog field is `ai_assisted_weaponization`; accept it as an alias
273
- // so a factor bag built directly from a catalog entry still counts the AI
274
- // factor instead of silently dropping the +15 weight.
197
+ // Catalog field name, accepted as an alias so an entry-derived bag still
198
+ // counts the +15 AI factor.
275
199
  ai_assisted_weaponization = false,
276
200
  ai_discovered = false,
277
201
  active_exploitation = 'none',
@@ -279,11 +203,8 @@ function scoreCustom(factors, opts) {
279
203
  patch_available = false,
280
204
  live_patch_available = false,
281
205
  reboot_required = false,
282
- // v0.12.15: the CVE catalog field is `patch_required_reboot`
283
- // but scoreCustom historically expected `reboot_required`. validate()
284
- // already aliases at the call site; accept either spelling here so a
285
- // direct caller passing the catalog entry doesn't silently lose the
286
- // reboot factor.
206
+ // Likewise: the catalog spells this `patch_required_reboot`, so both
207
+ // spellings are accepted and a direct caller cannot lose the factor.
287
208
  patch_required_reboot,
288
209
  } = factors || {};
289
210
  const rebootFactor = (reboot_required === true) || (patch_required_reboot === true);
@@ -292,28 +213,15 @@ function scoreCustom(factors, opts) {
292
213
  score += cisa_kev ? RWEP_WEIGHTS.cisa_kev : 0;
293
214
  score += poc_available ? RWEP_WEIGHTS.poc_available : 0;
294
215
  score += (ai_assisted_weapon || ai_assisted_weaponization || ai_discovered) ? RWEP_WEIGHTS.ai_factor : 0;
295
- // active_exploitation goes through the ladder rather
296
- // than two hand-written branches with `Math.floor(weight/2)`. The floor
297
- // was a no-op for even weights (20/2 = 10) but would have silently
298
- // truncated to asymmetric results if a future operator bumped the
299
- // weight to 21. The ladder + multiplication preserves the contribution
300
- // exactly, including the new `unknown → 0.25 × weight = 5` mapping that
301
- // aligns the catalog scorer with playbook-runner._activeExploitationLadder.
216
+ // Multiplied through the ladder rather than branched: a weight bumped to an
217
+ // odd number would truncate under a `Math.floor(weight/2)` split.
302
218
  const aeMultiplier = activeExploitationMultiplier(active_exploitation);
303
219
  score += RWEP_WEIGHTS.active_exploitation * aeMultiplier;
304
- // v0.12.15: blast_radius numeric coercion must reject
305
- // NaN, Infinity, and strings explicitly. The prior `typeof === 'number'`
306
- // check passed NaN (which is `typeof === 'number'`) into `Math.min/max`
307
- // which propagates NaN through the final clamp, defeating the [0,100]
308
- // contract. Number.isFinite + Number() coercion catches all four classes:
309
- // NaN, Infinity, undefined, stringified-number.
310
- // Match validateFactors' contract exactly. Bare `Number()` coercion would
311
- // turn `true` into 1 and a single-element array like `[7]` into 7 — both of
312
- // which validateFactors rejects as "expected number" — so the scorer would
313
- // silently add a blast contribution the validator says is invalid. Accept
314
- // only a finite number or a trimmed-nonempty numeric string; everything
315
- // else (boolean, array, object, NaN, Infinity, empty/whitespace string)
316
- // contributes 0.
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.
317
225
  let brRaw = 0;
318
226
  if (typeof blast_radius === 'number' && Number.isFinite(blast_radius)) {
319
227
  brRaw = blast_radius;
@@ -330,13 +238,10 @@ function scoreCustom(factors, opts) {
330
238
  score += live_patch_available ? RWEP_WEIGHTS.live_patch_available : 0;
331
239
  score += rebootFactor ? RWEP_WEIGHTS.reboot_required : 0;
332
240
 
333
- // keep the pre-clamp value so collectWarnings consumers can
334
- // see deduction magnitude (e.g. a -25 raw score collapsed to 0 hides the
335
- // fact that the entry had three mitigating factors).
241
+ // Kept pre-clamp so collectWarnings consumers can see deduction magnitude: a
242
+ // raw -25 collapsed to 0 hides that three mitigating factors applied.
336
243
  const rawUnclamped = score;
337
244
 
338
- // v0.12.15: defense-in-depth clamp against any unforeseen
339
- // NaN production above (negative weight + Infinity + math edge case).
340
245
  const clamped = Number.isFinite(score) ? Math.min(100, Math.max(0, score)) : 0;
341
246
  if (opts && opts.collectWarnings) {
342
247
  return {
@@ -349,75 +254,47 @@ function scoreCustom(factors, opts) {
349
254
  }
350
255
 
351
256
  /**
352
- * Derive an RWEP score from a
353
- * `rwep_factors` object regardless of which shape it uses.
257
+ * RWEP from a `rwep_factors` object of either shape, so the curation
258
+ * apply-path and the auto-discovery builder share one derivation.
354
259
  *
355
- * - SHAPE A (boolean / string-ladder): values are booleans + an
356
- * active_exploitation string + a numeric blast_radius. Route through
357
- * scoreCustom() — the canonical formula.
358
- * - SHAPE B (catalog post-weight): values are numeric contributions
359
- * (0 / ±N) plus a numeric blast_radius. Sum the numeric values and
360
- * clamp to [0, 100]. This is how catalog `rwep_factors` are stored.
361
- *
362
- * Heuristic: if every value is a number, treat as Shape B (sum). If any
363
- * value is boolean or a recognised ladder string, treat as Shape A
364
- * (scoreCustom). This lets the curation apply-path and the auto-discovery
365
- * builder share one canonical derivation that handles either operator
366
- * input style without duplicating the scoring formula.
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].
367
263
  */
368
264
  function deriveRwepFromFactors(factors) {
369
265
  if (!factors || typeof factors !== 'object') return 0;
370
266
  const entries = Object.entries(factors);
371
267
  if (entries.length === 0) return 0;
372
- // A boolean factor OR a string active_exploitation ladder value is Shape-A
373
- // evidence — scoreCustom reads exactly those. active_exploitation's string
374
- // form legitimately appears in BOTH shapes (Shape A stores it as the literal
375
- // ladder string; a Shape B post-weight block can ALSO carry it as a
376
- // human-readable status alongside its post-weight integers), so it is the
377
- // hasPostWeightInt guard below — NOT excluding active_exploitation from this
378
- // check — that disambiguates them. Excluding it here under-scored an
379
- // active-exploitation-ONLY raw bag (e.g. `{ active_exploitation: 'confirmed',
380
- // blast_radius: 10 }`): hasBooleanOrLadder went false, the block fell through
381
- // to the Shape-B sum, and the ladder string was skipped (10 vs scoreCustom 30).
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.
382
274
  const aeAllowed = new Set(['none', 'unknown', 'suspected', 'theoretical', 'confirmed']);
383
275
  const hasBooleanOrLadder = entries.some(
384
276
  ([, v]) => (typeof v === 'boolean' || (typeof v === 'string' && aeAllowed.has(v.trim().toLowerCase()))),
385
277
  );
386
- // A boolean-named key carrying a post-weight integer (>=5) is unambiguous
387
- // Shape-B evidence. When present, the block is Shape B even if it also carries
388
- // a string active_exploitation — route to the post-weight sum, not scoreCustom.
278
+ // A boolean-named key carrying a post-weight integer (>=5) is unambiguously
279
+ // Shape B, even alongside a ladder string.
389
280
  const hasPostWeightInt = entries.some(
390
281
  ([k, v]) => k !== 'blast_radius' && typeof v === 'number' && Number.isFinite(v) && Math.abs(v) >= 5,
391
282
  );
392
283
  if (hasBooleanOrLadder && !hasPostWeightInt) {
393
284
  return scoreCustom(factors);
394
285
  }
395
- // Shape B: catalog post-weight. Sum + clamp.
396
- //
397
- // blast_radius is the one Shape B field with a per-factor ceiling: it is a
398
- // RAW 0..30 magnitude, not a post-weight contribution (see the dual-semantics
399
- // note at the top of this file). Clamp it to [0, RWEP_WEIGHTS.blast_radius]
400
- // before summing — exactly as scoreCustom does — so an out-of-range stored
401
- // value (a unit error such as 300, or a negative) cannot silently inflate or
402
- // zero the result by being absorbed only by the final aggregate clamp. Both
403
- // paths now produce the same score for the same factors, so the validate()
404
- // recompute-vs-stored divergence gate stays meaningful instead of flagging a
405
- // self-inconsistency the two scorers introduced. Every other Shape B value is
406
- // already a bounded post-weight contribution, so only blast_radius needs the
407
- // per-factor clamp.
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.
408
291
  let sum = 0;
409
292
  for (const [k, v] of Object.entries(factors)) {
410
293
  if (typeof v !== 'number' || !Number.isFinite(v)) continue;
411
- // Unrecognised key (a typo such as `cisa_kevv` / `reboot_requiredd`, or a
412
- // field outside the post-weight vocabulary): do NOT add it to the sum, and
413
- // surface it. scoreCustom/validateFactors already drop+warn on unknown
414
- // keys; the Shape-B summation previously added ANY numeric value blindly, so
415
- // the three scoring surfaces disagreed on what an unknown key means (a sub-5
416
- // typo silently inflated the derived breakdown). Align them here. The
417
- // warning mirrors the activeExploitationMultiplier precedent above — an
418
- // observable diagnostic on the standard Node channel, not a silent skip, so
419
- // the no-match path surfaces an error instead of defaulting (the file's own
420
- // "out-of-vocab token -> must surface, not silent-default" rule).
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.
421
298
  if (!RECOGNISED_POST_WEIGHT_KEYS.has(k)) {
422
299
  process.emitWarning(
423
300
  `rwep_factors carries unrecognised key '${k}'; excluded from the derived sum`,
@@ -425,11 +302,8 @@ function deriveRwepFromFactors(factors) {
425
302
  );
426
303
  continue;
427
304
  }
428
- // reboot_required and patch_required_reboot are aliases for the SAME
429
- // post-weight contribution (scoreCustom collapses them). A block carrying
430
- // both must count it once; summing both double-counts the reboot weight,
431
- // inflating the derived RWEP past the formula AND past the stored score the
432
- // validate() divergence gate compares against.
305
+ // Aliases for one contribution; a block carrying both must count it once or
306
+ // the derived score exceeds both the formula and the stored value.
433
307
  if (k === 'patch_required_reboot' && Object.prototype.hasOwnProperty.call(factors, 'reboot_required')) continue;
434
308
  if (k === 'blast_radius') {
435
309
  sum += Math.max(0, Math.min(RWEP_WEIGHTS.blast_radius, v));
@@ -476,99 +350,68 @@ function compare(cveId, catalog, opts) {
476
350
  const entry = catalog[cveId];
477
351
  if (!entry) throw new Error(`CVE not in catalog: ${cveId}`);
478
352
 
479
- // `--recompute` ignores the stored rwep_score and forces a
480
- // fresh computation from rwep_factors. Useful for catching catalog drift
481
- // (stored score grew stale relative to current weights) and for auditing
482
- // the divergence between stored vs. formula-derived scores.
353
+ // `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.
483
356
  const recompute = !!(opts && opts.recompute);
484
357
  let rwep;
485
358
  if (recompute) {
486
359
  const factors = entry.rwep_factors || {};
487
- // The catalog's rwep_factors shape is "post-weight" (Shape B). Route
488
- // through the shape-detecting helper so a catalog whose factors were
489
- // hand-edited in either shape still produces a usable score.
490
360
  rwep = deriveRwepFromFactors(factors);
491
361
  } else {
492
362
  rwep = entry.rwep_score;
493
363
  }
494
- // Normalize cvss before any arithmetic. An absent or non-finite cvss_score
495
- // must not flow into `cvss * 10` — `undefined * 10 === NaN` poisons the
496
- // delta, and NaN fails every `delta > 10` / `delta < -10` band, so the
497
- // entry would otherwise fall through to the "broadly aligned" arm and
498
- // assert an alignment that was never computed. Treat absent CVSS as
499
- // not-comparable instead.
364
+ // 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.
500
367
  const cvss = (typeof entry.cvss_score === 'number' && Number.isFinite(entry.cvss_score))
501
368
  ? entry.cvss_score
502
369
  : null;
503
370
  const cvssAbsent = cvss == null;
504
371
  const cvssEquivalent = cvssAbsent ? 0 : cvss * 10;
505
- // delta is null (not NaN) when there is no CVSS to compare against, so the
506
- // emitted result serializes cleanly and never claims a numeric divergence
507
- // that does not exist.
508
- // Guard the RWEP side exactly like CVSS above: an absent or non-finite
509
- // rwep_score must not flow into `rwep - cvssEquivalent` — NaN poisons the
510
- // delta, fails every band, and falls through to a false "broadly aligned".
372
+ // Same guard on the RWEP side. delta is null rather than NaN so the result
373
+ // serializes cleanly and never claims a divergence that was not computed.
511
374
  const rwepValid = (typeof rwep === 'number' && Number.isFinite(rwep));
512
375
  const delta = (cvssAbsent || !rwepValid) ? null : rwep - cvssEquivalent;
513
376
 
514
- // narrow the "broadly aligned" band from ±20 to ±10. The old
515
- // ±20 band swallowed the Copy Fail RWEP-vs-CVSS divergence (delta = 12)
516
- // where the operator-facing point is precisely that the CVSS-calibrated
517
- // SLA is insufficient. ±10 is the tightest classifier that still treats
518
- // ordinary CVSS rounding noise as alignment.
377
+ // 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.
519
381
  let explanation = '';
520
- // Surface the "no scoring signal" case distinctly from "broadly
521
- // aligned". Pre-fix a CVE with rwep_score: 0 AND cvss_score: 0 (e.g.
522
- // catalog entry created before scoring backfill) printed "broadly
523
- // aligned" — coincidence-passing per the field-present-not-populated
524
- // pitfall. Now the operator sees a specific signal pointing at the
525
- // catalog gap rather than a false sense of alignment.
526
382
  if (!rwepValid) {
527
383
  explanation = 'RWEP score absent or non-numeric for this CVE — no usable RWEP signal to compare. Backfill rwep_score / rwep_factors in the catalog.';
528
384
  } else if ((rwep == null || rwep === 0) && (cvss == null || cvss === 0)) {
529
385
  explanation = 'No scoring signal — both RWEP and CVSS are zero/null. Investigate the catalog entry; this CVE has no usable risk score.';
530
386
  } else if (cvssAbsent) {
531
- // RWEP carries a real signal but there is no CVSS to compare it against.
532
- // The two scores are not comparable, so do not assert alignment or a
533
- // divergence direction — surface the missing CVSS instead.
534
387
  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.';
535
388
  } else if (delta > 10) {
536
389
  explanation = `RWEP significantly higher than CVSS equivalent. Factors driving delta: `;
537
- // The explanation must list every factor scoreCustom actually counts, via
538
- // the same aliases/normalization — otherwise a CVE whose RWEP is driven by
539
- // ai_assisted_weaponization (not ai_discovered), a stray-cased 'Confirmed',
540
- // or the patch_required_reboot alias shows a higher RWEP with no stated
541
- // reason for the 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.
542
394
  const driving = [];
543
395
  if (entry.cisa_kev) driving.push('CISA KEV (+25)');
544
396
  if (entry.poc_available) driving.push('public PoC (+20)');
545
397
  if (entry.ai_discovered || entry.ai_assisted_weaponization) driving.push('AI-discovered (+15 weaponization)');
546
- // active_exploitation via the SAME ladder scoreCustom uses, so suspected
547
- // (+10) and unknown (+5) are listed with their actual contribution rather
548
- // than only 'confirmed' (+20). The bare confirmed-only test dropped every
549
- // suspected/unknown driver — so the enumerated factors summed to less than
550
- // the delta, or (on a purely suspected/blast-driven entry) to nothing,
551
- // leaving a dangling "driving delta: .".
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.
552
401
  const ae = resolveActiveExploitation(entry.active_exploitation);
553
402
  if (ae.multiplier > 0) {
554
403
  driving.push(`${ae.normalised} exploitation (+${Math.round(RWEP_WEIGHTS.active_exploitation * ae.multiplier)})`);
555
404
  }
556
- // blast_radius contributes its raw value (0..30) to RWEP but never appeared
557
- // in the driver list, so a blast-driven delta showed a higher RWEP with no
558
- // stated cause. List the clamped contribution when positive.
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.
559
407
  const blastRaw = Number((entry.rwep_factors || {}).blast_radius);
560
408
  const blast = Number.isFinite(blastRaw) ? Math.max(0, Math.min(RWEP_WEIGHTS.blast_radius, blastRaw)) : 0;
561
409
  if (blast > 0) driving.push(`blast radius (+${Math.round(blast)})`);
562
- // Mirror scoreCustom's rebootFactor EXACTLY: the +5 reboot weight is added
563
- // whenever a reboot is required, regardless of live_patch_available (a live
564
- // patch is a temporary workaround; the full-remediation window still extends
565
- // — see the RWEP_WEIGHTS header note). Gating this driver on
566
- // !live_patch_available made the enumerated factors sum to less than the
567
- // delta on any entry that both requires a reboot AND has a live patch
568
- // available, hiding a driver the score actually counted.
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.
569
413
  if (entry.reboot_required || entry.patch_required_reboot) driving.push('reboot required (+5)');
570
- // A positive delta with no enumerated driver still names the structural
571
- // cause instead of a dangling "driving delta: .".
414
+ // Names the structural cause rather than trailing off after "driving delta:".
572
415
  explanation += driving.length ? driving.join(', ') : 'blast magnitude / structural RWEP factors';
573
416
  explanation += '. Framework patch SLAs calibrated to CVSS are insufficient for this CVE.';
574
417
  } else if (delta < -10) {
@@ -578,8 +421,6 @@ function compare(cveId, catalog, opts) {
578
421
  if (entry.live_patch_available) mitigating.push('live patch available (-10)');
579
422
  if (!entry.poc_available) mitigating.push('no public PoC');
580
423
  if (!entry.cisa_kev) mitigating.push('not CISA KEV');
581
- // A negative delta with no enumerated mitigator still names the structural
582
- // cause (high CVSS base vs. a modest RWEP) instead of a dangling list.
583
424
  explanation += mitigating.length ? mitigating.join(', ') : 'high CVSS base vs. a modest RWEP (low blast radius / no exploitation signal)';
584
425
  } else {
585
426
  explanation = 'CVSS and RWEP are broadly aligned for this CVE.';
@@ -602,59 +443,35 @@ function compare(cveId, catalog, opts) {
602
443
  }
603
444
 
604
445
  /**
605
- * v0.13.0: detect rwep_factors shape. The catalog historically stored
606
- * factors in two distinct shapes that look identical at the field level:
607
- *
608
- * Shape A (raw): `{ cisa_kev: true, blast_radius: 30, ... }`
609
- * - booleans + integers in their natural form
610
- * - score derives from `scoreCustom(factors)` which applies weights
611
- *
612
- * Shape B (post-weight): `{ cisa_kev: 25, blast_radius: 30, ... }`
613
- * - integers in their post-weight contribution (cisa_kev: 25 not true)
614
- * - score = sum of values; no second weight pass
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.
615
448
  *
616
- * Mixing shapes inside ONE entry silently breaks the sum invariant —
617
- * a CVE with `cisa_kev: true, blast_radius: 30` reports rwep 30 (just
618
- * blast_radius summed) when the operator-intended score is 55 (KEV + br).
619
- * Until v0.13 nothing caught this; v0.13 adds shape detection that fires
620
- * an error when the entry mixes booleans with non-trivial numeric weights.
621
- *
622
- * Returns 'A' for raw, 'B' for post-weight, 'unknown' for empty/edge
623
- * cases, or 'mixed' for the violating case.
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.
624
452
  */
625
453
  function detectFactorShape(factors) {
626
454
  if (!factors || typeof factors !== 'object') return 'unknown';
627
- // Keys inspected for shape evidence. Covers BOTH spellings per factor:
628
- // the Shape A (raw boolean) names AND the Shape B (post-weight) names the
629
- // schema requires on rwep_factors — ai_factor and reboot_required — so a
630
- // post-weight integer on either canonical key registers as Shape B
631
- // evidence instead of slipping past the mixed-shape detector.
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.
632
457
  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'];
633
458
  let sawBool = false;
634
459
  let sawWeightedInt = false;
635
460
  for (const [k, v] of Object.entries(factors)) {
636
461
  if (k === 'blast_radius') continue; // always integer in both shapes
637
462
  if (k === 'active_exploitation' && typeof v === 'string') {
638
- // active_exploitation's string-ladder form is valid in BOTH shapes — a
639
- // Shape B (post-weight) block can carry it as the human-readable status
640
- // string alongside its post-weight integers, exactly the way Shape A does.
641
- // So a string active_exploitation is NOT Shape-A evidence; counting it as
642
- // sawBool produced a spurious 'mixed' verdict (and a validate() error) on
643
- // an otherwise-clean Shape B block. Its weight, when summed, is resolved
644
- // via resolveActiveExploitation in the post-weight path, not here.
463
+ // 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.
645
466
  continue;
646
467
  }
647
468
  if (typeof v === 'boolean' || v === null) {
648
469
  sawBool = true;
649
470
  } else if (typeof v === 'number' && Math.abs(v) >= 5 && boolFields.includes(k)) {
650
- // Field that's nominally boolean carrying a numeric weight (e.g. 25,
651
- // 20, 15) — Shape B signature.
652
- sawWeightedInt = true;
471
+ sawWeightedInt = true; // a boolean-named field carrying a weight
653
472
  } else if (typeof v === 'number' && (v === 0 || v === 1) && boolFields.includes(k)) {
654
- // 0/1 on a boolean-named field could be either shape; ambiguous, ignore.
655
- continue;
473
+ continue; // 0/1 fits either shape
656
474
  } else if (typeof v === 'string' && boolFields.includes(k)) {
657
- // String values on OTHER boolean-named fields are Shape A.
658
475
  sawBool = true;
659
476
  }
660
477
  }
@@ -668,15 +485,11 @@ function validate(catalog) {
668
485
  const errors = [];
669
486
  for (const [cveId, entry] of Object.entries(catalog)) {
670
487
  if (cveId.startsWith('_')) continue;
671
- // Skip auto-imported drafts. KEV/GHSA/OSV-discovered drafts store a
672
- // conservative-default rwep_score (poc=true, reboot=true, etc.)
673
- // alongside `poc_available: null` and other null-until-curated factor
674
- // fields, so the recomputed-vs-stored divergence check would always
675
- // fire against them and flood the predeploy gate. Drafts are reviewed
676
- // separately via the `_auto_imported_meta.curation_needed` list and
677
- // the strict catalog validator's draft-warning tier. Once curation
678
- // promotes
679
- // an entry, `_auto_imported` is cleared and full validation resumes.
488
+ // 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.
680
493
  if (entry && entry._auto_imported === true) continue;
681
494
  for (const field of CVE_SCHEMA_REQUIRED) {
682
495
  if (!(field in entry)) {
@@ -689,19 +502,14 @@ function validate(catalog) {
689
502
  if (entry.live_patch_available && (!entry.live_patch_tools || entry.live_patch_tools.length === 0)) {
690
503
  errors.push(`${cveId}: live_patch_available=true but live_patch_tools is empty`);
691
504
  }
692
- // v0.13.0: detect Shape A / Shape B / mixed factor shape. A 'mixed'
693
- // shape would silently break the sum invariant; refuse it. See
694
- // detectFactorShape() doc above for the failure mode.
695
505
  const shape = detectFactorShape(entry.rwep_factors);
696
506
  if (shape === 'mixed') {
697
507
  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.`);
698
508
  }
699
- // Per-factor coherence: in a Shape B (post-weight) block every stored
700
- // contribution must equal the weight implied by its source field.
701
- // Without this, two compensating per-factor errors cancel inside the
702
- // ±5 aggregate tolerance below and a factor block that contradicts the
703
- // entry's own flags ships unnoticed. blast_radius is exempt — it is the
704
- // one judgment-set factor with no deriving source field.
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.
705
513
  if (shape === 'B') {
706
514
  const f = entry.rwep_factors;
707
515
  const aeMultiplier = resolveActiveExploitation(entry.active_exploitation).multiplier;
@@ -718,12 +526,8 @@ function validate(catalog) {
718
526
  errors.push(`${cveId}: rwep_factors.${k} is ${f[k]} but the entry's source fields imply ${want}`);
719
527
  }
720
528
  }
721
- // The reboot contribution has ONE implied weight but TWO accepted
722
- // spellings — `reboot_required` (canonical) and `patch_required_reboot`
723
- // (the catalog field-name alias scoreCustom also honors). Check both
724
- // against that single weight; keying only on `reboot_required` let a
725
- // contradictory value stored under the alias slip past the coherence
726
- // gate, inflating the derived score.
529
+ // One implied weight, two accepted spellings. Both are checked, or a
530
+ // contradictory value stored under the alias passes the coherence gate.
727
531
  const rebootWant = entry.patch_required_reboot === true ? RWEP_WEIGHTS.reboot_required : 0;
728
532
  for (const rebootKey of ['reboot_required', 'patch_required_reboot']) {
729
533
  if (rebootKey in f && typeof f[rebootKey] === 'number' && f[rebootKey] !== rebootWant) {
@@ -740,9 +544,8 @@ function validate(catalog) {
740
544
  blast_radius: entry.rwep_factors ? entry.rwep_factors.blast_radius : 0,
741
545
  patch_available: entry.patch_available,
742
546
  live_patch_available: entry.live_patch_available,
743
- // Mirror the reboot alias scoreCustom itself honors (reboot_required OR
744
- // patch_required_reboot): passing only patch_required_reboot would drop a
745
- // top-level reboot_required field and compute a divergent expected RWEP.
547
+ // Both spellings, mirroring scoreCustom: passing only one drops the other
548
+ // and computes a divergent expected score.
746
549
  reboot_required: entry.reboot_required || entry.patch_required_reboot
747
550
  });
748
551
  if (Math.abs(calculatedRwep - entry.rwep_score) > 5) {
@@ -753,25 +556,16 @@ function validate(catalog) {
753
556
  }
754
557
 
755
558
  /**
756
- * Strict CVSS 3.1 vector parse. Returns `{ ok, version, reason? }`.
559
+ * Strict CVSS 3.x vector parse, as `{ ok, version, reason? }`.
757
560
  *
758
- * The CSAF 2.0 cvss_v3 score block requires a canonical CVSS 3.1 vector
759
- * string. Strict validators (BSI CSAF Validator, ENISA dashboard) reject
760
- * documents that emit a cvss_v3 block keyed off a malformed vector — the
761
- * pre-fix permissive `^CVSS:(\d+\.\d+)/` regex let through 3.0 vectors,
762
- * truncated metric sets, and unknown environmental-metric values, which
763
- * downstream tooling then rejected wholesale.
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.
764
565
  *
765
- * Required metric set (in order): AV / AC / PR / UI / S / C / I / A.
766
- * Optional temporal metrics: E / RL / RC.
767
- * Optional environmental metrics: CR / IR / AR / MAV / MAC / MPR / MUI /
768
- * MS / MC / MI / MA.
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.
769
568
  */
770
- // CVSS 3.0 and 3.1 share an identical vector grammar (metric set, value enums,
771
- // and metric order are the same; only the `CVSS:X.Y/` prefix differs). CSAF
772
- // 2.0 §3.2.4.3 accepts both versions in the cvss_v3 block. The strict regex
773
- // matches either prefix; the parser records which version the vector declared
774
- // so the emitter can stamp the right `version` field.
775
569
  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])?$/;
776
570
 
777
571
  function parseCvss31Vector(v) {
@@ -793,25 +587,17 @@ function parseCvss31Vector(v) {
793
587
  }
794
588
 
795
589
  /**
796
- * Package-Confidence Score (PCS) — a SUPPLEMENTARY 0-100 supply-chain
797
- * trustworthiness signal, surfaced ALONGSIDE RWEP, never replacing it.
798
- *
799
- * Polarity is the INVERSE of RWEP: high PCS = trustworthy provenance/behaviour;
800
- * low PCS = behaves like malware. RWEP answers "how urgently must I act on this
801
- * known vulnerability"; PCS answers "how much do I trust this package's
802
- * provenance/behaviour, independent of any CVE." The two are different axes and
803
- * must never be summed or compared numerically (the `package_confidence.polarity:
804
- * "trust"` const on catalog entries exists to assert direction before display).
590
+ * 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.
805
592
  *
806
- * CRITICAL: this function is NEVER called inside validate() / scoreCustom() /
807
- * deriveRwepFromFactors(). PCS lives OUTSIDE the RWEP factor key set so the
808
- * RWEP sum invariant + >5 divergence gate cannot see it — adding it is purely
809
- * additive and cannot perturb any stored rwep_score.
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.
810
598
  *
811
- * Equal-weight mean of the PRESENT sub-signals (maintainer / quality /
812
- * behavioral / provenance), each 0-100, clamped to [0,100]. Absent sub-signals
813
- * are skipped (not treated as 0) so a partially-curated entry isn't punished
814
- * for un-assessed dimensions. Returns null when no usable input is present.
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.
815
601
  */
816
602
  function packageConfidence(inputs) {
817
603
  if (!inputs || typeof inputs !== 'object') return null;