289 lines
11 KiB
Python
289 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")
|
|
|
|
_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
|