enodia-sentinal/src/sentinel.sh
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

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