@blamejs/exceptd-skills 0.18.7 → 0.18.9

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/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.18.9 — 2026-06-20
4
+
5
+ Internal: the refresh tarball pull and the vendor-source online check build their request URL entirely from constant and individually-validated bindings — a GitHub-name-shaped owner/repo, a hex commit id, a traversal-free path, and a strict-semver version — and the request is built from those validated bindings directly, so no unvalidated registry-metadata or provenance value reaches the outbound request. Behavior is unchanged; this tightens the data-flow into the fetchers.
6
+
7
+ ## 0.18.8 — 2026-06-20
8
+
9
+ Signature verification now fails closed on the two states an attacker with write access to `keys/` can engineer: a missing `keys/public.pem` makes `loadManifestValidated()` throw rather than return an unverified manifest, and a missing `keys/EXPECTED_FINGERPRINT` makes the manifest-signature check refuse rather than verify against a possibly-swapped key. The pin ships in the tarball and is committed, so its absence is treated as tampering. A missing pin fails closed unconditionally and the error directs you to restore the committed pin; `KEYS_ROTATED=1` governs a key-fingerprint mismatch (a deliberate rotation), which is the only state it applies to — a rotation updates the pin in place, it never removes it.
10
+
11
+ The compliance close phase no longer crashes when a jurisdiction obligation entry is malformed: a null/non-object entry is skipped during notification enrichment and synthesis instead of throwing; an obligation whose `window_hours` is not a finite number falls back to the pending-clock sentinel instead of computing an invalid date and aborting the phase; a `notification_action` whose `obligation_ref` resolves to nothing is dropped (the unmatched ref is surfaced as a diagnostic) rather than emitted with null jurisdiction/regulation; and a notify obligation with a non-number `window_hours` is surfaced as a diagnostic and skipped rather than synthesized into a malformed record. The threat-currency gate hard-blocks a non-numeric `threat_currency_score` (absent / null / NaN) instead of letting it slip past as "not less than 50"; `forceStale` still overrides. Air-gap mode flags an artifact that has no `air_gap_alternative` so its fall-back to the original (possibly network-bound) source is visible instead of a silent bypass. A CVE marked both `not_affected` and `fixed` in a VEX statement no longer appears as fixed while absent from the matched/baseline sets.
12
+
13
+ The `theater_score` exposed to `feeds_into` / escalation conditions now follows the same direction as the rest of the engine — a `theater` verdict (a compliance gap exists) scores high and a clear verdict scores low — so a condition such as `theater_score >= 50` chains exactly when a gap is found rather than the reverse.
14
+
15
+ RWEP scoring is consistent across its three surfaces: the derived score counts the `reboot_required` / `patch_required_reboot` alias once (no double-count), `validateFactors` normalizes `active_exploitation` and accepts the `patch_required_reboot` alias (matching the scorer), and the `compare()` explanation lists the AI factor for `ai_assisted_weaponization`, a stray-cased confirmed exploitation, and the reboot alias.
16
+
17
+ The orchestrator CSAF report emits a real `cvss_v3` block (base score + vector) for a catalogued CVE instead of a hardcoded `base_score: 0`, and never emits a null description or threat detail. The dispatch plan omits the per-entry `evidence` object when it has no content rather than emitting a bare `{}`, and it now keeps a plan entry for every distinct finding that routes to a skill — two different CVEs that route to the same skill each appear with their own evidence instead of all but the first being silently dropped; a parse-error finding routes directly to its skill rather than relying solely on the domain table. `attest diff`'s human output classifies the sidecar by tamper class first, so an unsigned-substitution attestation is not mislabeled as benign explicitly-unsigned, and prints `(no detail)` instead of `(null)` for an absent timestamp.
18
+
19
+ Empty-string flag values are rejected instead of silently degrading the command: `--mode ""`, `brief --phase ""`, and `--playbook ""` error rather than running with no scope. The catalog and playbook validators fail loud when a reference catalog or the playbook schema is absent, instead of silently skipping the framework-control, attack-ref, clock-start, and frameworks-in-scope checks — a partial tree can no longer pass an unresolvable reference or an invalid closed-vocabulary value.
20
+
21
+ `refresh --network`'s upstream prefetch retries a timed-out request (re-classified as a retryable network error) instead of dropping it, so a slow or rate-limiting upstream no longer pushes the scheduled refresh past its error budget. The tarball fetch builds its destination from the canonical npm URL plus a strict-semver-validated version rather than the registry-supplied `dist.tarball`, so a tampered metadata response cannot steer the download's host or path (content trust is unchanged: SHA-512/SRI + shasum + Ed25519); the vendor-source online check likewise validates every URL component (owner/repo shape, hex commit, traversal-free path) before fetching.
22
+
23
+ The publish-workflow audit now flags a floating (non-SHA) action reference even when the `uses:` line carries a trailing YAML comment — previously such refs were silently missed, reading as compliant.
24
+
25
+ The index gates fail closed on a degraded tree: a source that vanished between discovery and hashing, or a corrupted hash entry, is reported rather than crashing the validation; and `build-indexes --only` preserves the prior source hashes so a partial rebuild can no longer record stale outputs as fresh. Index files are written atomically.
26
+
27
+ Internal: the host OS-release lookup reads the file directly instead of spawning `cat`; the orchestrator watch-lock and the playbook mutex lock files are created with an explicit owner-only mode; the evidence-dir entry is opened through a single no-follow descriptor and validated on that descriptor (no check-then-reopen window); the index-output validator and a lock-reentrancy test read through a single descriptor; and a help-text assertion uses a literal substring instead of a partially-escaped regex.
28
+
3
29
  ## 0.18.7 — 2026-06-20
4
30
 
5
31
  Escalation and feeds_into conditions that use an `any`/`all` quantifier prefix, a quoted `matches '…'` pattern, or a quoted `contains` member now evaluate instead of silently failing closed. Several catalog chains that depended on these forms — most visibly the compliance-theater → SBOM correlation — were dead and now fire; the feeds_into evaluation context also exposes the bare `blast_radius_score`, `theater_verdict`, and `compliance_theater_check` tokens the catalog references, so an engine-computed value is no longer lost to a `null` lookup. The SBOM → kernel / MCP / AI-API follow-ups now fire too: a matched CVE carries an `attack_class`, the analyze and close contexts expose the matched-CVE array the `any matched_cve.attack_class == …` rules quantify over, and the CVEs that map unambiguously to one of those deep-dive classes are tagged in the catalog — so an SBOM scan that surfaces an MCP-server RCE or a prompt-injection-to-RCE routes the operator into the matching deep dive, while an unclassified match routes nowhere rather than guessing. `IN […]` member lists, AND/OR joins, and outer parentheses are parsed quote-aware, so a comma, bracket, or operator inside a quoted value no longer tears the clause, and a `contains`/`IN` against a path that doesn't resolve records a diagnostic instead of evaluating to an invisible false.
package/bin/exceptd.js CHANGED
@@ -1469,9 +1469,11 @@ function dispatchPlaybook(cmd, argv) {
1469
1469
  }
1470
1470
  runOpts.session_key = args["session-key"];
1471
1471
  }
1472
- if (args.mode) {
1472
+ if (args.mode !== undefined) {
1473
1473
  // Bug #32: validate --mode against the accepted set. Previously
1474
- // `--mode garbage` was silently accepted.
1474
+ // `--mode garbage` was silently accepted. Gate on `!== undefined` (not
1475
+ // truthiness) so `--mode ""` is also rejected by the set check below rather
1476
+ // than silently slipping past as a falsy value.
1475
1477
  const VALID_MODES = ["self_service", "authorized_pentest", "ir_response", "ctf", "research", "compliance_audit"];
1476
1478
  if (!VALID_MODES.includes(args.mode)) {
1477
1479
  // v0.13.2: did-you-mean on flag-value typos (Levenshtein ≤ 2).
@@ -2842,7 +2844,10 @@ function cmdLint(runner, args, runOpts, pretty) {
2842
2844
 
2843
2845
  function cmdBrief(runner, args, runOpts, pretty) {
2844
2846
  const playbookId = args._[0];
2845
- const onlyPhase = args.phase || null;
2847
+ // Preserve an explicit empty string (don't coerce "" -> null) so the
2848
+ // accepted-set check below rejects `--phase ""` instead of silently treating
2849
+ // it as "no filter" and emitting the full brief. Only an OMITTED flag is null.
2850
+ const onlyPhase = args.phase === undefined ? null : args.phase;
2846
2851
 
2847
2852
  // v0.12.9 (P2 #7 from production smoke): refuse garbage values to --phase.
2848
2853
  // Pre-v0.12.9 `brief secrets --phase foo` silently accepted any string and
@@ -2965,6 +2970,11 @@ function cmdVerifyAttestation(runner, args, runOpts, pretty) {
2965
2970
  }
2966
2971
 
2967
2972
  function cmdPlan(runner, args, runOpts, pretty) {
2973
+ // Reject an empty --playbook value rather than letting the truthy gate below
2974
+ // coerce it to null and silently plan across ALL playbooks (wrong scope).
2975
+ if (args.playbook === "" || (Array.isArray(args.playbook) && args.playbook.some(p => p === ""))) {
2976
+ return emitError("plan: --playbook was given an empty value; pass a playbook id, or omit --playbook to plan across all.", { verb: "plan", flag: "playbook" }, pretty);
2977
+ }
2968
2978
  let playbookIds = args.playbook
2969
2979
  ? (Array.isArray(args.playbook) ? args.playbook : [args.playbook])
2970
2980
  : null;
@@ -4243,55 +4253,83 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
4243
4253
  return emitError(`run: --evidence-dir entry ${f} resolves outside the directory; refusing.`, null, pretty);
4244
4254
  }
4245
4255
  // The path.resolve check above only catches `..` traversal in the
4246
- // joined path; fs.readFileSync(entryPath) still follows symlinks, so
4247
- // a `<pb-id>.json -> /etc/shadow` symlink inside the dir would happily
4248
- // slurp the target. lstat is symlink-aware (it does NOT follow);
4249
- // refuse anything that's not a regular file. Defense in depth on top
4250
- // of the readdir filter — a junction (Windows) or bind-mount can
4251
- // shape-shift in between filter and read.
4252
- let lst;
4253
- try { lst = fs.lstatSync(entryPath); }
4254
- catch (e) {
4255
- return emitError(`run: --evidence-dir entry ${f}: lstat failed: ${e.message}`, null, pretty);
4256
- }
4257
- if (lst.isSymbolicLink()) {
4258
- return emitError(`run: --evidence-dir entry ${f} is a symbolic link; refusing (symlinks bypass the directory-confinement check).`, { entry: f }, pretty);
4259
- }
4260
- if (!lst.isFile()) {
4261
- return emitError(`run: --evidence-dir entry ${f} is not a regular file; refusing.`, { entry: f }, pretty);
4262
- }
4263
- // Windows directory junctions are reparse-point dirs that
4264
- // `lstat().isSymbolicLink()` returns FALSE for (Node treats them as
4265
- // ordinary directories), bypassing the symlink refusal above. Use
4266
- // realpathSync to resolve the entry and confirm it still lives under
4267
- // the resolved evidence-dir — the realpath approach is portable
4268
- // (catches POSIX symlinks too, defense in depth) and works regardless
4269
- // of whether the OS exposes reparse-point bits.
4270
- let realEntry;
4271
- try { realEntry = fs.realpathSync(entryPath); }
4272
- catch (e) {
4273
- return emitError(`run: --evidence-dir entry ${f}: realpath failed: ${e.message}`, null, pretty);
4274
- }
4275
- if (realEntry !== entryPath && !realEntry.startsWith(resolvedDir + path.sep)) {
4276
- return emitError(
4277
- `run: --evidence-dir entry ${f} resolves outside the directory (junction / reparse-point / symlink target). Refusing.`,
4278
- { entry: f, resolved_to: realEntry },
4279
- pretty
4280
- );
4281
- }
4282
- // Hardlink defense in depth: no clean cross-platform refusal exists —
4283
- // hardlinks are indistinguishable from regular files at the inode
4284
- // level. Surface a stderr warning when nlink > 1 so the operator is
4285
- // aware a second name may point at the same file. Not a refusal —
4286
- // legitimate use cases (atomic rename, package-manager dedup) produce
4287
- // nlink > 1 without malicious intent.
4288
- if (lst.nlink > 1) {
4289
- process.stderr.write(`[exceptd run --evidence-dir] WARNING: ${f} has nlink=${lst.nlink}; a hardlink to this file exists elsewhere on the filesystem. Hardlinks cannot be refused cross-platform — confirm the file content is what you expect.\n`);
4256
+ // joined path; reading the path would still follow symlinks, so a
4257
+ // `<pb-id>.json -> /etc/shadow` symlink inside the dir would slurp the
4258
+ // target. Rather than lstat/realpath the PATH and then re-open it
4259
+ // (a check-then-use TOCTOU window), open a single O_NOFOLLOW descriptor
4260
+ // FIRST and make every subsequent decision about that exact descriptor.
4261
+ // O_NOFOLLOW refuses a symlinked leaf at open (ELOOP) on POSIX; on
4262
+ // Windows it is a no-op, so the fstat type check + realpath gate below
4263
+ // carry the junction/symlink defense. Opening before any path stat means
4264
+ // the bytes read come from the inode we validated, not a path that could
4265
+ // be re-pointed between check and read.
4266
+ let efd;
4267
+ try {
4268
+ const O_NOFOLLOW = fs.constants.O_NOFOLLOW || 0;
4269
+ efd = fs.openSync(entryPath, fs.constants.O_RDONLY | O_NOFOLLOW);
4270
+ } catch (e) {
4271
+ const why = e.code === "ELOOP"
4272
+ ? "symbolic link refused (symlinks bypass the directory-confinement check)"
4273
+ : e.message;
4274
+ return emitError(`run: --evidence-dir entry ${f}: open failed: ${why}`, { entry: f }, pretty);
4290
4275
  }
4291
4276
  try {
4292
- bundle[pbId] = JSON.parse(fs.readFileSync(entryPath, "utf8"));
4277
+ const st = fs.fstatSync(efd);
4278
+ if (!st.isFile()) {
4279
+ return emitError(`run: --evidence-dir entry ${f} is not a regular file; refusing (symlink / junction / dir / fifo bypass the directory-confinement check).`, { entry: f }, pretty);
4280
+ }
4281
+ // Hardlink defense in depth: no clean cross-platform refusal exists —
4282
+ // hardlinks are indistinguishable from regular files at the inode
4283
+ // level. Surface a stderr warning when nlink > 1 so the operator is
4284
+ // aware a second name may point at the same file. Not a refusal —
4285
+ // legitimate use cases (atomic rename, package-manager dedup) produce
4286
+ // nlink > 1 without malicious intent.
4287
+ if (st.nlink > 1) {
4288
+ process.stderr.write(`[exceptd run --evidence-dir] WARNING: ${f} has nlink=${st.nlink}; a hardlink to this file exists elsewhere on the filesystem. Hardlinks cannot be refused cross-platform — confirm the file content is what you expect.\n`);
4289
+ }
4290
+ // Read the bytes from `efd` FIRST — the descriptor was opened
4291
+ // O_NOFOLLOW and fstat-confirmed a regular file, so this reads the exact
4292
+ // inode we validated. Reading before the realpath gate (rather than
4293
+ // checking the path then reading) means there is no check-then-use
4294
+ // window at all; the containment gate below decides whether to USE the
4295
+ // bytes, and discards them otherwise.
4296
+ const raw = fs.readFileSync(efd, "utf8");
4297
+ // Symlink refusal. O_NOFOLLOW already rejects a symlinked leaf at open on
4298
+ // POSIX (ELOOP), but it is a no-op on Windows, where the open follows the
4299
+ // link. Detect and refuse a symlink explicitly via lstat — regardless of
4300
+ // where it points — so a symlinked entry is never accepted. This runs
4301
+ // AFTER the descriptor read (the bytes are dropped on refusal), so there
4302
+ // is no path-check-before-read TOCTOU window.
4303
+ let lst;
4304
+ try { lst = fs.lstatSync(entryPath); }
4305
+ catch (e) {
4306
+ return emitError(`run: --evidence-dir entry ${f}: lstat failed: ${e.message}`, null, pretty);
4307
+ }
4308
+ if (lst.isSymbolicLink()) {
4309
+ return emitError(`run: --evidence-dir entry ${f} is a symbolic link; refusing (symlinks bypass the directory-confinement check).`, { entry: f }, pretty);
4310
+ }
4311
+ // Windows directory junctions are reparse-point dirs that
4312
+ // lstat().isSymbolicLink() returns FALSE for, and O_NOFOLLOW is a
4313
+ // no-op there; realpath resolves the entry and confirms it still lives
4314
+ // under the resolved evidence-dir. A target that escapes the dir is
4315
+ // refused and the already-read bytes are dropped unused.
4316
+ let realEntry;
4317
+ try { realEntry = fs.realpathSync(entryPath); }
4318
+ catch (e) {
4319
+ return emitError(`run: --evidence-dir entry ${f}: realpath failed: ${e.message}`, null, pretty);
4320
+ }
4321
+ if (realEntry !== entryPath && !realEntry.startsWith(resolvedDir + path.sep)) {
4322
+ return emitError(
4323
+ `run: --evidence-dir entry ${f} resolves outside the directory (junction / reparse-point / symlink target). Refusing.`,
4324
+ { entry: f, resolved_to: realEntry },
4325
+ pretty
4326
+ );
4327
+ }
4328
+ bundle[pbId] = JSON.parse(raw);
4293
4329
  } catch (e) {
4294
- return emitError(`run: failed to parse --evidence-dir entry ${f}: ${e.message}`, null, pretty);
4330
+ return emitError(`run: failed to read --evidence-dir entry ${f}: ${e.message}`, null, pretty);
4331
+ } finally {
4332
+ try { fs.closeSync(efd); } catch { /* already closed / invalid fd */ }
4295
4333
  }
4296
4334
  }
4297
4335
  }
@@ -5641,8 +5679,8 @@ function cmdReattest(runner, args, runOpts, pretty) {
5641
5679
  lines.push(`attest diff: ${obj.session_id} (${obj.playbook_id})`);
5642
5680
  const icon = obj.status === "unchanged" ? "[ok]" : "[i DRIFTED]";
5643
5681
  lines.push(`\n${icon} status=${obj.status}`);
5644
- lines.push(` prior: ${obj.prior_evidence_hash} (${obj.prior_captured_at})`);
5645
- lines.push(` replay: ${obj.replay_evidence_hash} (${obj.replayed_at})`);
5682
+ lines.push(` prior: ${obj.prior_evidence_hash} (${obj.prior_captured_at || '(no detail)'})`);
5683
+ lines.push(` replay: ${obj.replay_evidence_hash} (${obj.replayed_at || '(no detail)'})`);
5646
5684
  if (obj.replay_classification) {
5647
5685
  lines.push(` replay classification: ${obj.replay_classification} RWEP=${obj.replay_rwep_adjusted ?? 0}`);
5648
5686
  }
@@ -5748,13 +5786,13 @@ function renderAttestDiff(obj) {
5748
5786
  lines.push(` artifact diff: ${ad.added?.length ?? 0} added, ${ad.removed?.length ?? 0} removed, ${ad.changed?.length ?? 0} changed, ${ad.unchanged_count ?? 0} unchanged (of ${ad.total_compared ?? 0})`);
5749
5787
  lines.push(` signal diff: ${sd.changed?.length ?? 0} changed, ${sd.unchanged_count ?? 0} unchanged (of ${sd.total_compared ?? 0})`);
5750
5788
  if (obj.sidecar_verify) {
5751
- const sv = obj.sidecar_verify;
5752
- let sidecarClass = "verified";
5753
- if (!sv.signed && sv.reason && sv.reason.includes("explicitly unsigned")) sidecarClass = "explicitly-unsigned";
5754
- else if (!sv.signed && sv.reason && sv.reason.includes("no .sig sidecar")) sidecarClass = "no-sidecar";
5755
- else if (sv.signed && !sv.verified) sidecarClass = "tamper-detected";
5756
- else if (!sv.signed) sidecarClass = "no-public-key";
5757
- lines.push(` sidecar verify: ${sidecarClass}`);
5789
+ // Use the canonical classifier, which checks tamper_class BEFORE the reason
5790
+ // strings. The previous inline logic matched reason.includes("explicitly
5791
+ // unsigned") first, so an unsigned-SUBSTITUTION attack (whose reason also
5792
+ // contains "explicitly unsigned" but carries tamper_class:
5793
+ // 'unsigned-substitution') was mislabeled as the benign 'explicitly-unsigned'
5794
+ // — hiding the substitution signal in the human diff output.
5795
+ lines.push(` sidecar verify: ${classifySidecarVerify(obj.sidecar_verify)}`);
5758
5796
  }
5759
5797
  return lines.join("\n");
5760
5798
  }
@@ -6685,11 +6723,13 @@ function cmdDiscover(runner, args, runOpts, pretty) {
6685
6723
  let hostDistro = null;
6686
6724
  if (hostPlatform === "linux") {
6687
6725
  try {
6688
- const res = spawnSync("cat", ["/etc/os-release"], { encoding: "utf8" });
6689
- if (res.status === 0 && res.stdout) {
6690
- const idMatch = res.stdout.match(/^ID=(.+)$/m);
6691
- const verMatch = res.stdout.match(/^VERSION_ID=(.+)$/m);
6692
- const prettyMatch = res.stdout.match(/^PRETTY_NAME=(.+)$/m);
6726
+ // Read the file directly instead of spawning `cat` (a process to do what
6727
+ // fs does, and a static-analysis "unnecessary use of cat" flag).
6728
+ const osRelease = fs.readFileSync("/etc/os-release", "utf8");
6729
+ if (osRelease) {
6730
+ const idMatch = osRelease.match(/^ID=(.+)$/m);
6731
+ const verMatch = osRelease.match(/^VERSION_ID=(.+)$/m);
6732
+ const prettyMatch = osRelease.match(/^PRETTY_NAME=(.+)$/m);
6693
6733
  hostDistro = {
6694
6734
  id: idMatch ? idMatch[1].replace(/^"|"$/g, "") : null,
6695
6735
  version_id: verMatch ? verMatch[1].replace(/^"|"$/g, "") : null,
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "schema_version": "1.1.0",
3
- "generated_at": "2026-06-20T16:35:33.578Z",
3
+ "generated_at": "2026-06-21T01:54:07.230Z",
4
4
  "generator": "scripts/build-indexes.js",
5
5
  "source_count": 64,
6
6
  "source_hashes": {
7
- "manifest.json": "6e8a5ac562c706b0142ad12b07a62982bb83d500870ca33a5d865bd17a16d23a",
7
+ "manifest.json": "f077895655b0127a2e31c8fd34db26fda9f20e663b07d01f2f1cc6132068bc89",
8
8
  "README.md": "e7b854e7db9a364a1b368b5084b4f0c2a8282f0459ce39800ac1d1dabdc06074",
9
9
  "data/atlas-ttps.json": "5bc59e23d6c2defa54168de161a0825299b9cc4a49c6b26df2dae70b4f42eedf",
10
10
  "data/attack-techniques.json": "53c6f248760eecb11a0354f74ab467a5814e95075a686b9b3bf18c34e2f7435e",
@@ -194,8 +194,11 @@ function scanPublishWorkflow(content, rel) {
194
194
  if (line.length > 4096) continue;
195
195
  // Allow optional leading `-` from a YAML list item: `- uses: ...`.
196
196
  // `^[ \t]*(?:-[ \t]*)?` anchors the indentation once, then an optional
197
- // `- ` list marker — no overlapping `\s*` runs that backtrack.
198
- const m = line.match(/^[ \t]*(?:-[ \t]*)?uses:\s*['"]?([^'"\s]+)['"]?\s*$/);
197
+ // `- ` list marker — no overlapping `\s*` runs that backtrack. Exclude `#`
198
+ // from the capture and drop the end anchor (mirroring the cicd collector) so
199
+ // a `uses: x@v1 # comment` line is still matched — the `$`-anchored pattern
200
+ // silently missed every floating ref that carried a trailing YAML comment.
201
+ const m = line.match(/^[ \t]*(?:-[ \t]*)?uses:\s*['"]?([^'"\s#]+)['"]?/);
199
202
  if (!m) continue;
200
203
  const ref = m[1];
201
204
  if (ref.startsWith("./") || ref.startsWith("./.github/")) continue; // local
@@ -249,15 +249,22 @@ function preflight(playbook, runOpts = {}) {
249
249
 
250
250
  // 1. Currency gate
251
251
  const score = meta.threat_currency_score;
252
- if (score < 50 && !runOpts.forceStale) {
252
+ // A non-numeric score (absent / null / NaN from a malformed _meta) must HARD
253
+ // BLOCK, not slip through: `undefined < 50` is false, which would silently
254
+ // bypass the staleness gate on exactly the playbooks whose currency metadata
255
+ // is broken. Treat "no usable score" as the most-stale state.
256
+ const scoreUsable = typeof score === 'number' && !Number.isNaN(score);
257
+ if ((!scoreUsable || score < 50) && !runOpts.forceStale) {
253
258
  return {
254
259
  ok: false,
255
260
  blocked_by: 'currency',
256
- reason: `threat_currency_score = ${score} (< 50). Hard-blocked. Pass forceStale=true to override.`,
261
+ reason: scoreUsable
262
+ ? `threat_currency_score = ${score} (< 50). Hard-blocked. Pass forceStale=true to override.`
263
+ : `threat_currency_score is absent or non-numeric (${JSON.stringify(score)}). Hard-blocked — a playbook without a usable currency score is treated as stale. Fix _meta.threat_currency_score, or pass forceStale=true to override.`,
257
264
  issues
258
265
  };
259
266
  }
260
- if (score < 70) {
267
+ if (scoreUsable && score < 70) {
261
268
  issues.push({ kind: 'currency_warn', message: `threat_currency_score = ${score} (< 70). Threat model is stale — recommend running the skill-update-loop before relying on findings.` });
262
269
  }
263
270
 
@@ -649,7 +656,12 @@ function look(playbookId, directiveId, runOpts = {}) {
649
656
  // Surface the air-gap alternative as the primary source when air_gap_mode
650
657
  // is active, so the agent doesn't accidentally hit the network.
651
658
  source: airGap && a.air_gap_alternative ? a.air_gap_alternative : a.source,
652
- _original_source: a.source
659
+ _original_source: a.source,
660
+ // In air-gap mode an artifact with NO air_gap_alternative silently keeps
661
+ // its original (possibly network-bound) source — defeating air-gap with no
662
+ // signal. Flag it so the agent treats the source as not-offline-verified
663
+ // and the gap is observable instead of a silent network fallback.
664
+ ...(airGap && !a.air_gap_alternative ? { air_gap_alternative_missing: true } : {}),
653
665
  })),
654
666
  collection_scope: l.collection_scope,
655
667
  environment_assumptions: l.environment_assumptions || [],
@@ -1019,8 +1031,12 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
1019
1031
  : [];
1020
1032
  // VEX-fixed CVEs remain in matched/catalog arrays but get annotated
1021
1033
  // with vex_status:'fixed' downstream so consumers see them as resolved.
1034
+ // Source from catalogBaselineCves (post-vexFilter survivors), NOT allCves: a
1035
+ // CVE the operator marked BOTH not_affected (dropped by vexFilter) AND fixed
1036
+ // is contradictory, and listing it as fixed while it's absent from
1037
+ // matched/baseline would have it appear in fixed_cves but nowhere else.
1022
1038
  const vexFixedIds = vexFixed
1023
- ? allCves.filter(c => vexFixed.has(c.cve_id)).map(c => c.cve_id)
1039
+ ? catalogBaselineCves.filter(c => vexFixed.has(c.cve_id)).map(c => c.cve_id)
1024
1040
  : [];
1025
1041
 
1026
1042
  // Build correlation map: cve_id -> array of "indicator_hit:<id>" / "signal:<id>" reasons.
@@ -1744,8 +1760,16 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
1744
1760
  };
1745
1761
  const enrichNotification = (na) => {
1746
1762
  const obligation = (g.jurisdiction_obligations || []).find(o =>
1763
+ o && typeof o === 'object' &&
1747
1764
  `${o.jurisdiction}/${o.regulation} ${o.window_hours}h` === na.obligation_ref
1748
1765
  );
1766
+ // A non-empty obligation_ref that resolves to nothing leaves the
1767
+ // jurisdiction / regulation / deadline fields null below — surface it as a
1768
+ // runtime_error so the unmatched ref is observable, not a silent null record.
1769
+ if (!obligation && na && typeof na.obligation_ref === 'string' && na.obligation_ref &&
1770
+ Array.isArray(runOpts && runOpts._runErrors)) {
1771
+ pushRunError(runOpts._runErrors, { kind: 'unresolved_obligation_ref', obligation_ref: na.obligation_ref }, { dedupeKey: x => x.obligation_ref || '' });
1772
+ }
1749
1773
  // Thread runOpts + the engine-computed classification through so
1750
1774
  // computeClockStart can check operator_consent.explicit before
1751
1775
  // auto-stamping detect_confirmed, and so an engine-confirmed detection
@@ -1776,9 +1800,23 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
1776
1800
  && autoStartEvent
1777
1801
  && eventReady
1778
1802
  && !(runOpts && runOpts.operator_consent && runOpts.operator_consent.explicit === true);
1779
- const deadline = obligation && clockValid
1803
+ // window_hours must be a finite number before it enters the deadline
1804
+ // arithmetic — a malformed obligation with an undefined/null/non-number
1805
+ // window_hours would otherwise compute `getTime() + NaN` and crash the
1806
+ // close phase at `new Date(NaN).toISOString()`. Runtime validation of the
1807
+ // playbook is not enforced, so guard here independently of the schema.
1808
+ const windowValid = obligation && typeof obligation.window_hours === 'number' && Number.isFinite(obligation.window_hours);
1809
+ const deadline = obligation && clockValid && windowValid
1780
1810
  ? new Date(clockStart.getTime() + obligation.window_hours * 3600 * 1000).toISOString()
1781
1811
  : 'pending_clock_start_event';
1812
+ // A notification_action whose obligation_ref was specified but resolves to
1813
+ // no obligation is an internally-inconsistent playbook (the schema requires
1814
+ // obligation_ref to name a real govern obligation). The unmatched ref is
1815
+ // already surfaced as a runtime_error above; drop the record rather than
1816
+ // emit a null-jurisdiction/regulation entry that pollutes the deadline list.
1817
+ if (!obligation && na && typeof na.obligation_ref === 'string' && na.obligation_ref) {
1818
+ return null;
1819
+ }
1782
1820
  return {
1783
1821
  ...na,
1784
1822
  // Carry obligation metadata forward so each notification entry is
@@ -1827,7 +1865,10 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
1827
1865
  })(),
1828
1866
  };
1829
1867
  };
1830
- const notificationActions = (c.notification_actions || []).map(enrichNotification);
1868
+ // enrichNotification returns null for an unresolved specified obligation_ref
1869
+ // (already surfaced as a runtime_error); filter those out so the notification
1870
+ // list never carries a null-jurisdiction record.
1871
+ const notificationActions = (c.notification_actions || []).map(enrichNotification).filter(Boolean);
1831
1872
 
1832
1873
  // A govern obligation that declares a notification duty but has no
1833
1874
  // matching close.notification_actions entry would otherwise never surface
@@ -1839,15 +1880,27 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
1839
1880
  // can tell it apart from a playbook-authored action.
1840
1881
  const coveredObligationRefs = new Set((c.notification_actions || []).map(na => na.obligation_ref));
1841
1882
  for (const o of (g.jurisdiction_obligations || [])) {
1883
+ if (!o || typeof o !== 'object') continue; // skip a null/malformed obligation rather than crash close() during synthesis
1842
1884
  if (!String(o.obligation || '').startsWith('notify')) continue;
1885
+ // A notify obligation with a non-number window_hours is malformed: it would
1886
+ // synthesize a "…/… undefinedh" ref and could not produce a real deadline.
1887
+ // Surface it as a runtime_error and skip synthesis rather than emit a bogus
1888
+ // record (the deadline guard in enrichNotification also prevents the crash).
1889
+ if (typeof o.window_hours !== 'number' || !Number.isFinite(o.window_hours)) {
1890
+ if (Array.isArray(runOpts && runOpts._runErrors)) {
1891
+ pushRunError(runOpts._runErrors, { kind: 'malformed_obligation_window_hours', obligation: `${o.jurisdiction}/${o.regulation}` }, { dedupeKey: x => x.obligation || '' });
1892
+ }
1893
+ continue;
1894
+ }
1843
1895
  const ref = `${o.jurisdiction}/${o.regulation} ${o.window_hours}h`;
1844
1896
  if (coveredObligationRefs.has(ref)) continue;
1845
- notificationActions.push(enrichNotification({
1897
+ const synthesized = enrichNotification({
1846
1898
  obligation_ref: ref,
1847
1899
  recipient: null,
1848
1900
  draft_notification: null,
1849
1901
  synthesized_from_obligation: true,
1850
- }));
1902
+ });
1903
+ if (synthesized) notificationActions.push(synthesized);
1851
1904
  }
1852
1905
 
1853
1906
  // exception_generation — evaluate trigger.
@@ -1999,7 +2052,12 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
1999
2052
  // Govern-phase jurisdiction obligations so feeds_into conditions like
2000
2053
  // `… AND jurisdiction_obligations contains 'EU'` (framework → sbom) resolve.
2001
2054
  jurisdiction_obligations: (g && g.jurisdiction_obligations) || (playbook.phases && playbook.phases.govern && playbook.phases.govern.jurisdiction_obligations) || [],
2002
- theater_score: analyzeResult.compliance_theater_check?.verdict === 'theater' ? 0 : 100,
2055
+ // theater_score follows lib/framework-gap.js's convention: HIGH = more
2056
+ // theater detected (worse). A 'theater' verdict (a gap exists) is the
2057
+ // concerning case, so it scores 100; a clear verdict scores 0. (Earlier
2058
+ // this was inverted, so a feeds_into condition like `theater_score >= 50`
2059
+ // would have failed to fire exactly when a gap was found.)
2060
+ theater_score: analyzeResult.compliance_theater_check?.verdict === 'theater' ? 100 : 0,
2003
2061
  // Top-level matched_cve array so the shipped sbom feeds_into quantifiers
2004
2062
  // (`any matched_cve.attack_class == 'kernel-lpe'`, … IN ['ai-c2', …]) re-root
2005
2063
  // at each matched CVE. Without it the quantifier head resolves null and the
package/lib/prefetch.js CHANGED
@@ -289,7 +289,8 @@ Outputs:
289
289
 
290
290
  async function timedFetch(url, headers = {}) {
291
291
  const ac = new AbortController();
292
- const t = setTimeout(() => ac.abort(), REQUEST_TIMEOUT_MS);
292
+ let timedOut = false;
293
+ const t = setTimeout(() => { timedOut = true; ac.abort(); }, REQUEST_TIMEOUT_MS);
293
294
  try {
294
295
  const res = await fetch(url, {
295
296
  signal: ac.signal,
@@ -309,6 +310,20 @@ async function timedFetch(url, headers = {}) {
309
310
  const lastModified = res.headers.get("last-modified") || null;
310
311
  const json = await res.json();
311
312
  return { json, etag, lastModified };
313
+ } catch (e) {
314
+ // A timeout surfaces as an AbortError with NO statusCode, which the retry
315
+ // classifier would not retry — so under heavy upstream load (NVD rate
316
+ // limiting + slow responses) timed-out fetches piled up as final errors and
317
+ // pushed the total past --max-errors, failing the whole scheduled refresh.
318
+ // Re-mark a timeout as a retryable network error (ETIMEDOUT) so the job
319
+ // queue backs off and retries instead of dropping it on the first slow
320
+ // response.
321
+ if (timedOut || (e && (e.name === "AbortError" || e.code === "ABORT_ERR"))) {
322
+ const te = new Error(`request timed out after ${REQUEST_TIMEOUT_MS}ms`);
323
+ te.code = "ETIMEDOUT";
324
+ throw te;
325
+ }
326
+ throw e;
312
327
  } finally {
313
328
  clearTimeout(t);
314
329
  }
@@ -42,6 +42,24 @@ const os = require("os");
42
42
 
43
43
  const ROOT = path.resolve(__dirname, "..");
44
44
  const PKG_NAME = "@blamejs/exceptd-skills";
45
+ // Unscoped basename for the npm tarball filename: @scope/name -> name.
46
+ const PKG_UNSCOPED = PKG_NAME.includes("/") ? PKG_NAME.split("/").pop() : PKG_NAME;
47
+ // Strict semver — the ONLY registry-metadata field embedded in the fetch URL.
48
+ // A value that passes this guard cannot carry a path separator, URL scheme, or
49
+ // host, so the canonical tarball URL built from it has no metadata-controlled
50
+ // destination component.
51
+ const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
52
+
53
+ // The canonical npm tarball URL for this package@version, built entirely from
54
+ // string literals plus a semver-guarded version. The fetch destination is no
55
+ // longer derived from the metadata-supplied dist.tarball, so a tampered or
56
+ // fixture-injected /latest response cannot steer the download at an internal or
57
+ // attacker-controlled address — strictly stronger than host-allowlisting the
58
+ // metadata URL. Content trust still rests on the SHA-512/SRI + shasum + Ed25519
59
+ // checks downstream.
60
+ function canonicalTarballUrl(version) {
61
+ return `https://registry.npmjs.org/${PKG_NAME}/-/${PKG_UNSCOPED}-${version}.tgz`;
62
+ }
45
63
  const REQUEST_TIMEOUT_MS = 15000;
46
64
 
47
65
  function parseArgs(argv) {
@@ -446,11 +464,28 @@ async function main() {
446
464
  process.exitCode = 3; return;
447
465
  }
448
466
 
449
- progress(`fetching ${tarballUrl} (${tarballShasum?.slice(0, 12) || "no shasum"})...`, opts.json);
467
+ // Build the fetch destination from constants + a semver-validated version
468
+ // rather than from the metadata-supplied dist.tarball, so the registry
469
+ // response cannot steer the download's host or path. The metadata URL is
470
+ // still cross-checked for visibility; a divergence is noted, not fatal,
471
+ // because the authenticity anchor is the SHA-512/shasum/Ed25519 chain below.
472
+ // Guard the SAME binding that the URL is built from (`safeVersion`), so the
473
+ // semver check is the only path by which a metadata value reaches the fetch.
474
+ const safeVersion = typeof latestVersion === "string" ? latestVersion : "";
475
+ if (!SEMVER_RE.test(safeVersion)) {
476
+ emit({ ok: false, error: `registry metadata version is not valid semver: ${safeVersion.slice(0, 64)}` }, opts.json);
477
+ process.exitCode = 2; return;
478
+ }
479
+ const canonicalUrl = canonicalTarballUrl(safeVersion);
480
+ if (tarballUrl !== canonicalUrl) {
481
+ progress(`note: registry dist.tarball (${tarballUrl}) differs from canonical npm URL; fetching ${canonicalUrl}`, opts.json);
482
+ }
483
+
484
+ progress(`fetching ${canonicalUrl} (${tarballShasum?.slice(0, 12) || "no shasum"})...`, opts.json);
450
485
 
451
486
  let tgzBuf;
452
487
  try {
453
- tgzBuf = await getBuffer(tarballUrl, opts.timeoutMs);
488
+ tgzBuf = await getBuffer(canonicalUrl, opts.timeoutMs);
454
489
  } catch (e) {
455
490
  emit({ ok: false, error: `tarball fetch failed: ${e.message}` }, opts.json);
456
491
  process.exitCode = 2; return;
package/lib/scoring.js CHANGED
@@ -179,10 +179,15 @@ function validateFactors(factors) {
179
179
  const boolFields = ['cisa_kev', 'poc_available', 'ai_assisted_weapon', 'ai_discovered',
180
180
  'patch_available', 'live_patch_available', 'reboot_required'];
181
181
  for (const f of boolFields) {
182
- // The catalog field `ai_assisted_weaponization` satisfies `ai_assisted_weapon`.
182
+ // The catalog field `ai_assisted_weaponization` satisfies `ai_assisted_weapon`,
183
+ // and `patch_required_reboot` satisfies `reboot_required` — the same aliasing
184
+ // scoreCustom/deriveRwepFromFactors honor, so validateFactors must accept a
185
+ // block that supplies only the alias instead of flagging it "missing".
183
186
  const present = (f === 'ai_assisted_weapon')
184
187
  ? (factors.ai_assisted_weapon ?? factors.ai_assisted_weaponization)
185
- : factors[f];
188
+ : (f === 'reboot_required')
189
+ ? (factors.reboot_required ?? factors.patch_required_reboot)
190
+ : factors[f];
186
191
  if (present === undefined || present === null) {
187
192
  warnings.push(`${f}: missing (treated as false; explicit value recommended)`);
188
193
  } else if (typeof present !== 'boolean') {
@@ -190,10 +195,18 @@ function validateFactors(factors) {
190
195
  }
191
196
  }
192
197
  const aeAllowed = ['none', 'unknown', 'suspected', 'theoretical', 'confirmed'];
193
- if (factors.active_exploitation === undefined || factors.active_exploitation === null) {
198
+ const aeRaw = factors.active_exploitation;
199
+ if (aeRaw === undefined || aeRaw === null) {
194
200
  warnings.push("active_exploitation: missing (treated as 'none')");
195
- } else if (!aeAllowed.includes(factors.active_exploitation)) {
196
- warnings.push(`active_exploitation: expected one of ${aeAllowed.join(', ')}, got ${JSON.stringify(factors.active_exploitation)}`);
201
+ } else {
202
+ // Normalize (trim + lowercase) before the vocab check so validateFactors
203
+ // accepts exactly what scoreCustom/resolveActiveExploitation accept — a
204
+ // stray-cased 'Confirmed' / ' confirmed ' must not be flagged here while the
205
+ // scorer consumes it, or the two surfaces disagree.
206
+ const aeNorm = typeof aeRaw === 'string' ? aeRaw.trim().toLowerCase() : aeRaw;
207
+ if (!aeAllowed.includes(aeNorm)) {
208
+ warnings.push(`active_exploitation: expected one of ${aeAllowed.join(', ')}, got ${JSON.stringify(aeRaw)}`);
209
+ }
197
210
  }
198
211
  // NaN diagnostics. The prior message read "expected number,
199
212
  // got number (null)" because `JSON.stringify(NaN) === 'null'` and `typeof
@@ -346,7 +359,7 @@ function deriveRwepFromFactors(factors) {
346
359
  if (values.length === 0) return 0;
347
360
  const aeAllowed = new Set(['none', 'unknown', 'suspected', 'theoretical', 'confirmed']);
348
361
  const hasBooleanOrLadder = values.some(
349
- (v) => typeof v === 'boolean' || (typeof v === 'string' && aeAllowed.has(v)),
362
+ (v) => typeof v === 'boolean' || (typeof v === 'string' && aeAllowed.has(v.trim().toLowerCase())),
350
363
  );
351
364
  if (hasBooleanOrLadder) {
352
365
  return scoreCustom(factors);
@@ -367,6 +380,12 @@ function deriveRwepFromFactors(factors) {
367
380
  let sum = 0;
368
381
  for (const [k, v] of Object.entries(factors)) {
369
382
  if (typeof v !== 'number' || !Number.isFinite(v)) continue;
383
+ // reboot_required and patch_required_reboot are aliases for the SAME
384
+ // post-weight contribution (scoreCustom collapses them). A block carrying
385
+ // both must count it once; summing both double-counts the reboot weight,
386
+ // inflating the derived RWEP past the formula AND past the stored score the
387
+ // validate() divergence gate compares against.
388
+ if (k === 'patch_required_reboot' && Object.prototype.hasOwnProperty.call(factors, 'reboot_required')) continue;
370
389
  if (k === 'blast_radius') {
371
390
  sum += Math.max(0, Math.min(RWEP_WEIGHTS.blast_radius, v));
372
391
  } else {
@@ -447,12 +466,17 @@ function compare(cveId, catalog, opts) {
447
466
  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.';
448
467
  } else if (delta > 10) {
449
468
  explanation = `RWEP significantly higher than CVSS equivalent. Factors driving delta: `;
469
+ // The explanation must list every factor scoreCustom actually counts, via
470
+ // the same aliases/normalization — otherwise a CVE whose RWEP is driven by
471
+ // ai_assisted_weaponization (not ai_discovered), a stray-cased 'Confirmed',
472
+ // or the patch_required_reboot alias shows a higher RWEP with no stated
473
+ // reason for the delta.
450
474
  const driving = [];
451
475
  if (entry.cisa_kev) driving.push('CISA KEV (+25)');
452
476
  if (entry.poc_available) driving.push('public PoC (+20)');
453
- if (entry.ai_discovered) driving.push('AI-discovered (+15 weaponization)');
454
- if (entry.active_exploitation === 'confirmed') driving.push('confirmed exploitation (+20)');
455
- if (entry.patch_required_reboot && !entry.live_patch_available) driving.push('reboot required (+5)');
477
+ if (entry.ai_discovered || entry.ai_assisted_weaponization) driving.push('AI-discovered (+15 weaponization)');
478
+ if (String(entry.active_exploitation || '').trim().toLowerCase() === 'confirmed') driving.push('confirmed exploitation (+20)');
479
+ if ((entry.reboot_required || entry.patch_required_reboot) && !entry.live_patch_available) driving.push('reboot required (+5)');
456
480
  explanation += driving.join(', ');
457
481
  explanation += '. Framework patch SLAs calibrated to CVSS are insufficient for this CVE.';
458
482
  } else if (delta < -10) {
@@ -612,7 +636,10 @@ function validate(catalog) {
612
636
  blast_radius: entry.rwep_factors ? entry.rwep_factors.blast_radius : 0,
613
637
  patch_available: entry.patch_available,
614
638
  live_patch_available: entry.live_patch_available,
615
- reboot_required: entry.patch_required_reboot
639
+ // Mirror the reboot alias scoreCustom itself honors (reboot_required OR
640
+ // patch_required_reboot): passing only patch_required_reboot would drop a
641
+ // top-level reboot_required field and compute a divergent expected RWEP.
642
+ reboot_required: entry.reboot_required || entry.patch_required_reboot
616
643
  });
617
644
  if (Math.abs(calculatedRwep - entry.rwep_score) > 5) {
618
645
  errors.push(`${cveId}: rwep_score ${entry.rwep_score} diverges from calculated ${calculatedRwep} by more than 5 — verify factors`);