From 1a5b6f1d8dcbb7f293991943e7e556328ea8ecbc Mon Sep 17 00:00:00 2001 From: Luna Date: Sat, 13 Jun 2026 05:53:48 -0700 Subject: [PATCH] Persist dry-run response plans --- CLAUDE.md | 4 ++- README.md | 5 +++ docs/COMMAND_REFERENCE.md | 8 +++-- docs/OPERATIONS.md | 4 ++- docs/ROADMAP.md | 4 ++- docs/RUNBOOKS.md | 5 +++ docs/SPECIFICATION.md | 5 +++ docs/THREAT_MODEL.md | 6 ++++ enodia_sentinel/cli.py | 5 +++ enodia_sentinel/respond.py | 64 ++++++++++++++++++++++++++++++++++++++ tests/test_respond.py | 28 +++++++++++++++++ 11 files changed, 133 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 38901f7..2cbc94f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,7 +51,9 @@ Start with: - `events/` contains optional eBPF exec/syscall monitoring and declarative rules. - `snapshot.py` writes forensic `.log` and `.json` evidence. - `incident.py` groups snapshots by process lineage and time window. -- `respond.py` builds read-only response plans from incident evidence. +- `respond.py` builds read-only response plans from incident evidence; the CLI + persists reviewed dry-run plans under `response-plans/` and appends + `response-audit.log`, while dashboard/API previews stay write-free. - `web.py` serves the HTTPS-only management console and JSON APIs. - `rootcheck.py` performs anti-rootkit cross-view checks: hidden processes, processes hidden from `ps`, hidden modules, hidden diff --git a/README.md b/README.md index adbcc48..472cd9c 100644 --- a/README.md +++ b/README.md @@ -274,6 +274,11 @@ sudo systemctl enable --now enodia-sentinel-web API at `/api/status`, `/api/incidents`, `/api/respond/plan/`, `/api/posture`, `/api/alerts`, `/api/alerts/`, `/api/events`. +CLI-generated response plans are saved for handoff/review under +`/response-plans/`, with a JSONL trail in +`/response-audit.log`. Dashboard plan previews stay read-only and do +not create artifacts. + ## Phone push notifications When an alert at/above `notify_min_severity` fires, Sentinel pushes to whichever diff --git a/docs/COMMAND_REFERENCE.md b/docs/COMMAND_REFERENCE.md index 4356ecb..14d3300 100644 --- a/docs/COMMAND_REFERENCE.md +++ b/docs/COMMAND_REFERENCE.md @@ -229,8 +229,12 @@ outbound IP blocks, systemd unit disablement, suspicious-file quarantine, package-restore lookup, and follow-up verification checks. This command is intentionally read-only: it prints commands for operator review -and never executes them. The JSON schema is `enodia.response.plan.v1` and marks -`apply_supported: false` until an audited apply workflow exists. +and never executes them. Each CLI-generated plan is also saved under +`/response-plans/`, and a compact JSONL audit record is appended to +`/response-audit.log` with the plan id, incident id, action count, and +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. Exit code: diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index c3b5ba6..0cf98eb 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -93,7 +93,9 @@ When Sentinel fires: ``` Review the plan before acting; Sentinel does not execute containment - commands. + 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. 5. If the alert involves a binary, run: ```bash diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index e14b64d..51ba243 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -61,11 +61,13 @@ Purpose: move from "tell me" to "help me act" without unsafe automation. - ✅ Add response plan generation: `enodia-sentinel respond plan `. +- ✅ Persist CLI-generated dry-run plans under `response-plans/` and append + `response-audit.log` records for review/handoff. - Add dry-run first actions: kill process, stop/disable service, block remote IP, quarantine file, restore package-owned file by reinstalling its package, and freeze evidence. - Require explicit `--apply` for changes; default to read-only plans. -- Write response audit logs under the normal log directory. +- Extend response audit logs to any future state-changing `--apply` workflow. - Add baseline reconciliation: accept legitimate FIM/package/listener/SUID changes with a recorded reason. - Add richer false-positive suppression suggestions that can emit TOML snippets diff --git a/docs/RUNBOOKS.md b/docs/RUNBOOKS.md index a862a6d..4b1eacb 100644 --- a/docs/RUNBOOKS.md +++ b/docs/RUNBOOKS.md @@ -29,6 +29,11 @@ For any alert, before changing anything: enodia-sentinel respond plan ``` + The CLI writes a copy under + `/var/log/enodia-sentinel/response-plans/` and appends + `/var/log/enodia-sentinel/response-audit.log`, so the reviewed plan can be + handed off or compared with later actions. + 5. **Preserve evidence before you touch anything:** ```bash diff --git a/docs/SPECIFICATION.md b/docs/SPECIFICATION.md index dc97843..d24224d 100644 --- a/docs/SPECIFICATION.md +++ b/docs/SPECIFICATION.md @@ -105,6 +105,11 @@ When a fresh alert survives cooldown, Sentinel writes: | Push | ntfy, Pushover, and generic webhook notifications. | | Red-team harness | Safe drills for testing signatures and demos. | +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. + ## Target Scope: Host Security Platform The next product shape should make Enodia useful before, during, and after an diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index 2cf8a94..ea329b5 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -108,6 +108,12 @@ Sentinel should remain conservative: Future response features should produce dry-run plans first, log all changes, and avoid shell-string execution where structured APIs exist. +Current CLI response planning already leaves a local audit trail: generated +dry-run plans are saved under `response-plans/`, and +`response-audit.log` records who/what generated the plan and where it was +stored. This is a review/handoff record only; it does not prove containment +commands were executed. + ## Abuse Considerations Some security tools can become dual-use. Enodia should avoid: diff --git a/enodia_sentinel/cli.py b/enodia_sentinel/cli.py index 738aef4..07f0c0b 100644 --- a/enodia_sentinel/cli.py +++ b/enodia_sentinel/cli.py @@ -359,6 +359,9 @@ def _cmd_respond(cfg: Config, action: str, iid: str | None, as_json: bool) -> in print(f"error: no such incident: {iid}", file=sys.stderr) return 1 plan = respond.build_plan(bundle, cfg) + artifacts = respond.persist_plan(cfg, plan) + plan = dict(plan) + plan["artifacts"] = artifacts if as_json: print(json.dumps(plan, indent=2)) return 0 @@ -369,6 +372,8 @@ def _cmd_respond(cfg: Config, action: str, iid: str | None, as_json: bool) -> in print(f" mode: {plan['mode']} (no commands executed)") print(f" signatures: {', '.join(summary['signatures']) or '—'}") print(f" snapshots: {summary['snapshot_count']}") + print(f" saved: {artifacts['plan_path']}") + print(f" audit: {artifacts['audit_log']}") print(" actions:") for a in plan["actions"]: cmd = " ".join(a["command"]) diff --git a/enodia_sentinel/respond.py b/enodia_sentinel/respond.py index 55a05f3..980fe15 100644 --- a/enodia_sentinel/respond.py +++ b/enodia_sentinel/respond.py @@ -8,6 +8,7 @@ an explicit plan an operator can review. No action is executed here. Future from __future__ import annotations import json +import os import re from dataclasses import dataclass, field from datetime import datetime @@ -209,6 +210,62 @@ def build_plan(bundle: dict, cfg: Config) -> dict: } +def persist_plan(cfg: Config, plan: dict, actor: str = "cli") -> dict[str, str]: + """Persist a generated dry-run plan and append an audit record. + + This is intentionally separate from ``build_plan`` so dashboard/API reads can + render plans without creating new artifacts. The persisted file is the + operator-reviewed command proposal; it is not evidence that actions ran. + """ + cfg.log_dir.mkdir(parents=True, exist_ok=True) + plan_dir = cfg.log_dir / "response-plans" + plan_dir.mkdir(parents=True, exist_ok=True) + + stamp = datetime.now().astimezone().strftime("%Y%m%d-%H%M%S-%f") + incident_id = _safe_name(str(plan.get("incident_id", "unknown"))) + plan_id = _safe_name(str(plan.get("plan_id", f"plan-{incident_id}"))) + plan_path = plan_dir / f"{stamp}-{plan_id}.json" + + stored = dict(plan) + artifacts = dict(stored.get("artifacts", {})) + artifacts["plan_path"] = str(plan_path) + artifacts["audit_log"] = str(response_audit_path(cfg)) + stored["artifacts"] = artifacts + + tmp = plan_path.with_suffix(".json.tmp") + tmp.write_text(json.dumps(stored, indent=2) + "\n") + os.replace(tmp, plan_path) + try: + plan_path.chmod(0o640) + except OSError: + pass + + audit_path = response_audit_path(cfg) + record = { + "time": datetime.now().astimezone().isoformat(), + "event": "response_plan_generated", + "actor": actor, + "plan_id": stored.get("plan_id"), + "incident_id": stored.get("incident_id"), + "mode": stored.get("mode"), + "apply_supported": stored.get("apply_supported"), + "action_count": stored.get("summary", {}).get("action_count", 0), + "plan_path": str(plan_path), + } + 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 {"plan_path": str(plan_path), "audit_log": str(audit_path)} + + +def response_audit_path(cfg: Config) -> Path: + return cfg.log_dir / "response-audit.log" + + def _alerts(snapshots: list[dict]) -> list[dict]: return [a for snap in snapshots for a in snap.get("alerts", [])] @@ -259,6 +316,13 @@ def _systemd_units(alerts: list[dict]) -> set[str]: return units +_SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9_.-]+") + + +def _safe_name(value: str) -> str: + return _SAFE_NAME_RE.sub("-", value).strip(".-") or "unknown" + + def _is_quarantine_candidate(path: str, sig: str) -> bool: if sig == "new_suid": return True diff --git a/tests/test_respond.py b/tests/test_respond.py index bf4c2fb..f969d99 100644 --- a/tests/test_respond.py +++ b/tests/test_respond.py @@ -85,6 +85,34 @@ class TestRespondPlan(unittest.TestCase): plan = json.loads(buf.getvalue()) self.assertEqual(plan["incident_id"], self.iid) self.assertGreaterEqual(plan["summary"]["action_count"], 5) + artifacts = plan["artifacts"] + saved = Path(artifacts["plan_path"]) + audit = Path(artifacts["audit_log"]) + self.assertTrue(saved.is_file()) + self.assertTrue(audit.is_file()) + stored = json.loads(saved.read_text()) + self.assertEqual(stored["plan_id"], plan["plan_id"]) + self.assertEqual(stored["artifacts"]["plan_path"], str(saved)) + record = json.loads(audit.read_text().splitlines()[-1]) + self.assertEqual(record["event"], "response_plan_generated") + self.assertEqual(record["incident_id"], self.iid) + self.assertEqual(record["plan_path"], str(saved)) + finally: + os.environ.pop("ENODIA_LOG_DIR", None) + + def test_cli_text_reports_persisted_plan(self): + os.environ["ENODIA_LOG_DIR"] = str(self.tmp) + try: + buf = io.StringIO() + with redirect_stdout(buf): + code = main(["respond", "plan", self.iid]) + self.assertEqual(code, 0) + out = buf.getvalue() + self.assertIn("saved:", out) + self.assertIn("audit:", out) + plans = list((self.tmp / "response-plans").glob("*.json")) + self.assertEqual(len(plans), 1) + self.assertTrue((self.tmp / "response-audit.log").is_file()) finally: os.environ.pop("ENODIA_LOG_DIR", None)