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.
|
- `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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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"])
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue