@blamejs/exceptd-skills 0.16.29 → 0.16.31

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.
@@ -301,6 +301,13 @@ const KEV_SOURCE = {
301
301
  errors.push(`KEV: no local entry for ${d.id}`);
302
302
  continue;
303
303
  }
304
+ // A de-listing flagged for review (a curated entry with strong
305
+ // exploitation signal that upstream KEV no longer lists) is surfaced
306
+ // in the report but NOT auto-applied: the curated flag, factor, score
307
+ // and dates are left intact so a maintainer can confirm a genuine
308
+ // CISA removal vs a transient / incomplete feed before the entry is
309
+ // downgraded. Skip the write here.
310
+ if (d.review_only) continue;
304
311
  catalog[d.id][d.field] = d.after;
305
312
  // A cisa_kev flip changes the entry's RWEP: the KEV factor carries
306
313
  // RWEP_WEIGHTS.cisa_kev points, and the catalog invariant requires
@@ -437,7 +444,7 @@ const NVD_SOURCE = {
437
444
  if (r.status === "unreachable") errors++;
438
445
  for (const d of r.discrepancies || []) {
439
446
  if (d.field === "cvss_score" || d.field === "cvss_vector") {
440
- diffs.push({ id: r.cve_id, field: d.field, before: d.local, after: d.fetched, severity: d.severity });
447
+ diffs.push(cvssDiff(r.cve_id, d.field, d.local, d.fetched, d.severity, ctx.cveCatalog?.[r.cve_id]));
441
448
  }
442
449
  }
443
450
  }
@@ -454,6 +461,10 @@ const NVD_SOURCE = {
454
461
  const catalogPath = ctx.cvePath || ABS("data/cve-catalog.json");
455
462
  await withCatalogLock(catalogPath, (catalog) => {
456
463
  for (const d of diffs) {
464
+ // A curator-owned CVSS re-score is surfaced in the report but not
465
+ // applied — the curated value is preserved until a maintainer accepts
466
+ // the upstream delta (symmetric with the KEV review_only path).
467
+ if (d.review_only) continue;
457
468
  if (!catalog[d.id]) {
458
469
  errors.push(`NVD: no local entry for ${d.id}`);
459
470
  continue;
@@ -864,6 +875,54 @@ function readCachedJson(cacheDir, source, id, opts) {
864
875
  return parsed;
865
876
  }
866
877
 
878
+ // A curated entry carries strong human-curated exploitation signal when its
879
+ // active_exploitation is confirmed/suspected, or it has a non-empty PoC
880
+ // description / verification sources. A de-listing of such an entry is far
881
+ // more likely a transient or incomplete upstream feed than a genuine CISA
882
+ // removal, so it is surfaced for review rather than auto-applied.
883
+ function hasCuratedExploitSignal(entry) {
884
+ if (!entry || typeof entry !== "object") return false;
885
+ const ae = typeof entry.active_exploitation === "string" ? entry.active_exploitation.toLowerCase() : "";
886
+ if (ae === "confirmed" || ae === "suspected") return true;
887
+ if (typeof entry.poc_description === "string" && entry.poc_description.trim()) return true;
888
+ if (Array.isArray(entry.verification_sources) && entry.verification_sources.length > 0) return true;
889
+ return false;
890
+ }
891
+
892
+ // A catalog entry is curator-owned (its CVSS is hand-verified, not an upstream
893
+ // auto-import) unless it carries `_auto_imported: true`. An NVD CVSS re-score on
894
+ // a curator-owned entry is surfaced for review rather than auto-applied — the
895
+ // same principle as the curated-KEV de-listing guard above. The version-
896
+ // downgrade guards already suppress a v3.x→v2 regression; this additionally
897
+ // keeps a *same-version* NVD re-score (e.g. a curated 10.0 the maintainer pinned
898
+ // dropping to NVD's 9.8) from silently overwriting the curated value. Raw
899
+ // auto-imported drafts (`_auto_imported: true`) are not yet curated, so NVD is
900
+ // their source of truth and their CVSS applies normally.
901
+ function isCuratorOwnedCvss(entry) {
902
+ return !!entry && typeof entry === "object" && entry._auto_imported !== true;
903
+ }
904
+
905
+ // Build an NVD CVSS diff, marking it review-only when the local entry is
906
+ // curator-owned so applyDiff preserves the curated value while the report still
907
+ // surfaces the upstream delta for a maintainer to accept deliberately.
908
+ function cvssDiff(id, field, before, after, severity, local) {
909
+ const d = { id, field, before, after, severity };
910
+ if (isCuratorOwnedCvss(local)) {
911
+ d.review_only = true;
912
+ d.cvss_review = true;
913
+ d.note = `NVD ${field} change held for review: ${id} is curator-owned (hand-verified CVSS). Confirm and re-curate to accept NVD's ${after} over the curated ${before}; not auto-applied so the curated value is preserved.`;
914
+ }
915
+ return d;
916
+ }
917
+
918
+ // Below this many entries the cached KEV feed is treated as truncated /
919
+ // incomplete rather than a genuine CISA snapshot. CISA KEV has carried well
920
+ // over a thousand entries since 2021 and only grows; a feed this small means
921
+ // a partial download or a tampered cache, and trusting it to de-list curated
922
+ // entries would silently erase confirmed-exploitation intel. De-listings are
923
+ // refused wholesale when the feed is implausibly small.
924
+ const KEV_FEED_MIN_PLAUSIBLE = 500;
925
+
867
926
  function kevDiffFromCache(ctx) {
868
927
  const feed = readCachedJson(ctx.cacheDir, "kev", "known_exploited_vulnerabilities", { forceStale: ctx.forceStale });
869
928
  if (!feed) {
@@ -877,12 +936,39 @@ function kevDiffFromCache(ctx) {
877
936
  if (v.dateAdded) kevDates.set(v.cveID, v.dateAdded);
878
937
  }
879
938
  }
939
+ // An implausibly small feed cannot be trusted to de-list curated entries.
940
+ // First-listings (false→true) still flow — a small feed never invents new
941
+ // exploitation; only the de-list direction is suppressed.
942
+ const feedComplete = kevSet.size >= KEV_FEED_MIN_PLAUSIBLE;
880
943
  const diffs = [];
881
944
  for (const [id, entry] of Object.entries(ctx.cveCatalog)) {
882
945
  if (!/^CVE-\d{4}-\d{4,7}$/.test(id)) continue;
883
946
  const upstream = kevSet.has(id);
884
947
  if (typeof entry.cisa_kev === "boolean" && entry.cisa_kev !== upstream) {
885
- diffs.push({ id, field: "cisa_kev", before: entry.cisa_kev, after: upstream, severity: "high" });
948
+ const isDelist = entry.cisa_kev === true && upstream === false;
949
+ // Symmetric with the NVD path's curated-downgrade guard: never silently
950
+ // regress curated exploitation intel against an upstream that disagrees.
951
+ // A de-listing of a curated entry with strong exploitation signal, OR
952
+ // any de-listing when the feed is implausibly small, is re-tagged as a
953
+ // review-only diff — surfaced in the report so a maintainer confirms a
954
+ // genuine CISA removal, but NOT auto-applied (applyDiff skips
955
+ // review_only diffs, leaving cisa_kev / rwep / dates intact).
956
+ if (isDelist && (!feedComplete || hasCuratedExploitSignal(entry))) {
957
+ diffs.push({
958
+ id,
959
+ field: "cisa_kev",
960
+ before: entry.cisa_kev,
961
+ after: upstream,
962
+ severity: "high",
963
+ review_only: true,
964
+ kev_delist_review: true,
965
+ note: !feedComplete
966
+ ? `KEV de-listing held for review: cached feed has only ${kevSet.size} entries (< ${KEV_FEED_MIN_PLAUSIBLE}), likely incomplete. Confirm against a complete CISA KEV snapshot before de-listing.`
967
+ : "KEV de-listing held for review: entry carries curated exploitation signal (active_exploitation / PoC / verification sources). Confirm a genuine CISA removal vs a transient feed gap before de-listing.",
968
+ });
969
+ } else {
970
+ diffs.push({ id, field: "cisa_kev", before: entry.cisa_kev, after: upstream, severity: "high" });
971
+ }
886
972
  }
887
973
  const upDate = kevDates.get(id) || null;
888
974
  // First listings arrive with a null local date — emit the date diff
@@ -950,10 +1036,10 @@ function nvdDiffFromCache(ctx) {
950
1036
  up.version != null && localVersion != null && up.version < localVersion;
951
1037
  if (!isDowngrade) {
952
1038
  if (up.baseScore != null && local.cvss_score != null && Math.abs(up.baseScore - local.cvss_score) > 0.05) {
953
- diffs.push({ id, field: "cvss_score", before: local.cvss_score, after: up.baseScore, severity: "high" });
1039
+ diffs.push(cvssDiff(id, "cvss_score", local.cvss_score, up.baseScore, "high", local));
954
1040
  }
955
1041
  if (up.vector && local.cvss_vector && up.vector !== local.cvss_vector) {
956
- diffs.push({ id, field: "cvss_vector", before: local.cvss_vector, after: up.vector, severity: "medium" });
1042
+ diffs.push(cvssDiff(id, "cvss_vector", local.cvss_vector, up.vector, "medium", local));
957
1043
  }
958
1044
  }
959
1045
  }
@@ -1553,10 +1639,54 @@ async function main() {
1553
1639
  // refresh loop and could egress + write refresh-report.json despite
1554
1640
  // --no-network.
1555
1641
  if (opts.prefetch || opts.noNetwork) {
1642
+ // Validate --source against the prefetchable (cache-backed) subset BEFORE
1643
+ // delegating to prefetch.js. prefetch.js only knows kev/nvd/epss/rfc/pins;
1644
+ // the refresh-only sources (ghsa, osv, advisories, cve-regression-watcher)
1645
+ // resolve advisories by live id lookup and have no cache layer. Without
1646
+ // this guard, `refresh --prefetch --source osv` reached prefetch.js and
1647
+ // died with `prefetch: fatal: unknown source "osv"` — leaking the internal
1648
+ // verb name (the operator typed `refresh`) and calling a source "unknown"
1649
+ // that the refresh help just listed as valid. Emit a refresh-prefixed,
1650
+ // actionable message instead and forward only the cacheable subset.
1651
+ const PREFETCHABLE = new Set(["kev", "nvd", "epss", "rfc", "pins"]);
1652
+ let forwardSource = opts.source;
1653
+ if (opts.source) {
1654
+ const names = opts.source.split(",").map((s) => s.trim()).filter(Boolean);
1655
+ if (names.length === 0) {
1656
+ process.stderr.write(JSON.stringify({
1657
+ ok: false,
1658
+ verb: "refresh",
1659
+ error: `refresh: --source given but resolved to no source names (empty or comma-only value); prefetchable sources: ${[...PREFETCHABLE].join(",")}`,
1660
+ }) + "\n");
1661
+ process.exitCode = 2;
1662
+ return;
1663
+ }
1664
+ const unsupported = names.filter((n) => !PREFETCHABLE.has(n) && ALL_SOURCES[n]);
1665
+ const unknown = names.filter((n) => !PREFETCHABLE.has(n) && !ALL_SOURCES[n]);
1666
+ if (unknown.length > 0) {
1667
+ process.stderr.write(JSON.stringify({
1668
+ ok: false,
1669
+ verb: "refresh",
1670
+ error: `refresh: unknown source ${unknown.map((n) => `"${n}"`).join(", ")}; prefetchable sources: ${[...PREFETCHABLE].join(",")}`,
1671
+ }) + "\n");
1672
+ process.exitCode = 2;
1673
+ return;
1674
+ }
1675
+ if (unsupported.length > 0) {
1676
+ process.stderr.write(JSON.stringify({
1677
+ ok: false,
1678
+ verb: "refresh",
1679
+ error: `refresh: source ${unsupported.map((n) => `"${n}"`).join(", ")} has no prefetch cache layer (live id lookup only); prefetchable sources: ${[...PREFETCHABLE].join(",")}`,
1680
+ }) + "\n");
1681
+ process.exitCode = 2;
1682
+ return;
1683
+ }
1684
+ forwardSource = names.join(",");
1685
+ }
1556
1686
  const { spawnSync } = require("child_process");
1557
1687
  const pfArgs = [require.resolve("./prefetch.js")];
1558
1688
  if (opts.noNetwork) pfArgs.push("--no-network");
1559
- if (opts.source) pfArgs.push("--source", opts.source);
1689
+ if (forwardSource) pfArgs.push("--source", forwardSource);
1560
1690
  if (opts.quiet) pfArgs.push("--quiet");
1561
1691
  const r = spawnSync(process.execPath, pfArgs, { stdio: "inherit" });
1562
1692
  process.exitCode = r.status == null ? 1 : r.status;
@@ -1740,4 +1870,4 @@ if (require.main === module) {
1740
1870
  });
1741
1871
  }
1742
1872
 
1743
- module.exports = { ALL_SOURCES, loadCtx, parseArgs, seedSingleAdvisory, withCatalogLock, writeJsonAtomic, nvdDiffFromCache };
1873
+ module.exports = { ALL_SOURCES, loadCtx, parseArgs, seedSingleAdvisory, withCatalogLock, writeJsonAtomic, nvdDiffFromCache, kevDiffFromCache };
package/lib/scoring.js CHANGED
@@ -214,7 +214,23 @@ function scoreCustom(factors, opts) {
214
214
  // which propagates NaN through the final clamp, defeating the [0,100]
215
215
  // contract. Number.isFinite + Number() coercion catches all four classes:
216
216
  // NaN, Infinity, undefined, stringified-number.
217
- const brRaw = Number.isFinite(Number(blast_radius)) ? Number(blast_radius) : 0;
217
+ // Match validateFactors' contract exactly. Bare `Number()` coercion would
218
+ // turn `true` into 1 and a single-element array like `[7]` into 7 — both of
219
+ // which validateFactors rejects as "expected number" — so the scorer would
220
+ // silently add a blast contribution the validator says is invalid. Accept
221
+ // only a finite number or a trimmed-nonempty numeric string; everything
222
+ // else (boolean, array, object, NaN, Infinity, empty/whitespace string)
223
+ // contributes 0.
224
+ let brRaw = 0;
225
+ if (typeof blast_radius === 'number' && Number.isFinite(blast_radius)) {
226
+ brRaw = blast_radius;
227
+ } else if (
228
+ typeof blast_radius === 'string' &&
229
+ blast_radius.trim() !== '' &&
230
+ Number.isFinite(Number(blast_radius))
231
+ ) {
232
+ brRaw = Number(blast_radius);
233
+ }
218
234
  const brClamped = Math.max(0, Math.min(RWEP_WEIGHTS.blast_radius, brRaw));
219
235
  score += brClamped;
220
236
  score += patch_available ? RWEP_WEIGHTS.patch_available : 0;
@@ -303,9 +319,21 @@ function compare(cveId, catalog, opts) {
303
319
  } else {
304
320
  rwep = entry.rwep_score;
305
321
  }
306
- const cvss = entry.cvss_score;
307
- const cvssEquivalent = cvss * 10;
308
- const delta = rwep - cvssEquivalent;
322
+ // Normalize cvss before any arithmetic. An absent or non-finite cvss_score
323
+ // must not flow into `cvss * 10` — `undefined * 10 === NaN` poisons the
324
+ // delta, and NaN fails every `delta > 10` / `delta < -10` band, so the
325
+ // entry would otherwise fall through to the "broadly aligned" arm and
326
+ // assert an alignment that was never computed. Treat absent CVSS as
327
+ // not-comparable instead.
328
+ const cvss = (typeof entry.cvss_score === 'number' && Number.isFinite(entry.cvss_score))
329
+ ? entry.cvss_score
330
+ : null;
331
+ const cvssAbsent = cvss == null;
332
+ const cvssEquivalent = cvssAbsent ? 0 : cvss * 10;
333
+ // delta is null (not NaN) when there is no CVSS to compare against, so the
334
+ // emitted result serializes cleanly and never claims a numeric divergence
335
+ // that does not exist.
336
+ const delta = cvssAbsent ? null : rwep - cvssEquivalent;
309
337
 
310
338
  // narrow the "broadly aligned" band from ±20 to ±10. The old
311
339
  // ±20 band swallowed the Copy Fail RWEP-vs-CVSS divergence (delta = 12)
@@ -321,6 +349,11 @@ function compare(cveId, catalog, opts) {
321
349
  // catalog gap rather than a false sense of alignment.
322
350
  if ((rwep == null || rwep === 0) && (cvss == null || cvss === 0)) {
323
351
  explanation = 'No scoring signal — both RWEP and CVSS are zero/null. Investigate the catalog entry; this CVE has no usable risk score.';
352
+ } else if (cvssAbsent) {
353
+ // RWEP carries a real signal but there is no CVSS to compare it against.
354
+ // The two scores are not comparable, so do not assert alignment or a
355
+ // divergence direction — surface the missing CVSS instead.
356
+ 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.';
324
357
  } else if (delta > 10) {
325
358
  explanation = `RWEP significantly higher than CVSS equivalent. Factors driving delta: `;
326
359
  const driving = [];
@@ -460,13 +493,24 @@ function validate(catalog) {
460
493
  active_exploitation: RWEP_WEIGHTS.active_exploitation * aeMultiplier,
461
494
  patch_available: entry.patch_available === true ? RWEP_WEIGHTS.patch_available : 0,
462
495
  live_patch_available: entry.live_patch_available === true ? RWEP_WEIGHTS.live_patch_available : 0,
463
- reboot_required: entry.patch_required_reboot === true ? RWEP_WEIGHTS.reboot_required : 0,
464
496
  };
465
497
  for (const [k, want] of Object.entries(implied)) {
466
498
  if (k in f && typeof f[k] === 'number' && f[k] !== want) {
467
499
  errors.push(`${cveId}: rwep_factors.${k} is ${f[k]} but the entry's source fields imply ${want}`);
468
500
  }
469
501
  }
502
+ // The reboot contribution has ONE implied weight but TWO accepted
503
+ // spellings — `reboot_required` (canonical) and `patch_required_reboot`
504
+ // (the catalog field-name alias scoreCustom also honors). Check both
505
+ // against that single weight; keying only on `reboot_required` let a
506
+ // contradictory value stored under the alias slip past the coherence
507
+ // gate, inflating the derived score.
508
+ const rebootWant = entry.patch_required_reboot === true ? RWEP_WEIGHTS.reboot_required : 0;
509
+ for (const rebootKey of ['reboot_required', 'patch_required_reboot']) {
510
+ if (rebootKey in f && typeof f[rebootKey] === 'number' && f[rebootKey] !== rebootWant) {
511
+ errors.push(`${cveId}: rwep_factors.${rebootKey} is ${f[rebootKey]} but the entry's source fields imply ${rebootWant}`);
512
+ }
513
+ }
470
514
  }
471
515
  const calculatedRwep = scoreCustom({
472
516
  cisa_kev: entry.cisa_kev,
@@ -264,8 +264,20 @@ function additionalChecks(key, entry, ctx) {
264
264
  for (const { field, set, file } of REF_FIELDS) {
265
265
  if (!set) continue; // catalog absent — skip silently (defense-in-depth)
266
266
  const refs = entry[field];
267
- if (!Array.isArray(refs)) continue;
268
- for (const ref of refs) {
267
+ // Most ref fields are arrays of id strings (atlas_refs, cwe_refs, ...),
268
+ // but framework_control_gaps is a map keyed BY control id. An
269
+ // Array.isArray-only guard skipped the map form entirely, so orphaned
270
+ // control ids never surfaced. Derive the candidate ids from whichever
271
+ // shape the field uses: array elements, or the keys of a plain object.
272
+ let ids;
273
+ if (Array.isArray(refs)) {
274
+ ids = refs;
275
+ } else if (refs && typeof refs === 'object') {
276
+ ids = Object.keys(refs);
277
+ } else {
278
+ continue;
279
+ }
280
+ for (const ref of ids) {
269
281
  if (typeof ref !== 'string') continue;
270
282
  if (!set.has(ref)) {
271
283
  warnings.push(
@@ -25,6 +25,14 @@
25
25
  * - phases.detect.indicators[].cve_ref → data/cve-catalog.json
26
26
  * - phases.detect.false_positive_profile[].indicator_id
27
27
  * → phases.detect.indicators[].id
28
+ * - directives[].applies_to.cve → data/cve-catalog.json keys
29
+ * - directives[].applies_to.atlas_ttp → data/atlas-ttps.json keys
30
+ * - directives[].applies_to.attack_technique → data/attack-techniques.json
31
+ * - directives[].phase_overrides.govern.jurisdiction_obligations[].clock_starts
32
+ * → closed clock_starts vocabulary
33
+ * - directives[].phase_overrides.direct.rwep_threshold → ordering + range
34
+ * - directives[].phase_overrides.close.notification_actions[].obligation_ref
35
+ * → effective jurisdiction_obligations
28
36
  *
29
37
  * Internal consistency:
30
38
  * - Indicator ids are unique within a playbook.
@@ -438,28 +446,15 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
438
446
  }
439
447
 
440
448
  // rwep_threshold ordering. Hard error — a misordered threshold actively
441
- // breaks the scoring path.
442
- const rwep = direct.rwep_threshold || {};
443
- if (
444
- typeof rwep.close === 'number' &&
445
- typeof rwep.monitor === 'number' &&
446
- typeof rwep.escalate === 'number'
447
- ) {
448
- if (!(rwep.close <= rwep.monitor && rwep.monitor <= rwep.escalate)) {
449
- err(
450
- `phases.direct.rwep_threshold: ordering violation — expected close <= monitor <= escalate, got close=${rwep.close} monitor=${rwep.monitor} escalate=${rwep.escalate}`,
451
- );
452
- }
453
- for (const [k, v] of [
454
- ['close', rwep.close],
455
- ['monitor', rwep.monitor],
456
- ['escalate', rwep.escalate],
457
- ]) {
458
- if (v < 0 || v > 100) {
459
- err(`phases.direct.rwep_threshold.${k}: ${v} outside 0..100`);
460
- }
461
- }
462
- }
449
+ // breaks the scoring path. Factored into a helper so the same check runs
450
+ // against the playbook-level direct phase AND any directive-level
451
+ // phase_overrides.direct copy the runner deep-merges at run time.
452
+ checkRwepThreshold(direct.rwep_threshold, 'phases.direct.rwep_threshold');
453
+
454
+ // clock_starts closed-vocab against the base govern phase. Factored into a
455
+ // helper for the same reason — an override-supplied jurisdiction_obligations
456
+ // copy must pass the same closed-vocabulary gate.
457
+ checkClockStarts(govern.jurisdiction_obligations, 'phases.govern.jurisdiction_obligations');
463
458
 
464
459
  // notification_actions obligation_ref resolution.
465
460
  const obligationKeys = new Set(
@@ -553,22 +548,9 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
553
548
  );
554
549
  }
555
550
 
556
- // Closed controlled-vocabulary enums: a value outside the schema's closed
557
- // enum is an error, not a warning, so a typo cannot ship. Unlike the
558
- // evolving-drift enums (artifact `type`, indicator `type`) which the generic
559
- // validator keeps at warning, these vocabularies are fixed and load-bearing
560
- // (clock_starts decides when a notification deadline starts counting;
561
- // frameworks_in_scope drives gap-analysis routing).
562
- if (ctx.clockStartsEnum) {
563
- for (const [i, o] of (govern.jurisdiction_obligations || []).entries()) {
564
- if (!o || typeof o !== 'object') continue;
565
- if (o.clock_starts !== undefined && !ctx.clockStartsEnum.has(o.clock_starts)) {
566
- err(
567
- `phases.govern.jurisdiction_obligations[${i}].clock_starts: invalid value ${JSON.stringify(o.clock_starts)} — not in closed vocabulary ${JSON.stringify([...ctx.clockStartsEnum])}`,
568
- );
569
- }
570
- }
571
- }
551
+ // frameworks_in_scope closed vocabulary. A value outside the schema's closed
552
+ // enum is an error, not a warning, so a typo cannot ship frameworks_in_scope
553
+ // drives gap-analysis routing.
572
554
  if (ctx.frameworksEnum) {
573
555
  for (const [i, f] of (domain.frameworks_in_scope || []).entries()) {
574
556
  if (typeof f === 'string' && !ctx.frameworksEnum.has(f)) {
@@ -579,7 +561,120 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
579
561
  }
580
562
  }
581
563
 
564
+ // Directive-level coverage. A directive's applies_to fields and its
565
+ // phase_overrides both reach the runner live (the runner selects directives
566
+ // by id, deep-merges phase_overrides into the base phase, and surfaces
567
+ // applies_to in the discovery API) but neither was cross-referenced or
568
+ // re-validated — so a stale CVE/TTP reference or a tampered override
569
+ // (bogus clock_starts, out-of-range rwep_threshold) shipped past this gate
570
+ // even though the identical content is a hard error at playbook level.
571
+ for (const [i, d] of (playbook.directives || []).entries()) {
572
+ if (!d || typeof d !== 'object') continue;
573
+ const label = d.id ? `directives[${i}] (${d.id})` : `directives[${i}]`;
574
+
575
+ // applies_to.{cve,atlas_ttp,attack_technique} resolution, mirroring the
576
+ // domain-ref checks at warning severity (promoted to error under --strict).
577
+ const at = d.applies_to;
578
+ if (at && typeof at === 'object') {
579
+ if (at.cve && !ctx.cveKeys.has(at.cve)) {
580
+ warn(`${label}.applies_to.cve: unresolved "${at.cve}" (not in data/cve-catalog.json)`);
581
+ }
582
+ if (at.atlas_ttp && !ctx.atlasKeys.has(at.atlas_ttp)) {
583
+ warn(`${label}.applies_to.atlas_ttp: unresolved "${at.atlas_ttp}" (not in data/atlas-ttps.json)`);
584
+ }
585
+ // Guard the attack catalog the same way domain.attack_refs does:
586
+ // attack-techniques.json is loaded via readJsonIfExists and may be null.
587
+ if (at.attack_technique && ctx.attackKeys && !ctx.attackKeys.has(at.attack_technique)) {
588
+ warn(`${label}.applies_to.attack_technique: unresolved "${at.attack_technique}" (not in data/attack-techniques.json)`);
589
+ }
590
+ }
591
+
592
+ // phase_overrides re-validation. The runner merges these into the base
593
+ // phase before govern()/close() consume them, so an override-supplied
594
+ // clock_starts or rwep_threshold must pass the same gates as the base
595
+ // phase or the regulatory clock / scoring path breaks at run time.
596
+ const ov = d.phase_overrides;
597
+ if (ov && typeof ov === 'object') {
598
+ if (ov.govern && typeof ov.govern === 'object') {
599
+ checkClockStarts(
600
+ ov.govern.jurisdiction_obligations,
601
+ `${label}.phase_overrides.govern.jurisdiction_obligations`,
602
+ );
603
+ }
604
+ if (ov.direct && typeof ov.direct === 'object') {
605
+ checkRwepThreshold(
606
+ ov.direct.rwep_threshold,
607
+ `${label}.phase_overrides.direct.rwep_threshold`,
608
+ );
609
+ }
610
+ // An override-supplied notification obligation_ref must resolve against
611
+ // the EFFECTIVE obligation set the runner sees after the merge: the
612
+ // base govern obligations, plus any the override adds. Warning severity,
613
+ // matching the base-phase obligation_ref precedent.
614
+ if (ov.close && typeof ov.close === 'object' && Array.isArray(ov.close.notification_actions)) {
615
+ const overrideObligations =
616
+ (ov.govern && Array.isArray(ov.govern.jurisdiction_obligations))
617
+ ? ov.govern.jurisdiction_obligations
618
+ : (govern.jurisdiction_obligations || []);
619
+ const effectiveKeys = new Set(overrideObligations.map(obligationKey));
620
+ for (const [j, na] of ov.close.notification_actions.entries()) {
621
+ if (!na || typeof na !== 'object') continue;
622
+ if (na.obligation_ref && !effectiveKeys.has(na.obligation_ref)) {
623
+ warn(
624
+ `${label}.phase_overrides.close.notification_actions[${j}].obligation_ref: unresolved "${na.obligation_ref}" — no matching jurisdiction_obligations entry (synthesized as "<jurisdiction>/<regulation> <window_hours>h")`,
625
+ );
626
+ }
627
+ }
628
+ }
629
+ }
630
+ }
631
+
582
632
  return findings;
633
+
634
+ // ---- local helpers (hoisted; close over `findings`/`ctx`/`err`) ----
635
+
636
+ // rwep_threshold ordering + range. close <= monitor <= escalate, each in
637
+ // 0..100. Error severity — a misordered or out-of-range threshold actively
638
+ // breaks the scoring path. `pathPrefix` keeps the message accurate whether
639
+ // the source is the base phase or a directive override.
640
+ function checkRwepThreshold(rwepObj, pathPrefix) {
641
+ const rwep = rwepObj || {};
642
+ if (
643
+ typeof rwep.close === 'number' &&
644
+ typeof rwep.monitor === 'number' &&
645
+ typeof rwep.escalate === 'number'
646
+ ) {
647
+ if (!(rwep.close <= rwep.monitor && rwep.monitor <= rwep.escalate)) {
648
+ err(
649
+ `${pathPrefix}: ordering violation — expected close <= monitor <= escalate, got close=${rwep.close} monitor=${rwep.monitor} escalate=${rwep.escalate}`,
650
+ );
651
+ }
652
+ for (const [k, v] of [
653
+ ['close', rwep.close],
654
+ ['monitor', rwep.monitor],
655
+ ['escalate', rwep.escalate],
656
+ ]) {
657
+ if (v < 0 || v > 100) {
658
+ err(`${pathPrefix}.${k}: ${v} outside 0..100`);
659
+ }
660
+ }
661
+ }
662
+ }
663
+
664
+ // clock_starts closed-vocabulary check over a jurisdiction_obligations list.
665
+ // Error severity — clock_starts decides when a notification deadline starts
666
+ // counting; an out-of-vocabulary value silently never starts the clock.
667
+ function checkClockStarts(obligations, pathPrefix) {
668
+ if (!ctx.clockStartsEnum || !Array.isArray(obligations)) return;
669
+ for (const [i, o] of obligations.entries()) {
670
+ if (!o || typeof o !== 'object') continue;
671
+ if (o.clock_starts !== undefined && !ctx.clockStartsEnum.has(o.clock_starts)) {
672
+ err(
673
+ `${pathPrefix}[${i}].clock_starts: invalid value ${JSON.stringify(o.clock_starts)} — not in closed vocabulary ${JSON.stringify([...ctx.clockStartsEnum])}`,
674
+ );
675
+ }
676
+ }
677
+ }
583
678
  }
584
679
 
585
680
  /* Cross-playbook mutex-reciprocity check.