@blamejs/exceptd-skills 0.12.16 → 0.12.20
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 +174 -31
- package/README.md +1 -1
- package/bin/exceptd.js +378 -50
- package/data/_indexes/_meta.json +2 -2
- package/data/playbooks/ai-api.json +26 -5
- package/data/playbooks/containers.json +23 -4
- package/data/playbooks/cred-stores.json +18 -3
- package/data/playbooks/crypto-codebase.json +18 -3
- package/data/playbooks/crypto.json +12 -2
- package/data/playbooks/framework.json +15 -3
- package/data/playbooks/hardening.json +21 -4
- package/data/playbooks/kernel.json +10 -2
- package/data/playbooks/mcp.json +15 -3
- package/data/playbooks/runtime.json +17 -3
- package/data/playbooks/sbom.json +16 -3
- package/data/playbooks/secrets.json +30 -5
- package/lib/auto-discovery.js +96 -10
- package/lib/playbook-runner.js +188 -32
- package/lib/prefetch.js +62 -15
- package/lib/refresh-external.js +27 -0
- package/lib/refresh-network.js +91 -3
- package/lib/schemas/playbook.schema.json +7 -1
- package/lib/sign.js +171 -2
- package/lib/verify.js +171 -2
- package/manifest-snapshot.json +1 -1
- package/manifest-snapshot.sha256 +1 -1
- package/manifest.json +44 -40
- package/orchestrator/scheduler.js +10 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
- package/scripts/predeploy.js +5 -0
- package/scripts/verify-shipped-tarball.js +89 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,15 +1,165 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.12.20 — 2026-05-14
|
|
4
|
+
|
|
5
|
+
**Patch: e2e scenarios attest FP checks for indicators that the v0.12.19 classification-override block now forces to `inconclusive` when unattested.**
|
|
6
|
+
|
|
7
|
+
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
|
+
|
|
9
|
+
- `09-secrets-aws-key`: attest `aws-secret-access-key` (3 checks)
|
|
10
|
+
- `10-kernel-copy-fail`: attest `unpriv-userns-enabled` (2 checks)
|
|
11
|
+
- `14-framework-jurisdiction-gap`: attest `exception-missing-expiry-or-owner` + `jurisdiction-without-framework` (2 + 2)
|
|
12
|
+
- `16-containers-root-user`: attest `dockerfile-curl-pipe-bash` (3 checks; `dockerfile-runs-as-root` was already attested)
|
|
13
|
+
- `19-crypto-rsa-2048-eol`: attest `openssl-pre-3-5` + `ml-dsa-slh-dsa-absent` (3 + 3)
|
|
14
|
+
|
|
15
|
+
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.
|
|
16
|
+
|
|
17
|
+
## 0.12.19 — 2026-05-14
|
|
18
|
+
|
|
19
|
+
**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.**
|
|
20
|
+
|
|
21
|
+
### Trust chain
|
|
22
|
+
|
|
23
|
+
- **`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.
|
|
24
|
+
- **`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.
|
|
25
|
+
- **`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).
|
|
26
|
+
- **`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.
|
|
27
|
+
- **`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.
|
|
28
|
+
- **`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.
|
|
29
|
+
- **`manifest_signature.algorithm` validated strictly** (`=== 'Ed25519'`). A missing field previously bypassed the algorithm guard; now refused unless the field is present and matches.
|
|
30
|
+
- **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.
|
|
31
|
+
- **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.
|
|
32
|
+
- **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).
|
|
33
|
+
|
|
34
|
+
### Engine + FP-check enforcement
|
|
35
|
+
|
|
36
|
+
- **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.
|
|
37
|
+
- **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.
|
|
38
|
+
- **`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.
|
|
39
|
+
|
|
40
|
+
### Bundle correctness
|
|
41
|
+
|
|
42
|
+
- **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.
|
|
43
|
+
- **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.
|
|
44
|
+
- **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"`.
|
|
45
|
+
- **`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.
|
|
46
|
+
- **SARIF `invocations[0].properties` strips nulls**. Aligns with the rest of the SARIF emitter so consumer dashboards don't see `{ "exit_code": null }` noise.
|
|
47
|
+
|
|
48
|
+
### CLI hardening
|
|
49
|
+
|
|
50
|
+
- **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`).
|
|
51
|
+
- **`--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.`.
|
|
52
|
+
- **`--vex` enforces a 32 MB size cap**. `fs.statSync` check before `fs.readFileSync` matches the cap on `--evidence`.
|
|
53
|
+
- **`--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`.
|
|
54
|
+
- **`--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.
|
|
55
|
+
- **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.
|
|
56
|
+
- **`--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`.
|
|
57
|
+
- **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.
|
|
58
|
+
- **`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.
|
|
59
|
+
|
|
60
|
+
### Concurrency
|
|
61
|
+
|
|
62
|
+
- **`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`.
|
|
63
|
+
- **`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.
|
|
64
|
+
- **`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.
|
|
65
|
+
- **`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.
|
|
66
|
+
|
|
67
|
+
### Auto-discovery + curation
|
|
68
|
+
|
|
69
|
+
- **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).
|
|
70
|
+
|
|
71
|
+
### Operator-facing factual
|
|
72
|
+
|
|
73
|
+
- **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.
|
|
74
|
+
|
|
75
|
+
### Predeploy
|
|
76
|
+
|
|
77
|
+
- **`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.
|
|
78
|
+
|
|
79
|
+
### Catalog
|
|
80
|
+
|
|
81
|
+
- **`ai-api` playbook `domain.cve_refs` += `CVE-2026-42208`** (cited in threat_context, was missing from the structured refs).
|
|
82
|
+
|
|
83
|
+
### Tests
|
|
84
|
+
|
|
85
|
+
- 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`.
|
|
86
|
+
- 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).
|
|
87
|
+
|
|
88
|
+
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`).
|
|
89
|
+
|
|
90
|
+
## 0.12.18 — 2026-05-14
|
|
91
|
+
|
|
92
|
+
**Patch: e2e scenarios attest FP-check satisfaction for indicators that carry `false_positive_checks_required[]`.**
|
|
93
|
+
|
|
94
|
+
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:
|
|
95
|
+
|
|
96
|
+
- `12-crypto-codebase-md5-eol`: attest FP checks for `weak-hash-import` + `no-ml-kem-implementation`
|
|
97
|
+
- `15-cred-stores-aws-static`: attest FP checks for `aws-static-key-present`
|
|
98
|
+
- `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)
|
|
99
|
+
- `20-ai-api-openai-dotfile`: attest FP checks for `cleartext-api-key-in-dotfile` + `long-lived-aws-keys`
|
|
100
|
+
|
|
101
|
+
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."
|
|
102
|
+
|
|
103
|
+
## 0.12.17 — 2026-05-14
|
|
104
|
+
|
|
105
|
+
**Patch: manifest signing, Windows ACL on signing key, indicator FP-check backfill, schema promotion.**
|
|
106
|
+
|
|
107
|
+
### Manifest signing
|
|
108
|
+
|
|
109
|
+
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).
|
|
110
|
+
|
|
111
|
+
Now: `manifest.json` carries a top-level `manifest_signature` field (Ed25519 over canonical sort-keys representation with the signature field excluded and `normalize()`-applied bytes). `lib/sign.js sign-all` and `lib/sign.js sign-skill` both re-sign the manifest after per-skill work; `lib/verify.js loadManifestValidated()` verifies the manifest signature before iterating skills. Tampered manifest entries (path swap, signature substitution) now fail the manifest-level check. Missing `manifest_signature` field emits a warning but doesn't block (backward-compat for legacy tarballs in the wild).
|
|
112
|
+
|
|
113
|
+
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).
|
|
114
|
+
|
|
115
|
+
### Windows ACL on `.keys/private.pem`
|
|
116
|
+
|
|
117
|
+
`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.
|
|
118
|
+
|
|
119
|
+
### Indicator FP-check backfill
|
|
120
|
+
|
|
121
|
+
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:
|
|
122
|
+
|
|
123
|
+
- `ai-api` — 4 indicators (cleartext-api-key-in-dotfile, long-lived-aws-keys, gcp-service-account-json, kubeconfig-with-static-token)
|
|
124
|
+
- `containers` — 4 (dockerfile-runs-as-root, dockerfile-curl-pipe-bash, compose-cap-add-sys-admin, compose-host-network)
|
|
125
|
+
- `cred-stores` — 3 (aws-static-key-present, docker-cleartext-auth, credentials-file-bad-perms)
|
|
126
|
+
- `crypto-codebase` — 3 (weak-hash-import, weak-cipher-mode, tls-old-protocol)
|
|
127
|
+
- `crypto` — 2 (ml-dsa-slh-dsa-absent, openssl-pre-3-5)
|
|
128
|
+
- `framework` — 3 (exception-missing-expiry-or-owner, jurisdiction-without-framework, compound-theater)
|
|
129
|
+
- `hardening` — 4 (kptr-restrict-disabled, yama-ptrace-permissive, kaslr-disabled-at-boot, mitigations-off)
|
|
130
|
+
- `kernel` — 2 (unpriv-userns-enabled, unpriv-bpf-allowed)
|
|
131
|
+
- `mcp` — 3 (mcp-response-ansi-escape, mcp-response-unicode-tag-smuggling, mcp-server-running-as-root)
|
|
132
|
+
- `runtime` — 3 (duplicate-uid-zero, world-writable-in-trusted-path, orphan-privileged-process)
|
|
133
|
+
- `sbom` — 3 (lockfile-no-integrity, kev-listed-match, windsurf-vulnerable-version)
|
|
134
|
+
- `secrets` — 5 (aws-secret-access-key, slack-bot-or-user-token, stripe-secret-key, openai-api-key, anthropic-api-key)
|
|
135
|
+
|
|
136
|
+
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.
|
|
137
|
+
|
|
138
|
+
### Schema promotion
|
|
139
|
+
|
|
140
|
+
`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.
|
|
141
|
+
|
|
142
|
+
### Operator-facing surfaces
|
|
143
|
+
|
|
144
|
+
- **`--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.
|
|
145
|
+
- **`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.
|
|
146
|
+
|
|
147
|
+
### Auto-discovery hygiene
|
|
148
|
+
|
|
149
|
+
`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.
|
|
150
|
+
|
|
151
|
+
Test count: 740 → 760. Predeploy gates: 14/14. Skills: 38/38 signed; manifest itself signed.
|
|
152
|
+
|
|
3
153
|
## 0.12.16 — 2026-05-14
|
|
4
154
|
|
|
5
|
-
**Patch:
|
|
155
|
+
**Patch: trust chain hardening, CI workflow injection sinks, CLI fuzz fixes, scoring math, curation + auto-discovery + prefetch fixes, playbook hygiene.**
|
|
6
156
|
|
|
7
|
-
### Sign/verify trust chain
|
|
157
|
+
### Sign/verify trust chain
|
|
8
158
|
|
|
9
159
|
- **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.
|
|
10
160
|
- **`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.
|
|
11
161
|
|
|
12
|
-
### CI workflow security
|
|
162
|
+
### CI workflow security
|
|
13
163
|
|
|
14
164
|
- **`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`.
|
|
15
165
|
- **`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.
|
|
@@ -18,7 +168,7 @@
|
|
|
18
168
|
- `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.
|
|
19
169
|
- 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:`.
|
|
20
170
|
|
|
21
|
-
### CLI hardening
|
|
171
|
+
### CLI hardening
|
|
22
172
|
|
|
23
173
|
- **`--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.
|
|
24
174
|
- **`cmdIngest` auto-detects piped stdin.** Mirrors the `cmdRun` shape — `echo '{...}' | exceptd ingest` now works without an explicit `--evidence -`.
|
|
@@ -30,7 +180,7 @@
|
|
|
30
180
|
- `--block-on-jurisdiction-clock` exit code split from `FAIL` (exit 2) → `CLOCK_STARTED` (exit 5). CI gates can distinguish "detected" from "clock fired".
|
|
31
181
|
- `cmdReattest --since` validated as parseable ISO-8601.
|
|
32
182
|
|
|
33
|
-
### Scoring math hardening
|
|
183
|
+
### Scoring math hardening
|
|
34
184
|
|
|
35
185
|
- `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.
|
|
36
186
|
- 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.
|
|
@@ -40,7 +190,7 @@
|
|
|
40
190
|
- `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."
|
|
41
191
|
- `Math.floor(20/2)` arithmetic replaced with `RWEP_WEIGHTS.active_exploitation * 0.5` (no behavior change today; closes a future odd-weight asymmetry).
|
|
42
192
|
|
|
43
|
-
### Curation + auto-discovery + prefetch
|
|
193
|
+
### Curation + auto-discovery + prefetch
|
|
44
194
|
|
|
45
195
|
- **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()`.
|
|
46
196
|
- **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.
|
|
@@ -48,7 +198,7 @@
|
|
|
48
198
|
- **`lib/prefetch.js` docs corrected**: header comment + `printHelp()` no longer reference non-existent source names `ietf` and `github`.
|
|
49
199
|
- **`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).
|
|
50
200
|
|
|
51
|
-
### Playbook quality
|
|
201
|
+
### Playbook quality
|
|
52
202
|
|
|
53
203
|
- **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.).
|
|
54
204
|
- **`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).
|
|
@@ -69,11 +219,9 @@ Test count: 701 → 738 (+37: 29 scoring vectors + 8 workflow-security). Predepl
|
|
|
69
219
|
|
|
70
220
|
## 0.12.15 — 2026-05-14
|
|
71
221
|
|
|
72
|
-
**Patch:
|
|
222
|
+
**Patch: RWEP factor-scaling three-tier fallback + silent-disable regression closures.**
|
|
73
223
|
|
|
74
|
-
The v0.12.14
|
|
75
|
-
|
|
76
|
-
v0.12.15 ships the v0.12.14 deep-audit fix payload plus:
|
|
224
|
+
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`).
|
|
77
225
|
|
|
78
226
|
### Engine: class-of-vulnerability RWEP fallback
|
|
79
227
|
|
|
@@ -85,34 +233,29 @@ v0.12.15 ships the v0.12.14 deep-audit fix payload plus:
|
|
|
85
233
|
|
|
86
234
|
The breakdown emits `factor_cve_source: 'evidence' | 'domain' | 'class'` so operators see which tier the run used.
|
|
87
235
|
|
|
88
|
-
### Silent-disable
|
|
236
|
+
### Silent-disable regression closures
|
|
89
237
|
|
|
90
|
-
Three
|
|
238
|
+
Three prior fixes were silently dead:
|
|
91
239
|
|
|
92
|
-
- **`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
|
|
93
|
-
- **`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.
|
|
94
|
-
- **`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.
|
|
240
|
+
- **`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.
|
|
241
|
+
- **`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.
|
|
242
|
+
- **`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).
|
|
95
243
|
|
|
96
|
-
### Scoring math hardening
|
|
244
|
+
### Scoring math hardening
|
|
97
245
|
|
|
98
246
|
- `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.
|
|
99
247
|
- `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.
|
|
100
248
|
- Defense-in-depth: the final clamp now rejects non-finite scores explicitly (`Number.isFinite(score) ? clamp : 0`).
|
|
101
249
|
|
|
102
|
-
### CLI fuzz fixes
|
|
250
|
+
### CLI fuzz fixes
|
|
103
251
|
|
|
104
252
|
- `--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`.
|
|
105
253
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
- `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).
|
|
109
|
-
- `tests/auto-discovery.test.js` updated to assert the new `_auto_imported: true` + `_auto_imported_meta: {...}` shape.
|
|
110
|
-
|
|
111
|
-
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.
|
|
254
|
+
Test count: 701 (700 pass + 1 skipped POSIX-only SIGTERM test). Predeploy gates: 14/14. Skills: 38/38 signed and verified.
|
|
112
255
|
|
|
113
256
|
## 0.12.14 — 2026-05-14
|
|
114
257
|
|
|
115
|
-
**Patch:
|
|
258
|
+
**Patch: hardening across trust chain, engine, refresh sources, orchestrator/watch, predeploy gates, catalogs, and skill content.**
|
|
116
259
|
|
|
117
260
|
### Trust chain (lib/refresh-network.js)
|
|
118
261
|
|
|
@@ -155,8 +298,8 @@ GHSA fixture envelope now rejects null / number / string roots; OSV `OSV_HOST_OV
|
|
|
155
298
|
|
|
156
299
|
### CLI (bin/exceptd.js)
|
|
157
300
|
|
|
158
|
-
- **Path traversal on attest read paths closed
|
|
159
|
-
- **`
|
|
301
|
+
- **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.
|
|
302
|
+
- **`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.
|
|
160
303
|
- **`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.
|
|
161
304
|
- **`attest list --playbook` honors multi-flag** (was: array-vs-scalar comparison silently returned `count: 0`). `--since` validated as parseable ISO-8601.
|
|
162
305
|
- `--evidence-dir` per-entry path-traversal guard hardened.
|
|
@@ -178,8 +321,8 @@ GHSA fixture envelope now rejects null / number / string roots; OSV `OSV_HOST_OV
|
|
|
178
321
|
|
|
179
322
|
### Predeploy gates
|
|
180
323
|
|
|
181
|
-
- New `keys/EXPECTED_FINGERPRINT` pin
|
|
182
|
-
- New `manifest-snapshot.sha256` pin
|
|
324
|
+
- New `keys/EXPECTED_FINGERPRINT` pin: silent key rotation now fails the gate unless `KEYS_ROTATED=1` is explicitly set.
|
|
325
|
+
- New `manifest-snapshot.sha256` pin: manifest-snapshot integrity is now check-able instead of trusted blindly.
|
|
183
326
|
- `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).
|
|
184
327
|
- `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.
|
|
185
328
|
- `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.
|
|
@@ -219,9 +362,9 @@ Test count: 586 → 693 (+107: refresh-network rewrite tests, engine non-engine
|
|
|
219
362
|
|
|
220
363
|
## 0.12.13 — 2026-05-14
|
|
221
364
|
|
|
222
|
-
**Patch: e2e scenarios
|
|
365
|
+
**Patch: e2e scenarios pass `--ack` to exercise the v0.12.12 jurisdiction-clock contract.**
|
|
223
366
|
|
|
224
|
-
Two e2e scenarios (`02-tanstack-worm-payload`, `09-secrets-aws-key`) assert that `phases.close.jurisdiction_clocks_count >= 1` against a `detected` classification.
|
|
367
|
+
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`.
|
|
225
368
|
|
|
226
369
|
Test count: 585/585. Predeploy gates: 16/16. Skills: 38/38 signed and verified.
|
|
227
370
|
|
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
|
|
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.
|