# 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 . import schemas 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"Identify package owner for {path}", command=("pacman", "-Qo", path), reason="Identify the owning package before reinstalling it from trusted media.", risk="low", targets={"path": path}, )) add(ResponseAction( id="", category="recovery", title=f"Reinstall owner of {path} from trusted package media", command=("pacman", "-S", "--overwrite", "*", ""), reason="After pacman -Qo identifies the owner, reinstall that package from a trusted, signature-verified source.", risk="high", targets={"path": path, "package": ""}, )) add(ResponseAction( id="", category="recovery", title=f"Re-anchor integrity after restoring {path}", command=("enodia-sentinel", "fim-update"), reason="Refresh the FIM baseline only after the operator verifies the restored file is legitimate.", risk="medium", 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 {"fim_pkg_modified", "pkg_signature_mismatch", "pkgdb_tamper"} & sigs: add(ResponseAction( id="", category="verification", title="Verify package-owned files against signed cache packages", command=("enodia-sentinel", "pkgdb-verify"), reason="Check on-disk package files against signed package metadata instead of the mutable local DB.", 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", )) if {"persistence", "new_suid", "fim_added", "fim_modified", "fim_removed"} & sigs: add(ResponseAction( id="", category="verification", title="Confirm no persistence diff remains", command=("enodia-sentinel", "check", "--json"), reason="Re-run the detector set after containment to confirm persistence-related alerts are gone.", risk="low", )) watchdog_url = _watchdog_url(cfg) add(ResponseAction( id="", category="verification", title="Confirm heartbeat and dashboard visibility", command=("enodia-sentinel", "watchdog", "--url", watchdog_url, "--token", "", "--insecure-tls"), reason="Run from another host or tailnet peer to prove the sensor heartbeat and HTTPS dashboard are reachable.", risk="low", targets={"url": watchdog_url}, )) 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": schemas.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 = { "schema": schemas.RESPONSE_AUDIT_V1, "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 def _watchdog_url(cfg: Config) -> str: if cfg.dashboard_url: return cfg.dashboard_url.rstrip("/") host = cfg.web_bind or "" if ":" in host and not host.startswith("[") and host != "": host = f"[{host}]" return f"https://{host}:{cfg.web_port}"