@blamejs/exceptd-skills 0.12.8 → 0.12.10
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/AGENTS.md +2 -2
- package/ARCHITECTURE.md +21 -5
- package/CHANGELOG.md +120 -0
- package/README.md +1 -1
- package/bin/exceptd.js +227 -17
- package/data/_indexes/_meta.json +20 -20
- package/data/_indexes/activity-feed.json +17 -17
- package/data/_indexes/catalog-summaries.json +5 -5
- package/data/_indexes/chains.json +90 -11
- package/data/_indexes/frequency.json +2 -0
- package/data/_indexes/section-offsets.json +463 -355
- package/data/_indexes/token-budget.json +113 -53
- package/data/cve-catalog.json +385 -23
- package/data/cwe-catalog.json +34 -0
- package/data/playbooks/library-author.json +14 -0
- package/data/playbooks/mcp.json +1 -0
- package/data/zeroday-lessons.json +223 -1
- package/lib/playbook-runner.js +119 -35
- package/lib/prefetch.js +27 -6
- package/lib/refresh-external.js +81 -18
- package/lib/source-osv.js +493 -0
- package/manifest-snapshot.json +1 -1
- package/manifest.json +51 -51
- package/orchestrator/index.js +1 -1
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
- package/scripts/check-test-coverage.js +27 -6
- package/scripts/predeploy.js +7 -9
- package/skills/ai-attack-surface/skill.md +25 -0
- package/skills/ai-c2-detection/skill.md +24 -0
- package/skills/compliance-theater/skill.md +6 -0
- package/skills/exploit-scoring/skill.md +6 -0
- package/skills/mcp-agent-trust/skill.md +24 -0
- package/skills/policy-exception-gen/skill.md +6 -0
- package/skills/rag-pipeline-security/skill.md +28 -2
- package/skills/researcher/skill.md +6 -0
- package/skills/security-maturity-tiers/skill.md +6 -0
- package/skills/skill-update-loop/skill.md +6 -0
- package/skills/threat-model-currency/skill.md +4 -0
- package/skills/zeroday-gap-learn/skill.md +6 -0
package/AGENTS.md
CHANGED
|
@@ -46,7 +46,7 @@ Also read [CONTEXT.md](CONTEXT.md) for a complete orientation to the skill syste
|
|
|
46
46
|
|
|
47
47
|
Mechanical enforcement lives in `scripts/check-test-coverage.js` and runs as the 15th gate of `npm run predeploy` (also the `Diff coverage` job in `ci.yml`). Docs (`*.md`), workflow YAML, and skill body changes are allowlisted — skill bodies are covered by the Ed25519 signature gate (Hard Rule #13), workflows surface a manual-review flag rather than a hard finding. Whitespace-only diffs are ignored.
|
|
48
48
|
|
|
49
|
-
The gate
|
|
49
|
+
The gate is blocking: a covered surface change without a covering test reference fails the predeploy run and the `Diff coverage` CI job. Never bypass with `--no-verify` or `--warn-only` — add the covering test first. This rule is additive to Hard Rule #11 (no-MVP ban): a new playbook indicator or CLI surface that ships without a regression test is the same shape of incomplete-feature ship that #11 forbids, applied to the test layer.
|
|
50
50
|
|
|
51
51
|
---
|
|
52
52
|
|
|
@@ -97,7 +97,7 @@ Schema reference: `lib/schemas/playbook.schema.json`. Reference playbook (read t
|
|
|
97
97
|
|
|
98
98
|
Each playbook's `_meta.feeds_into[]` declares downstream playbooks the host AI should consider chaining into after this run, and the condition that fires the chain. The condition expressions evaluate at `close()` against `analyze` + `validate` + `agentSignals` context. AI assistants surface the suggested next playbook to the operator but never auto-execute; the operator decides.
|
|
99
99
|
|
|
100
|
-
The current
|
|
100
|
+
The current matrix:
|
|
101
101
|
|
|
102
102
|
| From | Triggers | To | Why |
|
|
103
103
|
|---|---|---|---|
|
package/ARCHITECTURE.md
CHANGED
|
@@ -161,7 +161,7 @@ Schema per entry:
|
|
|
161
161
|
|
|
162
162
|
### `data/global-frameworks.json`
|
|
163
163
|
|
|
164
|
-
Maps jurisdiction to framework to current coverage and lag assessment. Currently covers
|
|
164
|
+
Maps jurisdiction to framework to current coverage and lag assessment. Currently covers 35 jurisdictions including EU member states, UK, AU, SG, IN, JP, CA, and major sectoral regulators (DORA, NIS2, EU AI Act, EU CRA at the EU layer; APRA CPS 234, MAS TRM, CERT-In, SEBI, OSFI B-10 at the national layer). See schema in file.
|
|
165
165
|
|
|
166
166
|
### `data/zeroday-lessons.json`
|
|
167
167
|
|
|
@@ -173,23 +173,23 @@ Tracks PoC status, weaponization stage, and AI-assist factor per CVE. Updated wh
|
|
|
173
173
|
|
|
174
174
|
### `data/cwe-catalog.json`
|
|
175
175
|
|
|
176
|
-
|
|
176
|
+
51 CWE entries pinned to **CWE v4.17**. Covers the Top 25 Most Dangerous Software Weaknesses (2024 release) plus AI- and supply-chain-relevant weakness classes (prompt-injection-as-trust-boundary failure, training data integrity, dependency confusion, untrusted artifact ingestion). Each entry records root-cause description, common consequences, mitigation patterns, and the CVEs in `cve-catalog.json` that instantiate the weakness. Skills cite CWE IDs in `cwe_refs` to anchor a finding to a stable weakness taxonomy rather than to a single CVE; the CWE provides the durable root-cause lens that survives across exploit generations.
|
|
177
177
|
|
|
178
178
|
`_meta.cwe_version` pins the version; on a CWE release, audit IDs for renames or deprecations, bump `last_threat_review` on affected skills, and update `_meta`.
|
|
179
179
|
|
|
180
180
|
### `data/d3fend-catalog.json`
|
|
181
181
|
|
|
182
|
-
|
|
182
|
+
28 MITRE D3FEND defensive technique entries pinned to **D3FEND v1.0.0**. Each entry records the defensive technique ID (e.g., `D3-EAL` Executable Allowlisting), the tactic / artifact it defends, the offensive ATLAS / ATT&CK TTPs it counters, defense-in-depth layer position, least-privilege scope assumptions, zero-trust posture compatibility, and AI-pipeline applicability per Hard Rule #9. Skills cite D3FEND IDs in `d3fend_refs` to map offensive findings to a defensive countermeasure rather than to abstract control language. The `defensive-countermeasure-mapping` skill is the canonical consumer; any skill shipped on or after 2026-05-11 includes a Defensive Countermeasure Mapping section referencing this catalog.
|
|
183
183
|
|
|
184
184
|
`_meta.d3fend_version` pins the version; D3FEND ontology additions are tracked in skill `forward_watch` fields.
|
|
185
185
|
|
|
186
186
|
### `data/rfc-references.json`
|
|
187
187
|
|
|
188
|
-
|
|
188
|
+
31 IETF RFC / Internet-Draft references covering authentication and authorization (OAuth 2.0 Security BCP RFC 9700, JWT BCP, FIDO/WebAuthn-related drafts), cryptography (TLS 1.3 RFC 8446, hybrid PQC drafts), disclosure (security.txt RFC 9116), and adjacent IETF standards skills depend on. Each entry tracks: title, status (Proposed Standard / Best Current Practice / Internet-Draft / Historic), errata count, replaces / replaced-by chains, IESG / IRTF stream, and a `last_verified` date. Skills cite RFC IDs in `rfc_refs`. Per Hard Rule #12, RFC references are version-pinned: when an RFC is obsoleted or a draft is published as an RFC, the catalog entry's `replaced_by` field is updated, `last_verified` is refreshed, and affected skills bump `last_threat_review`. Frameworks lag RFCs; RFCs lag attacker innovation — this catalog makes that middle layer auditable.
|
|
189
189
|
|
|
190
190
|
### `data/dlp-controls.json`
|
|
191
191
|
|
|
192
|
-
|
|
192
|
+
22 DLP control entries indexed along five axes: **channel** (where data flows — LLM prompt, RAG retrieval, MCP tool response, email, SaaS API, endpoint), **classifier** (how sensitive data is identified — regex, ML, embedding similarity, watermark), **surface** (where enforcement runs — endpoint, network proxy, API gateway, model gateway), **enforcement** mode (block, redact, warn, log-only), and **evidence** type (the audit artifact each control produces). The `dlp-gap-analysis` skill is the canonical consumer; other DLP-relevant skills cite control IDs in `dlp_refs`. Entries explicitly flag classical DLP controls that are architecturally inadequate for LLM/RAG channels (DR-1 framework-as-truth drift applied to DLP).
|
|
193
193
|
|
|
194
194
|
---
|
|
195
195
|
|
|
@@ -232,6 +232,22 @@ Framework lag scoring and gap report generation.
|
|
|
232
232
|
- `gapReport(frameworkId, scope)` — Generate gap report for a framework within a scope (e.g., "kernel LPE", "AI attack surface")
|
|
233
233
|
- `theaterCheck(controlId, orgControls)` — Run compliance theater detection for a specific control
|
|
234
234
|
|
|
235
|
+
### `scripts/check-test-coverage.js`
|
|
236
|
+
|
|
237
|
+
Diff-coverage analyzer. Walks the staged/working-tree diff for the changed-surface shapes Hard Rule #15 enforces (CLI verbs, CLI flags, `module.exports` identifiers, new playbook indicator IDs, CVE `iocs` fields) and asserts that each change has a covering test reference somewhere under `tests/`. Skill bodies, docs, and workflow YAML are allowlisted. Runs as the 15th gate of `npm run predeploy` (and the `Diff coverage` job in `ci.yml`). Direct invocation: `npm run diff-coverage`.
|
|
238
|
+
|
|
239
|
+
### `scripts/check-sbom-currency.js`
|
|
240
|
+
|
|
241
|
+
Compares `sbom.cdx.json` against the live `manifest.json` skill count and `data/*.json` catalog counts. Fails the predeploy gate when the SBOM drifts from the shipped surface. Refresh with `npm run refresh-sbom`.
|
|
242
|
+
|
|
243
|
+
### `scripts/verify-shipped-tarball.js`
|
|
244
|
+
|
|
245
|
+
Packs the project with `npm pack`, extracts the tarball, and runs Ed25519 signature verification against the extracted bytes — the same path a downstream `npm install` exercises. Predeploy gate guaranteeing the shipped tarball verifies, independent of source-tree verification.
|
|
246
|
+
|
|
247
|
+
### `tests/_helpers/cli.js`
|
|
248
|
+
|
|
249
|
+
Shared test harness for spawning the CLI under tempdir-isolated state. Tests that exercise verb dispatch should consume this helper rather than spawning subprocesses ad-hoc — the helper enforces the "no mutation outside the tempdir" contract that prevents CI-vs-local state divergence.
|
|
250
|
+
|
|
235
251
|
---
|
|
236
252
|
|
|
237
253
|
## manifest.json
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,125 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.12.10 — 2026-05-13
|
|
4
|
+
|
|
5
|
+
**Patch: OSV.dev wired as an upstream source, three new catalog entries, one new library-author indicator.**
|
|
6
|
+
|
|
7
|
+
### OSV.dev as a new upstream source
|
|
8
|
+
|
|
9
|
+
`lib/source-osv.js` + `OSV_SOURCE` in `lib/refresh-external.js` add OSV.dev (https://api.osv.dev/) as a recognised upstream pull. Operators run `exceptd refresh --source osv` to import advisories from the OSV-aggregated dataset, which covers the OSSF Malicious Packages namespace (`MAL-*`), Snyk advisories (`SNYK-*`), GitHub Advisory Database (`GHSA-*`), RustSec (`RUSTSEC-*`), Mageia (`MGASA-*`), Go Vuln DB (`GO-*`), Ubuntu USN (`USN-*`), PYSEC, and UVI — one unauthenticated API in place of N per-vendor feeds.
|
|
10
|
+
|
|
11
|
+
The `--advisory <id>` flag now routes non-CVE / non-GHSA identifiers (`MAL-*`, `SNYK-*`, `RUSTSEC-*`, `USN-*`, `UVI-*`, `GO-*`, `MGASA-*`, `PYSEC-*`) through `source-osv`. CVE-* and GHSA-* continue routing through `source-ghsa` because the GitHub Advisory Database carries richer field coverage for those namespaces. Imported entries land as `_auto_imported: true` / `_draft: true` drafts, the same shape GHSA imports use — editorial fields (framework_control_gaps, full iocs, atlas_refs, attack_refs, rwep_factors) remain null until a human or AI assistant runs the cve-curation skill.
|
|
12
|
+
|
|
13
|
+
When an OSV record carries a `CVE-*` value in its `aliases`, the catalog key is the CVE form and the OSV identifier moves to an `aliases` array on the entry. When no CVE is assigned (e.g. MAL-* malicious-package compromises), the OSV identifier IS the catalog key. The previous identifier convention (CVE-only keys) is preserved as the default; the new identifier shapes are an extension.
|
|
14
|
+
|
|
15
|
+
Fixture support: `EXCEPTD_OSV_FIXTURE` env var (path to a JSON file with one or many OSV records) enables offline testing — same convention as the existing `EXCEPTD_GHSA_FIXTURE`.
|
|
16
|
+
|
|
17
|
+
### Three new catalog entries
|
|
18
|
+
|
|
19
|
+
- **`MAL-2026-3083`** (OSV-native key for the **elementary-data PyPI worm**, April 2026). 1.1M-monthly-downloads package compromised via a GitHub Actions script-injection sink in the project's own workflow (`update_pylon_issue.yml` interpolated `${{ github.event.comment.body }}` directly into a `run:` shell, escalated via the workflow's `GITHUB_TOKEN` to forge an orphan-commit release). Payload was a single `elementary.pth` file in the wheel (Python auto-exec at install time, not import time); infostealer sweeping dbt warehouse creds, AWS/GCP/Azure credentials, SSH keys, Kubernetes configs, cryptocurrency wallets to `igotnofriendsonlineorirl-imgonnakmslmao.skyhanni.cloud` with second-stage at `litter.catbox.moe/iqesmbhukgd2c7hq.sh`. Cataloged from OSV's OSSF Malicious Packages dataset (which published 2026-04-24, 4 days before the Snyk advisory). Aliases retained: `SNYK-PYTHON-ELEMENTARYDATA-16316110`, `pypi/2026-04-compr-elementary-data/elementary-data`. Full Hard Rule #14 IoC block; precedent-setting first MAL-* entry in the catalog.
|
|
20
|
+
|
|
21
|
+
- **`CVE-2026-42208`** (BerriAI LiteLLM Proxy Auth SQL Injection). CVSS 9.3, **on CISA KEV** (dateAdded 2026-05-08). Crafted Authorization header to any LLM API route reaches a SQL query through the error-logging pathway with the attacker value concatenated rather than parameterised — read/modify the LiteLLM-managed-credentials database without prior auth. Affected: `litellm >= 1.81.16, < 1.83.7`. Patched: 1.83.7+ (parameterised query). Temporary workaround: `general_settings: disable_error_logs: true`. RWEP 65 (P1 / 72h timeline). Operator IoCs: Authorization header > 100 chars or carrying SQL metacharacters; mass key-mint events in LiteLLM logs without admin-UI sessions.
|
|
22
|
+
|
|
23
|
+
- **`CVE-2026-39884`** (Flux159 mcp-server-kubernetes Argument Injection). CVSS 8.3. The `port_forward` MCP tool builds a kubectl command string and `.split(' ')`s it instead of using an argv array, so an AI assistant feeding `resourceName: "pod-name --address=0.0.0.0"` (typically via prompt injection upstream) lands attacker flags in kubectl's argv — binds port-forward to all interfaces or redirects to attacker namespace. Affected: `mcp-server-kubernetes <= 3.4.0`. Patched: 3.5.0+ (argv-array refactor). Operator IoCs: MCP audit logs showing port_forward calls with spaces or `--`/`-n` in resourceName; kubectl port-forward processes with `--address=0.0.0.0` on hosts that don't manually port-forward.
|
|
24
|
+
|
|
25
|
+
Three matching `data/zeroday-lessons.json` entries follow the CVE-2026-45321 lesson shape. Five new control requirements derived from the lessons: NEW-CTRL-011 (GHA script-injection-sink ban), NEW-CTRL-012 (orphan-commit release detection), NEW-CTRL-013 (AI-gateway credential-store isolation), NEW-CTRL-014 (MCP-server argv not shellstring), NEW-CTRL-015 (MCP tool allowlist enforcement).
|
|
26
|
+
|
|
27
|
+
### One new library-author indicator
|
|
28
|
+
|
|
29
|
+
`gha-workflow-script-injection-sink` flags any `.github/workflows/*.yml` workflow that interpolates an attacker-controllable `${{ github.event.* }}` field directly into a `run:` shell script — the exact sink the elementary-data attack exploited. Detection grep covers `github.event.comment.body`, `github.event.issue.body`, `github.event.issue.title`, `github.event.pull_request.body`, `github.event.pull_request.title`, `github.event.review.body`, `github.event.head_commit.message`, `github.head_ref`, `github.event.discussion.body`, `github.event.discussion.title`. False-positive demotion path: if the workflow captures the value into an `env:` variable first OR runs only on `pull_request` (sandboxed, not `pull_request_target`) with default-read permissions, the sink isn't exploitable. Cross-referenced to MAL-2026-3083.
|
|
30
|
+
|
|
31
|
+
### Catalog extensions
|
|
32
|
+
|
|
33
|
+
- `data/cwe-catalog.json` gains CWE-506 (Embedded Malicious Code) and CWE-88 (Improper Neutralization of Argument Delimiters). Both backed by the new catalog entries.
|
|
34
|
+
- `data/cve-catalog.json` `_meta.id_conventions` documents the MAL-*/SNYK-*/GHSA-*/RUSTSEC-* identifier shapes the catalog now accepts, the alias-retention convention when MITRE issues a CVE later, and the EPSS limitation (FIRST only indexes CVE identifiers).
|
|
35
|
+
|
|
36
|
+
### Repository
|
|
37
|
+
|
|
38
|
+
Test count: 441 → 459 (+18: OSV source tests + matching test references for Hard Rule #15 coverage). Predeploy gates: 15/15. Skills: 38/38 signed and verified. No skill bodies changed in this patch.
|
|
39
|
+
|
|
40
|
+
## 0.12.9 — 2026-05-13
|
|
41
|
+
|
|
42
|
+
**Patch: post-v0.12.8 audit pass — Hard Rule #15 gate flips blocking, sbom evidence-correlation fix, CVE catalog freshness corrections, and recovery of two v0.12.8 stash-restore casualties.**
|
|
43
|
+
|
|
44
|
+
### Hard Rule #15 — diff-coverage gate is now blocking
|
|
45
|
+
|
|
46
|
+
`scripts/check-test-coverage.js` flips from `--warn-only` to a blocking gate. The 15th `npm run predeploy` gate and the `Diff coverage` CI job now fail a run if any change to a CLI verb, CLI flag, `module.exports` identifier, playbook indicator, or CVE `iocs` field lands without a covering test reference. Two analyzer bugs that would have made the gate unreliable under blocking are fixed in the same release:
|
|
47
|
+
|
|
48
|
+
- `coversLibExport` now recognises subprocess-based test invocations (e.g. `spawnSync(... "scripts/check-sbom-currency.js" ...)`) alongside `require(...)`-form coverage.
|
|
49
|
+
- `extractLibExports` strips block and line comments before matching `module.exports = {...}`, eliminating the doc-comment shadow bug where the analyzer's regex captured a JSDoc banner and returned an empty export set.
|
|
50
|
+
|
|
51
|
+
`tests/playbook-indicators.test.js` lands as a table-driven test referencing all 12 indicator ids added in v0.12.7 (`mcp.json` × 6) and v0.12.8 (`containers.json` × 2, `hardening.json` × 4). The new tests cover the Hard Rule #15 surface the analyzer flagged.
|
|
52
|
+
|
|
53
|
+
### sbom `matched_cves` now evidence-correlated
|
|
54
|
+
|
|
55
|
+
`exceptd run sbom` previously surfaced every CVE in the playbook's `domain.cve_refs` under `analyze.matched_cves`, regardless of whether the operator's submitted evidence correlated to any of them. Operators reading the output assumed they were affected by the listed CVEs. The analyze phase now splits into two fields:
|
|
56
|
+
|
|
57
|
+
- `analyze.matched_cves` — only CVEs correlated to operator evidence (indicator hit whose `attack_ref`/`atlas_ref` intersects the CVE's refs, or an explicit `signals[cveId]` set to `true`/`hit`/`detected`/`affected`). Each entry carries a `correlated_via` reason.
|
|
58
|
+
- `analyze.catalog_baseline_cves` — the playbook's CVE catalog (informational; not an affected-status list). Each entry carries `correlated_via: null` and a note documenting the distinction.
|
|
59
|
+
|
|
60
|
+
CSAF / SARIF / OpenVEX bundles consume `matched_cves` only — they correctly omit catalog-only CVEs as vulnerabilities. RWEP base now derives from evidence-correlated CVEs rather than the catalog ceiling, so inconclusive runs no longer inherit a misleading high score.
|
|
61
|
+
|
|
62
|
+
The `run` human renderer shows "No CVEs correlated to your evidence. Playbook catalog (informational): N CVE(s) this playbook scans for." when no evidence correlated.
|
|
63
|
+
|
|
64
|
+
### CLI surface — ci verdict / exit reconcile, signing-key resolution, fuzzy matches
|
|
65
|
+
|
|
66
|
+
`ci --scope <type>` with no evidence and all-inconclusive results now emits `verdict: "NO_EVIDENCE"` (was `"PASS"`) so the body and exit code 3 agree. Operators reading either field alone now see the same answer. The verdict computation is hoisted before the result emit so BLOCKED / FAIL / NO_EVIDENCE / PASS are all consistent end-to-end.
|
|
67
|
+
|
|
68
|
+
`ci` result top-level gains `framework_gap_rollup` aggregating per-playbook `framework_gap_mapping` entries across all scoped playbooks. Each rollup entry lists `{framework, claimed_control, why_insufficient, playbooks[]}` so a CI gate surfaces "what gaps did this run uncover" without the operator having to walk every per-playbook result.
|
|
69
|
+
|
|
70
|
+
`maybeSignAttestation()` now resolves `.keys/private.pem` cwd-first, package-root fallback — matching how `doctor --signatures` resolves the same key. Pre-v0.12.9, operators running `exceptd run` from a repo with their private key at the cwd-relative `.keys/private.pem` would see `doctor` report the key as present while attestations from the same directory were silently written UNSIGNED. The two surfaces now agree.
|
|
71
|
+
|
|
72
|
+
`run <typo>` error path adds Levenshtein-distance suggestions for misspelled playbook ids when no substring match fits. `run secrt` now suggests `secrets`; `run cret-stores` suggests `cred-stores`.
|
|
73
|
+
|
|
74
|
+
`brief --phase <value>` rejects unknown phases with a structured JSON error (accepted set: `govern | direct | look`). Pre-v0.12.9 any string was accepted silently and the full brief was emitted.
|
|
75
|
+
|
|
76
|
+
`doctor --signatures --shipped-tarball` runs the `verify-shipped-tarball` round-trip alongside the source-tree signature check, surfacing the integrity layer that closed the v0.11.x → v0.12.4 signature regression class. Opt-in; routine `doctor --signatures` stays fast.
|
|
77
|
+
|
|
78
|
+
`doctor --registry-check` text-mode output now surfaces the registry comparison alongside the other check lines. Pre-v0.12.9 the flag only populated `checks.registry.*` in the JSON output, leaving the text-mode operator with no signal the flag did anything.
|
|
79
|
+
|
|
80
|
+
`run` precondition renderer no longer prints `[undefined]` for preconditions without an `on_fail` field — the bracket is omitted and the description falls back to `check | description | reason` in order.
|
|
81
|
+
|
|
82
|
+
### CVE catalog freshness corrections
|
|
83
|
+
|
|
84
|
+
Five entries reconciled against authoritative public sources as of 2026-05-13:
|
|
85
|
+
|
|
86
|
+
- **CVE-2026-30615** (Windsurf MCP): CVSS corrected 9.8 → 8.0; vector AV:N → AV:L (the attack is local-vector via adversarial HTML content the Windsurf MCP client processes, not a network-vector zero-interaction RCE). Source: NVD authoritative metric block (`vulnStatus: Deferred`, last_modified 2026-04-27).
|
|
87
|
+
- **CVE-2026-31431** (Copy Fail): KEV `dateAdded` corrected 2026-03-15 → 2026-05-01, `dueDate` 2026-04-05 → 2026-05-15. The catalog was running six weeks ahead of the real KEV listing; downstream framework-SLA computations were anchored on a date that hadn't yet been authoritative. CWE-669 added. Source: CISA KEV JSON feed.
|
|
88
|
+
- **CVE-2026-43284** (Dirty Frag ESP): CVSS authoritative is 8.8 / `Scope:C` (kernel→user-namespace breakout — supports container-escape framing); 7.8 / `Scope:U` preserved as `cvss_score_alternate` for compatibility readers. CWE-123 added.
|
|
89
|
+
- **CVE-2026-43500** (Dirty Frag RxRPC): CWE-787 added.
|
|
90
|
+
- **EPSS values refreshed** for four CVEs (CVE-2026-31431, -43284, -43500, -45321) from live FIRST API values. Catalog previously stored cold-start estimates that overstated newly-published-CVE exposure.
|
|
91
|
+
|
|
92
|
+
Each correction carries an inline `*_correction_note` field with the source URL and the rationale for downstream auditors. Two new CVEs surfaced by the freshness sweep (CVE-2026-42208 LiteLLM SQLi on KEV; CVE-2026-39884 mcp-server-kubernetes argument injection) are deferred to a follow-up patch — each warrants its own Hard Rule #14 primary-source IoC review.
|
|
93
|
+
|
|
94
|
+
### v0.12.8 stash-restore casualties recovered
|
|
95
|
+
|
|
96
|
+
Two claims in the v0.12.8 CHANGELOG were not actually on disk in the squash commit, lost during the v0.12.8 recovery flow:
|
|
97
|
+
|
|
98
|
+
- `data/playbooks/mcp.json` `domain.cve_refs` now includes CVE-2025-53773 alongside CVE-2026-30615 and CVE-2026-45321. The Hard Rule #4 mismatch (the `copilot-yolo-mode-flag` / `copilot-chat-experimental-flags` indicators detected this CVE without the playbook claiming it) is now genuinely closed.
|
|
99
|
+
- `tests/operator-bugs.test.js` is now refactored to use `tests/_helpers/cli.js` for `makeCli` / `makeSuiteHome` / `tryJson`. The per-suite `EXCEPTD_HOME` tempdir routing applies to all 80+ tests in the file. Pre-v0.12.9 the inline helper continued writing attestations to the maintainer's real `~/.exceptd/attestations/` — 2,819 leaked attestations cleaned up alongside the refactor.
|
|
100
|
+
|
|
101
|
+
### Two real defects deferred from v0.12.8 fixed
|
|
102
|
+
|
|
103
|
+
- **Libuv `UV_HANDLE_CLOSING` crash on Windows + Node 25.** `lib/prefetch.js` `main()` called `process.exit(N)` after the summary `console.log` — same v0.11.10 #100 class as the run/ci sites already fixed. Replaced with `process.exitCode = N; return;` so undici / AbortController teardown completes before the event loop ends. Strengthened `#65 refresh --no-network` test asserts exit 0 AND no `Assertion failed` / `UV_HANDLE_CLOSING` lines on stderr.
|
|
104
|
+
- **Two 404'd pin sources.** `d3fend/d3fend-data` and `mitre/cwe` were registered as `SOURCES.pins` GitHub-Releases sources, but neither repository publishes Releases via that path (D3FEND distributes from `d3fend.mitre.org`; CWE from `cwe.mitre.org`). Both sources removed from `lib/prefetch.js` and `lib/refresh-external.js` `pinsDiffFromCache()` `PIN_REPOS`. `prefetch summary` now reports `0 error(s)` on a clean cache. A new regression test asserts every pins source URL matches `^https://api.github.com/repos/<org>/<repo>/releases\?`.
|
|
105
|
+
|
|
106
|
+
### Skill body second pass
|
|
107
|
+
|
|
108
|
+
Four priority skills gain a `## Defensive Countermeasure Mapping` body section per Hard Rule #11's post-2026-05-11 grandfathered-skill closeout: `ai-c2-detection`, `ai-attack-surface`, `mcp-agent-trust`, `rag-pipeline-security`. Each maps the skill's offensive findings to 3-7 D3FEND IDs from `data/d3fend-catalog.json` with rationale + ephemeral/serverless-workload alternatives per Hard Rule #9.
|
|
109
|
+
|
|
110
|
+
Eight meta skills (`researcher`, `threat-model-currency`, `skill-update-loop`, `zeroday-gap-learn`, `policy-exception-gen`, `security-maturity-tiers`, `exploit-scoring`, `compliance-theater`) gain a `## Frontmatter Scope` section documenting why their `atlas_refs` / `attack_refs` / `framework_gaps` lists are intentionally empty.
|
|
111
|
+
|
|
112
|
+
`rag-pipeline-security` `framework_gaps` token refined `UK-CAF-A1` → `UK-CAF-B2` — the RAG attack class resolves to retrieval-time access-control failure, which is the B2 (Identity and Access Control) surface, not the A1 (Governance) parent concern.
|
|
113
|
+
|
|
114
|
+
### Repository
|
|
115
|
+
|
|
116
|
+
- README "13 gates" → "15 gates"; ARCHITECTURE catalog counts refreshed (CWE 30→51, D3FEND 21→28, RFC 19→31, jurisdictions "22+" → "35"); ARCHITECTURE Logic Layer gains entries for `scripts/check-test-coverage.js`, `scripts/check-sbom-currency.js`, `scripts/verify-shipped-tarball.js`, `tests/_helpers/cli.js`.
|
|
117
|
+
- AGENTS.md feeds_into matrix heading drops the residual `(v0.10.x)` tag; Hard Rule #15 wording flips from `--warn-only` rollout language to present-tense blocking.
|
|
118
|
+
- CONTRIBUTING.md adds `npm run diff-coverage` to the pre-push gate list so contributors run the same Hard Rule #15 check CI does.
|
|
119
|
+
- Dependabot grouping for github-actions (already landed in v0.12.8) confirmed intact.
|
|
120
|
+
|
|
121
|
+
Test count: 418 → 439. Predeploy gates: 15/15 (gate 15 now blocking). Skills: 38/38 signed and verified.
|
|
122
|
+
|
|
3
123
|
## 0.12.8 — 2026-05-13
|
|
4
124
|
|
|
5
125
|
**Patch: comprehensive audit pass — CLI surface fixes, catalog completeness, test infrastructure hardening, AGENTS.md Hard Rule #15.**
|
package/README.md
CHANGED
|
@@ -167,7 +167,7 @@ You're adding a skill, updating a catalog, or cutting a release. Clone + bootstr
|
|
|
167
167
|
git clone https://github.com/blamejs/exceptd-skills
|
|
168
168
|
cd exceptd-skills
|
|
169
169
|
npm run bootstrap # auto-detects: verify-only / re-sign / first-init
|
|
170
|
-
npm run predeploy # full
|
|
170
|
+
npm run predeploy # full 15-gate CI sequence locally
|
|
171
171
|
```
|
|
172
172
|
|
|
173
173
|
`bootstrap` auto-detects the right mode based on which keys exist on disk:
|
package/bin/exceptd.js
CHANGED
|
@@ -581,7 +581,12 @@ function dispatchPlaybook(cmd, argv) {
|
|
|
581
581
|
bool: ["pretty", "air-gap", "force-stale", "all", "flat", "directives",
|
|
582
582
|
"ci", "latest", "diff-from-latest", "explain", "signal-list", "ack",
|
|
583
583
|
"force-overwrite", "no-stream", "block-on-jurisdiction-clock",
|
|
584
|
-
"json-stdout-only", "fix", "human", "json", "strict-preconditions"
|
|
584
|
+
"json-stdout-only", "fix", "human", "json", "strict-preconditions",
|
|
585
|
+
// v0.12.9: doctor --shipped-tarball runs the verify-shipped-tarball
|
|
586
|
+
// gate alongside --signatures. doctor --registry-check + --signatures
|
|
587
|
+
// were already accepted; explicit registration removes the silent
|
|
588
|
+
// "unknown bool flag" surface in parseArgs.
|
|
589
|
+
"shipped-tarball", "registry-check", "signatures", "currency", "cves", "rfcs"],
|
|
585
590
|
multi: ["playbook", "format"],
|
|
586
591
|
});
|
|
587
592
|
// v0.11.2 bug #60: flip defaults to human-readable. JSON via explicit --json
|
|
@@ -703,17 +708,62 @@ function buildSkillToPlaybookHint(runner, wanted) {
|
|
|
703
708
|
if (matches.length > 0) {
|
|
704
709
|
return `That is a SKILL (read-only knowledge unit), not a PLAYBOOK (executable). Skill "${wanted}" is loaded by playbook${matches.length === 1 ? "" : "s"}: ${matches.join(", ")}. ` +
|
|
705
710
|
`To execute: \`exceptd run ${matches[0]}\`. To read the skill: \`exceptd skill ${wanted}\`. ` +
|
|
706
|
-
`Tip: \`exceptd
|
|
711
|
+
`Tip: \`exceptd brief --all\` lists all 13 playbooks; \`exceptd watch\` lists skills.`;
|
|
707
712
|
}
|
|
708
713
|
// No matching skill either — provide nearest-playbook suggestions.
|
|
709
|
-
|
|
714
|
+
// v0.12.9 (P3 #9 from production smoke): substring fallback first (cheap),
|
|
715
|
+
// then edit-distance for typos that don't substring-match (`secrt`,
|
|
716
|
+
// `kernl`, `cret-stores`). Without the second pass `run secrt` returned
|
|
717
|
+
// the generic "13 playbooks" message even though `secrets` is one edit
|
|
718
|
+
// away.
|
|
719
|
+
const subMatches = ids.filter(id => id.includes(wanted) || wanted.includes(id)).slice(0, 3);
|
|
720
|
+
const fuzzyMatches = subMatches.length === 0 ? nearestByEditDistance(wanted, ids, 2).slice(0, 3) : [];
|
|
721
|
+
const near = subMatches.length ? subMatches : fuzzyMatches;
|
|
710
722
|
if (near.length > 0) {
|
|
711
|
-
return `Did you mean: ${near.join(", ")}? Run \`exceptd
|
|
723
|
+
return `Did you mean: ${near.join(", ")}? Run \`exceptd brief --all\` for the full list.`;
|
|
712
724
|
}
|
|
713
|
-
return `Run \`exceptd
|
|
725
|
+
return `Run \`exceptd brief --all\` to list the 13 playbooks.`;
|
|
714
726
|
} catch { return null; }
|
|
715
727
|
}
|
|
716
728
|
|
|
729
|
+
/**
|
|
730
|
+
* Cheap Levenshtein distance, used to surface "Did you mean X?" suggestions
|
|
731
|
+
* for misspelled playbook ids in the `run <typo>` error path. Returns ids
|
|
732
|
+
* whose distance from `wanted` is ≤ `maxDistance`, sorted by closest first.
|
|
733
|
+
* Bounded by the candidate set size (13 playbooks), so the O(n*m) cost is
|
|
734
|
+
* negligible.
|
|
735
|
+
*/
|
|
736
|
+
function nearestByEditDistance(wanted, ids, maxDistance) {
|
|
737
|
+
if (!wanted || !Array.isArray(ids)) return [];
|
|
738
|
+
const w = String(wanted).toLowerCase();
|
|
739
|
+
const scored = [];
|
|
740
|
+
for (const id of ids) {
|
|
741
|
+
const d = editDistance(w, id.toLowerCase());
|
|
742
|
+
if (d <= maxDistance) scored.push({ id, d });
|
|
743
|
+
}
|
|
744
|
+
scored.sort((a, b) => a.d - b.d);
|
|
745
|
+
return scored.map(s => s.id);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function editDistance(a, b) {
|
|
749
|
+
if (a === b) return 0;
|
|
750
|
+
if (a.length === 0) return b.length;
|
|
751
|
+
if (b.length === 0) return a.length;
|
|
752
|
+
const prev = new Array(b.length + 1);
|
|
753
|
+
for (let j = 0; j <= b.length; j++) prev[j] = j;
|
|
754
|
+
for (let i = 1; i <= a.length; i++) {
|
|
755
|
+
let cur = i;
|
|
756
|
+
for (let j = 1; j <= b.length; j++) {
|
|
757
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
758
|
+
const next = Math.min(prev[j] + 1, cur + 1, prev[j - 1] + cost);
|
|
759
|
+
prev[j - 1] = cur;
|
|
760
|
+
cur = next;
|
|
761
|
+
}
|
|
762
|
+
prev[b.length] = cur;
|
|
763
|
+
}
|
|
764
|
+
return prev[b.length];
|
|
765
|
+
}
|
|
766
|
+
|
|
717
767
|
function printPlaybookVerbHelp(verb) {
|
|
718
768
|
const cmds = {
|
|
719
769
|
plan: `plan — list playbooks + directives, grouped by scope.
|
|
@@ -1188,6 +1238,18 @@ function cmdBrief(runner, args, runOpts, pretty) {
|
|
|
1188
1238
|
const playbookId = args._[0];
|
|
1189
1239
|
const onlyPhase = args.phase || null;
|
|
1190
1240
|
|
|
1241
|
+
// v0.12.9 (P2 #7 from production smoke): refuse garbage values to --phase.
|
|
1242
|
+
// Pre-v0.12.9 `brief secrets --phase foo` silently accepted any string and
|
|
1243
|
+
// emitted the full brief — operators got no signal the flag was misused.
|
|
1244
|
+
// The legacy-compat surface is exactly the three v0.10.x verb names
|
|
1245
|
+
// (govern | direct | look); anything else is a typo or a misunderstanding.
|
|
1246
|
+
if (onlyPhase != null) {
|
|
1247
|
+
const ACCEPTED_PHASES = ["govern", "direct", "look"];
|
|
1248
|
+
if (!ACCEPTED_PHASES.includes(onlyPhase)) {
|
|
1249
|
+
return emitError(`brief: --phase "${onlyPhase}" not in accepted set ${JSON.stringify(ACCEPTED_PHASES)}.`, { verb: "brief", provided: onlyPhase }, pretty);
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1191
1253
|
if (!playbookId || args.all) {
|
|
1192
1254
|
// Multi-playbook brief (replaces `plan`). Reuses cmdPlan output shape.
|
|
1193
1255
|
return cmdPlan(runner, args, runOpts, pretty);
|
|
@@ -1782,10 +1844,19 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
1782
1844
|
const verdictIcon = cls === "detected" ? "[!! DETECTED]" : cls === "inconclusive" ? "[i INCONCLUSIVE]" : "[ok]";
|
|
1783
1845
|
lines.push(`\n${verdictIcon} classification=${cls} RWEP ${adj}/${top}${adj !== base ? ` (Δ${adj - base} from operator evidence)` : " (catalog baseline)"} blast_radius=${obj.phases?.analyze?.blast_radius_score ?? "n/a"}/5`);
|
|
1784
1846
|
const cves = obj.phases?.analyze?.matched_cves || [];
|
|
1847
|
+
const baseline = obj.phases?.analyze?.catalog_baseline_cves || [];
|
|
1785
1848
|
if (cves.length) {
|
|
1786
1849
|
lines.push(`\nMatched CVEs (${cves.length}):`);
|
|
1787
|
-
for (const c of cves.slice(0, 6))
|
|
1850
|
+
for (const c of cves.slice(0, 6)) {
|
|
1851
|
+
const via = Array.isArray(c.correlated_via) && c.correlated_via.length ? ` via ${c.correlated_via[0]}${c.correlated_via.length > 1 ? ` (+${c.correlated_via.length - 1})` : ""}` : "";
|
|
1852
|
+
lines.push(` ${c.cve_id} RWEP ${c.rwep} KEV=${c.cisa_kev} ${c.active_exploitation || ""}${via}`);
|
|
1853
|
+
}
|
|
1788
1854
|
if (cves.length > 6) lines.push(` … ${cves.length - 6} more`);
|
|
1855
|
+
} else if (baseline.length) {
|
|
1856
|
+
// No evidence correlated to any CVE — clarify rather than implying the
|
|
1857
|
+
// operator is affected by the catalog enumeration. Pre-fix output read
|
|
1858
|
+
// like a hit list; explicit zero + scan-coverage callout fixes that.
|
|
1859
|
+
lines.push(`\nNo CVEs correlated to your evidence. Playbook catalog (informational): ${baseline.length} CVE(s) this playbook scans for.`);
|
|
1789
1860
|
}
|
|
1790
1861
|
const indicators = obj.phases?.detect?.indicators || [];
|
|
1791
1862
|
const hits = indicators.filter(i => i.verdict === "hit");
|
|
@@ -1808,7 +1879,16 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
1808
1879
|
const issues = obj.preflight_issues || [];
|
|
1809
1880
|
if (issues.length) {
|
|
1810
1881
|
lines.push(`\nPreflight warnings (${issues.length}):`);
|
|
1811
|
-
|
|
1882
|
+
// v0.12.9 (P3 #12 from production smoke): handle preconditions without
|
|
1883
|
+
// an `on_fail` field (precondition.check was satisfied trivially or the
|
|
1884
|
+
// playbook omits the field). Pre-v0.12.9 these rendered as `[undefined]
|
|
1885
|
+
// <id>:`. Now: omit the bracket when on_fail is absent, and fall back
|
|
1886
|
+
// to the description if `check` is missing too.
|
|
1887
|
+
for (const i of issues) {
|
|
1888
|
+
const tag = i.on_fail ? `[${i.on_fail}] ` : "";
|
|
1889
|
+
const detail = i.check || i.description || i.reason || "(no detail)";
|
|
1890
|
+
lines.push(` ${tag}${i.id}: ${detail}`);
|
|
1891
|
+
}
|
|
1812
1892
|
}
|
|
1813
1893
|
lines.push(`\nFull structured result: --json (or --pretty for indented).`);
|
|
1814
1894
|
return lines.join("\n");
|
|
@@ -2107,6 +2187,15 @@ function persistAttestation(args) {
|
|
|
2107
2187
|
function maybeSignAttestation(filePath) {
|
|
2108
2188
|
const crypto = require("crypto");
|
|
2109
2189
|
const sigPath = filePath + ".sig";
|
|
2190
|
+
// v0.12.9 (P2 #3 from production smoke + codex P1 PR #4 review): keep the
|
|
2191
|
+
// sign key aligned with the VERIFY key. `attest verify` checks signatures
|
|
2192
|
+
// against PKG_ROOT/keys/public.pem; if we sign with cwd/.keys/private.pem
|
|
2193
|
+
// (e.g. the maintainer's repo-local keypair) the resulting `.sig` will
|
|
2194
|
+
// verify INVALID and report a false tamper signal on every freshly-written
|
|
2195
|
+
// attestation. PKG_ROOT-only resolution is the right answer; the original
|
|
2196
|
+
// smoke report's "doctor finds key, run does not" gap is fixed in `doctor`
|
|
2197
|
+
// (reporting only PKG_ROOT now), not by making `run` follow a cwd key the
|
|
2198
|
+
// verifier doesn't trust.
|
|
2110
2199
|
const privKeyPath = path.join(PKG_ROOT, ".keys", "private.pem");
|
|
2111
2200
|
const content = fs.readFileSync(filePath, "utf8");
|
|
2112
2201
|
// One-time-per-process unsigned warning so cron jobs don't spam stderr.
|
|
@@ -2840,6 +2929,46 @@ function cmdDoctor(runner, args, runOpts, pretty) {
|
|
|
2840
2929
|
...(ok ? {} : { exit_code: res.status, raw: text.slice(0, 500) }),
|
|
2841
2930
|
};
|
|
2842
2931
|
if (!ok) issues.push("signatures");
|
|
2932
|
+
|
|
2933
|
+
// v0.12.9 (P3 #10 from production smoke): also run the shipped-tarball
|
|
2934
|
+
// round-trip gate (sign + pack + extract + verify) when the operator
|
|
2935
|
+
// opts in via --shipped-tarball. This is the v0.12.3 verify-as-shipped
|
|
2936
|
+
// gate that closed the v0.11.x → v0.12.4 signature regression class
|
|
2937
|
+
// (source-tree verify passed; shipped-tarball verify failed). It's
|
|
2938
|
+
// opt-in because npm pack adds ~5-10s and creates tempdir churn —
|
|
2939
|
+
// routine `doctor --signatures` stays fast.
|
|
2940
|
+
if (args["shipped-tarball"]) {
|
|
2941
|
+
try {
|
|
2942
|
+
const tarballScript = path.join(PKG_ROOT, "scripts", "verify-shipped-tarball.js");
|
|
2943
|
+
if (fs.existsSync(tarballScript)) {
|
|
2944
|
+
const tRes = spawnSync(process.execPath, [tarballScript], {
|
|
2945
|
+
encoding: "utf8",
|
|
2946
|
+
cwd: PKG_ROOT,
|
|
2947
|
+
timeout: 120000,
|
|
2948
|
+
});
|
|
2949
|
+
const tText = (tRes.stdout || "") + (tRes.stderr || "");
|
|
2950
|
+
const tOk = tRes.status === 0;
|
|
2951
|
+
const tMatch = tText.match(/(\d+)\/(\d+)\s+pass,\s+(\d+)\s+fail/i);
|
|
2952
|
+
checks.signatures.shipped_tarball = {
|
|
2953
|
+
ok: tOk,
|
|
2954
|
+
skills_passed: tMatch ? Number(tMatch[1]) : null,
|
|
2955
|
+
skills_total: tMatch ? Number(tMatch[2]) : null,
|
|
2956
|
+
skills_failed: tMatch ? Number(tMatch[3]) : null,
|
|
2957
|
+
...(tOk ? {} : { exit_code: tRes.status, raw: tText.slice(-500) }),
|
|
2958
|
+
};
|
|
2959
|
+
if (!tOk) issues.push("signatures.shipped_tarball");
|
|
2960
|
+
} else {
|
|
2961
|
+
checks.signatures.shipped_tarball = {
|
|
2962
|
+
ok: null,
|
|
2963
|
+
skipped: true,
|
|
2964
|
+
reason: "scripts/verify-shipped-tarball.js not present (likely an installed package, not a source checkout). The tarball-verify gate runs at release time; routine integrity is covered by `--signatures`.",
|
|
2965
|
+
};
|
|
2966
|
+
}
|
|
2967
|
+
} catch (e) {
|
|
2968
|
+
checks.signatures.shipped_tarball = { ok: false, error: e.message };
|
|
2969
|
+
issues.push("signatures.shipped_tarball");
|
|
2970
|
+
}
|
|
2971
|
+
}
|
|
2843
2972
|
} catch (e) {
|
|
2844
2973
|
checks.signatures = { ok: false, error: e.message };
|
|
2845
2974
|
issues.push("signatures");
|
|
@@ -2941,9 +3070,14 @@ function cmdDoctor(runner, args, runOpts, pretty) {
|
|
|
2941
3070
|
|
|
2942
3071
|
if (runSigning) {
|
|
2943
3072
|
try {
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
3073
|
+
// v0.12.9 codex P1 (PR #4): report only PKG_ROOT — that's the path
|
|
3074
|
+
// maybeSignAttestation() and `attest verify` actually use. Pre-v0.12.9
|
|
3075
|
+
// doctor also reported cwd-resident keys as present, which gave a
|
|
3076
|
+
// false-positive "signing enabled" signal when the operator's cwd
|
|
3077
|
+
// key was misaligned with the PKG_ROOT-resident public key used at
|
|
3078
|
+
// verify time.
|
|
3079
|
+
const keyPath = path.join(PKG_ROOT, ".keys", "private.pem");
|
|
3080
|
+
const present = fs.existsSync(keyPath);
|
|
2947
3081
|
// Bug #61 (v0.11.2): signing-status missing key is a real WARNING. The
|
|
2948
3082
|
// attestation pipeline writes unsigned files when this is absent, which
|
|
2949
3083
|
// operators reading the attestation later cannot verify for authenticity.
|
|
@@ -3028,10 +3162,9 @@ function cmdDoctor(runner, args, runOpts, pretty) {
|
|
|
3028
3162
|
});
|
|
3029
3163
|
if (r.status === 0) {
|
|
3030
3164
|
// Re-verify the private key is now present so the JSON output reflects
|
|
3031
|
-
// the fix.
|
|
3032
|
-
const keyPath = path.join(
|
|
3033
|
-
const
|
|
3034
|
-
const present = fs.existsSync(keyPath) || fs.existsSync(fallback);
|
|
3165
|
+
// the fix. v0.12.9 codex P1: PKG_ROOT-only (sign + verify use this path).
|
|
3166
|
+
const keyPath = path.join(PKG_ROOT, ".keys", "private.pem");
|
|
3167
|
+
const present = fs.existsSync(keyPath);
|
|
3035
3168
|
checks.signing = { ok: present, severity: present ? "info" : "warn", private_key_present: present, can_sign_attestations: present };
|
|
3036
3169
|
out.checks = checks;
|
|
3037
3170
|
out.summary.fix_applied = "ed25519_keypair_generated";
|
|
@@ -3080,6 +3213,35 @@ function cmdDoctor(runner, args, runOpts, pretty) {
|
|
|
3080
3213
|
? `RFC catalog: ${c.total ?? "?"} entries, drift ${c.drift ?? 0}`
|
|
3081
3214
|
: `RFC catalog FAILED (exit=${c.exit_code ?? "?"})`
|
|
3082
3215
|
);
|
|
3216
|
+
// v0.12.9 (P3 #11 from production smoke): render registry-check in text mode.
|
|
3217
|
+
// Pre-v0.12.9 --registry-check populated checks.registry only in the JSON
|
|
3218
|
+
// output; operators in text mode had to add --json to see if the flag did
|
|
3219
|
+
// anything. Now the line surfaces in the human checklist.
|
|
3220
|
+
mark(checks.registry, c => {
|
|
3221
|
+
if (c.skipped) return `npm registry check: skipped (${c.reason || "unknown reason"})`;
|
|
3222
|
+
if (!c.ok && !c.same && c.behind) {
|
|
3223
|
+
const days = c.days_since_latest_publish != null ? `${c.days_since_latest_publish}d` : "?";
|
|
3224
|
+
return `npm registry: local v${c.local_version ?? "?"} BEHIND published v${c.published_version ?? "?"} (${days})`;
|
|
3225
|
+
}
|
|
3226
|
+
if (c.same) {
|
|
3227
|
+
return `npm registry: local v${c.local_version ?? "?"} == published v${c.published_version ?? "?"} (current)`;
|
|
3228
|
+
}
|
|
3229
|
+
if (c.ahead) {
|
|
3230
|
+
return `npm registry: local v${c.local_version ?? "?"} AHEAD of published v${c.published_version ?? "?"} (unreleased / dev install)`;
|
|
3231
|
+
}
|
|
3232
|
+
return `npm registry: check returned no comparison (raw exit=${c.exit_code ?? "?"})`;
|
|
3233
|
+
});
|
|
3234
|
+
// v0.12.9 (P3 #10): surface shipped_tarball sub-check when --shipped-tarball was used.
|
|
3235
|
+
if (checks.signatures?.shipped_tarball) {
|
|
3236
|
+
const st = checks.signatures.shipped_tarball;
|
|
3237
|
+
if (st.skipped) {
|
|
3238
|
+
lines.push(` [info] shipped tarball verify: skipped (${st.reason})`);
|
|
3239
|
+
} else if (st.ok) {
|
|
3240
|
+
lines.push(` [ok] shipped tarball verify: ${st.skills_passed ?? "?"}/${st.skills_total ?? "?"} skills pass on extracted tarball`);
|
|
3241
|
+
} else {
|
|
3242
|
+
lines.push(` [!!] shipped tarball verify FAILED: ${st.skills_failed ?? "?"}/${st.skills_total ?? "?"} skills fail (exit=${st.exit_code ?? "?"})`);
|
|
3243
|
+
}
|
|
3244
|
+
}
|
|
3083
3245
|
if (checks.signing) {
|
|
3084
3246
|
if (checks.signing.private_key_present) {
|
|
3085
3247
|
lines.push(` [ok] attestation signing: private key present (.keys/private.pem)`);
|
|
@@ -3711,17 +3873,65 @@ function cmdCi(runner, args, runOpts, pretty) {
|
|
|
3711
3873
|
const rwepValues = results.map(r => r.phases?.analyze?.rwep?.adjusted ?? 0);
|
|
3712
3874
|
const maxRwepObserved = rwepValues.length ? Math.max(...rwepValues) : 0;
|
|
3713
3875
|
|
|
3876
|
+
// v0.12.9 (P1 #2 from production smoke): reconcile verdict with exit code.
|
|
3877
|
+
// Pre-v0.12.9 the no-evidence-all-inconclusive path emitted verdict="PASS"
|
|
3878
|
+
// but the process exited 3 ("ran but no evidence"). CI consumers reading
|
|
3879
|
+
// exit code only failed a PASS run; consumers reading verdict only passed
|
|
3880
|
+
// a no-data run. Now compute the verdict up-front to match the exit-code
|
|
3881
|
+
// matrix (BLOCKED > FAIL > NO_EVIDENCE > PASS) so both surfaces agree.
|
|
3882
|
+
const suppliedEvidenceForVerdict = args.evidence || args["evidence-dir"];
|
|
3883
|
+
const blockedCount = results.filter(r => r && r.ok === false).length;
|
|
3884
|
+
const inconclusiveCount = results.filter(r => r.phases?.detect?.classification === "inconclusive").length;
|
|
3885
|
+
const totalForVerdict = results.length;
|
|
3886
|
+
const noEvidenceAllInconclusive = !suppliedEvidenceForVerdict && totalForVerdict > 0 && inconclusiveCount === totalForVerdict;
|
|
3887
|
+
const computedVerdict = blockedCount > 0
|
|
3888
|
+
? "BLOCKED"
|
|
3889
|
+
: fail
|
|
3890
|
+
? "FAIL"
|
|
3891
|
+
: noEvidenceAllInconclusive
|
|
3892
|
+
? "NO_EVIDENCE"
|
|
3893
|
+
: "PASS";
|
|
3894
|
+
|
|
3895
|
+
// v0.12.9 (P2 #8 from production smoke): roll up per-playbook framework_gap
|
|
3896
|
+
// mappings to the ci top-level. Phase 7 of the seven-phase contract surfaces
|
|
3897
|
+
// framework_gap_mapping per result; pre-v0.12.9 ci never aggregated them,
|
|
3898
|
+
// so operators got individual-playbook results only. Now: top-level
|
|
3899
|
+
// framework_gap_rollup lists each {framework, claimed_control} once with
|
|
3900
|
+
// the set of playbooks that flagged it — single-glance "what gaps did this
|
|
3901
|
+
// gate uncover across the scoped playbooks."
|
|
3902
|
+
const gapRollupMap = new Map();
|
|
3903
|
+
for (const r of results) {
|
|
3904
|
+
const gaps = r.phases?.analyze?.framework_gap_mapping || [];
|
|
3905
|
+
for (const g of gaps) {
|
|
3906
|
+
const key = `${g.framework || "unknown"}::${g.claimed_control || "unspecified"}`;
|
|
3907
|
+
const existing = gapRollupMap.get(key);
|
|
3908
|
+
if (existing) {
|
|
3909
|
+
if (!existing.playbooks.includes(r.playbook_id)) existing.playbooks.push(r.playbook_id);
|
|
3910
|
+
} else {
|
|
3911
|
+
gapRollupMap.set(key, {
|
|
3912
|
+
framework: g.framework || null,
|
|
3913
|
+
claimed_control: g.claimed_control || null,
|
|
3914
|
+
why_insufficient: g.why_insufficient || null,
|
|
3915
|
+
playbooks: [r.playbook_id],
|
|
3916
|
+
});
|
|
3917
|
+
}
|
|
3918
|
+
}
|
|
3919
|
+
}
|
|
3920
|
+
const frameworkGapRollup = [...gapRollupMap.values()];
|
|
3921
|
+
|
|
3714
3922
|
const summary = {
|
|
3715
3923
|
total: results.length,
|
|
3716
3924
|
detected: results.filter(r => r.phases?.detect?.classification === "detected").length,
|
|
3717
|
-
inconclusive:
|
|
3925
|
+
inconclusive: inconclusiveCount,
|
|
3718
3926
|
not_detected: results.filter(r => ["not_detected", "clean"].includes(r.phases?.detect?.classification)).length,
|
|
3719
|
-
blocked:
|
|
3927
|
+
blocked: blockedCount,
|
|
3720
3928
|
max_rwep_observed: maxRwepObserved,
|
|
3721
3929
|
jurisdiction_clocks_started: results
|
|
3722
3930
|
.flatMap(r => r.phases?.close?.notification_actions || [])
|
|
3723
3931
|
.filter(n => n && n.clock_started_at != null).length,
|
|
3724
|
-
|
|
3932
|
+
framework_gap_rollup: frameworkGapRollup,
|
|
3933
|
+
framework_gap_count: frameworkGapRollup.length,
|
|
3934
|
+
verdict: computedVerdict,
|
|
3725
3935
|
fail_reasons: failReasons,
|
|
3726
3936
|
};
|
|
3727
3937
|
|