# SPDX-License-Identifier: GPL-3.0-or-later """Dry-run response planning for incidents. The response layer starts read-only: turn an incident's captured evidence into an explicit plan an operator can review. No action is executed here. Future ``--apply`` support should consume this schema and write an audit log. """ from __future__ import annotations import json import os import re from dataclasses import dataclass, field from datetime import datetime from pathlib import Path from typing import Any from .config import Config from .netutil import is_public_ip @dataclass(frozen=True) class ResponseAction: id: str category: str title: str command: tuple[str, ...] reason: str risk: str = "medium" reversible: bool = True requires_review: bool = True targets: dict[str, Any] = field(default_factory=dict) def to_dict(self) -> dict: return { "id": self.id, "category": self.category, "title": self.title, "command": list(self.command), "reason": self.reason, "risk": self.risk, "reversible": self.reversible, "requires_review": self.requires_review, "targets": self.targets, } def load_bundle(cfg: Config, incident_id: str) -> dict | None: """Load an incident record and its still-retained snapshot reports.""" from . import incident inc = incident.load_index(cfg).get(incident_id) if inc is None: return None snaps = [] for name in inc.get("snapshots", []): path = (cfg.log_dir / name).with_suffix(".json") try: snaps.append(json.loads(path.read_text())) except (OSError, ValueError): continue return {"incident": inc, "snapshots": snaps} def build_plan(bundle: dict, cfg: Config) -> dict: inc = bundle["incident"] snapshots = bundle.get("snapshots", []) incident_id = inc["id"] actions: list[ResponseAction] = [] def add(action: ResponseAction) -> None: if (action.category, action.command, tuple(sorted(action.targets.items()))) in seen: return seen.add((action.category, action.command, tuple(sorted(action.targets.items())))) actions.append(action) seen: set[tuple] = set() evidence_name = f"enodia-evidence-{incident_id}.tgz" add(ResponseAction( id="A001", category="evidence", title="Freeze Sentinel evidence bundle", command=("tar", "-C", str(cfg.log_dir.parent), "-czf", f"/tmp/{evidence_name}", cfg.log_dir.name), reason="Preserve snapshots, event logs, baselines, and incident index before containment.", risk="low", reversible=False, targets={"path": f"/tmp/{evidence_name}"}, )) alerts = _alerts(snapshots) procs = _processes(snapshots) sigs = {a.get("signature", "") for a in alerts} for proc in procs.values(): pid = proc.get("pid") if not isinstance(pid, int): continue add(ResponseAction( id="", category="process", title=f"Freeze suspicious process {pid}", command=("kill", "-STOP", str(pid)), reason="Pause the process so /proc state can be inspected before termination.", risk="medium", targets={"pid": pid, "comm": proc.get("comm", "")}, )) add(ResponseAction( id="", category="process", title=f"Terminate suspicious process {pid}", command=("kill", "-TERM", str(pid)), reason="Stop a process directly tied to the incident after evidence is captured.", risk="high", targets={"pid": pid, "comm": proc.get("comm", "")}, )) for ip in sorted(_public_ips(alerts)): add(ResponseAction( id="", category="network", title=f"Block outbound traffic to {ip}", command=("nft", "add", "rule", "inet", "filter", "output", "ip", "daddr", ip, "drop"), reason="Contain suspicious C2/egress while the incident is investigated.", risk="medium", targets={"ip": ip}, )) for unit in sorted(_systemd_units(alerts)): add(ResponseAction( id="", category="service", title=f"Stop and disable {unit}", command=("systemctl", "disable", "--now", unit), reason="Disable persistence from a modified systemd unit.", risk="high", targets={"unit": unit}, )) for path, sig in sorted(_alert_paths(alerts)): if _is_quarantine_candidate(path, sig): add(ResponseAction( id="", category="file", title=f"Quarantine suspicious file {path}", command=("mv", "--", path, f"{path}.enodia-quarantine"), reason="Move a suspicious added/SUID/preload artifact out of its execution path.", risk="high", targets={"path": path}, )) if sig in {"fim_pkg_modified", "pkg_signature_mismatch"}: add(ResponseAction( id="", category="recovery", title=f"Restore package-owned file {path}", command=("pacman", "-Qo", path), reason="Identify the owning package before reinstalling it from trusted media.", risk="low", targets={"path": path}, )) if {"fim_modified", "fim_pkg_modified", "pkg_signature_mismatch", "pkgdb_tamper"} & sigs: add(ResponseAction( id="", category="verification", title="Re-run integrity checks after containment", command=("enodia-sentinel", "fim-check", "--packages"), reason="Confirm package-owned and FIM-monitored files match expected state.", risk="low", )) if any(s.startswith("rootkit_") or s in {"new_listener", "promiscuous_interface"} for s in sigs): add(ResponseAction( id="", category="verification", title="Re-run rootcheck after containment", command=("enodia-sentinel", "rootcheck"), reason="Confirm cross-view hidden process/module/port findings are gone.", risk="low", )) numbered = [] for i, action in enumerate(actions, 1): d = action.to_dict() d["id"] = action.id or f"A{i:03d}" numbered.append(d) return { "schema": "enodia.response.plan.v1", "plan_id": f"plan-{incident_id}", "incident_id": incident_id, "created_at": datetime.now().astimezone().isoformat(), "mode": "dry-run", "apply_supported": False, "summary": { "severity": inc.get("severity", "?"), "signatures": inc.get("signatures", []), "snapshot_count": len(snapshots), "action_count": len(numbered), }, "actions": numbered, "notes": [ "This plan is read-only; Sentinel did not execute any command.", "Preserve evidence before stopping processes, services, or moving files.", "Review each target against live state because snapshots can be stale.", ], } 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", [])] def _processes(snapshots: list[dict]) -> dict[int, dict]: out = {} for snap in snapshots: for proc in snap.get("processes", []): pid = proc.get("pid") if isinstance(pid, int): out[pid] = proc return out _IP_RE = re.compile(r"(? set[str]: ips = set() for alert in alerts: for ip in _IP_RE.findall(alert.get("detail", "")): if is_public_ip(ip): ips.add(ip) return ips def _paths(alerts: list[dict]) -> set[str]: return {path for path, _sig in _alert_paths(alerts)} def _alert_paths(alerts: list[dict]) -> set[tuple[str, str]]: paths = set() for alert in alerts: detail = alert.get("detail", "") key = alert.get("key", "") for text in (detail, key): for token in re.findall(r"(? set[str]: units = set() for path in _paths(alerts): p = Path(path) if "/systemd/system/" in path and p.name.endswith((".service", ".timer", ".socket")): units.add(p.name) 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 if sig == "fim_added": return True if sig in {"ld_preload", "deleted_exe", "fim_added"} and path.startswith( ("/tmp/", "/dev/shm/", "/var/tmp/", "/run/user/")): return True if path == "/etc/ld.so.preload": return False return False