enodia-sentinal/enodia_sentinel/respond.py
2026-07-08 17:05:17 -07:00

377 lines
13 KiB
Python

# 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"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 {"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", "<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"(?<![\w.])(?:\d{1,3}\.){3}\d{1,3}(?![\w.])")
def _public_ips(alerts: list[dict]) -> 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"(?<![\w.])/[^\s,\])]+", text):
paths.add((token.rstrip(":"), alert.get("signature", "")))
return paths
def _systemd_units(alerts: list[dict]) -> 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 "<dashboard-host>"
if ":" in host and not host.startswith("[") and host != "<dashboard-host>":
host = f"[{host}]"
return f"https://{host}:{cfg.web_port}"