#!/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: SEVERITYsignaturedetail # 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// 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//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