@blamejs/exceptd-skills 0.12.18 → 0.12.21

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.
Files changed (53) hide show
  1. package/CHANGELOG.md +224 -52
  2. package/README.md +1 -1
  3. package/bin/exceptd.js +841 -68
  4. package/data/_indexes/_meta.json +14 -14
  5. package/data/_indexes/activity-feed.json +3 -3
  6. package/data/_indexes/catalog-summaries.json +3 -3
  7. package/data/_indexes/chains.json +15 -0
  8. package/data/_indexes/jurisdiction-map.json +3 -2
  9. package/data/_indexes/section-offsets.json +175 -175
  10. package/data/_indexes/summary-cards.json +1 -1
  11. package/data/_indexes/token-budget.json +83 -83
  12. package/data/cve-catalog.json +169 -2
  13. package/data/exploit-availability.json +16 -0
  14. package/data/playbooks/ai-api.json +20 -1
  15. package/data/playbooks/containers.json +30 -0
  16. package/data/playbooks/cred-stores.json +18 -0
  17. package/data/playbooks/crypto.json +18 -0
  18. package/data/playbooks/hardening.json +26 -1
  19. package/data/playbooks/kernel.json +22 -2
  20. package/data/playbooks/mcp.json +18 -0
  21. package/data/playbooks/runtime.json +20 -1
  22. package/data/playbooks/sbom.json +18 -0
  23. package/data/playbooks/secrets.json +6 -0
  24. package/data/zeroday-lessons.json +102 -0
  25. package/lib/auto-discovery.js +68 -15
  26. package/lib/cross-ref-api.js +43 -10
  27. package/lib/cve-curation.js +4 -4
  28. package/lib/playbook-runner.js +545 -63
  29. package/lib/prefetch.js +65 -18
  30. package/lib/refresh-external.js +40 -2
  31. package/lib/refresh-network.js +100 -12
  32. package/lib/scoring.js +22 -13
  33. package/lib/sign.js +14 -6
  34. package/lib/validate-catalog-meta.js +1 -1
  35. package/lib/validate-indexes.js +2 -2
  36. package/lib/verify.js +51 -10
  37. package/manifest.json +47 -48
  38. package/orchestrator/scheduler.js +10 -0
  39. package/package.json +1 -1
  40. package/sbom.cdx.json +6 -6
  41. package/scripts/check-manifest-snapshot.js +1 -1
  42. package/scripts/check-sbom-currency.js +1 -1
  43. package/scripts/predeploy.js +10 -5
  44. package/scripts/refresh-manifest-snapshot.js +2 -2
  45. package/scripts/validate-vendor-online.js +1 -1
  46. package/scripts/verify-shipped-tarball.js +94 -6
  47. package/skills/compliance-theater/skill.md +4 -1
  48. package/skills/exploit-scoring/skill.md +20 -1
  49. package/skills/framework-gap-analysis/skill.md +6 -2
  50. package/skills/kernel-lpe-triage/skill.md +50 -3
  51. package/skills/threat-model-currency/skill.md +6 -4
  52. package/skills/webapp-security/skill.md +1 -1
  53. package/skills/zeroday-gap-learn/skill.md +44 -1
package/CHANGELOG.md CHANGED
@@ -1,25 +1,208 @@
1
1
  # Changelog
2
2
 
3
- ## 0.12.18 — 2026-05-14
3
+ ## 0.12.21 — 2026-05-14
4
+
5
+ **Patch: Fragnesia (CVE-2026-46300) catalog + skill integration; trust-chain bypass closures; engine FP-gate extension; CSAF + SARIF + OpenVEX correctness; CLI fuzz; Hard Rule #5 global-first coverage; predeploy regression fix.**
6
+
7
+ ### Catalog — Fragnesia
8
+
9
+ `CVE-2026-46300` (Fragnesia) added — a Linux kernel local privilege escalation disclosed 2026-05-13 by William Bowling / V12 security team. CVSS 7.8 / AV:L. The flaw is in the kernel XFRM ESP-in-TCP path: `skb_try_coalesce()` fails to propagate `SKBFL_SHARED_FRAG` when transferring paged fragments between socket buffers. An unprivileged user can deterministically rewrite read-only page-cache pages without modifying on-disk bytes — no race condition required. A public proof-of-concept demonstrates root shell via `/usr/bin/su`. Mitigation: blacklist or unload `esp4`, `esp6`, `rxrpc` kernel modules (the same set already documented for CVE-2026-31431); AlmaLinux + CloudLinux ship patched kernels in testing; live-patch is available via Canonical Livepatch, kpatch, kGraft, and CloudLinux KernelCare. RWEP today: 20 (will jump to 45 on CISA KEV listing).
10
+
11
+ The `kernel`, `runtime`, and `hardening` playbooks now reference Fragnesia in `domain.cve_refs[]`. Seven skills carry cross-references: `kernel-lpe-triage`, `exploit-scoring`, `compliance-theater`, `framework-gap-analysis`, `zeroday-gap-learn`, `threat-model-currency`. `data/zeroday-lessons.json` adds three new control requirements that codify the lesson: page-cache integrity verification (file-integrity tools hashing on-disk bytes miss this class), bug-family mitigation persistence (operators who blacklisted modules for the parent bug remain mitigated for the sequel), and scanner paper-compliance test (a "patched" vulnerability-scanner report based on kernel-package version misses the module-unload mitigation surface).
12
+
13
+ ### Trust chain
14
+
15
+ - **`algorithm: "unsigned"` sidecar substitution closed**. An attacker with write access to the attestation directory previously bypassed signed-tamper detection by overwriting `.sig` with `{"algorithm":"unsigned"}`. `attest verify` now refuses with exit 6 + `ok:false` when the substitution shape is detected on a host that has a private key present (legitimate unsigned attestations remain serviceable only on hosts where signing is intentionally disabled). `cmdReattest` requires explicit `--force-replay` to replay an explicitly-unsigned attestation regardless of host state; the persisted replay body records `sidecar_verify_class` and `force_replay: true`.
16
+ - **Corrupt-sidecar `.sig` JSON parse bypass closed**. Previously `cmdReattest` refused only on `reason === "no .sig sidecar"`; a truncated or malformed sidecar fell through to the benign branch. The refusal class now covers any non-clean verify reason. `cmdAttest verify` also wraps the sidecar `JSON.parse` so a corrupt sidecar exits 6 (TAMPERED) rather than exit 1 (generic).
17
+ - **`EXPECTED_FINGERPRINT` consulted inside `verifyManifestSignature`**. The pin previously fired only at the CLI tail; library callers (refresh-network gate, verify-shipped-tarball gate, tests, downstream consumers) bypassed it. The pin now gates manifest-envelope authentication at every load site. Honors `KEYS_ROTATED=1`; missing pin file remains warn-and-continue.
18
+
19
+ ### Engine
20
+
21
+ - **Classification-override block extended to all override values**. The previous gate refused only `'detected'` overrides when an indicator with `false_positive_checks_required[]` was unsatisfied. An agent submitting `'clean'` or `'not_detected'` previously hid hits under a falsely-clean run verdict — strictly worse than the false-positive case the gate was meant to prevent. The substitution now applies to every override (`'detected' | 'clean' | 'not_detected' | 'inconclusive'`): when any indicator has unsatisfied FP checks, classification is forced to `'inconclusive'`. The `classification_override_blocked` runtime error records the offending indicator IDs and the count of unsatisfied checks (the literal check-name strings are no longer disclosed — they had been an attestation-bypass hint).
22
+ - **`vex_status: 'fixed'` propagation closed end-to-end**. The runner's bundle gates (CSAF `product_status: fixed` / OpenVEX `status: fixed`) previously never fired on operator runs: the `--vex` CLI consumed `vexFilterFromDoc()` for the `vex_filter` set but never read the `.fixed` companion property. The CSAF + OpenVEX `fixed` semantics introduced in v0.12.19 now actually engage when an operator submits a CycloneDX `analysis.state: resolved` or OpenVEX `status: fixed` statement.
23
+ - **`normalizeSubmission` flat-submission runtime errors reach `analyze.runtime_errors[]`**. The v0.12.19 promise to surface `signal_overrides_invalid` errors in the analyze phase was silently incomplete for flat-shape submissions (`{observations, verdict, signal_overrides}`); the constructed `out` object dropped the `_runErrors` accumulator. Errors are now threaded through both submission shapes.
24
+ - **Off-allowlist `detection_classification` values surface a runtime error**. `'present'`, `'unknown'`, `''`, case variants, leading/trailing whitespace, and other non-allowlist strings previously failed silent. They now push `classification_override_invalid` onto `runtime_errors[]`.
25
+ - **Proxy-throwing FP attestation no longer crashes detect()**. A malicious attestation whose getter throws is now caught: the indicator verdict downgrades to `'inconclusive'`, every required FP check is treated as unsatisfied, and a `fp_attestation_threw` runtime error records the indicator ID.
26
+
27
+ ### Bundles (CSAF / SARIF / OpenVEX)
28
+
29
+ - **CSAF `tracking.status: 'interim'`** is the default for runtime emissions. `'final'` is an immutable-advisory marker; runtime detections without an operator review loop don't qualify. Operators promote to `final` via `--csaf-status final` after review. Strict validators (BSI CSAF Validator, Secvisogram) no longer refuse the bundles.
30
+ - **CSAF non-CVE identifiers routed correctly**. Per CSAF 2.0 §3.2.1.2 the `cve` field requires a strict CVE-ID shape. `MAL-2026-3083`, GHSA-*, RUSTSEC-* identifiers are now emitted under `ids: [{system_name, text}]` instead of misappropriating the `cve` field. Validators no longer reject the document.
31
+ - **CSAF `document.publisher.namespace`** now derives from `--publisher-namespace <url>` (new CLI flag) or, when omitted, from `--operator` if it parses as a URL. Without either, the bundle emits `urn:exceptd:operator:unknown` and pushes a `bundle_publisher_unclaimed` runtime warning. Operators are no longer misattributed to the tool vendor's marketing domain.
32
+ - **CSAF `document.tracking.generator`** populated with the exceptd engine + version; `publisher.contact_details` carries the validated `--operator` value when supplied.
33
+ - **`bundles_by_format` always populated**. The field was previously `null` when only the primary format was requested; multi-format-aware consumers had to special-case the no-extras shape.
34
+ - **CSAF `cvss_v3` block requires `vectorString`**. Per the CVSS v3.1 schema referenced by CSAF, the vector is mandatory. The block is now omitted when the vector is unavailable rather than emitting a partial structure that downstream tooling would reject.
35
+ - **SARIF `ruleId` prefixed with `<playbook-slug>/`**. Multi-playbook runs no longer collide on rule IDs (`framework-gap-0` from kernel-lpe and `framework-gap-0` from crypto-codebase are now distinct in GitHub Code Scanning dashboards).
36
+
37
+ ### CLI
38
+
39
+ - **Stdin auto-detect uses `fstatSync` size probe** at `cmdRun`, `cmdIngest`, and `cmdAiRun --no-stream`. The previous truthy `!process.stdin.isTTY` check hung indefinitely on wrapped streams where `isTTY` was undefined but no data was piped (Mocha/Jest test harnesses, some Docker stdin-passthrough modes). The auto-detect now skips stdin when fstat reports size 0 on a non-TTY descriptor.
40
+ - **`--vex` accepts CycloneDX SBOMs without `vulnerabilities[]`**. A document with `bomFormat: "CycloneDX"` and no vulnerabilities array is now read as a zero-CVE VEX filter rather than refused. Operators with legitimate "no known vulnerabilities" SBOMs can now thread them through.
41
+ - **`--vex` and `--evidence` tolerate UTF-8 / UTF-16 BOMs**. A new shared `readJsonFile` helper detects the BOM (`FF FE` / `FE FF` / `EF BB BF`), decodes accordingly, strips the residual code point, and surfaces clean parse errors. Windows-generated CycloneDX documents (which routinely emit UTF-16LE or UTF-8 BOM) now parse correctly.
42
+ - **`--vex` enforces a 32 MiB size cap** with a clear error message (`exceeds 32 MiB limit (33,554,432 bytes)`).
43
+ - **`--operator` rejects Unicode bidi / format / control characters**. NFC-normalized input is validated against an allowlist that excludes Unicode general categories `Cc` (control), `Cf` (format — RTL override, zero-width, etc.), `Cs`, `Co`, `Cn`. Operator-identity forgery via right-to-left override or Zalgo is closed.
44
+ - **`--evidence-dir` refuses symbolic links, Windows directory junctions, and surfaces a warning on hardlinks**. The previous `lstatSync().isSymbolicLink()` gate missed Windows reparse-point junctions (which Node treats as directories) and gave no signal on hardlinked entries. A `realpathSync` check now enforces containment under the resolved directory; `nlink > 1` emits a defense-in-depth stderr warning.
45
+ - **`--ack` refused on non-clock verbs**. `brief`, `list`, and similar info-only verbs that don't engage jurisdiction-clock semantics now refuse the flag with a clear "irrelevant on this verb" error. On `run`, `--ack` is consumed only when classification is `'detected'`; on a `not_detected` run, consent persistence is skipped and `ack_skipped_reason` is surfaced.
46
+ - **`--help` text scrubbed**. The `ai-run` subverb help no longer carries internal-process vocabulary.
47
+
48
+ ### CLI flag additions
49
+
50
+ - **`--csaf-status <interim|final>`** controls CSAF emission status.
51
+ - **`--publisher-namespace <url>`** sets the CSAF `document.publisher.namespace` field.
52
+
53
+ ### Auto-discovery + curation
54
+
55
+ - **KEV-discovered draft predeploy regression closed**. `scoring.validate()` previously flagged every newly-imported KEV draft as score-diverged (the `buildScoringInputs` shape sets `poc_available: true` for the contribution while `buildKevDraftEntry` stores `null` on the draft for review). The validator now skips entries flagged `_auto_imported: true`; promoted entries are validated normally.
56
+ - **`--air-gap` CLI flag wired through `refresh-external`**. The flag was previously accepted only via `EXCEPTD_AIR_GAP=1` env. Both the `parseArgs` and `loadCtx` paths now thread `--air-gap` into `ctx.airGap`; GHSA + OSV diff applicators correctly skip network calls.
57
+ - **`cross-ref-api.byCve()` filters out auto-imported drafts by default**. An optional `{ include_drafts: true }` opt-in is available for the curation questionnaire path. Bundles, analyze, and other operator-facing surfaces no longer treat unreviewed drafts as authoritative.
58
+
59
+ ### Concurrency
60
+
61
+ - **`cross-ref-api` cache invalidates on file mtime change**. The previous process-lifetime cache meant a long-running `orchestrator watch` process never observed catalog updates applied by an out-of-band `refresh-external --apply`. Each `loadCatalog` / `loadIndex` call now compares the cached mtime against `fs.statSync`; mismatch re-parses.
62
+ - **`persistAttestation --force-overwrite` retry cap reduced** from 50 to 10 (~1 second worst-case event-loop block under attestation contention, down from ~10 seconds). Failure returns include a `lock_contention: true` sentinel + `LOCK_CONTENTION:` error prefix so callers can distinguish lock-busy from hard failure. An async refactor of `persistAttestation` and its call sites is a v0.13.0 candidate.
63
+ - **`acquireLock` (playbook-runner) probes PID liveness on EEXIST**. Previously a stale-PID lockfile caused `acquireLock` to return null silently; callers proceeded unlocked. The function now parses the lockfile PID, calls `process.kill(pid, 0)`, reclaims on `ESRCH`, and returns a structured diagnostic when the lock is held by a live process.
64
+
65
+ ### CI workflows
66
+
67
+ - **Top-level `permissions: contents: read`** added to `.github/workflows/release.yml` and `.github/workflows/refresh.yml`. Per-job blocks retain their elevated scopes. Closes outstanding Scorecard `TokenPermissionsID` alerts.
68
+
69
+ ### Tests
70
+
71
+ - New: `tests/audit-aa-trust-fixes.test.js`, `tests/audit-bb-p1-fixes.test.js`, `tests/audit-cc-csaf-fixes.test.js`, `tests/audit-ee-gg-cli-fixes.test.js`, `tests/audit-ff-dd-hh-fixes.test.js`.
72
+ - `tests/audit-r-cli-fixes.test.js` — 8 `notEqual(r.status, 0)` assertions tightened to `assert.equal(r.status, 1)` per the coincidence-passing-tests contract.
73
+ - `tests/audit-s-t-u-z-fixes.test.js` — classification-override assertion pinned to `'inconclusive'` (was `notEqual('detected')`).
74
+ - `tests/operator-bugs.test.js` — `#87 doctor --fix is registered` rewritten as a non-mutating `--help` probe; the previous shape staged a dummy `.keys/private.pem` in the real repo root, replicating the v0.12.4 incident anti-pattern.
75
+
76
+ ### Skill content
77
+
78
+ - `webapp-security` skill — `CVE-2025-53773` CVSS aligned to catalog (`7.8 / AV:L`, was `9.6`).
79
+ - `kernel-lpe-triage` skill — `CVE-2026-31431` KEV listing date aligned to catalog (`2026-05-01`, was `2026-03-15`).
80
+
81
+ ### Hard Rule #5 (global-first) coverage
82
+
83
+ UK CAF + AU Essential 8 / ISM entries added to the framework-control-gap declarations across 10 playbooks (`kernel`, `mcp`, `ai-api`, `crypto`, `sbom`, `runtime`, `cred-stores`, `secrets`, `containers`, `hardening`). NIS2 Art. 21 + DORA Art. 9 added to `hardening` and `containers`. Each entry follows the existing schema shape; the gold-standard templates from `framework`, `crypto-codebase`, and `library-author` remain the reference.
84
+
85
+ ### Operator-facing comments
86
+
87
+ A scrub across 19 shipped source files (`bin/`, `lib/`, `scripts/`) removed 101 internal-vocabulary references (`(audit X PN-N)`, `// Audit Y PN-N: ...`, `v0.12.X (audit Z PN-N):`). The remaining behavior-framing comments describe the change itself; the surrounding context (version pin, WHY) is preserved where it carries operator value.
88
+
89
+ ### Operator action required
90
+
91
+ - **`NPM_TOKEN` env-scope migration**. The publish token is still org-scoped on `blamejs.NPM_TOKEN` with `visibility=all`. To complete the v0.12.16 audit goal, run:
92
+ ```
93
+ gh secret set NPM_TOKEN --env npm-publish --body "<paste npm automation token>"
94
+ gh secret list --env npm-publish # verify
95
+ gh secret delete NPM_TOKEN --org blamejs # AFTER verifying the env-scoped one is picked up
96
+ ```
97
+ The repo-side wiring (`release.yml` declares `environment: npm-publish` on the publish job; the env has the `tag: v*.*.*` deploy filter) is already in place.
98
+
99
+ Test count: 840 → 943 (new audit closure files + scrubbed assertions). Predeploy gates: 14/14. Skills: 38/38 signed; manifest envelope signed.
100
+
101
+ ## 0.12.20 — 2026-05-14
4
102
 
5
- **Patch: e2e FP-check attestations for v0.12.17 indicator backfill. v0.12.17 publish payload.**
103
+ **Patch: e2e scenarios attest FP checks for indicators that the v0.12.19 classification-override block now forces to `inconclusive` when unattested.**
6
104
 
7
- The v0.12.17 tag exists on git but never reached npm release.yml's validate gate's `npm run test:e2e` failed 4/20 scenarios because v0.12.17's indicator FP-check backfill (audit K) caused the runner to downgrade hits to inconclusive without operator attestation, dropping RWEP scores below the e2e expect thresholds.
105
+ The v0.12.19 engine fix blocks `detection_classification: 'detected'` agent overrides when ANY indicator with `false_positive_checks_required[]` fires without operator attestation. Five e2e scenarios asserting `classification: detected` were submitting FP-required indicator hits without attestations, so the runner correctly downgraded them. The scenarios now attest the FP checks:
8
106
 
9
- v0.12.18 ships the v0.12.17 fix payload plus FP-check attestations on the affected e2e scenarios:
107
+ - `09-secrets-aws-key`: attest `aws-secret-access-key` (3 checks)
108
+ - `10-kernel-copy-fail`: attest `unpriv-userns-enabled` (2 checks)
109
+ - `14-framework-jurisdiction-gap`: attest `exception-missing-expiry-or-owner` + `jurisdiction-without-framework` (2 + 2)
110
+ - `16-containers-root-user`: attest `dockerfile-curl-pipe-bash` (3 checks; `dockerfile-runs-as-root` was already attested)
111
+ - `19-crypto-rsa-2048-eol`: attest `openssl-pre-3-5` + `ml-dsa-slh-dsa-absent` (3 + 3)
112
+
113
+ The v0.12.19 tag exists on git but never reached the npm registry — the release workflow's `validate` job failed against the pre-update scenarios. v0.12.20 ships the v0.12.19 trust-chain + engine + bundle + concurrency payload plus the scenario updates.
114
+
115
+ ## 0.12.19 — 2026-05-14
116
+
117
+ **Patch: trust-chain hardening across attestation verify + refresh-network + verify-shipped-tarball; engine FP-bypass closures; bundle correctness; concurrency safety; KEV-draft promotability; README CVSS correction.**
118
+
119
+ ### Trust chain
120
+
121
+ - **`attest verify` returns exit 6 + `ok:false` on TAMPERED**. The subverb previously emitted `{verb, session_id, results}` without `ok:false` when any sidecar failed verification — the `emit()` auto-exitCode contract only fires on `ok:false`, so a tampered attestation passed exit 0. CI gates and shell pipelines now see the correct failure signal.
122
+ - **`reattest` refuses missing `.sig` sidecar** unless `--force-replay` is supplied. A deleted sidecar previously hit the same silently-consumed path as a clean attestation; the drift verdict was meaningless. `--force-replay` records `sidecar_verify` + `force_replay: true` in the persisted body so the override is auditable.
123
+ - **`refresh-network` verifies the tarball's `manifest_signature`** before swapping in the new skill set. The previous swap only verified per-skill signatures and trusted the manifest itself unconditionally; a coordinated attacker who could rewrite the manifest envelope's `skills[].signature` field (without breaking individual skill-body crypto) passed the check. Swap now refuses on `invalid` OR `missing` (stricter than the post-install loader, which still degrades to warn-and-continue for legacy unsigned tarballs).
124
+ - **`verify-shipped-tarball` predeploy gate verifies the manifest envelope signature** in addition to per-skill bodies. Mirrors the post-install verifier so the publish-time gate catches manifest-level tampering before the tarball reaches operators.
125
+ - **`keys/EXPECTED_FINGERPRINT` consulted at every public-key load site**. `attest verify`, `reattest` (via `verifyAttestationSidecar`), and the attestation sign path now cross-check the loaded public key against the pinned fingerprint, refusing on mismatch. Honors `KEYS_ROTATED=1` for legitimate rotation; missing pin file warns and continues. Closes the previously-misleading note in the v0.12.16 entry — the pin was claimed at "every load site" but the bin/exceptd.js sites were not consulting it.
126
+ - **`manifest_signature.signed_at` removed** from the signed-bytes envelope. The field was excluded from the canonical input but included in the output object, letting an attacker replay a stale signature and rewrite the timestamp to lie about freshness. `manifest_signature` now carries `{algorithm, signature_base64}` only; consumers needing a freshness signal read git log or filesystem mtime.
127
+ - **`manifest_signature.algorithm` validated strictly** (`=== 'Ed25519'`). A missing field previously bypassed the algorithm guard; now refused unless the field is present and matches.
128
+ - **Unsigned-manifest warning deduplicated** via `process.emitWarning(..., { code: 'EXCEPTD_MANIFEST_UNSIGNED' })`. CLI verbs calling `loadManifestValidated` more than once per invocation no longer emit the warning N times.
129
+ - **Attestation sign + verify normalize CRLF/BOM**. All three attestation pipeline sites (`maybeSignAttestation`, `verifyAttestationSidecar`, `attest verify`) now apply the same `normalize()` contract as the manifest signer. Closes the CRLF-on-Windows divergence class that produced the v0.11.x signature regressions, now mirrored at attestation granularity.
130
+ - **Cross-implementation `normalize()` contract test** asserts byte-identical output across `lib/sign.js`, `lib/verify.js`, `lib/refresh-network.js`, `scripts/verify-shipped-tarball.js`, and `bin/exceptd.js#normalizeAttestationBytes` against a 16-input fuzz corpus (plain LF, CRLF, BOM+LF, BOM+CRLF, double BOM, embedded `\r`, mixed line endings, embedded nulls, empty string, unicode codepoints, fixed-point convergence).
131
+
132
+ ### Engine + FP-check enforcement
133
+
134
+ - **Array-shape FP attestation rejected**. `signal_overrides: { '<indicator>__fp_checks': [true, true] }` (array) previously bypassed the gate: `typeof [] === 'object'` is true and index-string fallback `att[String(idx)]` matched the array indices. Arrays now land in the empty-attestation branch and every required FP check is treated as unsatisfied.
135
+ - **Agent-supplied `detection_classification: 'detected'` override blocked when any indicator is FP-downgraded**. The runner previously honored the override unconditionally; an agent could mark the run `detected` even though every indicator with `false_positive_checks_required[]` had unsatisfied checks. Substitution to `inconclusive` is now forced and a `classification_override_blocked` runtime_error records the attempted value, the substituted value, and the indicators driving the downgrade.
136
+ - **`normalizeSubmission` runtime errors reach `analyze.runtime_errors[]`**. The helper recorded validation errors (e.g. `signal_overrides_invalid` for non-object input) on its own scratch array but the engine never harvested them; the v0.12.14 promise that `runtime_errors[]` surfaces every validation failure was silently incomplete. Errors now splice into the run-level accumulator before the F1 evidence-hash digest, then the scratch property is deleted so the digest stays deterministic.
137
+
138
+ ### Bundle correctness
139
+
140
+ - **CSAF + OpenVEX `fixed` status gated on `vex_status`, not `live_patch_available`**. The catalog's `live_patch_available` field means "vendor publishes a live-patch in the world" — NOT "operator has deployed it." Bundles were emitting `product_status: fixed` / OpenVEX `status: "fixed"` for every CVE in the catalog with a live-patch route, regardless of operator disposition. Now: `fixed` requires `c.vex_status === 'fixed'` (operator-supplied via `--vex`); live-patchable CVEs without an operator attestation emit `known_affected` / OpenVEX `affected` with `remediations[].category: vendor_fix` pointing at the live-patch.
141
+ - **SARIF `artifactLocation.uri` validates path shape**. The previous logic stripped `^https?://` and split on `AND|OR`, leaving shell commands like `uname -r` or English prose as the URI. GitHub Code Scanning rejected or rendered these garbled. A path-shape predicate now accepts POSIX absolute, home (`~`), relative dot, Windows drive, `file:` URI, and bare relative paths; rejects whitespace + shell metachars. Non-path sources omit `locations` cleanly.
142
+ - **CSAF framework gaps emitted as `document.notes[]`** instead of `vulnerabilities[]`. Framework-gap entries previously carried `ids: [{system_name: "exceptd-framework-gap"}]` — not a recognized vulnerability tracking authority. NVD / Red Hat dashboards saw 9 false-positive advisories per run. Now rendered as `notes[].category: "details"`.
143
+ - **`bundle_body` and `bundles_by_format` share timestamps**. `buildEvidenceBundle` was called twice in close(); each invocation minted independent `new Date().toISOString()` values, so `document.tracking.initial_release_date` (CSAF) and `timestamp` (OpenVEX) differed by milliseconds across the two bundle surfaces. A memoized build now produces one bundle reused at both call sites.
144
+ - **SARIF `invocations[0].properties` strips nulls**. Aligns with the rest of the SARIF emitter so consumer dashboards don't see `{ "exit_code": null }` noise.
145
+
146
+ ### CLI hardening
147
+
148
+ - **Windows stdin auto-detect fixed**. `cmdRun` and `cmdIngest` used `process.stdin.isTTY === false` (strict equality). On Windows MSYS bash, `process.stdin.isTTY === undefined` for a piped stream, so the check failed and `echo '{...}' | exceptd run ...` was not picked up as evidence. Both call sites now use truthy `!process.stdin.isTTY` (parity with `cmdAiRun`).
149
+ - **`--vex` validates document shape on empty `vulnerabilities[]`**. The detect heuristic previously returned `entriesLookVex` true for any document with an empty `vulnerabilities` array — including `{"bomFormat":"NOT-CycloneDX","vulnerabilities":[]}`. Empty arrays now require `bomFormat === "CycloneDX"` OR `specVersion` starting with `1.`.
150
+ - **`--vex` enforces a 32 MB size cap**. `fs.statSync` check before `fs.readFileSync` matches the cap on `--evidence`.
151
+ - **`--scope ""` rejected with the accepted-set message** instead of silently auto-detecting. The gate changed from truthy `args.scope` to `args.scope !== undefined`, so empty string reaches `validateScopeOrThrow`.
152
+ - **`--since` validated against ISO-8601 regex BEFORE `Date.parse`** on `attest list` and `reattest`. `Date.parse("99")` returned 1999-12-01 (a legitimate-looking ISO-8601 short form). The regex now requires `YYYY-MM-DD` minimum.
153
+ - **Session-id validation runs before `findSessionDir`** in `cmdAttest`. Previously a regex-rejected id (e.g. `'../../..'`) and a valid-shape-but-not-found id both surfaced as "no session dir" — the validation error is now reported distinctly.
154
+ - **`--evidence-dir` refuses symbolic links** via `fs.lstatSync` check. Prior path-traversal guards covered string-resolved paths but symlinks pointing outside the directory followed transparently through `readFileSync`.
155
+ - **Three `process.exit(N)` sites after stderr writes** in the main dispatcher (unknown command, missing script, spawn error) replaced with `emitError()` + `process.exitCode = N; return;`. Stderr drains under piped CI consumers.
156
+ - **`buildJurisdictionClockRollup` output carries both `obligation` and `obligation_ref`**. The CHANGELOG previously claimed the dedupe key was `(jurisdiction, regulation, obligation, window_hours)` while the rollup body emitted `obligation_ref` only; both shapes now ship.
157
+
158
+ ### Concurrency
159
+
160
+ - **`withCatalogLock` (refresh-external) and `withIndexLock` (prefetch) probe PID liveness** before falling through to the mtime-based stale-lock check. A lockfile written by a dead process is now reclaimed immediately (`process.kill(pid, 0)` → ESRCH → unlink). Matches the pattern already used in `orchestrator/index.js#_acquireWatchLock` and `lib/playbook-runner.js#acquireLock`.
161
+ - **`persistAttestation --force-overwrite` serialized via a lockfile**. Concurrent overwrites of the same path previously last-write-wins; the `prior_evidence_hash` chain lost intermediate writers. An `O_EXCL` lockfile gate at `<filePath>.lock` (with PID-liveness reclaim) now serializes the read-prior / write-new sequence.
162
+ - **`prefetch.js` payload staging atomic**. The fetcher previously wrote the cached payload before acquiring the index lock; a lock-acquisition timeout left orphan payload files with no index entry. Payload is now written to `<targetPath>.tmp.<pid>.<rand>` first; inside `withIndexLock` the rename + index update happen as an atomic pair; on lock-acquisition failure the tmp file is unlinked.
163
+ - **`scheduleEvery(0)` / `(-1)` / `(NaN)` rejected** with `RangeError`. Previously `scheduleEvery(0, fn)` fired ~93 times in 100 ms; negative values produced similar tight loops. `Number.isFinite(intervalMs) && intervalMs > 0` is now required.
164
+
165
+ ### Auto-discovery + curation
166
+
167
+ - **KEV-discovered drafts now promotable**. `buildKevDraftEntry` previously stored `rwep_factors` with boolean values (the input shape for `scoreCustom`) plus `source_verified: null` — both shapes violated the strict catalog schema, hard-failing promotion. Drafts now carry post-weight numeric `rwep_factors` (matching the catalog norm) summing to `rwep_score` exactly, and `source_verified: <today>` (the KEV listing IS the verification source).
168
+
169
+ ### Operator-facing factual
170
+
171
+ - **README CVE-2025-53773 CVSS aligned to catalog** (7.8, not 9.6). The catalog correction landed in v0.12.14 across 11 skills; the README example was missed.
172
+
173
+ ### Predeploy
174
+
175
+ - **`Validate playbooks` gate caps informational exit at 1** via `informationalMaxExitCode: 1`. A CRASH (137/139) now surfaces as a real failure instead of being demoted to informational, matching the forward-watch gate's existing ceiling.
176
+
177
+ ### Catalog
178
+
179
+ - **`ai-api` playbook `domain.cve_refs` += `CVE-2026-42208`** (cited in threat_context, was missing from the structured refs).
180
+
181
+ ### Tests
182
+
183
+ - New: `tests/normalize-contract.test.js`, `tests/audit-o-q-r-fixes.test.js`, `tests/audit-r-cli-fixes.test.js`, `tests/audit-s-t-u-z-fixes.test.js`, `tests/bundle-correctness.test.js`, `tests/_helpers/concurrent-attestation-writer.js`.
184
+ - Touched: `tests/predeploy-gates.test.js` (gate-14 fixture signs the manifest envelope so per-skill verify still runs against tamper variants); `tests/operator-bugs.test.js` (#91 framework-gap assertion updated to the new `document.notes[]` contract); `tests/auto-discovery.test.js` (KEV-draft schema-shape + active_exploitation enum + source_verified date).
185
+
186
+ Test count: 760 → 840 (838 pass + 2 skipped). Predeploy gates: 14/14. Skills: 38/38 signed; manifest envelope signed; manifest signature shape `{algorithm, signature_base64}` (no `signed_at`).
187
+
188
+ ## 0.12.18 — 2026-05-14
189
+
190
+ **Patch: e2e scenarios attest FP-check satisfaction for indicators that carry `false_positive_checks_required[]`.**
191
+
192
+ Four e2e scenarios assert `classification: detected` against indicators whose v0.12.17 FP-check backfill now requires explicit operator attestation. Without the attestation, the engine downgrades hits to `inconclusive` and the scenarios' RWEP thresholds aren't met. The scenarios now carry the attestation shape:
10
193
 
11
194
  - `12-crypto-codebase-md5-eol`: attest FP checks for `weak-hash-import` + `no-ml-kem-implementation`
12
195
  - `15-cred-stores-aws-static`: attest FP checks for `aws-static-key-present`
13
- - `16-containers-root-user`: attest FP checks for `dockerfile-runs-as-root`; lower `adjusted` threshold from 15 → 10 (only `dockerfile-from-latest` carries an `rwep_inputs` entry on the containers playbook; the FP-attested `dockerfile-runs-as-root` fires but doesn't drive RWEP)
196
+ - `16-containers-root-user`: attest FP checks for `dockerfile-runs-as-root`; `adjusted` threshold lowered from 15 → 10 (only `dockerfile-from-latest` carries an `rwep_inputs` entry on the containers playbook; the FP-attested `dockerfile-runs-as-root` fires but doesn't drive RWEP)
14
197
  - `20-ai-api-openai-dotfile`: attest FP checks for `cleartext-api-key-in-dotfile` + `long-lived-aws-keys`
15
198
 
16
- Attestation shape per the E1 contract (v0.12.12): `signal_overrides: { '<indicator>__fp_checks': { '0': true, '1': true, ... } }` — each entry means "I've verified that this FP scenario does NOT apply; this is a real hit."
199
+ Attestation shape per the E1 contract: `signal_overrides: { '<indicator>__fp_checks': { '0': true, '1': true, ... } }` — each entry means "I've verified that this FP scenario does NOT apply; this is a real hit."
17
200
 
18
201
  ## 0.12.17 — 2026-05-14
19
202
 
20
- **Patch: remaining deferred P1/P2 items from the v0.12.15 audit pile — manifest signing, Windows ACL, indicator FP backfill, schema promotion.**
203
+ **Patch: manifest signing, Windows ACL on signing key, indicator FP-check backfill, schema promotion.**
21
204
 
22
- ### Manifest signing (audit I P1-4)
205
+ ### Manifest signing
23
206
 
24
207
  The previous trust chain signed each skill body individually but the manifest itself was just an unsigned index. A coordinated attacker who could rewrite `manifest.json` + `manifest-snapshot.json` + `manifest-snapshot.sha256` passed every gate (snapshot is checked locally, the sha256 also computed locally).
25
208
 
@@ -27,11 +210,11 @@ Now: `manifest.json` carries a top-level `manifest_signature` field (Ed25519 ove
27
210
 
28
211
  Canonical-form contract documented in both `lib/sign.js` and `lib/verify.js` headers — future shape changes to manifest.json must respect the invariants (sort top-level keys, exclude `manifest_signature`, normalize line endings).
29
212
 
30
- ### Windows ACL on `.keys/private.pem` (audit I P1-3)
213
+ ### Windows ACL on `.keys/private.pem`
31
214
 
32
215
  `lib/sign.js` previously wrote the private key with `{ mode: 0o600 }`. On POSIX this restricts read access to the owner. On Windows the `mode` argument maps to read/write attributes only, not POSIX permissions; ACLs inherited from the parent directory. A multi-user maintainer workstation or shared CI runner therefore allowed any process under the same desktop user to read the key. Now: on `win32`, `lib/sign.js` calls `icacls /inheritance:r /grant:r ${USERNAME}:F` after writing the private key, narrowing the ACL to the current user. The same restriction is applied via `restrictWindowsAcl(targetPath)` from `scripts/bootstrap.js` when bootstrap creates the keypair. Falls back to a stderr warning if `icacls` is unavailable; doesn't fail key generation.
33
216
 
34
- ### Indicator FP-check backfill (audit K)
217
+ ### Indicator FP-check backfill
35
218
 
36
219
  36 deterministic indicators across 11 playbooks now carry `false_positive_checks_required[]` entries (the gold-standard pattern from `library-author.gha-workflow-script-injection-sink` in v0.12.13). Per-playbook coverage:
37
220
 
@@ -48,37 +231,33 @@ Canonical-form contract documented in both `lib/sign.js` and `lib/verify.js` hea
48
231
  - `sbom` — 3 (lockfile-no-integrity, kev-listed-match, windsurf-vulnerable-version)
49
232
  - `secrets` — 5 (aws-secret-access-key, slack-bot-or-user-token, stripe-secret-key, openai-api-key, anthropic-api-key)
50
233
 
51
- Each entry is a 1-line check an AI assistant or operator must satisfy before the indicator's `hit` verdict can drive `classification: detected`. The runner downgrades a hit with unsatisfied FP checks to `inconclusive` (v0.12.12 E1 contract). Backfill complements the playbook-level `false_positive_profile[]` documentation by binding FP checks per-indicator at the schema layer.
234
+ Each entry is a 1-line check an AI assistant or operator must satisfy before the indicator's `hit` verdict can drive `classification: detected`. The runner downgrades a hit with unsatisfied FP checks to `inconclusive` (E1 contract from v0.12.12). Binding FP checks per-indicator at the schema layer complements the playbook-level `false_positive_profile[]` documentation.
52
235
 
53
- ### Schema promotion (audit K + audit M)
236
+ ### Schema promotion
54
237
 
55
- `lib/schemas/playbook.schema.json` indicator object now formally declares `false_positive_checks_required[]` and `cve_ref` as optional fields (was unschema'd; produced WARN noise on every validate run). The `cve_ref` field has been load-bearing since v0.12.14 F3 (drives `analyze.matched_cves[]` correlation); the schema declaration just catches up. After the schema promotion + the v0.12.16 enum-drift normalisation, `validate-playbooks` runs 13/13 PASS with zero warnings (was 12/13 PASS + 1 WARN in v0.12.16, 8/13 PASS + 5 WARN in v0.12.15).
238
+ `lib/schemas/playbook.schema.json` indicator object now formally declares `false_positive_checks_required[]` and `cve_ref` as optional fields (was unschema'd; produced WARN noise on every validate run). The `cve_ref` field has been load-bearing since v0.12.14 (drives `analyze.matched_cves[]` correlation); the schema declaration catches up. `validate-playbooks` runs 13/13 PASS with zero warnings.
56
239
 
57
240
  ### Operator-facing surfaces
58
241
 
59
- - **`--diff-from-latest` result surfaced in `run` human renderer (audit L F11)**. Operators running with `--diff-from-latest` and no `--json` previously got no visibility on drift; now: `> drift vs prior: unchanged (same evidence_hash as session <prior_id>)` or `> drift vs prior: DRIFTED — evidence_hash differs from session <prior_id>` is added near the classification line. No line when there's no prior attestation for the playbook (don't clutter).
60
- - **`ai-run` stdin acceptance contract documented in `--help`**. The streaming + no-stream paths both consume "first parseable evidence event wins on stdin; subsequent evidence events ignored; non-evidence chatter silently ignored; invalid JSON exits 1." Was implicit behavior; now explicit so AI agents calling `exceptd ai-run` know the contract.
242
+ - **`--diff-from-latest` result surfaced in `run` human renderer**. Operators running with `--diff-from-latest` and no `--json` previously got no visibility on drift; now: `> drift vs prior: unchanged (same evidence_hash as session <prior_id>)` or `> drift vs prior: DRIFTED — evidence_hash differs from session <prior_id>` is added near the classification line. No line when there's no prior attestation for the playbook.
243
+ - **`ai-run` stdin acceptance contract documented in `--help`**. The streaming + no-stream paths both consume "first parseable evidence event wins on stdin; subsequent evidence events ignored; non-evidence chatter silently ignored; invalid JSON exits 1." Was implicit behavior; now explicit.
61
244
 
62
- ### Auto-discovery hygiene (audit M P3-O)
245
+ ### Auto-discovery hygiene
63
246
 
64
247
  `lib/auto-discovery.js discoverNewKev` previously hardcoded `severity: 'high'` on every KEV-discovered diff. Now uses `deriveKevSeverity(kevEntry)` — returns `'critical'` when `knownRansomwareCampaignUse === 'Known'` OR `dueDate` is within 7 days; otherwise `'high'`. Downstream PR-body categorization can now route ransomware-use + imminent-due-date KEVs differently.
65
248
 
66
- ### Tests
67
-
68
- - 20 new regression tests in `tests/audit-i-l-m-fixes.test.js` covering: Windows ACL helper export, manifest canonical-bytes determinism (stale signature + key-order invariants), sign + verify round trip, live manifest carries valid signature, tampered signature/skill detection, backward-compat `missing` status, `--diff-from-latest` renderer string/branch assertions, ai-run help-text content, `deriveKevSeverity` matrix, `discoverNewKev` propagation against synthetic KEV feed.
69
-
70
- Test count: 740 → 760 (+20 + 1 reworked). Predeploy gates: 14/14. Skills: 38/38 signed; manifest itself signed.
249
+ Test count: 740 → 760. Predeploy gates: 14/14. Skills: 38/38 signed; manifest itself signed.
71
250
 
72
251
  ## 0.12.16 — 2026-05-14
73
252
 
74
- **Patch: highest-impact P1 security findings from the v0.12.15 audit pile.**
253
+ **Patch: trust chain hardening, CI workflow injection sinks, CLI fuzz fixes, scoring math, curation + auto-discovery + prefetch fixes, playbook hygiene.**
75
254
 
76
- ### Sign/verify trust chain (audit I)
255
+ ### Sign/verify trust chain
77
256
 
78
257
  - **CRLF/BOM bypass on the shipped-tarball verify gate closed.** `scripts/verify-shipped-tarball.js` previously read raw on-disk bytes and called `crypto.verify` directly — bypassing the CRLF/BOM normalization that `lib/sign.js` + `lib/verify.js` apply on both sides of the byte-stability contract. The gate's whole purpose is to catch the v0.11.x signature regression class; without the same normalization, it would itself report 0/38 on any tree where line-ending normalization touched the source between sign and pack (a Windows contributor with `core.autocrlf=true`, or any tool like Prettier in the CI pipeline). The `normalizeSkillBytes` helper is now mirrored in this fourth normalize() implementation.
79
258
  - **`keys/EXPECTED_FINGERPRINT` pin now consulted at every public-key load site.** Previously only `lib/verify.js` + `scripts/verify-shipped-tarball.js` checked the pin. `lib/refresh-network.js` and `bin/exceptd.js attest verify` both loaded `keys/public.pem` and trusted it without the cross-check. A coordinated attacker who tampered with `keys/public.pem` on the operator's host (e.g. via a prior compromised refresh) passed every check because the local↔tarball fingerprints matched each other. Now the pin is the external trust anchor at all four load sites. Honors `KEYS_ROTATED=1` env to allow legitimate rotation without re-bootstrap; missing pin file degrades to warn-and-continue.
80
259
 
81
- ### CI workflow security (audit N)
260
+ ### CI workflow security
82
261
 
83
262
  - **`atlas-currency.yml` script-injection sink closed (CWE-1395).** `${{ steps.currency.outputs.report }}` was interpolated directly into a github-script template literal; the `report` value is unescaped output of `node orchestrator/index.js currency`. A skill author who landed a string containing a backtick followed by `${process.exit(0)}` (or worse, an exfil to a webhook with `${process.env.GITHUB_TOKEN}`) got arbitrary JS execution inside the github-script runtime with the workflow's token. Now routed via `env.REPORT_TEXT` and read inside the script body as `process.env.REPORT_TEXT`.
84
263
  - **`refresh.yml` shell-injection from `workflow_dispatch` input closed (CWE-78).** `${{ inputs.source }}` was interpolated directly into a bash `run:` block. An operator passing `kev; rm -rf /; #` got shell injection inside the runner. Now routed via `env.SOURCE_INPUT` and validated against `^[a-z,]+$` (the documented `kev,epss,nvd,rfc,pins` allowlist shape) before passing to the CLI.
@@ -87,7 +266,7 @@ Test count: 740 → 760 (+20 + 1 reworked). Predeploy gates: 14/14. Skills: 38/3
87
266
  - `gitleaks` resolver now has a hardcoded fallback version + non-fatal failure path so a GitHub API HTML-error response doesn't block every CI run.
88
267
  - New `tests/workflows-security.test.js` enforces: no `${{ steps.*.outputs.* }}` inside github-script template literals; no `${{ inputs.* }}` inside bash `run:` blocks; every third-party action is SHA-pinned; every workflow declares `permissions:`.
89
268
 
90
- ### CLI hardening (audit L)
269
+ ### CLI hardening
91
270
 
92
271
  - **`--block-on-jurisdiction-clock` now honored on `cmdRun`.** Previously the flag was registered + documented but only `cmdCi` consumed it; `run --block-on-jurisdiction-clock` exited 0 even when an NIS2 24h clock had started. Now both verbs exit 5 (`CLOCK_STARTED`) when any notification action has a non-null `clock_started_at` and an unacked operator consent.
93
272
  - **`cmdIngest` auto-detects piped stdin.** Mirrors the `cmdRun` shape — `echo '{...}' | exceptd ingest` now works without an explicit `--evidence -`.
@@ -99,7 +278,7 @@ Test count: 740 → 760 (+20 + 1 reworked). Predeploy gates: 14/14. Skills: 38/3
99
278
  - `--block-on-jurisdiction-clock` exit code split from `FAIL` (exit 2) → `CLOCK_STARTED` (exit 5). CI gates can distinguish "detected" from "clock fired".
100
279
  - `cmdReattest --since` validated as parseable ISO-8601.
101
280
 
102
- ### Scoring math hardening (audit J)
281
+ ### Scoring math hardening
103
282
 
104
283
  - `scoreCustom` now treats `active_exploitation: 'unknown'` as `0.25 × weight` (was 0) — aligning with `playbook-runner._activeExploitationLadder` semantics so catalog-side and runtime-side scoring agree.
105
284
  - New `deriveRwepFromFactors(factors)` helper exported; detects whether `rwep_factors` is in Shape A (boolean inputs to `scoreCustom`) or Shape B (numeric weighted contributions) and produces a consistent score. Documents the dual-semantics so the rename can land cleanly in v0.13.0.
@@ -109,7 +288,7 @@ Test count: 740 → 760 (+20 + 1 reworked). Predeploy gates: 14/14. Skills: 38/3
109
288
  - `compare()` "broadly aligned" band tightened from ±20 to ±10. The Copy Fail RWEP-vs-CVSS divergence (delta 12) now correctly surfaces as "significantly higher than CVSS equivalent."
110
289
  - `Math.floor(20/2)` arithmetic replaced with `RWEP_WEIGHTS.active_exploitation * 0.5` (no behavior change today; closes a future odd-weight asymmetry).
111
290
 
112
- ### Curation + auto-discovery + prefetch (audit M)
291
+ ### Curation + auto-discovery + prefetch
113
292
 
114
293
  - **Hidden second scoring path in `lib/cve-curation.js` closed.** The apply path previously derived `rwep_score` via `Object.values(rwep_factors).reduce(sum, 0)` — bypassing `scoring.js` entirely. Replaced with `deriveRwepFromFactors()`.
115
294
  - **Auto-discovery RWEP divergence closed.** `lib/auto-discovery.js` previously stored `rwep_factors` with null values for poc_available/ai_*/reboot_required while calling `scoreCustom` with `true` defaults; stored factors and stored score were inconsistent and `scoring.validate()` always flagged it. New `buildScoringInputs(kev, nvd)` is the single source of truth.
@@ -117,7 +296,7 @@ Test count: 740 → 760 (+20 + 1 reworked). Predeploy gates: 14/14. Skills: 38/3
117
296
  - **`lib/prefetch.js` docs corrected**: header comment + `printHelp()` no longer reference non-existent source names `ietf` and `github`.
118
297
  - **`readCached` no longer returns stale data as fresh** when `fetched_at` is missing/corrupt (the `NaN > maxAgeMs === false` short-circuit was treating undefined-age entries as eternally-fresh).
119
298
 
120
- ### Playbook quality (audit K)
299
+ ### Playbook quality
121
300
 
122
301
  - **Mutex reciprocity validator** in `lib/validate-playbooks.js`: walks every `_meta.mutex` entry, emits WARNING per asymmetric edge. Reciprocity backfilled across 7 mutex relationships (secrets↔library-author, kernel↔hardening, containers↔library-author, etc.).
123
302
  - **`containers → sbom` feeds_into edge** added (container-image-layer SBOM matching against KEV-listed CVEs is a primary v0.12.x use case but wasn't declared).
@@ -138,11 +317,9 @@ Test count: 701 → 738 (+37: 29 scoring vectors + 8 workflow-security). Predepl
138
317
 
139
318
  ## 0.12.15 — 2026-05-14
140
319
 
141
- **Patch: e2e RWEP factor-scaling fix + audit-surfaced silent-disable regressions. v0.12.14 publish payload.**
142
-
143
- The v0.12.14 tag exists on git but never reached npm — the `validate` job's `npm run test:e2e` gate failed 9/20 scenarios because the v0.12.14 RWEP factor-scaling change (F5 from the engine agent) had no fallback for class-of-vulnerability playbooks that detect without per-CVE evidence correlation. `_factorScale` returned 0 when no `factorCve` was available; every fired indicator's `weight_applied` was forced to 0; catalog-shape playbooks (secrets, library-author, crypto-codebase, framework, cred-stores, containers, runtime, crypto, ai-api) emitted `adjusted: 0` for every detection.
320
+ **Patch: RWEP factor-scaling three-tier fallback + silent-disable regression closures.**
144
321
 
145
- v0.12.15 ships the v0.12.14 deep-audit fix payload plus:
322
+ The v0.12.14 RWEP factor-scaling change had no fallback for class-of-vulnerability playbooks that detect without per-CVE evidence correlation. `_factorScale` returned 0 when no `factorCve` was available, forcing `weight_applied` to 0 and emitting `adjusted: 0` for every detection on catalog-shape playbooks (`secrets`, `library-author`, `crypto-codebase`, `framework`, `cred-stores`, `containers`, `runtime`, `crypto`, `ai-api`).
146
323
 
147
324
  ### Engine: class-of-vulnerability RWEP fallback
148
325
 
@@ -154,34 +331,29 @@ v0.12.15 ships the v0.12.14 deep-audit fix payload plus:
154
331
 
155
332
  The breakdown emits `factor_cve_source: 'evidence' | 'domain' | 'class'` so operators see which tier the run used.
156
333
 
157
- ### Silent-disable regressions closed
334
+ ### Silent-disable regression closures
158
335
 
159
- Three v0.12.12+v0.12.14 fixes that audit M discovered were silently dead:
336
+ Three prior fixes were silently dead:
160
337
 
161
- - **`lib/cve-curation.js loadCveEntrySchema()`** always returned `null` because the function looked for `root.patternProperties["^CVE-\\d{4}-\\d+$"]` or an object `root.additionalProperties`, but the actual `lib/schemas/cve-catalog.schema.json` has neither — its top level IS the entry shape. The v0.12.12 codex P1 #1 fix that gates promotion on strict-schema validation was silently disabled; schema-violating entries promoted out of draft anyway. Fixed to use the root schema directly.
162
- - **`lib/cve-curation.js loadJson("data/attack-ttps.json")`** referenced a path that doesn't exist (canonical is `data/attack-techniques.json`). `loadJsonRaw` swallowed the ENOENT and cached `null`, so the ATT&CK candidate-ranking branch in the curation questionnaire always returned zero proposals. Fixed.
163
- - **`lib/auto-discovery.js _auto_imported`** wrote object-shape provenance (`{source, imported_at, curation_needed}`) but `lib/validate-cve-catalog.js` checks `entry._auto_imported === true` (strict identity). KEV-discovered drafts were treated as production-grade entries instead of warning-tier drafts, hard-failing the strict catalog gate. Fixed to write the boolean `true` with provenance moved to a sibling `_auto_imported_meta` field. Also: `source_verified: false` (boolean) violated the schema's `YYYY-MM-DD | null` shape — fixed to `null`. Template literal bug on the RFC errata URL hint also fixed (was printing literal `${number}` to operators).
338
+ - **`lib/cve-curation.js loadCveEntrySchema()`** always returned `null` because the function looked for `root.patternProperties["^CVE-\\d{4}-\\d+$"]` or an object `root.additionalProperties`, but `lib/schemas/cve-catalog.schema.json` has neither — its top level IS the entry shape. The strict-schema gate on draft promotion never fired; schema-violating entries promoted anyway. Now uses the root schema directly.
339
+ - **`lib/cve-curation.js loadJson("data/attack-ttps.json")`** referenced a path that doesn't exist (canonical is `data/attack-techniques.json`). `loadJsonRaw` swallowed the ENOENT and cached `null`, so the ATT&CK candidate-ranking branch in the curation questionnaire always returned zero proposals. Path corrected.
340
+ - **`lib/auto-discovery.js _auto_imported`** wrote object-shape provenance (`{source, imported_at, curation_needed}`) but `lib/validate-cve-catalog.js` checks `entry._auto_imported === true` (strict identity). KEV-discovered drafts were treated as production-grade entries instead of warning-tier drafts, hard-failing the strict catalog gate. Now writes the boolean `true` with provenance moved to a sibling `_auto_imported_meta` field. `source_verified: false` (boolean) violated the schema's `YYYY-MM-DD | null` shape — now `null`. Template literal bug on the RFC errata URL hint also fixed (was printing literal `${number}` to operators).
164
341
 
165
- ### Scoring math hardening (audit J)
342
+ ### Scoring math hardening
166
343
 
167
344
  - `scoreCustom` now rejects `NaN` / `Infinity` / stringified-number `blast_radius` cleanly via `Number.isFinite(Number(blast_radius))`. The prior `typeof === 'number'` check accepted `NaN` (which IS `typeof === 'number'`) and propagated it through `Math.min/max` to the final return — defeating the `[0, 100]` clamp contract.
168
345
  - `scoreCustom` now accepts either `reboot_required` or the catalog's `patch_required_reboot` field name. The catalog stores `patch_required_reboot`; `scoreCustom` expected `reboot_required`. `validate()` aliased at the call site, but a direct caller passing the catalog entry silently lost the reboot factor.
169
346
  - Defense-in-depth: the final clamp now rejects non-finite scores explicitly (`Number.isFinite(score) ? clamp : 0`).
170
347
 
171
- ### CLI fuzz fixes (audit L)
348
+ ### CLI fuzz fixes
172
349
 
173
350
  - `--scope <invalid>` now produces a structured error instead of silently producing zero results. The prior shape: `run --scope nonsense` returned `count: 0` + `ok: true` + exit 0; `ci --scope nonsense` silently ran only the cross-cutting set (`framework`) with `verdict: PASS`. Both validated as operator-intent loss patterns. Accepted scope set: `system | code | service | cross-cutting | all`.
174
351
 
175
- ### Test alignment
176
-
177
- - `tests/e2e-scenarios/11-library-author-static-token/expect.json` `json_path_min.adjusted` floor adjusted from `20` to `18` to reflect the post-v0.12.14 RWEP semantics. The scenario evidence supplies `verdict.blast_radius: 4` which legitimately scales blast-radius-factor weights by 0.8; the computed value is `19` (was implicitly `>= 20` only because pre-v0.12.14 every fired indicator applied full weight regardless of CVE attributes or agent-supplied blast score).
178
- - `tests/auto-discovery.test.js` updated to assert the new `_auto_imported: true` + `_auto_imported_meta: {...}` shape.
179
-
180
- Test count: 701 (700 pass + 1 skipped POSIX-only SIGTERM test). Predeploy gates: 14/14. Skills: 38/38 signed and verified. v0.12.14 deep-audit fix payload is included.
352
+ Test count: 701 (700 pass + 1 skipped POSIX-only SIGTERM test). Predeploy gates: 14/14. Skills: 38/38 signed and verified.
181
353
 
182
354
  ## 0.12.14 — 2026-05-14
183
355
 
184
- **Patch: ~210-finding deep-audit fix batch across trust chain, engine, refresh sources, orchestrator/watch, predeploy gates, catalogs, and skill content.**
356
+ **Patch: hardening across trust chain, engine, refresh sources, orchestrator/watch, predeploy gates, catalogs, and skill content.**
185
357
 
186
358
  ### Trust chain (lib/refresh-network.js)
187
359
 
@@ -224,8 +396,8 @@ GHSA fixture envelope now rejects null / number / string roots; OSV `OSV_HOST_OV
224
396
 
225
397
  ### CLI (bin/exceptd.js)
226
398
 
227
- - **Path traversal on attest read paths closed** (audit A P1-1). `attest show / export / verify / diff` and `reattest` now validate session-id against the same `^[A-Za-z0-9._-]{1,64}$` regex used on writes. Live reproducer `exceptd attest show '../../..'` (which dumped `~/.claude.json` and other home-dir JSON) no longer reads outside the attestation root.
228
- - **`emitError` v0.11.10 anti-pattern closed**. The `process.exit(1)` after stderr-write in `emitError` (and three sibling sites in `cmdRun` / `cmdCi`) replaced with `process.exitCode = 1; return;` — stderr drains under piped CI consumers.
399
+ - **Path traversal on attest read paths closed.** `attest show / export / verify / diff` and `reattest` now validate session-id against the same `^[A-Za-z0-9._-]{1,64}$` regex used on writes. Live reproducer `exceptd attest show '../../..'` (which dumped `~/.claude.json` and other home-dir JSON) no longer reads outside the attestation root.
400
+ - **`process.exit(1)` after stderr-write replaced with `process.exitCode = 1; return;`** in `emitError` and three sibling sites in `cmdRun` / `cmdCi`. Stderr drains under piped CI consumers.
229
401
  - **`ai-run` now persists attestations** in both `--no-stream` and streaming modes. Previously the returned `session_id` couldn't be resolved by `attest show / verify / diff` or `reattest` because the persistence call was missing.
230
402
  - **`attest list --playbook` honors multi-flag** (was: array-vs-scalar comparison silently returned `count: 0`). `--since` validated as parseable ISO-8601.
231
403
  - `--evidence-dir` per-entry path-traversal guard hardened.
@@ -247,8 +419,8 @@ GHSA fixture envelope now rejects null / number / string roots; OSV `OSV_HOST_OV
247
419
 
248
420
  ### Predeploy gates
249
421
 
250
- - New `keys/EXPECTED_FINGERPRINT` pin (closes audit G F4): silent key rotation now fails the gate unless `KEYS_ROTATED=1` is explicitly set.
251
- - New `manifest-snapshot.sha256` pin (audit G F23): manifest-snapshot integrity is now check-able instead of trusted blindly.
422
+ - New `keys/EXPECTED_FINGERPRINT` pin: silent key rotation now fails the gate unless `KEYS_ROTATED=1` is explicitly set.
423
+ - New `manifest-snapshot.sha256` pin: manifest-snapshot integrity is now check-able instead of trusted blindly.
252
424
  - `scripts/check-sbom-currency.js` now cross-checks `sbom.components[]` names + versions against `manifest.skills` and `vendor/blamejs/_PROVENANCE.json`. A renamed/version-bumped skill that didn't regenerate SBOM now fails the gate (was: count-only comparison).
253
425
  - `scripts/check-test-coverage.js` (diff-coverage gate) tightened: identifier must appear inside an actual `test(`/`it(`/`describe(`/`assert(` call body in the same test file that has the matching `require()` — not just anywhere in the corpus. Default routing for unclassified files changed from `other → allowlisted` to `manual-review` so schema files / data catalogs / package.json drift surface in CI output.
254
426
  - `scripts/verify-shipped-tarball.js` now re-`require()`s the extracted tarball's `lib/refresh-network.js` and re-parses the tarball with the shipped parser — `npm pack --offline` flag added. A regression in the parser that previously would have been invisible (gate only used the source-tree parser) now produces a structured divergence error.
@@ -288,9 +460,9 @@ Test count: 586 → 693 (+107: refresh-network rewrite tests, engine non-engine
288
460
 
289
461
  ## 0.12.13 — 2026-05-14
290
462
 
291
- **Patch: e2e scenarios updated for the v0.12.12 jurisdiction-clock semantics.**
463
+ **Patch: e2e scenarios pass `--ack` to exercise the v0.12.12 jurisdiction-clock contract.**
292
464
 
293
- Two e2e scenarios (`02-tanstack-worm-payload`, `09-secrets-aws-key`) assert that `phases.close.jurisdiction_clocks_count >= 1` against a `detected` classification. In v0.12.12 the clock-starts contract was tightened: `clock_starts: detect_confirmed` no longer auto-stamps when classification turns `detected`; the operator must pass `--ack` for the clock to start. Both scenarios now pass `--ack` so the contract is exercised end-to-end. No code changes; v0.12.13 ships solely to land the scenario update and a corresponding npm publish — the v0.12.12 tag exists on git but never reached the npm registry because the validate gate failed against the pre-update scenarios.
465
+ Two e2e scenarios (`02-tanstack-worm-payload`, `09-secrets-aws-key`) assert that `phases.close.jurisdiction_clocks_count >= 1` against a `detected` classification. The v0.12.12 contract: `clock_starts: detect_confirmed` no longer auto-stamps when classification turns `detected`; the operator must pass `--ack` for the clock to start. Both scenarios now pass `--ack`.
294
466
 
295
467
  Test count: 585/585. Predeploy gates: 16/16. Skills: 38/38 signed and verified.
296
468
 
package/README.md CHANGED
@@ -55,7 +55,7 @@ Assess Linux kernel local privilege escalation exposure. Covers Copy Fail (CVE-2
55
55
  ### AI-Specific Attack Surface
56
56
 
57
57
  **[ai-attack-surface](skills/ai-attack-surface/skill.md)**
58
- Comprehensive AI/ML attack surface assessment mapped to MITRE ATLAS v5.1.0 with explicit gap flags. Covers prompt injection as enterprise RCE (CVE-2025-53773 CVSS 9.6, 85%+ bypass rate against SOTA defenses), MCP supply chain RCE (CVE-2026-30615, zero user interaction, 150M+ downloads), RAG exfiltration, model poisoning, AI-assisted exploit development (41% of 2025 zero-days), credential theft acceleration (160% increase).
58
+ Comprehensive AI/ML attack surface assessment mapped to MITRE ATLAS v5.1.0 with explicit gap flags. Covers prompt injection as enterprise RCE (CVE-2025-53773 CVSS 7.8, 85%+ bypass rate against SOTA defenses), MCP supply chain RCE (CVE-2026-30615, zero user interaction, 150M+ downloads), RAG exfiltration, model poisoning, AI-assisted exploit development (41% of 2025 zero-days), credential theft acceleration (160% increase).
59
59
 
60
60
  **[mcp-agent-trust](skills/mcp-agent-trust/skill.md)**
61
61
  Enumerate MCP (Model Context Protocol) trust boundary failures. Covers tool allowlisting gaps, unsigned server manifests, prompt injection via tool responses, supply chain compromise. CVE-2026-30615 (Windsurf, zero-interaction RCE). Generates: tool allowlist policy, server signing requirements, bearer auth config, output sanitization requirements.