enodia-sentinal/enodia_sentinel/snapshot.py

294 lines
11 KiB
Python

# SPDX-License-Identifier: GPL-3.0-or-later
"""Forensic snapshot writer.
When detections fire, this captures a timestamped report — both a human-readable
``.log`` and a structured ``.json`` sidecar — with per-signature incident-
response guidance, the flagged processes' /proc detail, the process tree,
sockets, recent file changes, and auth events.
"""
from __future__ import annotations
import json
import os
import subprocess
import time
from datetime import datetime
from pathlib import Path
from . import schemas
from .alert import Alert, Severity
from .config import Config
from .system import Process, SystemState
RESPONSES: dict[str, str] = {
"reverse_shell": (
"A shell/interpreter has a network socket wired to its stdin/stdout — "
"the canonical reverse-shell signature. RESPONSE: identify the peer IP, "
"inspect the parent process (web/db parent = RCE), preserve /proc/<pid> "
"before killing if you can, then kill the pid."
),
"ld_preload": (
"Library-injection detected (ld.so.preload or LD_PRELOAD from a writable "
"path) — a userland rootkit / credential-theft hook. RESPONSE: capture "
"the named .so and hash it, check package ownership, inspect the process "
"tree, and assume host compromise until cleared."
),
"deleted_exe": (
"A process is executing from a deleted or memfd-backed binary — fileless "
"malware that left no file on disk. RESPONSE: dump /proc/<pid>/exe to "
"recover the binary BEFORE killing, capture maps and sockets."
),
"new_listener": (
"A listening socket appeared that wasn't present at baseline — possible "
"backdoor/bind shell. RESPONSE: identify the binary, confirm it's an "
"expected service, check for matching inbound connections."
),
"new_suid": (
"A new SUID/SGID binary appeared (critical if in a writable dir) — a "
"privilege-escalation persistence trick. RESPONSE: verify package "
"ownership; an unowned SUID binary in /tmp or /home is almost never "
"legitimate."
),
"persistence": (
"A persistence-relevant file (cron, systemd unit, authorized_keys, shell "
"rc) was modified. RESPONSE: diff against backup/version control, review "
"the change, and check auth logs for who made it."
),
"egress": (
"An interpreter is holding an outbound connection to a public IP — "
"possible C2 beacon or exfil. RESPONSE: resolve/geolocate the peer, check "
"reputation, inspect the process and its parentage."
),
}
_EXEC_RESPONSE = (
"An eBPF rule matched a process execution as it happened (caught even if the "
"process has since exited). RESPONSE: review the parent process and the full "
"command line, correlate with the captured sockets, and pivot on the parent "
"if it's a network-facing service."
)
def _response_for(signature: str) -> str:
if signature.startswith("exec_rule."):
return _EXEC_RESPONSE
return RESPONSES.get(signature, "Review the captured context manually.")
def _run(cmd: list[str], timeout: int = 8) -> str:
try:
res = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
return res.stdout
except (OSError, subprocess.SubprocessError) as exc:
return f" ({' '.join(cmd)} failed: {exc})\n"
def _pid_detail(state: SystemState, pid: int) -> dict:
proc = state.process(pid) or Process(pid)
fds: dict[str, str] = {}
fd_dir = f"/proc/{pid}/fd"
try:
for name in sorted(os.listdir(fd_dir), key=lambda x: int(x) if x.isdigit() else 0):
try:
fds[name] = os.readlink(os.path.join(fd_dir, name))
except OSError:
continue
except OSError:
pass
parent = state.process(proc.ppid)
return {
"pid": pid,
"comm": proc.comm,
"exe": proc.exe,
"cwd": proc.cwd,
"cmdline": proc.cmdline,
"ppid": proc.ppid,
"ppid_comm": parent.comm if parent else "",
"uid": proc.uid,
"ld_preload": proc.environ.get("LD_PRELOAD", ""),
"fds": dict(list(fds.items())[:40]),
}
def _format_text(report: dict, extras: dict[str, str]) -> str:
L: list[str] = []
L.append("=== ENODIA SENTINEL ALERT ===")
L.append(f"Time: {report['time']}")
L.append(f"Host: {report['host']}")
L.append(f"Severity: {report['severity']}")
if report.get("incident_id"):
L.append(f"Incident: {report['incident_id']}")
L.append("")
L.append("## Triggering detections")
for a in report["alerts"]:
L.append(f" [{a['severity']}] sid:{a.get('sid', 0)} "
f"{a['signature']} ({a.get('classtype', '?')}) — {a['detail']}")
L.append("")
L.append("## Response guidance")
for sig in dict.fromkeys(a["signature"] for a in report["alerts"]):
L.append(f"{sig}:")
L.append(f" {_response_for(sig)}")
L.append("")
L.append("## Flagged process detail")
if report["processes"]:
for d in report["processes"]:
L.append(f"### pid {d['pid']}")
L.append(f" comm: {d['comm']}")
L.append(f" exe: {d['exe']}")
L.append(f" cwd: {d['cwd']}")
L.append(f" cmdline: {d['cmdline']}")
L.append(f" ppid: {d['ppid']} ({d['ppid_comm']})")
L.append(f" uid: {d['uid']}")
L.append(f" LD_PRELOAD env: {d['ld_preload']}")
L.append(" open fds:")
for fd, tgt in d["fds"].items():
L.append(f" {fd} -> {tgt}")
L.append("")
else:
L.append(" (no specific pid in alerts)")
L.append("")
for title, body in extras.items():
L.append(f"## {title}")
L.append(body.rstrip("\n"))
L.append("")
return "\n".join(L) + "\n"
def capture(alerts: list[Alert], state: SystemState, cfg: Config) -> Path:
"""Write text + JSON snapshot, append events.log, and notify. Returns path."""
cfg.log_dir.mkdir(parents=True, exist_ok=True)
now = datetime.now().astimezone()
stamp = now.strftime("%Y%m%d-%H%M%S")
base = cfg.log_dir / f"alert-{stamp}"
severity = max((a.severity for a in alerts), default=Severity.HIGH)
pids: list[int] = sorted({p for a in alerts for p in a.pids})
host = os.uname().nodename
# Group this batch into an incident by process lineage (then time). Additive:
# the per-alert JSON schema is unchanged; incident_id sits at report level.
from . import incident
lineage = incident.lineage_from_state(pids, state, cfg)
incident_id = incident.record(
cfg, base.with_suffix(".log").name, alerts, lineage, now.timestamp(), host)
cutoff = int(time.time()) - 3600
recent_files = []
for root in cfg.watch_persistence:
try:
if os.path.isfile(root) and os.lstat(root).st_mtime > cutoff:
recent_files.append(root)
except OSError:
pass
report = {
"schema": schemas.ALERT_SNAPSHOT_V1,
"time": now.isoformat(),
"host": host,
"severity": str(severity),
"incident_id": incident_id,
"alerts": [a.to_dict() for a in alerts],
"processes": [_pid_detail(state, p) for p in pids],
}
from . import enrich
report["enrichment"] = enrich.build(
report, state, cfg, lineage=lineage, recent_files=recent_files)
ps_out = _run(["ps", "-eo", "pid,ppid,user,etimes,pcpu,pmem,stat,comm",
"--sort=-pcpu"])
extras = {
"Post-alert enrichment": enrich.format_text(report["enrichment"]),
"Process tree (top by CPU)": "\n".join(ps_out.splitlines()[:40]),
"Sockets (ss -tanp)": "\n".join(_run(["ss", "-tanp"]).splitlines()[:80]),
"/etc/ld.so.preload": (
Path("/etc/ld.so.preload").read_text()
if Path("/etc/ld.so.preload").is_file()
and Path("/etc/ld.so.preload").stat().st_size
else " (empty — good)"
),
"Recently modified watched files (1h)": "\n".join(recent_files) or " (none)",
"Loaded kernel modules (head)": "\n".join(_run(["lsmod"]).splitlines()[:15]),
"Logins": _run(["who"]) + "--\n" + "\n".join(_run(["last", "-n", "5"]).splitlines()[:5]),
"Auth events (authpriv, 10m)": "\n".join(_run(
["journalctl", "--since", "10 minutes ago",
"--facility=authpriv", "--no-pager"]).splitlines()[-20:]),
}
if cfg.capture_execve_bpftrace:
extras["bpftrace execve (3s)"] = _run([
"bpftrace", "-e",
"tracepoint:syscalls:sys_enter_execve { "
"printf(\"%d %s %s\\n\", pid, comm, str(args->filename)); } "
"interval:s:3 { exit(); }",
], timeout=6)
text = _format_text(report, extras)
base.with_suffix(".log").write_text(text)
base.with_suffix(".json").write_text(json.dumps(report, indent=2))
try:
base.with_suffix(".log").chmod(0o640)
base.with_suffix(".json").chmod(0o640)
except OSError:
pass
sigs = ", ".join(dict.fromkeys(a.signature for a in alerts))
with open(cfg.events_log, "a") as fh:
fh.write(f"{now.isoformat()} [{severity}] captured "
f"{base.with_suffix('.log')} — signatures: {sigs}\n")
from . import assurance
assurance.append_artifact(cfg, "snapshot-log", base.with_suffix(".log"))
assurance.append_artifact(cfg, "snapshot-json", base.with_suffix(".json"))
assurance.append_artifact(cfg, "events-log", cfg.events_log)
_notify(cfg, f"{severity}: {sigs}")
# Phone push (ntfy / Pushover / webhook), gated by notify_min_severity.
from . import notify
notif = notify.Notification.from_alerts(
alerts, host=report["host"],
snapshot_name=base.with_suffix(".log").name,
dashboard_url=cfg.dashboard_url)
notify.dispatch(cfg, notif)
return base.with_suffix(".log")
def _notify(cfg: Config, body: str) -> None:
for user in cfg.notify_users:
try:
uid = int(subprocess.run(["id", "-u", user], capture_output=True,
text=True, timeout=3).stdout.strip())
except (OSError, subprocess.SubprocessError, ValueError):
continue
env = dict(os.environ,
DBUS_SESSION_BUS_ADDRESS=f"unix:path=/run/user/{uid}/bus")
try:
subprocess.Popen(
["sudo", "-u", user, "notify-send", "-u", cfg.notify_urgency,
"-a", "enodia-sentinel", "⚠ Enodia Sentinel alert", body],
env=env)
except OSError:
pass
def prune(cfg: Config) -> None:
snaps = sorted(cfg.log_dir.glob("alert-*.log"),
key=lambda p: p.stat().st_mtime, reverse=True)
if cfg.max_snapshot_age_days > 0:
cutoff = time.time() - cfg.max_snapshot_age_days * 86400
for p in list(snaps):
if p.stat().st_mtime < cutoff:
_remove_pair(p)
snaps.remove(p)
if cfg.max_snapshots > 0:
for p in snaps[cfg.max_snapshots:]:
_remove_pair(p)
def _remove_pair(log_path: Path) -> None:
for p in (log_path, log_path.with_suffix(".json")):
try:
p.unlink()
except OSError:
pass