enodia-sentinal/enodia_sentinel/snapshot.py
Luna 586f74b929 Relicense under GPL-3.0-or-later
Replace MIT with the full GNU GPLv3 text, update license metadata in
pyproject.toml (+ trove classifiers) and PKGBUILD, and add
SPDX-License-Identifier headers to all Python modules and shell scripts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 06:52:34 -07:00

248 lines
9.4 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 .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."
),
}
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']}] {a['signature']}{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" {RESPONSES.get(sig, 'Review the captured context manually.')}")
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