# 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/ " "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']}") 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) 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], } 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}") # 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