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>
527 lines
22 KiB
Bash
Executable file
527 lines
22 KiB
Bash
Executable file
#!/bin/bash
|
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
# Enodia Sentinel — a host intrusion-detection daemon.
|
|
#
|
|
# Continuously runs a set of detectors over live system state (processes,
|
|
# sockets, file descriptors, SUID binaries, sensitive files). When a detector
|
|
# fires, it writes a detailed forensic snapshot with incident-response guidance,
|
|
# the same way freeze-watcher captures a snapshot on a performance anomaly.
|
|
#
|
|
# Architecture mirrors freeze-watcher.sh:
|
|
# sample loop -> run_detectors -> (cooldown dedup) -> capture_snapshot
|
|
#
|
|
# Run modes:
|
|
# sentinel.sh daemon loop (default; used by systemd)
|
|
# sentinel.sh --check run detectors once, print alerts, exit (no capture)
|
|
# sentinel.sh --baseline (re)build listener/SUID baselines and exit
|
|
|
|
set -u
|
|
|
|
LOG_DIR="${ENODIA_LOG_DIR:-/var/log/enodia-sentinel}"
|
|
CONFIG="${ENODIA_CONFIG:-/etc/enodia-sentinel.conf}"
|
|
TRIGGER_FILE="$LOG_DIR/.trigger"
|
|
|
|
# --- defaults (overridden by /etc/enodia-sentinel.conf) ------------------
|
|
SAMPLE_INTERVAL=4 # seconds between detector sweeps
|
|
COOLDOWN=60 # min seconds before re-alerting the same signature+key
|
|
SUID_REFRESH=3600 # seconds between SUID baseline refreshes
|
|
SUID_SCAN_INTERVAL=60 # seconds between filesystem-wide SUID scans (expensive)
|
|
BASELINE_GRACE=10 # seconds after start before new-listener/suid alerts arm
|
|
|
|
# Which process names count as "interpreters" for reverse-shell / egress checks
|
|
INTERPRETERS="bash sh dash zsh ksh ash nc ncat netcat socat telnet python python2 python3 perl ruby php lua awk"
|
|
|
|
# Egress: interpreters talking to a public IP are suspicious unless the remote
|
|
# is in one of these trusted CIDRs (space separated, e.g. "203.0.113.0/24").
|
|
EGRESS_ALLOW_CIDRS=""
|
|
# Listeners on these ports never alert even if they appear after baseline.
|
|
LISTENER_ALLOW_PORTS=""
|
|
|
|
# Directories where a SUID binary is always critical (attacker-writable).
|
|
SUID_HOT_DIRS="/tmp /dev/shm /var/tmp /home /run/user"
|
|
|
|
# Files/dirs watched for persistence tampering (space separated; globs ok).
|
|
WATCH_PERSISTENCE="/etc/cron.d /etc/crontab /etc/cron.daily /etc/cron.hourly /etc/cron.weekly /var/spool/cron /etc/systemd/system /etc/ld.so.preload /etc/passwd /etc/sudoers /etc/sudoers.d /root/.ssh/authorized_keys /root/.bashrc /root/.profile"
|
|
# Per-user files to watch under each /home/* (appended to WATCH_PERSISTENCE at start)
|
|
WATCH_HOME_FILES=".ssh/authorized_keys .bashrc .bash_profile .profile .zshrc .config/autostart"
|
|
|
|
# Detector toggles
|
|
DET_REVERSE_SHELL=1
|
|
DET_LD_PRELOAD=1
|
|
DET_DELETED_EXE=1
|
|
DET_NEW_LISTENER=1
|
|
DET_NEW_SUID=1
|
|
DET_PERSISTENCE=1
|
|
DET_EGRESS=1
|
|
|
|
# eBPF on-ramp: run a short bpftrace execve trace inside each snapshot, to
|
|
# catch short-lived processes that polling misses. Requires bpftrace.
|
|
CAPTURE_EXECVE_BPFTRACE=0
|
|
|
|
# Retention
|
|
MAX_SNAPSHOTS=300
|
|
MAX_SNAPSHOT_AGE_DAYS=60
|
|
|
|
# Notifications (desktop)
|
|
NOTIFY_USERS=""
|
|
NOTIFY_URGENCY=critical
|
|
|
|
[ -f "$CONFIG" ] && . "$CONFIG"
|
|
|
|
mkdir -p "$LOG_DIR"
|
|
chmod 750 "$LOG_DIR" 2>/dev/null
|
|
|
|
START_TIME=$(date +%s)
|
|
last_suid_refresh=0
|
|
captures_since_prune=0
|
|
|
|
# Per-signature cooldown map: key -> last alert epoch
|
|
declare -A last_alert
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Helpers
|
|
# --------------------------------------------------------------------------
|
|
|
|
# ip_to_int DOTTED -> integer, for CIDR matching. Empty on parse failure.
|
|
ip_to_int() {
|
|
local ip="$1" a b c d
|
|
IFS=. read -r a b c d <<< "$ip"
|
|
[[ "$a$b$c$d" =~ ^[0-9]+$ ]] || return 1
|
|
echo $(( (a<<24) + (b<<16) + (c<<8) + d ))
|
|
}
|
|
|
|
# is_public_ip ADDR -> 0 if a routable public IPv4 (not private/loopback/link-local)
|
|
is_public_ip() {
|
|
local ip="$1"
|
|
# Strip IPv6 brackets / zone; treat any IPv6 as "public-ish" unless loopback
|
|
case "$ip" in
|
|
::1|::ffff:127.*) return 1 ;;
|
|
*:*) return 0 ;; # IPv6 non-loopback: treat as routable
|
|
esac
|
|
local n; n=$(ip_to_int "$ip") || return 1
|
|
# 10.0.0.0/8
|
|
[ "$n" -ge 167772160 ] && [ "$n" -le 184549375 ] && return 1
|
|
# 172.16.0.0/12
|
|
[ "$n" -ge 2886729728 ] && [ "$n" -le 2887778303 ] && return 1
|
|
# 192.168.0.0/16
|
|
[ "$n" -ge 3232235520 ] && [ "$n" -le 3232301055 ] && return 1
|
|
# 127.0.0.0/8
|
|
[ "$n" -ge 2130706432 ] && [ "$n" -le 2147483647 ] && return 1
|
|
# 169.254.0.0/16 link-local
|
|
[ "$n" -ge 2851995648 ] && [ "$n" -le 2852061183 ] && return 1
|
|
# 100.64.0.0/10 CGNAT (often VPN/tailscale)
|
|
[ "$n" -ge 1681915904 ] && [ "$n" -le 1686110207 ] && return 1
|
|
return 0
|
|
}
|
|
|
|
# ip_in_cidrs ADDR "cidr cidr ..." -> 0 if ADDR is inside any CIDR
|
|
ip_in_cidrs() {
|
|
local ip="$1" list="$2" cidr base bits mask n base_n
|
|
n=$(ip_to_int "$ip") || return 1
|
|
for cidr in $list; do
|
|
base=${cidr%/*}; bits=${cidr#*/}
|
|
[[ "$bits" =~ ^[0-9]+$ ]] || continue
|
|
base_n=$(ip_to_int "$base") || continue
|
|
if [ "$bits" -eq 0 ]; then return 0; fi
|
|
mask=$(( 0xFFFFFFFF << (32 - bits) & 0xFFFFFFFF ))
|
|
[ $(( n & mask )) -eq $(( base_n & mask )) ] && return 0
|
|
done
|
|
return 1
|
|
}
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Detectors — each prints lines: SEVERITY<TAB>signature<TAB>detail
|
|
# SEVERITY in {CRITICAL,HIGH,MEDIUM}. detail begins with a stable "key=..."
|
|
# token used for cooldown dedup.
|
|
# --------------------------------------------------------------------------
|
|
|
|
detect_reverse_shell() {
|
|
# Map network-socket inode -> "local -> peer" (TCP/UDP only) in ONE awk pass.
|
|
# A reverse shell has a TCP/UDP socket on its stdio; a unix socket or pipe
|
|
# (journald logging, shell harness pipes) is benign and must not alert.
|
|
declare -A net_peer
|
|
local ino peer
|
|
while read -r ino peer; do
|
|
[ -n "$ino" ] && net_peer[$ino]="$peer"
|
|
done < <( { ss -H -tanep 2>/dev/null; ss -H -uanep 2>/dev/null; } | awk '
|
|
{ ino=""; for (i=1;i<=NF;i++) if ($i ~ /^ino:/) ino=substr($i,5);
|
|
if (ino != "") print ino, $4" -> "$5 }' )
|
|
|
|
# Drive off the (small) set of fd 0/1/2 that are sockets, found in ONE ls.
|
|
local line target path pid inode comm cmd
|
|
while IFS= read -r line; do
|
|
target=${line##* -> }
|
|
[[ "$target" == socket:* ]] || continue
|
|
path=${line% -> *}; path=${path##* } # -> /proc/PID/fd/N
|
|
pid=${path#/proc/}; pid=${pid%%/*}
|
|
inode=${target#socket:[}; inode=${inode%]}
|
|
peer="${net_peer[$inode]:-}"
|
|
[ -n "$peer" ] || continue # network sockets only
|
|
read -r comm < "/proc/$pid/comm" 2>/dev/null || continue
|
|
case " $INTERPRETERS " in *" $comm "*) ;; *) continue ;; esac
|
|
cmd=$(tr '\0' ' ' 2>/dev/null < "/proc/$pid/cmdline"); cmd=${cmd% }
|
|
printf 'CRITICAL\treverse_shell\tkey=rsh:%s pid=%s comm=%s stdio=net-socket peer=[%s] cmd=[%s]\n' \
|
|
"$pid" "$pid" "$comm" "$peer" "${cmd:0:90}"
|
|
done < <(ls -l /proc/[0-9]*/fd/0 /proc/[0-9]*/fd/1 /proc/[0-9]*/fd/2 2>/dev/null)
|
|
}
|
|
|
|
detect_ld_preload() {
|
|
if [ -s /etc/ld.so.preload ]; then
|
|
local contents; contents=$(tr '\n' ' ' < /etc/ld.so.preload 2>/dev/null)
|
|
printf 'CRITICAL\tld_preload_global\tkey=ldp:global /etc/ld.so.preload is non-empty: [%s]\n' "${contents% }"
|
|
fi
|
|
# One grep finds the (usually zero) processes with LD_PRELOAD set, instead
|
|
# of reading every process's environ individually.
|
|
local f pid pre comm
|
|
while IFS= read -r -d '' f; do
|
|
pid=${f#/proc/}; pid=${pid%%/*}
|
|
pre=$(tr '\0' '\n' 2>/dev/null < "$f" | sed -n 's/^LD_PRELOAD=//p' | head -1)
|
|
[ -n "$pre" ] || continue
|
|
case "$pre" in
|
|
/tmp/*|/dev/shm/*|/var/tmp/*|/run/user/*|./*)
|
|
read -r comm < "/proc/$pid/comm" 2>/dev/null || comm="?"
|
|
printf 'CRITICAL\tld_preload_proc\tkey=ldp:%s pid=%s comm=%s LD_PRELOAD=[%s]\n' \
|
|
"$pid" "$pid" "$comm" "$pre" ;;
|
|
esac
|
|
done < <(grep -alZ 'LD_PRELOAD=' /proc/[0-9]*/environ 2>/dev/null)
|
|
}
|
|
|
|
detect_deleted_exe() {
|
|
# Resolve every /proc/*/exe symlink in ONE ls pass.
|
|
local line target path pid comm
|
|
while IFS= read -r line; do
|
|
target=${line##* -> }
|
|
case "$target" in *"(deleted)"*|memfd:*|*"/memfd:"*) ;; *) continue ;; esac
|
|
# Only alert for attacker-controlled / fileless locations. Normal-path
|
|
# deleted exes are usually daemons still running after a package upgrade.
|
|
case "$target" in
|
|
/tmp/*|/dev/shm/*|/var/tmp/*|/run/*|memfd:*|*"/memfd:"*) ;;
|
|
*) continue ;;
|
|
esac
|
|
path=${line% -> *}; path=${path##* } # -> /proc/PID/exe
|
|
pid=${path#/proc/}; pid=${pid%%/*}
|
|
read -r comm < "/proc/$pid/comm" 2>/dev/null || comm="?"
|
|
printf 'CRITICAL\tdeleted_exe\tkey=del:%s pid=%s comm=%s exe=[%s]\n' \
|
|
"$pid" "$pid" "$comm" "$target"
|
|
done < <(ls -l /proc/[0-9]*/exe 2>/dev/null)
|
|
}
|
|
|
|
# Listener baseline: set of "port/comm" present at baseline time.
|
|
build_listener_baseline() {
|
|
ss -H -tlnp 2>/dev/null | awk '{
|
|
n=split($4,a,":"); port=a[n];
|
|
comm="?"; if (match($0,/"[^"]+"/)) comm=substr($0,RSTART+1,RLENGTH-2);
|
|
print port"/"comm
|
|
}' | sort -u > "$LOG_DIR/.listener-baseline"
|
|
}
|
|
|
|
detect_new_listener() {
|
|
[ -r "$LOG_DIR/.listener-baseline" ] || return 0
|
|
local line port comm key
|
|
while IFS= read -r line; do
|
|
port=$(awk '{n=split($4,a,":"); print a[n]}' <<< "$line")
|
|
comm="?"; [[ "$line" =~ \"([^\"]+)\" ]] && comm="${BASH_REMATCH[1]}"
|
|
key="$port/$comm"
|
|
grep -qxF "$key" "$LOG_DIR/.listener-baseline" && continue
|
|
case " $LISTENER_ALLOW_PORTS " in *" $port "*) continue ;; esac
|
|
local addr; addr=$(awk '{print $4}' <<< "$line")
|
|
printf 'HIGH\tnew_listener\tkey=lis:%s new listening socket %s by comm=%s\n' \
|
|
"$key" "$addr" "$comm"
|
|
done < <(ss -H -tlnp 2>/dev/null)
|
|
}
|
|
|
|
build_suid_baseline() {
|
|
find / -xdev \( -perm -4000 -o -perm -2000 \) -type f 2>/dev/null \
|
|
| sort -u > "$LOG_DIR/.suid-baseline"
|
|
}
|
|
|
|
detect_new_suid() {
|
|
[ -r "$LOG_DIR/.suid-baseline" ] || return 0
|
|
local f
|
|
while IFS= read -r f; do
|
|
grep -qxF "$f" "$LOG_DIR/.suid-baseline" && continue
|
|
local sev=HIGH why="new SUID/SGID binary"
|
|
for d in $SUID_HOT_DIRS; do
|
|
case "$f" in "$d"/*) sev=CRITICAL; why="SUID/SGID binary in writable dir"; break ;; esac
|
|
done
|
|
printf '%s\tnew_suid\tkey=suid:%s %s: %s\n' "$sev" "$f" "$why" "$f"
|
|
done < <(find / -xdev \( -perm -4000 -o -perm -2000 \) -type f 2>/dev/null)
|
|
}
|
|
|
|
detect_persistence() {
|
|
local since="$1" f
|
|
# -newermt @epoch matches files modified strictly after that time.
|
|
while IFS= read -r f; do
|
|
[ -n "$f" ] || continue
|
|
printf 'HIGH\tpersistence\tkey=persist:%s persistence file modified: %s\n' "$f" "$f"
|
|
done < <(find $WATCH_PERSISTENCE -newermt "@$since" \( -type f -o -type l \) 2>/dev/null)
|
|
}
|
|
|
|
detect_egress() {
|
|
local line addr port comm pid
|
|
while IFS= read -r line; do
|
|
# ss -Hntp output: ... peer_addr:port users:(("comm",pid=NNN,fd=N))
|
|
local raddr; raddr=$(awk '{print $5}' <<< "$line")
|
|
addr=${raddr%:*}; port=${raddr##*:}
|
|
addr=${addr#[}; addr=${addr%]}
|
|
[[ "$line" =~ \"([^\"]+)\",pid=([0-9]+) ]] || continue
|
|
comm="${BASH_REMATCH[1]}"; pid="${BASH_REMATCH[2]}"
|
|
case " $INTERPRETERS " in *" $comm "*) ;; *) continue ;; esac
|
|
is_public_ip "$addr" || continue
|
|
ip_in_cidrs "$addr" "$EGRESS_ALLOW_CIDRS" && continue
|
|
printf 'HIGH\tsuspicious_egress\tkey=egr:%s:%s pid=%s comm=%s -> %s:%s (interpreter to public IP)\n' \
|
|
"$pid" "$addr" "$pid" "$comm" "$addr" "$port"
|
|
done < <(ss -H -tnp state established 2>/dev/null)
|
|
}
|
|
|
|
run_detectors() {
|
|
local since="$1" do_suid="${2:-0}"
|
|
[ "$DET_REVERSE_SHELL" = 1 ] && detect_reverse_shell
|
|
[ "$DET_LD_PRELOAD" = 1 ] && detect_ld_preload
|
|
[ "$DET_DELETED_EXE" = 1 ] && detect_deleted_exe
|
|
[ "$DET_EGRESS" = 1 ] && detect_egress
|
|
# New-listener / new-suid / persistence only after the grace window so we
|
|
# don't alert on everything that existed at startup.
|
|
if [ $(( $(date +%s) - START_TIME )) -ge "$BASELINE_GRACE" ]; then
|
|
[ "$DET_NEW_LISTENER" = 1 ] && detect_new_listener
|
|
[ "$DET_PERSISTENCE" = 1 ] && detect_persistence "$since"
|
|
# SUID scan walks the whole filesystem, so it runs on a slow cadence
|
|
# (do_suid is decided by the caller), not every sweep.
|
|
[ "$DET_NEW_SUID" = 1 ] && [ "$do_suid" = 1 ] && detect_new_suid
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Per-signature incident-response guidance (the security analogue of
|
|
# freeze-watcher's classify_snapshot diagnoses).
|
|
# --------------------------------------------------------------------------
|
|
signature_response() {
|
|
case "$1" in
|
|
reverse_shell)
|
|
echo "A shell/interpreter has a network socket wired to its stdin/stdout — the canonical reverse-shell signature. RESPONSE: identify the peer IP, kill the pid, inspect parent process and how it was spawned (web/db service = RCE), preserve /proc/<pid>/ before killing if you can." ;;
|
|
ld_preload_global)
|
|
echo "/etc/ld.so.preload injects a library into EVERY dynamically-linked process — a classic userland rootkit persistence mechanism. RESPONSE: capture the named .so, hash it, check package ownership (pacman -Qo), assume full host compromise until cleared." ;;
|
|
ld_preload_proc)
|
|
echo "A running process has LD_PRELOAD pointing at a writable/temp path — function-hooking, often to hide files/processes or intercept credentials. RESPONSE: capture the .so, inspect the process tree, determine who set the env var." ;;
|
|
deleted_exe)
|
|
echo "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)
|
|
echo "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)
|
|
echo "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)
|
|
echo "A persistence-relevant file (cron, systemd unit, authorized_keys, shell rc) was modified. RESPONSE: diff against backup/version control, review the change, check surrounding auth logs for who made it." ;;
|
|
suspicious_egress)
|
|
echo "An interpreter is holding an outbound connection to a public IP — possible C2 beacon or data exfil. RESPONSE: resolve/geolocate the peer, check reputation, inspect the process and its parentage." ;;
|
|
*) echo "Review the captured process, socket, and file context manually." ;;
|
|
esac
|
|
}
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Snapshot capture
|
|
# --------------------------------------------------------------------------
|
|
notify_users() {
|
|
[ -z "$NOTIFY_USERS" ] && return 0
|
|
local body="$1" IFS=,
|
|
for user in $NOTIFY_USERS; do
|
|
user=$(echo "$user" | tr -d '[:space:]'); [ -z "$user" ] && continue
|
|
local uid; uid=$(id -u "$user" 2>/dev/null) || continue
|
|
sudo -u "$user" \
|
|
DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$uid/bus" \
|
|
notify-send -u "$NOTIFY_URGENCY" -a "enodia-sentinel" \
|
|
"⚠ Enodia Sentinel alert" "$body" 2>/dev/null &
|
|
done
|
|
}
|
|
|
|
prune_snapshots() {
|
|
if [ "${MAX_SNAPSHOT_AGE_DAYS:-0}" -gt 0 ]; then
|
|
find "$LOG_DIR" -maxdepth 1 -name 'alert-*.log' -type f \
|
|
-mtime "+$MAX_SNAPSHOT_AGE_DAYS" -delete 2>/dev/null
|
|
fi
|
|
if [ "${MAX_SNAPSHOTS:-0}" -gt 0 ]; then
|
|
local files
|
|
files=$(ls -1t "$LOG_DIR"/alert-*.log 2>/dev/null | tail -n "+$((MAX_SNAPSHOTS+1))")
|
|
[ -n "$files" ] && echo "$files" | xargs -r rm -f
|
|
fi
|
|
}
|
|
|
|
# Extract the pids referenced in the triggering alerts so we can deep-dive them.
|
|
pids_from_alerts() {
|
|
grep -oE 'pid=[0-9]+' <<< "$1" | cut -d= -f2 | sort -un
|
|
}
|
|
|
|
capture_pid_detail() {
|
|
local pid="$1"
|
|
local d="/proc/$pid"
|
|
[ -d "$d" ] || { echo " pid $pid: gone"; return; }
|
|
echo "### pid $pid"
|
|
echo " comm: $(cat "$d/comm" 2>/dev/null)"
|
|
echo " exe: $(readlink "$d/exe" 2>/dev/null)"
|
|
echo " cwd: $(readlink "$d/cwd" 2>/dev/null)"
|
|
echo " cmdline: $(tr '\0' ' ' < "$d/cmdline" 2>/dev/null)"
|
|
local ppid; ppid=$(awk '/^PPid:/{print $2}' "$d/status" 2>/dev/null)
|
|
echo " ppid: $ppid ($(cat "/proc/$ppid/comm" 2>/dev/null))"
|
|
echo " uid: $(awk '/^Uid:/{print $2}' "$d/status" 2>/dev/null) gid: $(awk '/^Gid:/{print $2}' "$d/status" 2>/dev/null)"
|
|
echo " LD_PRELOAD env: $(tr '\0' '\n' 2>/dev/null < "$d/environ" | sed -n 's/^LD_PRELOAD=//p')"
|
|
echo " open fds (socket/file targets):"
|
|
local fd t
|
|
for fd in "$d"/fd/*; do
|
|
t=$(readlink "$fd" 2>/dev/null) || continue
|
|
printf ' %s -> %s\n' "${fd##*/}" "$t"
|
|
done | head -40
|
|
}
|
|
|
|
capture_execve_bpftrace() {
|
|
command -v bpftrace >/dev/null 2>&1 || { echo "bpftrace not installed"; return; }
|
|
echo "## bpftrace execve trace (3s) — short-lived processes polling can miss"
|
|
timeout 4 bpftrace -e '
|
|
tracepoint:syscalls:sys_enter_execve {
|
|
printf("%-8d %-16s %s\n", pid, comm, str(args->filename));
|
|
}
|
|
interval:s:3 { exit(); }
|
|
' 2>/dev/null | tail -60
|
|
}
|
|
|
|
capture_snapshot() {
|
|
local alerts="$1"
|
|
local final="$LOG_DIR/alert-$(date +%Y%m%d-%H%M%S).log"
|
|
local sigs; sigs=$(awk -F'\t' '{print $2}' <<< "$alerts" | sort -u)
|
|
local top_sev; top_sev=$(awk -F'\t' '{print $1}' <<< "$alerts" \
|
|
| grep -m1 -E 'CRITICAL' || awk -F'\t' 'NR==1{print $1}' <<< "$alerts")
|
|
|
|
{
|
|
echo "=== ENODIA SENTINEL ALERT ==="
|
|
echo "Time: $(date -Iseconds)"
|
|
echo "Host: $(hostname)"
|
|
echo "Severity: ${top_sev:-HIGH}"
|
|
echo
|
|
echo "## Triggering detections"
|
|
awk -F'\t' '{printf " [%s] %s — %s\n", $1, $2, $3}' <<< "$alerts"
|
|
echo
|
|
echo "## Response guidance"
|
|
while IFS= read -r s; do
|
|
[ -n "$s" ] || continue
|
|
echo " • $s:"
|
|
echo " $(signature_response "$s")"
|
|
done <<< "$sigs"
|
|
echo
|
|
echo "## Flagged process detail"
|
|
local pids; pids=$(pids_from_alerts "$alerts")
|
|
if [ -n "$pids" ]; then
|
|
for p in $pids; do capture_pid_detail "$p"; echo; done
|
|
else
|
|
echo " (no specific pid in alerts)"
|
|
fi
|
|
echo "## Process tree"
|
|
ps -eo pid,ppid,user,etimes,pcpu,pmem,stat,comm --sort=-pcpu 2>/dev/null | head -40
|
|
echo
|
|
echo "## All established + listening TCP (ss -tanp)"
|
|
ss -tanp 2>/dev/null | head -80
|
|
echo
|
|
echo "## /etc/ld.so.preload"
|
|
if [ -s /etc/ld.so.preload ]; then cat /etc/ld.so.preload; else echo " (empty — good)"; fi
|
|
echo
|
|
echo "## Recently modified watched files (last 1h)"
|
|
find $WATCH_PERSISTENCE -newermt "@$(( $(date +%s) - 3600 ))" \
|
|
\( -type f -o -type l \) -printf ' %TY-%Tm-%Td %TH:%TM %p\n' 2>/dev/null | head -40
|
|
echo
|
|
echo "## Recently loaded kernel modules (tail of lsmod)"
|
|
lsmod 2>/dev/null | head -15
|
|
echo
|
|
echo "## Logged-in users / recent logins"
|
|
who 2>/dev/null; echo "--"; last -n 5 2>/dev/null | head -5
|
|
echo
|
|
echo "## Recent auth events (authpriv, 10m)"
|
|
journalctl --since "10 minutes ago" --facility=authpriv --no-pager 2>/dev/null | tail -20
|
|
echo
|
|
if [ "${CAPTURE_EXECVE_BPFTRACE:-0}" = 1 ]; then
|
|
capture_execve_bpftrace
|
|
fi
|
|
} > "$final" 2>&1
|
|
|
|
chmod 640 "$final" 2>/dev/null
|
|
|
|
local sig_list; sig_list=$(tr '\n' ',' <<< "$sigs" | sed 's/,$//;s/,/, /g')
|
|
echo "$(date -Iseconds) [${top_sev:-HIGH}] captured $final — signatures: ${sig_list}" \
|
|
>> "$LOG_DIR/events.log"
|
|
notify_users "${top_sev:-HIGH}: ${sig_list}"
|
|
}
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Entry points
|
|
# --------------------------------------------------------------------------
|
|
case "${1:-}" in
|
|
--baseline)
|
|
build_listener_baseline; build_suid_baseline
|
|
echo "Baselines written to $LOG_DIR/.listener-baseline and .suid-baseline"
|
|
exit 0 ;;
|
|
--check)
|
|
# Re-arm immediately for a one-shot check
|
|
START_TIME=$((START_TIME - BASELINE_GRACE - 1))
|
|
[ -r "$LOG_DIR/.listener-baseline" ] || build_listener_baseline
|
|
[ -r "$LOG_DIR/.suid-baseline" ] || build_suid_baseline
|
|
out=$(run_detectors "$(( $(date +%s) - SAMPLE_INTERVAL ))" 1)
|
|
if [ -z "$out" ]; then echo "No alerts."; else
|
|
awk -F'\t' '{printf "[%s] %-18s %s\n", $1, $2, $3}' <<< "$out"
|
|
fi
|
|
exit 0 ;;
|
|
esac
|
|
|
|
# --- daemon loop ----------------------------------------------------------
|
|
echo "$(date -Iseconds) enodia-sentinel started (pid $$)" >> "$LOG_DIR/events.log"
|
|
build_listener_baseline
|
|
build_suid_baseline
|
|
last_suid_refresh=$START_TIME
|
|
prune_snapshots
|
|
last_scan=$START_TIME
|
|
last_suid_scan=0
|
|
|
|
while true; do
|
|
now=$(date +%s)
|
|
|
|
# Decide whether this sweep includes the filesystem-wide SUID scan.
|
|
do_suid=0
|
|
if [ $(( now - last_suid_scan )) -ge "${SUID_SCAN_INTERVAL:-60}" ]; then
|
|
do_suid=1; last_suid_scan=$now
|
|
fi
|
|
|
|
# Manual trigger (drop a word into $TRIGGER_FILE)
|
|
if [ -f "$TRIGGER_FILE" ]; then
|
|
rm -f "$TRIGGER_FILE"
|
|
capture_snapshot "$(printf 'HIGH\tmanual\tkey=manual manual capture requested')" &
|
|
fi
|
|
|
|
alerts=$(run_detectors "$last_scan" "$do_suid")
|
|
last_scan=$now
|
|
|
|
if [ -n "$alerts" ]; then
|
|
fresh=""
|
|
while IFS= read -r line; do
|
|
[ -n "$line" ] || continue
|
|
key=$(grep -oE 'key=[^ ]+' <<< "$line" | head -1)
|
|
key=${key:-$line}
|
|
prev=${last_alert[$key]:-0}
|
|
if [ $(( now - prev )) -ge "$COOLDOWN" ]; then
|
|
last_alert[$key]=$now
|
|
fresh+="$line"$'\n'
|
|
fi
|
|
done <<< "$alerts"
|
|
|
|
if [ -n "$fresh" ]; then
|
|
capture_snapshot "${fresh%$'\n'}" &
|
|
captures_since_prune=$((captures_since_prune+1))
|
|
if [ "$captures_since_prune" -ge 20 ]; then
|
|
prune_snapshots &
|
|
captures_since_prune=0
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
# Periodically refresh the SUID baseline so legit installs don't alert forever
|
|
if [ $(( now - last_suid_refresh )) -ge "$SUID_REFRESH" ]; then
|
|
build_suid_baseline; last_suid_refresh=$now
|
|
fi
|
|
|
|
sleep "$SAMPLE_INTERVAL"
|
|
done
|