272 lines
9 KiB
Python
272 lines
9 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 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 _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
|
|
|
|
|
|
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
|