# 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 .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/ " "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//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']}") 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}) report = { "time": now.isoformat(), "host": os.uname().nodename, "severity": str(severity), "alerts": [a.to_dict() for a in alerts], "processes": [_pid_detail(state, p) for p in pids], } 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 ps_out = _run(["ps", "-eo", "pid,ppid,user,etimes,pcpu,pmem,stat,comm", "--sort=-pcpu"]) extras = { "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") _notify(cfg, f"{severity}: {sigs}") 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