@blamejs/exceptd-skills 0.12.15 → 0.12.16

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
@@ -3,6 +3,40 @@
3
3
  /**
4
4
  * RWEP — Real-World Exploit Priority scoring engine
5
5
  * Supplements CVSS with exploit availability, active exploitation, and operational constraints.
6
+ *
7
+ * ----------------------------------------------------------------------------
8
+ * `rwep_factors` dual-semantics (audit J F2)
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
+ * ----------------------------------------------------------------------------
6
40
  */
7
41
 
8
42
  const CVE_SCHEMA_REQUIRED = [
@@ -28,6 +62,29 @@ const RWEP_WEIGHTS = {
28
62
  reboot_required: 5
29
63
  };
30
64
 
65
+ // audit J F4: active_exploitation ladder. Aligned with playbook-runner's
66
+ // _activeExploitationLadder so the catalog scorer and the runtime evaluator
67
+ // produce identical results for the same string value. 'unknown' contributes
68
+ // a quarter of the confirmed weight (5 points) — operationally "we have not
69
+ // confirmed, but absence of evidence is not evidence of absence; do not
70
+ // score zero on a fresh CVE that hasn't been triaged yet".
71
+ const ACTIVE_EXPLOITATION_LADDER = {
72
+ confirmed: 1.0,
73
+ suspected: 0.5,
74
+ unknown: 0.25,
75
+ none: 0,
76
+ };
77
+
78
+ // The canonical set of factor keys scoreCustom recognises. Used by
79
+ // validateFactors to flag unknown keys (audit J F8).
80
+ const RECOGNISED_FACTOR_KEYS = new Set([
81
+ 'cisa_kev', 'poc_available', 'ai_assisted_weapon', 'ai_discovered',
82
+ 'active_exploitation', 'blast_radius', 'patch_available',
83
+ 'live_patch_available', 'reboot_required',
84
+ // accepted alias for the catalog field name
85
+ 'patch_required_reboot',
86
+ ]);
87
+
31
88
  function score(cveId, catalog) {
32
89
  const entry = catalog[cveId];
33
90
  if (!entry) throw new Error(`CVE not in catalog: ${cveId}`);
@@ -68,13 +125,29 @@ function validateFactors(factors) {
68
125
  } else if (!aeAllowed.includes(factors.active_exploitation)) {
69
126
  warnings.push(`active_exploitation: expected one of ${aeAllowed.join(', ')}, got ${JSON.stringify(factors.active_exploitation)}`);
70
127
  }
128
+ // audit J F6: NaN diagnostics. The prior message read "expected number,
129
+ // got number (null)" because `JSON.stringify(NaN) === 'null'` and `typeof
130
+ // NaN === 'number'`. Number.isFinite catches NaN + Infinity + -Infinity
131
+ // and emits a useful message.
71
132
  if (factors.blast_radius === undefined || factors.blast_radius === null) {
72
133
  warnings.push('blast_radius: missing (treated as 0)');
73
- } else if (typeof factors.blast_radius !== 'number' || Number.isNaN(factors.blast_radius)) {
134
+ } else if (typeof factors.blast_radius !== 'number') {
74
135
  warnings.push(`blast_radius: expected number, got ${typeof factors.blast_radius} (${JSON.stringify(factors.blast_radius)})`);
136
+ } else if (Number.isNaN(factors.blast_radius)) {
137
+ warnings.push('blast_radius: NaN is not a valid numeric value (treated as 0)');
138
+ } else if (!Number.isFinite(factors.blast_radius)) {
139
+ warnings.push(`blast_radius: ${factors.blast_radius > 0 ? 'Infinity' : '-Infinity'} is not a finite numeric value (treated as 0)`);
75
140
  } else if (factors.blast_radius < 0 || factors.blast_radius > 30) {
76
141
  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)`);
77
142
  }
143
+ // audit J F8: surface unknown factor keys so a typo'd answer file
144
+ // (`patch_avilable`, `cisa-kev`, etc.) doesn't silently default to false
145
+ // with no diagnostic.
146
+ for (const k of Object.keys(factors)) {
147
+ if (!RECOGNISED_FACTOR_KEYS.has(k)) {
148
+ warnings.push(`unknown factor: ${k} (ignored — not in the recognised key set)`);
149
+ }
150
+ }
78
151
  return warnings;
79
152
  }
80
153
 
@@ -113,8 +186,15 @@ function scoreCustom(factors, opts) {
113
186
  score += cisa_kev ? RWEP_WEIGHTS.cisa_kev : 0;
114
187
  score += poc_available ? RWEP_WEIGHTS.poc_available : 0;
115
188
  score += (ai_assisted_weapon || ai_discovered) ? RWEP_WEIGHTS.ai_factor : 0;
116
- score += active_exploitation === 'confirmed' ? RWEP_WEIGHTS.active_exploitation : 0;
117
- score += active_exploitation === 'suspected' ? Math.floor(RWEP_WEIGHTS.active_exploitation / 2) : 0;
189
+ // audit J F4 + F16: active_exploitation goes through the ladder rather
190
+ // than two hand-written branches with `Math.floor(weight/2)`. The floor
191
+ // was a no-op for even weights (20/2 = 10) but would have silently
192
+ // truncated to asymmetric results if a future operator bumped the
193
+ // weight to 21. The ladder + multiplication preserves the contribution
194
+ // exactly, including the new `unknown → 0.25 × weight = 5` mapping that
195
+ // aligns the catalog scorer with playbook-runner._activeExploitationLadder.
196
+ const aeMultiplier = ACTIVE_EXPLOITATION_LADDER[active_exploitation] ?? 0;
197
+ score += RWEP_WEIGHTS.active_exploitation * aeMultiplier;
118
198
  // v0.12.15 (audit J F1, F5): blast_radius numeric coercion must reject
119
199
  // NaN, Infinity, and strings explicitly. The prior `typeof === 'number'`
120
200
  // check passed NaN (which is `typeof === 'number'`) into `Math.min/max`
@@ -128,15 +208,60 @@ function scoreCustom(factors, opts) {
128
208
  score += live_patch_available ? RWEP_WEIGHTS.live_patch_available : 0;
129
209
  score += rebootFactor ? RWEP_WEIGHTS.reboot_required : 0;
130
210
 
211
+ // audit J F10: keep the pre-clamp value so collectWarnings consumers can
212
+ // see deduction magnitude (e.g. a -25 raw score collapsed to 0 hides the
213
+ // fact that the entry had three mitigating factors).
214
+ const rawUnclamped = score;
215
+
131
216
  // v0.12.15 (audit J F1): defense-in-depth clamp against any unforeseen
132
217
  // NaN production above (negative weight + Infinity + math edge case).
133
218
  const clamped = Number.isFinite(score) ? Math.min(100, Math.max(0, score)) : 0;
134
219
  if (opts && opts.collectWarnings) {
135
- return { score: clamped, _scoring_warnings: validateFactors(factors) };
220
+ return {
221
+ score: clamped,
222
+ _rwep_raw_unclamped: Number.isFinite(rawUnclamped) ? rawUnclamped : null,
223
+ _scoring_warnings: validateFactors(factors),
224
+ };
136
225
  }
137
226
  return clamped;
138
227
  }
139
228
 
229
+ /**
230
+ * audit J F3 + audit M P1-C bridge: derive an RWEP score from a
231
+ * `rwep_factors` object regardless of which shape it uses.
232
+ *
233
+ * - SHAPE A (boolean / string-ladder): values are booleans + an
234
+ * active_exploitation string + a numeric blast_radius. Route through
235
+ * scoreCustom() — the canonical formula.
236
+ * - SHAPE B (catalog post-weight): values are numeric contributions
237
+ * (0 / ±N) plus a numeric blast_radius. Sum the numeric values and
238
+ * clamp to [0, 100]. This is how catalog `rwep_factors` are stored.
239
+ *
240
+ * Heuristic: if every value is a number, treat as Shape B (sum). If any
241
+ * value is boolean or a recognised ladder string, treat as Shape A
242
+ * (scoreCustom). This lets the curation apply-path and the auto-discovery
243
+ * builder share one canonical derivation that handles either operator
244
+ * input style without duplicating the scoring formula.
245
+ */
246
+ function deriveRwepFromFactors(factors) {
247
+ if (!factors || typeof factors !== 'object') return 0;
248
+ const values = Object.values(factors);
249
+ if (values.length === 0) return 0;
250
+ const aeAllowed = new Set(['none', 'unknown', 'suspected', 'confirmed']);
251
+ const hasBooleanOrLadder = values.some(
252
+ (v) => typeof v === 'boolean' || (typeof v === 'string' && aeAllowed.has(v)),
253
+ );
254
+ if (hasBooleanOrLadder) {
255
+ return scoreCustom(factors);
256
+ }
257
+ // Shape B: catalog post-weight. Sum + clamp.
258
+ let sum = 0;
259
+ for (const v of values) {
260
+ if (typeof v === 'number' && Number.isFinite(v)) sum += v;
261
+ }
262
+ return Math.max(0, Math.min(100, sum));
263
+ }
264
+
140
265
  function timeline(rwepScore) {
141
266
  if (rwepScore >= 90) return { hours: 4, label: 'Immediate — live patch or isolate within 4 hours' };
142
267
  if (rwepScore >= 75) return { hours: 24, label: 'Urgent — patch or compensating controls within 24 hours' };
@@ -146,17 +271,36 @@ function timeline(rwepScore) {
146
271
  return { hours: null, label: 'Low — next scheduled maintenance' };
147
272
  }
148
273
 
149
- function compare(cveId, catalog) {
274
+ function compare(cveId, catalog, opts) {
150
275
  const entry = catalog[cveId];
151
276
  if (!entry) throw new Error(`CVE not in catalog: ${cveId}`);
152
277
 
153
- const rwep = entry.rwep_score;
278
+ // audit J F11: `--recompute` ignores the stored rwep_score and forces a
279
+ // fresh computation from rwep_factors. Useful for catching catalog drift
280
+ // (stored score grew stale relative to current weights) and for auditing
281
+ // the divergence between stored vs. formula-derived scores.
282
+ const recompute = !!(opts && opts.recompute);
283
+ let rwep;
284
+ if (recompute) {
285
+ const factors = entry.rwep_factors || {};
286
+ // The catalog's rwep_factors shape is "post-weight" (Shape B). Route
287
+ // through the shape-detecting helper so a catalog whose factors were
288
+ // hand-edited in either shape still produces a usable score.
289
+ rwep = deriveRwepFromFactors(factors);
290
+ } else {
291
+ rwep = entry.rwep_score;
292
+ }
154
293
  const cvss = entry.cvss_score;
155
294
  const cvssEquivalent = cvss * 10;
156
295
  const delta = rwep - cvssEquivalent;
157
296
 
297
+ // audit J F15: narrow the "broadly aligned" band from ±20 to ±10. The old
298
+ // ±20 band swallowed the Copy Fail RWEP-vs-CVSS divergence (delta = 12)
299
+ // where the operator-facing point is precisely that the CVSS-calibrated
300
+ // SLA is insufficient. ±10 is the tightest classifier that still treats
301
+ // ordinary CVSS rounding noise as alignment.
158
302
  let explanation = '';
159
- if (delta > 20) {
303
+ if (delta > 10) {
160
304
  explanation = `RWEP significantly higher than CVSS equivalent. Factors driving delta: `;
161
305
  const driving = [];
162
306
  if (entry.cisa_kev) driving.push('CISA KEV (+25)');
@@ -166,7 +310,7 @@ function compare(cveId, catalog) {
166
310
  if (entry.patch_required_reboot && !entry.live_patch_available) driving.push('reboot required (+5)');
167
311
  explanation += driving.join(', ');
168
312
  explanation += '. Framework patch SLAs calibrated to CVSS are insufficient for this CVE.';
169
- } else if (delta < -20) {
313
+ } else if (delta < -10) {
170
314
  explanation = `RWEP lower than CVSS equivalent. Mitigating factors: `;
171
315
  const mitigating = [];
172
316
  if (entry.patch_available) mitigating.push('patch available (-15)');
@@ -178,15 +322,20 @@ function compare(cveId, catalog) {
178
322
  explanation = 'CVSS and RWEP are broadly aligned for this CVE.';
179
323
  }
180
324
 
181
- return {
325
+ const out = {
182
326
  cve_id: cveId,
183
327
  cvss: cvss,
184
328
  rwep: rwep,
185
329
  cvss_framework_sla: timeline(cvssEquivalent),
186
330
  rwep_actual_sla: timeline(rwep),
187
331
  delta,
188
- explanation
332
+ explanation,
189
333
  };
334
+ if (recompute) {
335
+ out.stored_rwep_score = entry.rwep_score;
336
+ out.recomputed = true;
337
+ }
338
+ return out;
190
339
  }
191
340
 
192
341
  function validate(catalog) {
@@ -222,4 +371,15 @@ function validate(catalog) {
222
371
  return errors;
223
372
  }
224
373
 
225
- module.exports = { score, scoreCustom, timeline, compare, validate, validateFactors, RWEP_WEIGHTS };
374
+ module.exports = {
375
+ score,
376
+ scoreCustom,
377
+ timeline,
378
+ compare,
379
+ validate,
380
+ validateFactors,
381
+ deriveRwepFromFactors,
382
+ RWEP_WEIGHTS,
383
+ ACTIVE_EXPLOITATION_LADDER,
384
+ RECOGNISED_FACTOR_KEYS,
385
+ };
@@ -31,6 +31,9 @@
31
31
  * govern.jurisdiction_obligations[] (the schema does not give
32
32
  * jurisdiction_obligations an explicit `id` field; the shipped playbooks
33
33
  * reference them by this composite string).
34
+ * - _meta.mutex is symmetric across the whole playbook set: if A lists B,
35
+ * B must list A. Asymmetry surfaces as a warning in v0.12.16 (and will
36
+ * flip to error in v0.13.0) — see checkMutexReciprocity().
34
37
  *
35
38
  * Finding severity:
36
39
  * - error — structural problems that block the runner (missing required
@@ -397,6 +400,44 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
397
400
  return findings;
398
401
  }
399
402
 
403
+ /* Cross-playbook mutex-reciprocity check.
404
+ *
405
+ * `_meta.mutex` is a symmetric relation: if playbook A lists B, B must list A.
406
+ * Asymmetry is a latent runner bug — the engine's mutex enforcement only
407
+ * blocks concurrent execution from whichever side declared the conflict, so
408
+ * an asymmetric declaration silently degrades to a race condition when the
409
+ * undeclared side is started first.
410
+ *
411
+ * Emits one warning per asymmetric pair (keyed off the side that declares
412
+ * the edge). v0.12.16 keeps this at warning severity per the patch-class
413
+ * cadence; v0.13.0 will flip it to error via --strict / predeploy
414
+ * `informational: false`.
415
+ */
416
+ function checkMutexReciprocity(playbooks) {
417
+ const findings = [];
418
+ const mutexMap = new Map();
419
+ for (const pb of playbooks) {
420
+ if (!pb.data || !pb.data._meta || !pb.data._meta.id) continue;
421
+ const id = pb.data._meta.id;
422
+ const mutex = Array.isArray(pb.data._meta.mutex) ? pb.data._meta.mutex : [];
423
+ mutexMap.set(id, new Set(mutex));
424
+ }
425
+ const byPlaybook = new Map(); // playbookId -> array of warning messages
426
+ for (const [id, mset] of mutexMap.entries()) {
427
+ for (const other of mset) {
428
+ const otherSet = mutexMap.get(other);
429
+ if (!otherSet) continue; // unresolved-id warning is already emitted by checkCrossRefs
430
+ if (!otherSet.has(id)) {
431
+ const msg = `_meta.mutex: asymmetric mutex with "${other}" — "${other}" does not list "${id}" in its _meta.mutex. v0.13.0 will flip this to a hard error.`;
432
+ if (!byPlaybook.has(id)) byPlaybook.set(id, []);
433
+ byPlaybook.get(id).push(msg);
434
+ }
435
+ }
436
+ }
437
+ findings.push(byPlaybook);
438
+ return byPlaybook;
439
+ }
440
+
400
441
  function main() {
401
442
  const opts = parseArgs(process.argv);
402
443
  const schema = readJson(SCHEMA_PATH);
@@ -408,6 +449,7 @@ function main() {
408
449
  playbookIds.add(pb.data._meta.id);
409
450
  }
410
451
  }
452
+ const mutexAsymmetries = checkMutexReciprocity(playbooks);
411
453
 
412
454
  let errored = 0;
413
455
  let warned = 0;
@@ -425,6 +467,9 @@ function main() {
425
467
  ...validate(pb.data, schema, 'playbook', label),
426
468
  ...checkCrossRefs(pb.data, ctx, playbookIds),
427
469
  ];
470
+ const reciprocityMsgs =
471
+ (pb.data && pb.data._meta && mutexAsymmetries.get(pb.data._meta.id)) || [];
472
+ for (const m of reciprocityMsgs) findings.push({ severity: 'warning', message: m });
428
473
  const effective = opts.strict
429
474
  ? findings.map((f) => ({ ...f, severity: 'error' }))
430
475
  : findings;
@@ -459,6 +504,7 @@ function main() {
459
504
  module.exports = {
460
505
  validate,
461
506
  checkCrossRefs,
507
+ checkMutexReciprocity,
462
508
  loadContext,
463
509
  loadPlaybooks,
464
510
  obligationKey,