Persist dry-run response plans

This commit is contained in:
Luna 2026-06-13 05:53:48 -07:00
parent 812cf0c836
commit 1a5b6f1d8d
11 changed files with 133 additions and 5 deletions

View file

@ -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

View file

@ -274,6 +274,11 @@ sudo systemctl enable --now enodia-sentinel-web
API at `/api/status`, `/api/incidents`, `/api/respond/plan/<id>`,
`/api/posture`, `/api/alerts`, `/api/alerts/<id>`, `/api/events`.
CLI-generated response plans are saved for handoff/review under
`<log_dir>/response-plans/`, with a JSONL trail in
`<log_dir>/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

View file

@ -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
`<log_dir>/response-plans/`, and a compact JSONL audit record is appended to
`<log_dir>/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:

View file

@ -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

View file

@ -61,11 +61,13 @@ Purpose: move from "tell me" to "help me act" without unsafe automation.
- ✅ Add response plan generation:
`enodia-sentinel respond plan <incident-id>`.
- ✅ 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

View file

@ -29,6 +29,11 @@ For any alert, before changing anything:
enodia-sentinel respond plan <incident-id>
```
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

View file

@ -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

View file

@ -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:

View file

@ -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"])

View file

@ -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

View file

@ -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)