From 0b010df51437b8b448563057b5ef09c4c2ea4246 Mon Sep 17 00:00:00 2001 From: Luna Date: Thu, 9 Jul 2026 05:56:25 -0700 Subject: [PATCH] Add audited response apply rehearsal --- README.md | 5 +++ docs/COMMAND_REFERENCE.md | 23 +++++++----- docs/OPERATIONS.md | 6 +++ docs/ROADMAP.md | 9 ++++- docs/SCHEMAS.md | 7 +++- docs/SPECIFICATION.md | 4 +- enodia_sentinel/cli.py | 71 ++++++++++++++++++++++++++++++----- enodia_sentinel/completion.py | 6 +-- enodia_sentinel/respond.py | 63 +++++++++++++++++++++++++++++++ tests/test_respond.py | 30 ++++++++++++++- tests/test_tui.py | 2 + 11 files changed, 198 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index f08b16c..7d54375 100644 --- a/README.md +++ b/README.md @@ -355,6 +355,11 @@ not create artifacts. Plans also include pacman package-restore guidance plus read-only recovery checks for package verification, rootcheck, persistence re-checks, and off-host watchdog visibility. +`enodia-sentinel respond apply --dry-run` reloads a saved plan, +prints the reviewed actions, and appends an audit record without executing +commands. Apply execution remains unsupported until the state-changing workflow +is separately designed and tested. + Alert snapshots include a best-effort enrichment block that annotates flagged processes and paths with package ownership, executable hashes, parent chains, remote IP classification, file metadata, recent watched writes, and local diff --git a/docs/COMMAND_REFERENCE.md b/docs/COMMAND_REFERENCE.md index 79440c5..c943c0d 100644 --- a/docs/COMMAND_REFERENCE.md +++ b/docs/COMMAND_REFERENCE.md @@ -319,6 +319,8 @@ Exit code: ```bash enodia-sentinel respond plan enodia-sentinel respond plan --json +enodia-sentinel respond apply --dry-run +enodia-sentinel respond apply --dry-run --json ``` Builds a dry-run response plan from an incident's retained JSON snapshots. The @@ -341,11 +343,17 @@ saved path. The JSON schema is `enodia.response.plan.v1` and marks `apply_supported: false` until an audited apply workflow exists. Dashboard/API plan previews remain read-only and do not create new artifacts. +`respond apply` currently supports only `--dry-run`. It reloads a saved plan by +path, filename, or `plan_id`, prints the exact reviewed actions, and appends a +`response_apply_rehearsed` audit record with `executed: false`. Running +`respond apply` without `--dry-run` exits with an error; Sentinel still does not +execute containment commands. + Exit code: -- `0`: plan generated. -- `1`: unknown incident id. -- `2`: missing incident id. +- `0`: plan generated, or apply rehearsal recorded. +- `1`: unknown incident id or saved plan reference. +- `2`: missing id/reference, or `respond apply` requested without `--dry-run`. ### `status` @@ -498,11 +506,6 @@ does not require pip or a virtualenv. ## Future Command Contracts -The roadmap reserves these command shapes: - -```bash -enodia-sentinel respond apply -``` - State-changing response commands should be introduced with stable JSON schemas, -audit logs, and tests before they are documented as supported. +audit logs, and tests before they are documented as supported. The current +`respond apply --dry-run` command is only an audited rehearsal path. diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 61e52fb..0a504e9 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -109,6 +109,12 @@ When Sentinel fires: commands. CLI-generated plans are saved under `/var/log/enodia-sentinel/response-plans/` and indexed in `/var/log/enodia-sentinel/response-audit.log` for later review. + To rehearse a saved plan and record that review without executing anything: + + ```bash + enodia-sentinel respond apply --dry-run + ``` + 5. If the alert involves a package-owned binary, review the package recovery actions in the response plan and run the restore sequence only after the owning package and package source are trusted: diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index fe8ce8f..050bf06 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -71,8 +71,13 @@ Purpose: move from "tell me" to "help me act" without unsafe automation. identify the owning package, verify signed package-cache metadata, reinstall from trusted package media, re-run integrity checks, and re-anchor FIM only after operator review. Debian/RPM support remains future work. -- Require explicit `--apply` for changes; default to read-only plans. -- Extend response audit logs to any future state-changing `--apply` workflow. +- ✅ Add an explicit apply boundary: `respond apply --dry-run` + reloads a saved reviewed plan, prints the exact actions, and appends a + `response_apply_rehearsed` audit record without executing commands. +- Require a future explicit execution flag for state changes; default to + read-only plans and audited apply rehearsals. +- ✅ Extend response audit logs to the apply boundary with `executed: false` + records. Future state-changing execution must append the same audit trail. - ✅ Add baseline reconciliation: accept legitimate FIM/package/listener/SUID drift with a mandatory reason via `baseline accept/revoke/list`. Acks are fingerprint-bound (content kinds re-alert on further change), support an diff --git a/docs/SCHEMAS.md b/docs/SCHEMAS.md index 7dcbf21..d098f4a 100644 --- a/docs/SCHEMAS.md +++ b/docs/SCHEMAS.md @@ -132,7 +132,8 @@ CLI-generated plans may also include `artifacts` with saved plan/audit paths. ## `enodia.response.audit.v1` -JSONL record appended when the CLI persists a response plan. +JSONL record appended when the CLI persists a response plan or rehearses a +saved plan with `respond apply --dry-run`. Required fields: @@ -140,7 +141,7 @@ Required fields: |---|---|---| | `schema` | string | `enodia.response.audit.v1`. | | `time` | string | ISO-8601 audit time. | -| `event` | string | Currently `response_plan_generated`. | +| `event` | string | `response_plan_generated` or `response_apply_rehearsed`. | | `actor` | string | Caller identity supplied by the CLI/workflow. | | `plan_id` | string | Persisted plan id. | | `incident_id` | string | Source incident id. | @@ -149,6 +150,8 @@ Required fields: | `action_count` | integer | Number of proposed actions. | | `plan_path` | string | Persisted plan artifact path. | +Apply rehearsal records also include `executed: false` and a short `reason`. + ## `enodia.reconcile.v1` Acknowledged-drift store written by `baseline accept/revoke/list` diff --git a/docs/SPECIFICATION.md b/docs/SPECIFICATION.md index 95ea2b6..8cc08f0 100644 --- a/docs/SPECIFICATION.md +++ b/docs/SPECIFICATION.md @@ -109,7 +109,9 @@ When a fresh alert survives cooldown, Sentinel writes: CLI-generated response plans are durable artifacts: `respond plan` writes the reviewed dry-run plan under `response-plans/` and appends a JSONL `response-audit.log` record. Dashboard/API previews remain read-only and do not -create artifacts. +create artifacts. `respond apply --dry-run` reloads a saved plan and +appends an apply-rehearsal audit record with `executed: false`; it does not run +commands. Current IPS scope is explicit prevention workflow, not transparent inline enforcement. Sentinel can recommend evidence preservation, process diff --git a/enodia_sentinel/cli.py b/enodia_sentinel/cli.py index d877a5c..16a92b5 100644 --- a/enodia_sentinel/cli.py +++ b/enodia_sentinel/cli.py @@ -237,11 +237,14 @@ def main(argv: list[str] | None = None) -> int: help="posture action (default: check)") po.add_argument("--json", action="store_true", help="emit findings as JSON") rsp = sub.add_parser("respond", - help="build dry-run response plans for incidents") - rsp.add_argument("action", nargs="?", default="plan", choices=["plan"], + help="build and rehearse response plans for incidents") + rsp.add_argument("action", nargs="?", default="plan", choices=["plan", "apply"], help="response action (default: plan)") - rsp.add_argument("id", nargs="?", help="incident id") - rsp.add_argument("--json", action="store_true", help="emit plan as JSON") + rsp.add_argument("id", nargs="?", + help="incident id for plan, or saved plan path/id for apply") + rsp.add_argument("--json", action="store_true", help="emit JSON") + rsp.add_argument("--dry-run", action="store_true", + help="for respond apply, audit and print actions without executing") wd = sub.add_parser("watchdog", help="poll a remote dashboard and push if Sentinel is silent") wd.add_argument("--url", required=True, help="dashboard base URL") @@ -305,7 +308,7 @@ def main(argv: list[str] | None = None) -> int: if args.cmd == "posture": return _cmd_posture(cfg, args.json) if args.cmd == "respond": - return _cmd_respond(cfg, args.action, args.id, args.json) + return _cmd_respond(cfg, args.action, args.id, args.json, args.dry_run) if args.cmd == "watchdog": return _cmd_watchdog(cfg, args.url, args.token, args.max_age, not args.insecure_tls) @@ -581,14 +584,14 @@ def _cmd_posture(cfg: Config, as_json: bool) -> int: return 1 -def _cmd_respond(cfg: Config, action: str, iid: str | None, as_json: bool) -> int: +def _cmd_respond(cfg: Config, action: str, iid: str | None, as_json: bool, + dry_run: bool = False) -> int: import json from . import respond - if action != "plan": - print(f"error: unsupported respond action: {action}", file=sys.stderr) - return 2 + if action == "apply": + return _cmd_respond_apply(cfg, iid, as_json, dry_run) if not iid: print("error: 'respond plan' needs an incident id " "(see 'incident list')", file=sys.stderr) @@ -622,6 +625,56 @@ def _cmd_respond(cfg: Config, action: str, iid: str | None, as_json: bool) -> in return 0 +def _cmd_respond_apply(cfg: Config, ref: str | None, as_json: bool, + dry_run: bool) -> int: + import json + + from . import respond + + if not ref: + print("error: 'respond apply' needs a saved plan path or plan id", + file=sys.stderr) + return 2 + if not dry_run: + print("error: response apply execution is not implemented; rerun with " + "--dry-run to rehearse and audit the reviewed plan", + file=sys.stderr) + return 2 + plan, plan_path = respond.load_persisted_plan(cfg, ref) + if plan is None or plan_path is None: + print(f"error: no such response plan: {ref}", file=sys.stderr) + return 1 + result = respond.rehearse_apply(cfg, plan, plan_path) + output = { + "schema": schemas.RESPONSE_AUDIT_V1, + "plan": { + "plan_id": plan.get("plan_id"), + "incident_id": plan.get("incident_id"), + "plan_path": str(plan_path), + "action_count": len(plan.get("actions", [])), + }, + "audit": result["record"], + "audit_log": result["audit_log"], + "executed": False, + } + if as_json: + print(json.dumps(output, indent=2)) + return 0 + + print(f"Response apply rehearsal {plan.get('plan_id', ref)}") + print(f" incident: {plan.get('incident_id', '?')}") + print(f" plan: {plan_path}") + print(f" audit: {result['audit_log']}") + print(" executed: no") + print(" actions:") + for action in plan.get("actions", []): + cmd = " ".join(action.get("command", [])) + print(f" {action.get('id', '?')} [{action.get('risk', '?')}] " + f"{action.get('title', '')}") + print(f" {cmd}") + return 0 + + def _cmd_fim_check(cfg: Config, packages: bool) -> int: from . import fim, reconcile sentinel = Sentinel(cfg) diff --git a/enodia_sentinel/completion.py b/enodia_sentinel/completion.py index 3e1aeba..7436a22 100644 --- a/enodia_sentinel/completion.py +++ b/enodia_sentinel/completion.py @@ -13,7 +13,7 @@ RULE_ACTIONS = ("list", "show", "test", "docs") BASELINE_ACTIONS = ("build", "accept", "revoke", "list") INCIDENT_ACTIONS = ("list", "show", "export") POSTURE_ACTIONS = ("check",) -RESPOND_ACTIONS = ("plan",) +RESPOND_ACTIONS = ("plan", "apply") COMPLETION_SHELLS = ("bash", "zsh") @@ -47,7 +47,7 @@ _{prog.replace('-', '_')}_complete() {{ baseline) COMPREPLY=( $(compgen -W "{baseline_actions} --reason --expires --force --stale --json" -- "$cur") ) ;; incident) COMPREPLY=( $(compgen -W "{incident_actions} --json" -- "$cur") ) ;; posture) COMPREPLY=( $(compgen -W "{posture_actions} --json" -- "$cur") ) ;; - respond) COMPREPLY=( $(compgen -W "{respond_actions} --json" -- "$cur") ) ;; + respond) COMPREPLY=( $(compgen -W "{respond_actions} --json --dry-run" -- "$cur") ) ;; completion) COMPREPLY=( $(compgen -W "{shells}" -- "$cur") ) ;; check|fim-check|status) COMPREPLY=( $(compgen -W "--json --packages" -- "$cur") ) ;; pkgdb-verify) COMPREPLY=( $(compgen -W "--sample" -- "$cur") ) ;; @@ -82,7 +82,7 @@ case $state in baseline) _arguments '1:action:((build accept revoke list))' '2:kind:((fim pkgfile listener suid))' '--reason[reason]' '--expires[TTL]' '--force[force]' '--stale[stale]' '--json[emit JSON]' ;; incident) _arguments '1:action:((list show export))' '--json[emit JSON]' ;; posture) _arguments '1:action:((check))' '--json[emit JSON]' ;; - respond) _arguments '1:action:((plan))' '--json[emit JSON]' ;; + respond) _arguments '1:action:((plan apply))' '--json[emit JSON]' '--dry-run[rehearse apply without executing]' ;; completion) _arguments '1:shell:((bash zsh))' ;; check|status) _arguments '--json[emit JSON]' ;; fim-check) _arguments '--packages[verify package-owned files]' ;; diff --git a/enodia_sentinel/respond.py b/enodia_sentinel/respond.py index 5eaf491..e307a8e 100644 --- a/enodia_sentinel/respond.py +++ b/enodia_sentinel/respond.py @@ -312,6 +312,69 @@ def persist_plan(cfg: Config, plan: dict, actor: str = "cli") -> dict[str, str]: return {"plan_path": str(plan_path), "audit_log": str(audit_path)} +def load_persisted_plan(cfg: Config, ref: str) -> tuple[dict, Path] | tuple[None, None]: + """Load a persisted response plan by path, filename, or plan_id. + + ``respond apply`` deliberately consumes a saved artifact instead of a freshly + generated preview, so the operator is applying exactly what was reviewed. + """ + candidates: list[Path] = [] + raw = Path(ref) + if raw.is_file(): + candidates.append(raw) + plan_dir = cfg.log_dir / "response-plans" + if not raw.is_absolute(): + candidates.extend([ + plan_dir / ref, + plan_dir / f"{ref}.json", + ]) + try: + candidates.extend(sorted(plan_dir.glob("*.json"), reverse=True)) + except OSError: + pass + + seen = set() + for path in candidates: + if path in seen or not path.is_file(): + continue + seen.add(path) + try: + plan = json.loads(path.read_text()) + except (OSError, ValueError): + continue + if path == raw or path.name == ref or plan.get("plan_id") == ref: + return plan, path + return None, None + + +def rehearse_apply(cfg: Config, plan: dict, plan_path: Path, + actor: str = "cli") -> dict: + """Audit an explicit apply rehearsal without executing plan commands.""" + record = { + "schema": schemas.RESPONSE_AUDIT_V1, + "time": datetime.now().astimezone().isoformat(), + "event": "response_apply_rehearsed", + "actor": actor, + "plan_id": plan.get("plan_id"), + "incident_id": plan.get("incident_id"), + "mode": plan.get("mode"), + "apply_supported": plan.get("apply_supported", False), + "action_count": len(plan.get("actions", [])), + "plan_path": str(plan_path), + "executed": False, + "reason": "dry-run rehearsal; no commands executed", + } + audit_path = response_audit_path(cfg) + cfg.log_dir.mkdir(parents=True, exist_ok=True) + with audit_path.open("a") as fh: + fh.write(json.dumps(record, sort_keys=True) + "\n") + try: + audit_path.chmod(0o640) + except OSError: + pass + return {"audit_log": str(audit_path), "record": record} + + def response_audit_path(cfg: Config) -> Path: return cfg.log_dir / "response-audit.log" diff --git a/tests/test_respond.py b/tests/test_respond.py index dcdcedb..1b48a01 100644 --- a/tests/test_respond.py +++ b/tests/test_respond.py @@ -4,7 +4,7 @@ import json import os import tempfile import unittest -from contextlib import redirect_stdout +from contextlib import redirect_stderr, redirect_stdout from pathlib import Path from enodia_sentinel import incident, respond @@ -155,6 +155,34 @@ class TestRespondPlan(unittest.TestCase): finally: os.environ.pop("ENODIA_LOG_DIR", None) + def test_apply_requires_dry_run_and_audits_rehearsal(self): + os.environ["ENODIA_LOG_DIR"] = str(self.tmp) + try: + with redirect_stdout(io.StringIO()): + self.assertEqual(main(["respond", "plan", self.iid]), 0) + plan_path = next((self.tmp / "response-plans").glob("*.json")) + + err = io.StringIO() + with redirect_stderr(err): + code = main(["respond", "apply", str(plan_path)]) + self.assertEqual(code, 2) + self.assertIn("not implemented", err.getvalue()) + + buf = io.StringIO() + with redirect_stdout(buf): + code = main(["respond", "apply", str(plan_path), + "--dry-run", "--json"]) + self.assertEqual(code, 0) + output = json.loads(buf.getvalue()) + self.assertFalse(output["executed"]) + self.assertEqual(output["audit"]["event"], "response_apply_rehearsed") + self.assertEqual(output["plan"]["plan_path"], str(plan_path)) + audit = (self.tmp / "response-audit.log").read_text().splitlines() + self.assertEqual(json.loads(audit[-1])["event"], + "response_apply_rehearsed") + finally: + os.environ.pop("ENODIA_LOG_DIR", None) + def test_cli_requires_incident_id(self): os.environ["ENODIA_LOG_DIR"] = str(self.tmp) try: diff --git a/tests/test_tui.py b/tests/test_tui.py index aeee578..512fa69 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -175,6 +175,8 @@ class TestTuiCore(unittest.TestCase): self.assertEqual(code, 0) self.assertIn("enodia-sentinel", buf.getvalue()) self.assertIn("tui", buf.getvalue()) + self.assertIn("apply", buf.getvalue()) + self.assertIn("--dry-run", buf.getvalue()) def test_help_lists_all_commands(self): help_lines = render_lines(TuiState(view="help"), width=100)