458 lines
16 KiB
Python
458 lines
16 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"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", "*", "<package>"),
|
|
reason="After pacman -Qo identifies the owner, reinstall that package from a trusted, signature-verified source.",
|
|
risk="high",
|
|
targets={"path": path, "package": "<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", "<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 load_persisted_plan(cfg: Config, ref: str) -> tuple[dict, Path] | tuple[None, None]:
|
|
"""Load a persisted response plan by path, filename, or plan_id.
|
|
|
|
``respond apply`` deliberately consumes a saved artifact instead of a freshly
|
|
generated preview, so the operator is applying exactly what was reviewed.
|
|
"""
|
|
candidates: list[Path] = []
|
|
raw = Path(ref)
|
|
if raw.is_file():
|
|
candidates.append(raw)
|
|
plan_dir = cfg.log_dir / "response-plans"
|
|
if not raw.is_absolute():
|
|
candidates.extend([
|
|
plan_dir / ref,
|
|
plan_dir / f"{ref}.json",
|
|
])
|
|
try:
|
|
candidates.extend(sorted(plan_dir.glob("*.json"), reverse=True))
|
|
except OSError:
|
|
pass
|
|
|
|
seen = set()
|
|
for path in candidates:
|
|
if path in seen or not path.is_file():
|
|
continue
|
|
seen.add(path)
|
|
try:
|
|
plan = json.loads(path.read_text())
|
|
except (OSError, ValueError):
|
|
continue
|
|
if path == raw or path.name == ref or plan.get("plan_id") == ref:
|
|
return plan, path
|
|
return None, None
|
|
|
|
|
|
def rehearse_apply(cfg: Config, plan: dict, plan_path: Path,
|
|
actor: str = "cli") -> dict:
|
|
"""Audit an explicit apply rehearsal without executing plan commands."""
|
|
record = {
|
|
"schema": schemas.RESPONSE_AUDIT_V1,
|
|
"time": datetime.now().astimezone().isoformat(),
|
|
"event": "response_apply_rehearsed",
|
|
"actor": actor,
|
|
"plan_id": plan.get("plan_id"),
|
|
"incident_id": plan.get("incident_id"),
|
|
"mode": plan.get("mode"),
|
|
"apply_supported": plan.get("apply_supported", False),
|
|
"action_count": len(plan.get("actions", [])),
|
|
"plan_path": str(plan_path),
|
|
"executed": False,
|
|
"reason": "dry-run rehearsal; no commands executed",
|
|
}
|
|
audit_path = response_audit_path(cfg)
|
|
cfg.log_dir.mkdir(parents=True, exist_ok=True)
|
|
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 {"audit_log": str(audit_path), "record": record}
|
|
|
|
|
|
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}"
|