Persist dry-run response plans
This commit is contained in:
parent
812cf0c836
commit
1a5b6f1d8d
11 changed files with 133 additions and 5 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue