Add HTTPS management console and response planning

This commit is contained in:
Luna 2026-06-12 02:39:53 -07:00
parent a56d72edd6
commit a7129e5666
16 changed files with 927 additions and 160 deletions

272
enodia_sentinel/respond.py Normal file
View 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