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. - `events/` contains optional eBPF exec/syscall monitoring and declarative rules.
- `snapshot.py` writes forensic `.log` and `.json` evidence. - `snapshot.py` writes forensic `.log` and `.json` evidence.
- `incident.py` groups snapshots by process lineage and time window. - `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. - `web.py` serves the HTTPS-only management console and JSON APIs.
- `rootcheck.py` performs anti-rootkit cross-view checks: - `rootcheck.py` performs anti-rootkit cross-view checks:
hidden processes, processes hidden from `ps`, hidden modules, hidden 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 at `/api/status`, `/api/incidents`, `/api/respond/plan/<id>`,
`/api/posture`, `/api/alerts`, `/api/alerts/<id>`, `/api/events`. `/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 ## Phone push notifications
When an alert at/above `notify_min_severity` fires, Sentinel pushes to whichever 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. package-restore lookup, and follow-up verification checks.
This command is intentionally read-only: it prints commands for operator review 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 and never executes them. Each CLI-generated plan is also saved under
`apply_supported: false` until an audited apply workflow exists. `<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: Exit code:

View file

@ -93,7 +93,9 @@ When Sentinel fires:
``` ```
Review the plan before acting; Sentinel does not execute containment 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: 5. If the alert involves a binary, run:
```bash ```bash

View file

@ -61,11 +61,13 @@ Purpose: move from "tell me" to "help me act" without unsafe automation.
- ✅ Add response plan generation: - ✅ Add response plan generation:
`enodia-sentinel respond plan <incident-id>`. `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: - Add dry-run first actions:
kill process, stop/disable service, block remote IP, quarantine file, restore kill process, stop/disable service, block remote IP, quarantine file, restore
package-owned file by reinstalling its package, and freeze evidence. package-owned file by reinstalling its package, and freeze evidence.
- Require explicit `--apply` for changes; default to read-only plans. - 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: - Add baseline reconciliation:
accept legitimate FIM/package/listener/SUID changes with a recorded reason. accept legitimate FIM/package/listener/SUID changes with a recorded reason.
- Add richer false-positive suppression suggestions that can emit TOML snippets - 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> 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:** 5. **Preserve evidence before you touch anything:**
```bash ```bash

View file

@ -105,6 +105,11 @@ When a fresh alert survives cooldown, Sentinel writes:
| Push | ntfy, Pushover, and generic webhook notifications. | | Push | ntfy, Pushover, and generic webhook notifications. |
| Red-team harness | Safe drills for testing signatures and demos. | | 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 ## Target Scope: Host Security Platform
The next product shape should make Enodia useful before, during, and after an 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, Future response features should produce dry-run plans first, log all changes,
and avoid shell-string execution where structured APIs exist. 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 ## Abuse Considerations
Some security tools can become dual-use. Enodia should avoid: 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) print(f"error: no such incident: {iid}", file=sys.stderr)
return 1 return 1
plan = respond.build_plan(bundle, cfg) plan = respond.build_plan(bundle, cfg)
artifacts = respond.persist_plan(cfg, plan)
plan = dict(plan)
plan["artifacts"] = artifacts
if as_json: if as_json:
print(json.dumps(plan, indent=2)) print(json.dumps(plan, indent=2))
return 0 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" mode: {plan['mode']} (no commands executed)")
print(f" signatures: {', '.join(summary['signatures']) or ''}") print(f" signatures: {', '.join(summary['signatures']) or ''}")
print(f" snapshots: {summary['snapshot_count']}") print(f" snapshots: {summary['snapshot_count']}")
print(f" saved: {artifacts['plan_path']}")
print(f" audit: {artifacts['audit_log']}")
print(" actions:") print(" actions:")
for a in plan["actions"]: for a in plan["actions"]:
cmd = " ".join(a["command"]) 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 from __future__ import annotations
import json import json
import os
import re import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime 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]: def _alerts(snapshots: list[dict]) -> list[dict]:
return [a for snap in snapshots for a in snap.get("alerts", [])] 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 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: def _is_quarantine_candidate(path: str, sig: str) -> bool:
if sig == "new_suid": if sig == "new_suid":
return True return True

View file

@ -85,6 +85,34 @@ class TestRespondPlan(unittest.TestCase):
plan = json.loads(buf.getvalue()) plan = json.loads(buf.getvalue())
self.assertEqual(plan["incident_id"], self.iid) self.assertEqual(plan["incident_id"], self.iid)
self.assertGreaterEqual(plan["summary"]["action_count"], 5) 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: finally:
os.environ.pop("ENODIA_LOG_DIR", None) os.environ.pop("ENODIA_LOG_DIR", None)