@blamejs/exceptd-skills 0.14.11 → 0.14.13

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.
@@ -691,15 +691,26 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
691
691
  }
692
692
  }
693
693
  } else {
694
- // Without an explicit override, treat any captured artifact as evidence
694
+ // An override WAS supplied for this indicator but its value didn't
695
+ // canonicalize to hit/miss/inconclusive (e.g. "maybe", "present", a
696
+ // number). Pre-fix this was silently dropped — the operator believed
697
+ // they'd asserted a result but the signal vanished, yielding a false
698
+ // not_detected. Surface it as a runtime_error (mirrors the
699
+ // classification_override_invalid signal) so the drop is visible.
700
+ if (rawOverride !== undefined && runOpts && Array.isArray(runOpts._runErrors)) {
701
+ pushRunError(runOpts._runErrors, {
702
+ kind: 'signal_override_unrecognized',
703
+ indicator_id: ind.id,
704
+ supplied_value: (typeof rawOverride === 'object') ? JSON.stringify(rawOverride).slice(0, 80) : String(rawOverride).slice(0, 80),
705
+ message: `signal_overrides["${ind.id}"] value was not recognized (expected hit/miss/inconclusive or a boolean); the signal was ignored.`,
706
+ }, { dedupeKey: e => e.indicator_id || '' });
707
+ }
708
+ // Without a usable override, treat any captured artifact as evidence
695
709
  // the indicator could be evaluated. Mark inconclusive if any artifact
696
710
  // was captured (engine doesn't pattern-match raw artifact content; the
697
711
  // host AI is responsible for that). With NO captured artifacts, this is
698
712
  // a clean empty submission — emit 'miss' so the run can reach
699
713
  // classification:'not_detected' rather than getting stuck inconclusive.
700
- // A clean empty run with no captured artifacts must emit 'miss' so
701
- // classification can reach 'not_detected'; otherwise theater_verdict
702
- // stays 'pending_agent_run' indefinitely.
703
714
  const anyCaptured = Object.values(artifacts).some(a => a && a.captured);
704
715
  verdict = anyCaptured ? 'inconclusive' : 'miss';
705
716
  }
@@ -765,12 +776,16 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
765
776
  const anyFpDowngrade = indicatorResults.some(r => Array.isArray(r.fp_checks_unsatisfied) && r.fp_checks_unsatisfied.length > 0);
766
777
 
767
778
  let classification;
779
+ // Track whether the override was actually honored, so we don't report a
780
+ // refused override as "applied" (L6).
781
+ let overrideEffective = !!override;
768
782
  if (override) {
769
783
  classification = override === 'clean' ? 'not_detected' : override;
770
784
  if (anyFpDowngrade) {
771
785
  const substituted = 'inconclusive';
772
786
  const attempted = override; // record what the operator submitted, not the mapped form
773
787
  classification = substituted;
788
+ overrideEffective = false;
774
789
  if (runOpts && Array.isArray(runOpts._runErrors)) {
775
790
  pushRunError(runOpts._runErrors, {
776
791
  kind: 'classification_override_blocked',
@@ -782,6 +797,24 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
782
797
  .map(r => ({ id: r.id, fp_checks_unsatisfied_count: r.fp_checks_unsatisfied.length })),
783
798
  }, { dedupeKey: e => String(e.attempted) });
784
799
  }
800
+ } else if (classification === 'not_detected' && hasDeterministicHit) {
801
+ // A not_detected/clean override must not silently bury a DETERMINISTIC
802
+ // hit. A deterministic indicator firing is high-signal evidence; hiding
803
+ // it as not_detected is a strictly worse false-negative than leaving the
804
+ // run inconclusive. (Probabilistic hits remain overridable — that's the
805
+ // legitimate "I confirmed these are benign" workflow.) Substitute
806
+ // inconclusive and surface why.
807
+ classification = 'inconclusive';
808
+ overrideEffective = false;
809
+ if (runOpts && Array.isArray(runOpts._runErrors)) {
810
+ pushRunError(runOpts._runErrors, {
811
+ kind: 'classification_override_masks_deterministic_hit',
812
+ attempted: override,
813
+ substituted: 'inconclusive',
814
+ reason: 'A not_detected/clean classification override was refused because one or more deterministic indicators fired. A deterministic hit cannot be downgraded to not_detected.',
815
+ deterministic_hit_indicators: hits.filter(r => r.deterministic).map(r => r.id),
816
+ }, { dedupeKey: e => String(e.attempted) });
817
+ }
785
818
  }
786
819
  } else if (hasDeterministicHit || hasHighConfHit) {
787
820
  classification = 'detected';
@@ -818,7 +851,11 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
818
851
  from_observation: agentSubmission._signal_origins?.[i.id] || null,
819
852
  })),
820
853
  indicators_evaluated_count: indicatorResults.length,
821
- classification_override_applied: override ? (override === 'clean' ? 'not_detected' : override) : null,
854
+ // Only report the override as applied when it was actually honored — a
855
+ // refused override (FP-downgrade or deterministic-hit masking) left
856
+ // `classification` as inconclusive, so reporting the attempted value here
857
+ // would contradict the verdict.
858
+ classification_override_applied: (override && overrideEffective) ? (override === 'clean' ? 'not_detected' : override) : null,
822
859
  submission_shape_seen: agentSubmission._original_shape || (agentSubmission.artifacts ? 'nested (v0.10.x)' : 'empty'),
823
860
  // Pass through any flat-shape observation collisions detected at
824
861
  // normalize time so analyze() can publish them under
@@ -2345,7 +2382,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
2345
2382
  }] : [];
2346
2383
  const base = {
2347
2384
  scores,
2348
- threats: c.active_exploitation === 'confirmed' ? [{ category: 'exploit_status', details: 'Active exploitation confirmed (CISA KEV).' }] : [],
2385
+ threats: c.active_exploitation === 'confirmed' ? [{ category: 'exploit_status', details: `Active exploitation confirmed${c.cisa_kev ? ' (CISA KEV)' : ''}.` }] : [],
2349
2386
  remediations,
2350
2387
  product_status: isFixed ? { fixed: [productId] } : { known_affected: [productId] }
2351
2388
  };
@@ -2473,9 +2510,18 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
2473
2510
  ? { tlp: { label: CSAF_TLP_LABEL[runOpts.tlp] }, text: `TLP:${runOpts.tlp}` }
2474
2511
  : null;
2475
2512
 
2513
+ // CSAF 2.0: an advisory with zero vulnerabilities is a csaf_informational_advisory
2514
+ // (Profile 5, which does not require /vulnerabilities) rather than a
2515
+ // csaf_security_advisory (Profile 4, where an empty vulnerabilities array is
2516
+ // semantically wrong and warns under strict profile validators). A clean run
2517
+ // becomes an informational attestation; any firing CVE/indicator keeps the
2518
+ // security-advisory category.
2519
+ const csafCategory = (cveVulns.length + indicatorVulns.length) > 0
2520
+ ? 'csaf_security_advisory'
2521
+ : 'csaf_informational_advisory';
2476
2522
  return {
2477
2523
  document: {
2478
- category: 'csaf_security_advisory',
2524
+ category: csafCategory,
2479
2525
  csaf_version: '2.0',
2480
2526
  publisher: publisherBlock,
2481
2527
  title: `exceptd finding: ${playbook.domain.name} (${analyze.matched_cves.length} CVE(s), ${indicatorHits.length} indicator hit(s), ${(analyze.framework_gap_mapping || []).length} framework gap(s))`,
@@ -2562,20 +2608,29 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
2562
2608
  // every rule definition is unambiguously attributable to one playbook,
2563
2609
  // and cross-playbook merges retain all results.
2564
2610
  const rulePrefix = `${playbookSlug}/`;
2565
- const cveResults = analyze.matched_cves.map(c => ({
2566
- ruleId: `${rulePrefix}${c.cve_id}`,
2567
- level: c.rwep >= 90 ? 'error' : c.rwep >= 70 ? 'warning' : 'note',
2568
- message: { text: `${c.cve_id}: RWEP ${c.rwep}, blast_radius ${analyze.blast_radius_score}. ${validate.selected_remediation?.description || ''}` },
2569
- properties: stripNulls({
2570
- kind: 'cve_match',
2571
- rwep: c.rwep,
2572
- cisa_kev: c.cisa_kev,
2573
- cisa_kev_due_date: c.cisa_kev_due_date ?? null,
2574
- active_exploitation: c.active_exploitation ?? null,
2575
- ai_discovered: c.ai_discovered ?? null,
2576
- blast_radius_score: analyze.blast_radius_score,
2577
- }),
2578
- }));
2611
+ // CVE-match results get the coarse playbook-source location fallback
2612
+ // (passing a null indicator skips the per-indicator evidence-locations
2613
+ // branch). Without any `locations`, GitHub Code Scanning silently DROPS
2614
+ // these results the highest-severity result class would never surface.
2615
+ const cveFallbackLocs = sarifLocationsForIndicator(playbook, null);
2616
+ const cveResults = analyze.matched_cves.map(c => {
2617
+ const result = {
2618
+ ruleId: `${rulePrefix}${c.cve_id}`,
2619
+ level: c.rwep >= 90 ? 'error' : c.rwep >= 70 ? 'warning' : 'note',
2620
+ message: { text: `${c.cve_id}: RWEP ${c.rwep}, blast_radius ${analyze.blast_radius_score == null ? 'not assessed' : analyze.blast_radius_score}. ${validate.selected_remediation?.description || ''}` },
2621
+ properties: stripNulls({
2622
+ kind: 'cve_match',
2623
+ rwep: c.rwep,
2624
+ cisa_kev: c.cisa_kev,
2625
+ cisa_kev_due_date: c.cisa_kev_due_date ?? null,
2626
+ active_exploitation: c.active_exploitation ?? null,
2627
+ ai_discovered: c.ai_discovered ?? null,
2628
+ blast_radius_score: analyze.blast_radius_score,
2629
+ }),
2630
+ };
2631
+ if (cveFallbackLocs) result.locations = cveFallbackLocs;
2632
+ return result;
2633
+ });
2579
2634
  const indicatorHits = (analyze._detect_indicators || []).filter(i => i.verdict === 'hit');
2580
2635
  const indicatorResults = indicatorHits.map(i => {
2581
2636
  const locs = sarifLocationsForIndicator(playbook, i);
@@ -2696,7 +2751,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
2696
2751
  vulnerability: { '@id': vulnIdToUrn(c.cve_id), name: c.cve_id },
2697
2752
  products: [productEntry],
2698
2753
  timestamp: issued,
2699
- impact_statement: `RWEP ${c.rwep}. Blast radius ${analyze.blast_radius_score}/5.`,
2754
+ impact_statement: `RWEP ${c.rwep}. ${analyze.blast_radius_score == null ? 'Blast radius not assessed.' : `Blast radius ${analyze.blast_radius_score}/5.`}`,
2700
2755
  };
2701
2756
  if (c.vex_status === 'fixed') {
2702
2757
  stmt.status = 'fixed';
@@ -2767,11 +2822,14 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
2767
2822
  vexAuthor = vexOperatorClean;
2768
2823
  } else {
2769
2824
  vexAuthor = 'urn:exceptd:operator:unknown';
2825
+ // Same shape + singleton dedupe as the CSAF path so a multi-format emit
2826
+ // produces one canonical bundle_publisher_unclaimed entry that machine
2827
+ // consumers can read consistently (reason/remediation, not message).
2770
2828
  pushRunError(runOpts._runErrors, {
2771
2829
  kind: 'bundle_publisher_unclaimed',
2772
- format: 'openvex',
2773
- message: 'OpenVEX author falls back to urn:exceptd:operator:unknown supply runOpts.operator or runOpts.publisherNamespace to claim disposition attribution.',
2774
- });
2830
+ reason: 'OpenVEX author fell back to urn:exceptd:operator:unknown because no --publisher-namespace and no URL-shaped --operator were supplied. Disposition attribution is unclaimed on this VEX document.',
2831
+ remediation: 'Re-run with --publisher-namespace <https-url> (or a URL-shaped --operator).'
2832
+ }, { dedupeKey: () => 'singleton' });
2775
2833
  }
2776
2834
  return {
2777
2835
  '@context': 'https://openvex.dev/ns/v0.2.0',
@@ -3356,11 +3414,22 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
3356
3414
  * through directly. Avoids JSON.stringify's replacer indirection because
3357
3415
  * a top-level array would otherwise miss the canonicalization recursion.
3358
3416
  */
3359
- function canonicalStringify(v) {
3417
+ const CANONICAL_MAX_DEPTH = 200;
3418
+ function canonicalStringify(v, _depth = 0) {
3360
3419
  if (v === null || typeof v !== 'object') return JSON.stringify(v);
3361
- if (Array.isArray(v)) return '[' + v.map(canonicalStringify).join(',') + ']';
3420
+ // Bounded recursion: adversarial (or accidental) deeply-nested evidence
3421
+ // would otherwise overflow the stack with an opaque "internal error". This
3422
+ // runs on every run() (evidence_hash + session_id derivation), so the guard
3423
+ // turns a crash into an actionable rejection. 200 levels is far beyond any
3424
+ // legitimate submission.
3425
+ if (_depth > CANONICAL_MAX_DEPTH) {
3426
+ const e = new Error(`evidence nesting exceeds the maximum depth of ${CANONICAL_MAX_DEPTH} — flatten the submission`);
3427
+ e.code = 'EVIDENCE_TOO_DEEP';
3428
+ throw e;
3429
+ }
3430
+ if (Array.isArray(v)) return '[' + v.map(x => canonicalStringify(x, _depth + 1)).join(',') + ']';
3362
3431
  const keys = Object.keys(v).sort();
3363
- return '{' + keys.map(k => JSON.stringify(k) + ':' + canonicalStringify(v[k])).join(',') + '}';
3432
+ return '{' + keys.map(k => JSON.stringify(k) + ':' + canonicalStringify(v[k], _depth + 1)).join(',') + '}';
3364
3433
  }
3365
3434
 
3366
3435
  /**
package/lib/prefetch.js CHANGED
@@ -121,6 +121,12 @@ function parseArgs(argv) {
121
121
  else if (a.startsWith("--max-age=")) out.maxAgeMs = parseDuration(a.slice("--max-age=".length));
122
122
  else if (a === "--cache-dir") out.cacheDir = path.resolve(argv[++i]);
123
123
  else if (a.startsWith("--cache-dir=")) out.cacheDir = path.resolve(a.slice("--cache-dir=".length));
124
+ // Any remaining --flag is an unrecognized typo. Record it; main() refuses
125
+ // before any network work rather than silently dropping it.
126
+ else if (typeof a === "string" && a.startsWith("--")) {
127
+ const base = a.indexOf("=") === -1 ? a : a.slice(0, a.indexOf("="));
128
+ (out._unknownFlags || (out._unknownFlags = [])).push(base);
129
+ }
124
130
  }
125
131
  // The global air-gap switch implies a report-only / no-egress run: treat
126
132
  // EXCEPTD_AIR_GAP=1 the same as --no-network so prefetch never plans live
@@ -650,12 +656,36 @@ function readCached(cacheDir, source, id, opts = {}) {
650
656
  }
651
657
  }
652
658
 
659
+ // Known --flag base names prefetch accepts. Drives the unknown-flag error
660
+ // message's known list.
661
+ const PREFETCH_KNOWN_FLAGS = Object.freeze([
662
+ "--force", "--no-network", "--dry-run", "--air-gap", "--quiet", "--help", "-h",
663
+ "--source", "--max-age", "--cache-dir",
664
+ ]);
665
+
653
666
  async function main() {
654
667
  const opts = parseArgs(process.argv);
655
668
  if (opts.help) {
656
669
  printHelp();
657
670
  return;
658
671
  }
672
+
673
+ // Reject unknown flags BEFORE any network work. A swallowed typo (e.g.
674
+ // `--max-aeg 12h`) previously fell through to a default full-cache fetch.
675
+ // Exit 2 matches prefetch's existing usage-error convention (invalid
676
+ // --source / --max-age also surface as exit 2 via main()'s catch).
677
+ if (Array.isArray(opts._unknownFlags) && opts._unknownFlags.length > 0) {
678
+ const uniq = [...new Set(opts._unknownFlags)];
679
+ process.stderr.write(JSON.stringify({
680
+ ok: false,
681
+ verb: "prefetch",
682
+ error: `prefetch: unknown flag(s): ${uniq.join(", ")}`,
683
+ unknown_flags: uniq,
684
+ known_flags: PREFETCH_KNOWN_FLAGS,
685
+ }) + "\n");
686
+ process.exitCode = 2;
687
+ return;
688
+ }
659
689
  // Why process.exitCode and not process.exit():
660
690
  // On Windows + Node 25 (libuv), calling process.exit() synchronously
661
691
  // while in-flight fetch / AbortController teardown is still mid-close
@@ -109,6 +109,22 @@ function parseArgs(argv) {
109
109
  // older than 7d or one that was prefetched without a signing keypair.
110
110
  // EXCEPTD_FORCE_STALE=1 mirrors for non-interactive automation.
111
111
  else if (a === "--force-stale") out.forceStale = true;
112
+ // Aliases that bin/exceptd.js may pass through or translate; accept them
113
+ // here so the unknown-flag guard below doesn't false-reject a legitimate
114
+ // operator invocation. (--no-network / --indexes-only / --network /
115
+ // --curate / --prefetch are normally rewritten upstream, but tolerate
116
+ // them when refresh-external is invoked directly.)
117
+ else if (
118
+ a === "--no-network" || a === "--prefetch" || a === "--indexes-only" ||
119
+ a === "--network" || a === "--curate" || a === "--force-stale-acked"
120
+ ) { /* accepted, no-op at this layer */ }
121
+ // Any remaining --flag is an unrecognized typo. Record it; refuse after
122
+ // the loop rather than silently dropping it into a default full-refresh
123
+ // (which previously hit the live network on every source).
124
+ else if (typeof a === "string" && a.startsWith("--")) {
125
+ const base = a.indexOf("=") === -1 ? a : a.slice(0, a.indexOf("="));
126
+ (out._unknownFlags || (out._unknownFlags = [])).push(base);
127
+ }
112
128
  }
113
129
  if (process.env.EXCEPTD_FORCE_STALE === "1") out.forceStale = true;
114
130
  // Report-only is intrinsic to the advisory poll regardless of flag order —
@@ -1423,6 +1439,15 @@ async function seedSingleAdvisory(opts) {
1423
1439
  process.exitCode = 3;
1424
1440
  }
1425
1441
 
1442
+ // Known --flag base names refresh accepts (operator-facing surface + the
1443
+ // bin-translated aliases). Drives the unknown-flag error message's known list.
1444
+ const REFRESH_KNOWN_FLAGS = Object.freeze([
1445
+ "--apply", "--quiet", "--swarm", "--json", "--help", "-h", "--advisory",
1446
+ "--check-advisories", "--catalog", "--from-cache", "--source", "--from-fixture",
1447
+ "--report-out", "--air-gap", "--force-stale", "--force-stale-acked",
1448
+ "--no-network", "--prefetch", "--indexes-only", "--network", "--curate",
1449
+ ]);
1450
+
1426
1451
  async function main() {
1427
1452
  const opts = parseArgs(process.argv);
1428
1453
  if (opts.help) {
@@ -1432,6 +1457,22 @@ async function main() {
1432
1457
  return;
1433
1458
  }
1434
1459
 
1460
+ // Reject unknown flags BEFORE any network / catalog work. A swallowed typo
1461
+ // (e.g. `--aply`) previously fell through to a default all-sources live
1462
+ // refresh. Exit 2 matches refresh's own scheme (2 = error / unknown source).
1463
+ if (Array.isArray(opts._unknownFlags) && opts._unknownFlags.length > 0) {
1464
+ const uniq = [...new Set(opts._unknownFlags)];
1465
+ process.stderr.write(JSON.stringify({
1466
+ ok: false,
1467
+ verb: "refresh",
1468
+ error: `refresh: unknown flag(s): ${uniq.join(", ")}`,
1469
+ unknown_flags: uniq,
1470
+ known_flags: REFRESH_KNOWN_FLAGS,
1471
+ }) + "\n");
1472
+ process.exitCode = 2;
1473
+ return;
1474
+ }
1475
+
1435
1476
  // v0.12.0: `--advisory <id>` short-circuits the normal source loop and
1436
1477
  // seeds a single CVE catalog entry from GHSA. Exits non-zero ("draft
1437
1478
  // written, please review") so CI pipelines surface the needed editorial
package/lib/rfc-cli.js CHANGED
@@ -57,7 +57,12 @@ const { resolveRfc } = require("./citation-resolve.js");
57
57
  const a = norm(claimedTitle), b = norm(r.title);
58
58
  titleMatch = a.length > 0 && (b.includes(a) || a.includes(b));
59
59
  }
60
- const body = { ok: true, verb: "rfc", ...r, ...(claimedTitle ? { claimed_title: claimedTitle, title_match: titleMatch } : {}) };
60
+ // Derive `ok` from the resolved status + title-check the same way the exit
61
+ // code is derived below — a non-zero exit (status nonexistent OR an explicit
62
+ // title mismatch) must carry ok:false, not the inverted ok:true the envelope
63
+ // previously hardcoded.
64
+ const fails = r.status === "nonexistent" || titleMatch === false;
65
+ const body = { verb: "rfc", ...r, ...(claimedTitle ? { claimed_title: claimedTitle, title_match: titleMatch } : {}), ok: !fails };
61
66
 
62
67
  if (json) {
63
68
  process.stdout.write(JSON.stringify(body, null, pretty ? 2 : 0) + "\n");
@@ -77,5 +82,5 @@ const { resolveRfc } = require("./citation-resolve.js");
77
82
  process.stdout.write(line + "\n");
78
83
  }
79
84
  // A mismatched or nonexistent citation is a non-zero exit for gates.
80
- if (r.status === "nonexistent" || titleMatch === false) process.exitCode = 2;
85
+ if (fails) process.exitCode = 2;
81
86
  })();
@@ -151,7 +151,7 @@
151
151
  "attack_refs": { "type": "array", "items": { "type": "string", "pattern": "^T\\d{4}(\\.\\d{3})?$" } },
152
152
  "cve_refs": { "type": "array", "items": { "type": "string", "pattern": "^CVE-\\d{4}-\\d{4,}$" }, "description": "All CVEs must exist in data/cve-catalog.json." },
153
153
  "cwe_refs": { "type": "array", "items": { "type": "string", "pattern": "^CWE-\\d+$" } },
154
- "d3fend_refs": { "type": "array", "items": { "type": "string" } },
154
+ "d3fend_refs": { "type": "array", "items": { "type": "string", "pattern": "^D3-[A-Z]+$" } },
155
155
  "frameworks_in_scope": {
156
156
  "type": "array",
157
157
  "items": {
@@ -300,6 +300,7 @@
300
300
  "properties": {
301
301
  "artifacts": {
302
302
  "type": "array",
303
+ "minItems": 1,
303
304
  "items": {
304
305
  "type": "object",
305
306
  "required": ["id", "type", "source", "description", "required"],
@@ -364,6 +365,7 @@
364
365
  "properties": {
365
366
  "indicators": {
366
367
  "type": "array",
368
+ "minItems": 1,
367
369
  "items": {
368
370
  "type": "object",
369
371
  "required": ["id", "type", "value", "confidence", "deterministic"],
package/lib/scoring.js CHANGED
@@ -132,7 +132,14 @@ function validateFactors(factors) {
132
132
  if (factors.blast_radius === undefined || factors.blast_radius === null) {
133
133
  warnings.push('blast_radius: missing (treated as 0)');
134
134
  } else if (typeof factors.blast_radius !== 'number') {
135
- warnings.push(`blast_radius: expected number, got ${typeof factors.blast_radius} (${JSON.stringify(factors.blast_radius)})`);
135
+ // scoreCustom coerces a numeric string (e.g. "30") via Number(); keep the
136
+ // two surfaces consistent — accept a finite numeric string with a soft note
137
+ // rather than rejecting what the scorer will happily use.
138
+ if (typeof factors.blast_radius === 'string' && Number.isFinite(Number(factors.blast_radius)) && factors.blast_radius.trim() !== '') {
139
+ warnings.push(`blast_radius: numeric string "${factors.blast_radius}" accepted (coerced to ${Number(factors.blast_radius)}); prefer a JSON number`);
140
+ } else {
141
+ warnings.push(`blast_radius: expected number, got ${typeof factors.blast_radius} (${JSON.stringify(factors.blast_radius)})`);
142
+ }
136
143
  } else if (Number.isNaN(factors.blast_radius)) {
137
144
  warnings.push('blast_radius: NaN is not a valid numeric value (treated as 0)');
138
145
  } else if (!Number.isFinite(factors.blast_radius)) {
@@ -16,15 +16,25 @@
16
16
  * phases.direct.skill_chain[].skill — both are resolved)
17
17
  * - phases.govern.skill_preload[] → manifest.json.skills[]
18
18
  * - domain.atlas_refs[] → data/atlas-ttps.json keys
19
+ * - domain.attack_refs[] → data/attack-techniques.json keys
19
20
  * - domain.cve_refs[] → data/cve-catalog.json keys
20
21
  * - domain.cwe_refs[] → data/cwe-catalog.json keys
21
22
  * - domain.d3fend_refs[] → data/d3fend-catalog.json keys
22
23
  * - phases.detect.indicators[].attack_ref → data/attack-techniques.json
23
24
  * - phases.detect.indicators[].atlas_ref → data/atlas-ttps.json
24
25
  * - phases.detect.indicators[].cve_ref → data/cve-catalog.json
26
+ * - phases.detect.false_positive_profile[].indicator_id
27
+ * → phases.detect.indicators[].id
25
28
  *
26
29
  * Internal consistency:
27
30
  * - Indicator ids are unique within a playbook.
31
+ * - Every playbook maps to at least one TTP via domain.atlas_refs or
32
+ * domain.attack_refs (the cross-cutting correlation layer is exempt).
33
+ * - When _meta.air_gap_mode is true, network-sourced look.artifacts carry
34
+ * a non-empty air_gap_alternative (error if missing).
35
+ * - Closed controlled vocabularies (jurisdiction_obligations[].clock_starts,
36
+ * domain.frameworks_in_scope[]) are enforced at error severity, unlike the
37
+ * evolving-drift enums (artifact/indicator `type`) which stay warnings.
28
38
  * - rwep_threshold ordering: close <= monitor <= escalate, each in 0..100.
29
39
  * - close.notification_actions[].obligation_ref resolves to a synthesized
30
40
  * "<jurisdiction>/<regulation> <window_hours>h" key from
@@ -229,6 +239,29 @@ function loadContext() {
229
239
  const d3 = readJson(D3FEND_PATH);
230
240
  const attack = readJsonIfExists(ATTACK_PATH);
231
241
 
242
+ // Closed controlled-vocabulary enums sourced from the schema so the
243
+ // hard-error enum checks in checkCrossRefs stay in lockstep with the
244
+ // schema's own enum lists. A typo'd clock_starts must hard-fail the
245
+ // predeploy gate (it changes when a notification clock starts ticking),
246
+ // so unlike evolving-drift enums (artifact/indicator `type`) these are
247
+ // promoted to error severity rather than left as generic-validator
248
+ // warnings.
249
+ let clockStartsEnum = null;
250
+ let frameworksEnum = null;
251
+ try {
252
+ const schema = readJson(SCHEMA_PATH);
253
+ clockStartsEnum =
254
+ schema.properties.phases.properties.govern.properties
255
+ .jurisdiction_obligations.items.properties.clock_starts.enum || null;
256
+ frameworksEnum =
257
+ schema.properties.domain.properties.frameworks_in_scope.items.enum || null;
258
+ } catch {
259
+ // If the schema shape changes, fall back to no closed-vocab enforcement
260
+ // here (the generic validator still emits warnings).
261
+ clockStartsEnum = null;
262
+ frameworksEnum = null;
263
+ }
264
+
232
265
  return {
233
266
  skillKeys: new Set(manifest.skills.map((s) => s.name)),
234
267
  atlasKeys: new Set(Object.keys(atlas).filter((k) => !k.startsWith('_'))),
@@ -238,6 +271,8 @@ function loadContext() {
238
271
  attackKeys: attack
239
272
  ? new Set(Object.keys(attack).filter((k) => !k.startsWith('_')))
240
273
  : null,
274
+ clockStartsEnum: clockStartsEnum ? new Set(clockStartsEnum) : null,
275
+ frameworksEnum: frameworksEnum ? new Set(frameworksEnum) : null,
241
276
  };
242
277
  }
243
278
 
@@ -311,6 +346,15 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
311
346
  warn(`domain.atlas_refs: unresolved "${a}" (not in data/atlas-ttps.json)`);
312
347
  }
313
348
  }
349
+ // Hard Rule #4 ("no orphaned controls"): domain.attack_refs must resolve to
350
+ // the ATT&CK technique catalog, mirroring the atlas_refs block above and the
351
+ // detect.indicators[].attack_ref check below. Without this, every TTP listed
352
+ // at the domain level bypassed catalog cross-referencing.
353
+ for (const a of domain.attack_refs || []) {
354
+ if (ctx.attackKeys && !ctx.attackKeys.has(a)) {
355
+ warn(`domain.attack_refs: unresolved "${a}" (not in data/attack-techniques.json)`);
356
+ }
357
+ }
314
358
  for (const c of domain.cve_refs || []) {
315
359
  if (!ctx.cveKeys.has(c)) {
316
360
  warn(`domain.cve_refs: unresolved "${c}" (not in data/cve-catalog.json)`);
@@ -359,6 +403,19 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
359
403
  }
360
404
  }
361
405
 
406
+ // false_positive_profile[].indicator_id must reference a real indicator id.
407
+ // A dangling reference means an FP-distinguishing test is wired to nothing,
408
+ // so the runner can never apply it. Warning severity (vocabulary-style
409
+ // drift, not a structural break).
410
+ for (const [i, fp] of (detect.false_positive_profile || []).entries()) {
411
+ if (!fp || typeof fp !== 'object') continue;
412
+ if (fp.indicator_id && !indIds.has(fp.indicator_id)) {
413
+ warn(
414
+ `phases.detect.false_positive_profile[${i}].indicator_id: unresolved "${fp.indicator_id}" — no matching phases.detect.indicators[].id`,
415
+ );
416
+ }
417
+ }
418
+
362
419
  // rwep_threshold ordering. Hard error — a misordered threshold actively
363
420
  // breaks the scoring path.
364
421
  const rwep = direct.rwep_threshold || {};
@@ -397,6 +454,68 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
397
454
  }
398
455
  }
399
456
 
457
+ // Air-gap completeness. When _meta.air_gap_mode is true the runner refuses
458
+ // to touch the network, so every artifact whose source is a network call
459
+ // (https://, http://, gh api, gh release, curl, wget, fetch) MUST carry a
460
+ // non-empty air_gap_alternative or the run is silently incomplete. The
461
+ // schema encodes this as an allOf/if/then block, but the inline validator
462
+ // does not implement conditional keywords, so it is enforced imperatively
463
+ // here at error severity.
464
+ if (meta.air_gap_mode === true) {
465
+ const look = phases.look || {};
466
+ const netSourceRe = /(https?:\/\/|gh api|gh release|curl |wget |fetch )/;
467
+ for (const [i, art] of (look.artifacts || []).entries()) {
468
+ if (!art || typeof art !== 'object') continue;
469
+ if (typeof art.source === 'string' && netSourceRe.test(art.source)) {
470
+ const alt = art.air_gap_alternative;
471
+ if (typeof alt !== 'string' || alt.trim().length === 0) {
472
+ err(
473
+ `phases.look.artifacts[${i}]: _meta.air_gap_mode is true and source "${art.source}" makes a network call, but no non-empty air_gap_alternative is set — the artifact cannot be collected offline`,
474
+ );
475
+ }
476
+ }
477
+ }
478
+ }
479
+
480
+ // TTP-mapping floor (Hard Rule #4): every playbook must map to at least one
481
+ // adversary technique via domain.atlas_refs OR domain.attack_refs. The sole
482
+ // exemption is the cross-cutting correlation layer (_meta.scope ===
483
+ // "cross-cutting"), which has no first-party TTPs — it correlates findings
484
+ // produced by the other playbooks. Error severity for everything else.
485
+ const atlasCount = (domain.atlas_refs || []).length;
486
+ const attackCount = (domain.attack_refs || []).length;
487
+ if (atlasCount === 0 && attackCount === 0 && meta.scope !== 'cross-cutting') {
488
+ err(
489
+ 'domain: no TTP mapping — at least one of domain.atlas_refs or domain.attack_refs must be non-empty (cross-cutting correlation playbooks are exempt)',
490
+ );
491
+ }
492
+
493
+ // Closed controlled-vocabulary enums: a value outside the schema's closed
494
+ // enum is an error, not a warning, so a typo cannot ship. Unlike the
495
+ // evolving-drift enums (artifact `type`, indicator `type`) which the generic
496
+ // validator keeps at warning, these vocabularies are fixed and load-bearing
497
+ // (clock_starts decides when a notification deadline starts counting;
498
+ // frameworks_in_scope drives gap-analysis routing).
499
+ if (ctx.clockStartsEnum) {
500
+ for (const [i, o] of (govern.jurisdiction_obligations || []).entries()) {
501
+ if (!o || typeof o !== 'object') continue;
502
+ if (o.clock_starts !== undefined && !ctx.clockStartsEnum.has(o.clock_starts)) {
503
+ err(
504
+ `phases.govern.jurisdiction_obligations[${i}].clock_starts: invalid value ${JSON.stringify(o.clock_starts)} — not in closed vocabulary ${JSON.stringify([...ctx.clockStartsEnum])}`,
505
+ );
506
+ }
507
+ }
508
+ }
509
+ if (ctx.frameworksEnum) {
510
+ for (const [i, f] of (domain.frameworks_in_scope || []).entries()) {
511
+ if (typeof f === 'string' && !ctx.frameworksEnum.has(f)) {
512
+ err(
513
+ `domain.frameworks_in_scope[${i}]: invalid value ${JSON.stringify(f)} — not in closed vocabulary`,
514
+ );
515
+ }
516
+ }
517
+ }
518
+
400
519
  return findings;
401
520
  }
402
521