Add HTTPS management console and response planning
This commit is contained in:
parent
a56d72edd6
commit
a7129e5666
16 changed files with 927 additions and 160 deletions
272
enodia_sentinel/respond.py
Normal file
272
enodia_sentinel/respond.py
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
# 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue