Enodia Sentinel v0.1 — bash host-IDS prototype
Poll-based host intrusion-detection daemon modeled on freeze-watcher's sample→trigger→snapshot→classify loop, with security signatures instead of performance ones: - reverse_shell (interpreter with a network socket on stdio) - ld_preload (/etc/ld.so.preload or LD_PRELOAD in writable dirs) - deleted_exe (process running from a deleted/memfd binary) - new_listener (listening port absent from startup baseline) - new_suid (new SUID/SGID binary; critical in writable dirs) - persistence (cron/systemd/authorized_keys/rc-file changes) - suspicious_egress (interpreter holding an outbound public connection) Each capture writes a forensic snapshot with per-signature incident- response guidance. Includes a safe, self-cleaning red-team harness (sentinel-redteam), hardened systemd unit, config, Makefile, and Arch packaging. Tested on Arch: detectors fire on drills, no false positives on a clean sweep, sweep cost optimized from ~15s to ~1s via batched syscalls and a gated filesystem-wide SUID scan. This bash implementation is retained as the regression oracle for the forthcoming Python rewrite. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
commit
45f8acb24a
10 changed files with 1026 additions and 0 deletions
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
*.pkg.tar.zst
|
||||||
|
*.tar.gz
|
||||||
|
pkg/
|
||||||
|
src/*.o
|
||||||
|
.claude/
|
||||||
|
*.log
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
53
Makefile
Normal 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
175
README.md
Normal 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
|
||||||
50
config/enodia-sentinel.conf
Normal file
50
config/enodia-sentinel.conf
Normal 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
24
packaging/PKGBUILD
Normal 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
8
packaging/build-package.sh
Executable 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
134
src/sentinel-redteam
Executable file
|
|
@ -0,0 +1,134 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Enodia Sentinel — red-team self-test harness.
|
||||||
|
#
|
||||||
|
# Simulates each detector's threat signature using SAFE, clearly-labeled
|
||||||
|
# stand-ins (all tagged "enodia-drill"), so you can watch Sentinel catch them
|
||||||
|
# live. Nothing here is malicious: no real payloads, no system files touched,
|
||||||
|
# everything is cleaned up on exit.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# sentinel-redteam run all drills (default 20s each window)
|
||||||
|
# sentinel-redteam <name>... run only the named drills
|
||||||
|
# sentinel-redteam --list list available drills
|
||||||
|
#
|
||||||
|
# Drills: reverse_shell ld_preload deleted_exe new_listener new_suid persistence
|
||||||
|
#
|
||||||
|
# Watch it work, in another terminal:
|
||||||
|
# sudo tail -f /var/log/enodia-sentinel/events.log
|
||||||
|
# or one-shot:
|
||||||
|
# sudo /usr/local/bin/sentinel.sh --check
|
||||||
|
|
||||||
|
set -u
|
||||||
|
HOLD="${HOLD:-20}" # seconds to keep each drill artifact alive
|
||||||
|
TMP="$(mktemp -d /tmp/enodia-drill.XXXXXX)"
|
||||||
|
PIDS=()
|
||||||
|
SANDBOX="$TMP/persistence-sandbox" # used by the persistence drill
|
||||||
|
LISTEN_PORT=14444
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
echo "[*] cleaning up drill artifacts..."
|
||||||
|
for p in "${PIDS[@]}"; do kill "$p" 2>/dev/null; done
|
||||||
|
rm -rf "$TMP"
|
||||||
|
echo "[*] done. (Sentinel snapshots in /var/log/enodia-sentinel/ are kept.)"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
note() { echo -e "\n=== DRILL: $1 ===\n $2"; }
|
||||||
|
|
||||||
|
drill_reverse_shell() {
|
||||||
|
note reverse_shell "a bash with a TCP socket wired to its stdin/stdout (classic reverse shell)"
|
||||||
|
# Local listener stands in for the attacker's box. No remote traffic leaves.
|
||||||
|
( exec -a enodia-drill-listener nc -l 127.0.0.1 "$LISTEN_PORT" >/dev/null 2>&1 ) &
|
||||||
|
PIDS+=("$!")
|
||||||
|
sleep 1 # let the listener bind (single-shot nc; don't probe-connect it)
|
||||||
|
# bash whose fd 0/1/2 are the socket — exactly what `nc -e /bin/bash` produces.
|
||||||
|
# The shell retries the connect itself so it wins the listener's accept slot.
|
||||||
|
# It then BLOCKS reading the socket (like a real reverse shell awaiting
|
||||||
|
# commands) so the process holding the socket stays a 'bash', not a sleep.
|
||||||
|
( exec -a enodia-drill-rsh bash -c \
|
||||||
|
'for i in 1 2 3 4 5 6 7 8 9 10; do exec 3<>/dev/tcp/127.0.0.1/'"$LISTEN_PORT"' && break; sleep 0.3; done
|
||||||
|
exec 0<&3 1>&3 2>&3; read -t '"$HOLD"' _line' ) &
|
||||||
|
PIDS+=("$!")
|
||||||
|
echo " -> spawned drill reverse shell (pid $!) for ${HOLD}s"
|
||||||
|
}
|
||||||
|
|
||||||
|
drill_ld_preload() {
|
||||||
|
note ld_preload "a process running with LD_PRELOAD pointing into /tmp"
|
||||||
|
: > "$TMP/evil.so" # empty file; we never actually load it, just set the env
|
||||||
|
( LD_PRELOAD="$TMP/evil.so" exec -a enodia-drill-preload sleep "$HOLD" ) &
|
||||||
|
PIDS+=("$!")
|
||||||
|
echo " -> spawned process with LD_PRELOAD=$TMP/evil.so (pid $!)"
|
||||||
|
echo " NOTE: does NOT touch /etc/ld.so.preload (that would be system-wide)."
|
||||||
|
}
|
||||||
|
|
||||||
|
drill_deleted_exe() {
|
||||||
|
note deleted_exe "a process whose executable was deleted from disk (fileless)"
|
||||||
|
cp /bin/sleep "$TMP/enodia-drill-ghost" 2>/dev/null
|
||||||
|
( exec "$TMP/enodia-drill-ghost" "$HOLD" ) &
|
||||||
|
local p=$!; PIDS+=("$p")
|
||||||
|
sleep 0.5
|
||||||
|
rm -f "$TMP/enodia-drill-ghost" # now /proc/<pid>/exe shows "(deleted)" in /tmp
|
||||||
|
echo " -> running pid $p from a now-deleted /tmp binary"
|
||||||
|
}
|
||||||
|
|
||||||
|
drill_new_listener() {
|
||||||
|
note new_listener "a new TCP listening port that wasn't in the baseline"
|
||||||
|
( exec -a enodia-drill-bind nc -l 127.0.0.1 $((LISTEN_PORT+1)) >/dev/null 2>&1 ) &
|
||||||
|
PIDS+=("$!")
|
||||||
|
echo " -> opened listener on 127.0.0.1:$((LISTEN_PORT+1)) (pid $!)"
|
||||||
|
}
|
||||||
|
|
||||||
|
drill_new_suid() {
|
||||||
|
note new_suid "a SUID binary appearing in a world-writable dir (/tmp)"
|
||||||
|
cp /bin/true "$TMP/enodia-drill-suid" 2>/dev/null
|
||||||
|
chmod 4755 "$TMP/enodia-drill-suid" 2>/dev/null
|
||||||
|
echo " -> dropped SUID binary at $TMP/enodia-drill-suid (perms: $(stat -c %A "$TMP/enodia-drill-suid" 2>/dev/null))"
|
||||||
|
echo " (will be detected on the next sweep; kept for ${HOLD}s)"
|
||||||
|
( sleep "$HOLD" ) & PIDS+=("$!")
|
||||||
|
}
|
||||||
|
|
||||||
|
drill_persistence() {
|
||||||
|
note persistence "modifying a watched persistence file"
|
||||||
|
echo " NOTE: this drill only fires if you've pointed WATCH_PERSISTENCE at the"
|
||||||
|
echo " sandbox below. To test safely, add this to /etc/enodia-sentinel.conf:"
|
||||||
|
echo " WATCH_PERSISTENCE=\"\$WATCH_PERSISTENCE $SANDBOX\""
|
||||||
|
echo " then restart the service. Otherwise this would require touching real"
|
||||||
|
echo " files like ~/.ssh/authorized_keys, which the harness will NOT do."
|
||||||
|
mkdir -p "$SANDBOX"
|
||||||
|
echo "# enodia-drill $(date)" >> "$SANDBOX/authorized_keys"
|
||||||
|
echo " -> wrote $SANDBOX/authorized_keys"
|
||||||
|
}
|
||||||
|
|
||||||
|
run() {
|
||||||
|
case "$1" in
|
||||||
|
reverse_shell) drill_reverse_shell ;;
|
||||||
|
ld_preload) drill_ld_preload ;;
|
||||||
|
deleted_exe) drill_deleted_exe ;;
|
||||||
|
new_listener) drill_new_listener ;;
|
||||||
|
new_suid) drill_new_suid ;;
|
||||||
|
persistence) drill_persistence ;;
|
||||||
|
*) echo "unknown drill: $1" >&2; return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
ALL=(reverse_shell ld_preload deleted_exe new_listener new_suid persistence)
|
||||||
|
|
||||||
|
if [ "${1:-}" = "--list" ]; then printf '%s\n' "${ALL[@]}"; exit 0; fi
|
||||||
|
|
||||||
|
echo "######################################################################"
|
||||||
|
echo "# ENODIA SENTINEL — RED TEAM DRILL #"
|
||||||
|
echo "# All artifacts are labeled 'enodia-drill' and auto-cleaned. #"
|
||||||
|
echo "# Watch detections: sudo tail -f /var/log/enodia-sentinel/events.log"
|
||||||
|
echo "######################################################################"
|
||||||
|
|
||||||
|
if [ "$#" -gt 0 ]; then targets=("$@"); else targets=("${ALL[@]}"); fi
|
||||||
|
for d in "${targets[@]}"; do run "$d"; done
|
||||||
|
|
||||||
|
echo -e "\n[*] drills live for ${HOLD}s. Triggering a Sentinel sweep check now:"
|
||||||
|
if [ -x /usr/local/bin/sentinel.sh ]; then
|
||||||
|
sudo /usr/local/bin/sentinel.sh --check 2>/dev/null \
|
||||||
|
| grep -i drill && echo " ^ Sentinel saw the drills." \
|
||||||
|
|| echo " (run 'sudo sentinel.sh --check' yourself, or watch events.log)"
|
||||||
|
fi
|
||||||
|
echo "[*] holding for ${HOLD}s, then cleaning up..."
|
||||||
|
sleep "$HOLD"
|
||||||
526
src/sentinel.sh
Executable file
526
src/sentinel.sh
Executable file
|
|
@ -0,0 +1,526 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Enodia Sentinel — a host intrusion-detection daemon.
|
||||||
|
#
|
||||||
|
# Continuously runs a set of detectors over live system state (processes,
|
||||||
|
# sockets, file descriptors, SUID binaries, sensitive files). When a detector
|
||||||
|
# fires, it writes a detailed forensic snapshot with incident-response guidance,
|
||||||
|
# the same way freeze-watcher captures a snapshot on a performance anomaly.
|
||||||
|
#
|
||||||
|
# Architecture mirrors freeze-watcher.sh:
|
||||||
|
# sample loop -> run_detectors -> (cooldown dedup) -> capture_snapshot
|
||||||
|
#
|
||||||
|
# Run modes:
|
||||||
|
# sentinel.sh daemon loop (default; used by systemd)
|
||||||
|
# sentinel.sh --check run detectors once, print alerts, exit (no capture)
|
||||||
|
# sentinel.sh --baseline (re)build listener/SUID baselines and exit
|
||||||
|
|
||||||
|
set -u
|
||||||
|
|
||||||
|
LOG_DIR="${ENODIA_LOG_DIR:-/var/log/enodia-sentinel}"
|
||||||
|
CONFIG="${ENODIA_CONFIG:-/etc/enodia-sentinel.conf}"
|
||||||
|
TRIGGER_FILE="$LOG_DIR/.trigger"
|
||||||
|
|
||||||
|
# --- defaults (overridden by /etc/enodia-sentinel.conf) ------------------
|
||||||
|
SAMPLE_INTERVAL=4 # seconds between detector sweeps
|
||||||
|
COOLDOWN=60 # min seconds before re-alerting the same signature+key
|
||||||
|
SUID_REFRESH=3600 # seconds between SUID baseline refreshes
|
||||||
|
SUID_SCAN_INTERVAL=60 # seconds between filesystem-wide SUID scans (expensive)
|
||||||
|
BASELINE_GRACE=10 # seconds after start before new-listener/suid alerts arm
|
||||||
|
|
||||||
|
# Which process names count as "interpreters" for reverse-shell / egress checks
|
||||||
|
INTERPRETERS="bash sh dash zsh ksh ash nc ncat netcat socat telnet python python2 python3 perl ruby php lua awk"
|
||||||
|
|
||||||
|
# Egress: interpreters talking to a public IP are suspicious unless the remote
|
||||||
|
# is in one of these trusted CIDRs (space separated, e.g. "203.0.113.0/24").
|
||||||
|
EGRESS_ALLOW_CIDRS=""
|
||||||
|
# Listeners on these ports never alert even if they appear after baseline.
|
||||||
|
LISTENER_ALLOW_PORTS=""
|
||||||
|
|
||||||
|
# Directories where a SUID binary is always critical (attacker-writable).
|
||||||
|
SUID_HOT_DIRS="/tmp /dev/shm /var/tmp /home /run/user"
|
||||||
|
|
||||||
|
# Files/dirs watched for persistence tampering (space separated; globs ok).
|
||||||
|
WATCH_PERSISTENCE="/etc/cron.d /etc/crontab /etc/cron.daily /etc/cron.hourly /etc/cron.weekly /var/spool/cron /etc/systemd/system /etc/ld.so.preload /etc/passwd /etc/sudoers /etc/sudoers.d /root/.ssh/authorized_keys /root/.bashrc /root/.profile"
|
||||||
|
# Per-user files to watch under each /home/* (appended to WATCH_PERSISTENCE at start)
|
||||||
|
WATCH_HOME_FILES=".ssh/authorized_keys .bashrc .bash_profile .profile .zshrc .config/autostart"
|
||||||
|
|
||||||
|
# Detector toggles
|
||||||
|
DET_REVERSE_SHELL=1
|
||||||
|
DET_LD_PRELOAD=1
|
||||||
|
DET_DELETED_EXE=1
|
||||||
|
DET_NEW_LISTENER=1
|
||||||
|
DET_NEW_SUID=1
|
||||||
|
DET_PERSISTENCE=1
|
||||||
|
DET_EGRESS=1
|
||||||
|
|
||||||
|
# eBPF on-ramp: run a short bpftrace execve trace inside each snapshot, to
|
||||||
|
# catch short-lived processes that polling misses. Requires bpftrace.
|
||||||
|
CAPTURE_EXECVE_BPFTRACE=0
|
||||||
|
|
||||||
|
# Retention
|
||||||
|
MAX_SNAPSHOTS=300
|
||||||
|
MAX_SNAPSHOT_AGE_DAYS=60
|
||||||
|
|
||||||
|
# Notifications (desktop)
|
||||||
|
NOTIFY_USERS=""
|
||||||
|
NOTIFY_URGENCY=critical
|
||||||
|
|
||||||
|
[ -f "$CONFIG" ] && . "$CONFIG"
|
||||||
|
|
||||||
|
mkdir -p "$LOG_DIR"
|
||||||
|
chmod 750 "$LOG_DIR" 2>/dev/null
|
||||||
|
|
||||||
|
START_TIME=$(date +%s)
|
||||||
|
last_suid_refresh=0
|
||||||
|
captures_since_prune=0
|
||||||
|
|
||||||
|
# Per-signature cooldown map: key -> last alert epoch
|
||||||
|
declare -A last_alert
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# ip_to_int DOTTED -> integer, for CIDR matching. Empty on parse failure.
|
||||||
|
ip_to_int() {
|
||||||
|
local ip="$1" a b c d
|
||||||
|
IFS=. read -r a b c d <<< "$ip"
|
||||||
|
[[ "$a$b$c$d" =~ ^[0-9]+$ ]] || return 1
|
||||||
|
echo $(( (a<<24) + (b<<16) + (c<<8) + d ))
|
||||||
|
}
|
||||||
|
|
||||||
|
# is_public_ip ADDR -> 0 if a routable public IPv4 (not private/loopback/link-local)
|
||||||
|
is_public_ip() {
|
||||||
|
local ip="$1"
|
||||||
|
# Strip IPv6 brackets / zone; treat any IPv6 as "public-ish" unless loopback
|
||||||
|
case "$ip" in
|
||||||
|
::1|::ffff:127.*) return 1 ;;
|
||||||
|
*:*) return 0 ;; # IPv6 non-loopback: treat as routable
|
||||||
|
esac
|
||||||
|
local n; n=$(ip_to_int "$ip") || return 1
|
||||||
|
# 10.0.0.0/8
|
||||||
|
[ "$n" -ge 167772160 ] && [ "$n" -le 184549375 ] && return 1
|
||||||
|
# 172.16.0.0/12
|
||||||
|
[ "$n" -ge 2886729728 ] && [ "$n" -le 2887778303 ] && return 1
|
||||||
|
# 192.168.0.0/16
|
||||||
|
[ "$n" -ge 3232235520 ] && [ "$n" -le 3232301055 ] && return 1
|
||||||
|
# 127.0.0.0/8
|
||||||
|
[ "$n" -ge 2130706432 ] && [ "$n" -le 2147483647 ] && return 1
|
||||||
|
# 169.254.0.0/16 link-local
|
||||||
|
[ "$n" -ge 2851995648 ] && [ "$n" -le 2852061183 ] && return 1
|
||||||
|
# 100.64.0.0/10 CGNAT (often VPN/tailscale)
|
||||||
|
[ "$n" -ge 1681915904 ] && [ "$n" -le 1686110207 ] && return 1
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# ip_in_cidrs ADDR "cidr cidr ..." -> 0 if ADDR is inside any CIDR
|
||||||
|
ip_in_cidrs() {
|
||||||
|
local ip="$1" list="$2" cidr base bits mask n base_n
|
||||||
|
n=$(ip_to_int "$ip") || return 1
|
||||||
|
for cidr in $list; do
|
||||||
|
base=${cidr%/*}; bits=${cidr#*/}
|
||||||
|
[[ "$bits" =~ ^[0-9]+$ ]] || continue
|
||||||
|
base_n=$(ip_to_int "$base") || continue
|
||||||
|
if [ "$bits" -eq 0 ]; then return 0; fi
|
||||||
|
mask=$(( 0xFFFFFFFF << (32 - bits) & 0xFFFFFFFF ))
|
||||||
|
[ $(( n & mask )) -eq $(( base_n & mask )) ] && return 0
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Detectors — each prints lines: SEVERITY<TAB>signature<TAB>detail
|
||||||
|
# SEVERITY in {CRITICAL,HIGH,MEDIUM}. detail begins with a stable "key=..."
|
||||||
|
# token used for cooldown dedup.
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
detect_reverse_shell() {
|
||||||
|
# Map network-socket inode -> "local -> peer" (TCP/UDP only) in ONE awk pass.
|
||||||
|
# A reverse shell has a TCP/UDP socket on its stdio; a unix socket or pipe
|
||||||
|
# (journald logging, shell harness pipes) is benign and must not alert.
|
||||||
|
declare -A net_peer
|
||||||
|
local ino peer
|
||||||
|
while read -r ino peer; do
|
||||||
|
[ -n "$ino" ] && net_peer[$ino]="$peer"
|
||||||
|
done < <( { ss -H -tanep 2>/dev/null; ss -H -uanep 2>/dev/null; } | awk '
|
||||||
|
{ ino=""; for (i=1;i<=NF;i++) if ($i ~ /^ino:/) ino=substr($i,5);
|
||||||
|
if (ino != "") print ino, $4" -> "$5 }' )
|
||||||
|
|
||||||
|
# Drive off the (small) set of fd 0/1/2 that are sockets, found in ONE ls.
|
||||||
|
local line target path pid inode comm cmd
|
||||||
|
while IFS= read -r line; do
|
||||||
|
target=${line##* -> }
|
||||||
|
[[ "$target" == socket:* ]] || continue
|
||||||
|
path=${line% -> *}; path=${path##* } # -> /proc/PID/fd/N
|
||||||
|
pid=${path#/proc/}; pid=${pid%%/*}
|
||||||
|
inode=${target#socket:[}; inode=${inode%]}
|
||||||
|
peer="${net_peer[$inode]:-}"
|
||||||
|
[ -n "$peer" ] || continue # network sockets only
|
||||||
|
read -r comm < "/proc/$pid/comm" 2>/dev/null || continue
|
||||||
|
case " $INTERPRETERS " in *" $comm "*) ;; *) continue ;; esac
|
||||||
|
cmd=$(tr '\0' ' ' 2>/dev/null < "/proc/$pid/cmdline"); cmd=${cmd% }
|
||||||
|
printf 'CRITICAL\treverse_shell\tkey=rsh:%s pid=%s comm=%s stdio=net-socket peer=[%s] cmd=[%s]\n' \
|
||||||
|
"$pid" "$pid" "$comm" "$peer" "${cmd:0:90}"
|
||||||
|
done < <(ls -l /proc/[0-9]*/fd/0 /proc/[0-9]*/fd/1 /proc/[0-9]*/fd/2 2>/dev/null)
|
||||||
|
}
|
||||||
|
|
||||||
|
detect_ld_preload() {
|
||||||
|
if [ -s /etc/ld.so.preload ]; then
|
||||||
|
local contents; contents=$(tr '\n' ' ' < /etc/ld.so.preload 2>/dev/null)
|
||||||
|
printf 'CRITICAL\tld_preload_global\tkey=ldp:global /etc/ld.so.preload is non-empty: [%s]\n' "${contents% }"
|
||||||
|
fi
|
||||||
|
# One grep finds the (usually zero) processes with LD_PRELOAD set, instead
|
||||||
|
# of reading every process's environ individually.
|
||||||
|
local f pid pre comm
|
||||||
|
while IFS= read -r -d '' f; do
|
||||||
|
pid=${f#/proc/}; pid=${pid%%/*}
|
||||||
|
pre=$(tr '\0' '\n' 2>/dev/null < "$f" | sed -n 's/^LD_PRELOAD=//p' | head -1)
|
||||||
|
[ -n "$pre" ] || continue
|
||||||
|
case "$pre" in
|
||||||
|
/tmp/*|/dev/shm/*|/var/tmp/*|/run/user/*|./*)
|
||||||
|
read -r comm < "/proc/$pid/comm" 2>/dev/null || comm="?"
|
||||||
|
printf 'CRITICAL\tld_preload_proc\tkey=ldp:%s pid=%s comm=%s LD_PRELOAD=[%s]\n' \
|
||||||
|
"$pid" "$pid" "$comm" "$pre" ;;
|
||||||
|
esac
|
||||||
|
done < <(grep -alZ 'LD_PRELOAD=' /proc/[0-9]*/environ 2>/dev/null)
|
||||||
|
}
|
||||||
|
|
||||||
|
detect_deleted_exe() {
|
||||||
|
# Resolve every /proc/*/exe symlink in ONE ls pass.
|
||||||
|
local line target path pid comm
|
||||||
|
while IFS= read -r line; do
|
||||||
|
target=${line##* -> }
|
||||||
|
case "$target" in *"(deleted)"*|memfd:*|*"/memfd:"*) ;; *) continue ;; esac
|
||||||
|
# Only alert for attacker-controlled / fileless locations. Normal-path
|
||||||
|
# deleted exes are usually daemons still running after a package upgrade.
|
||||||
|
case "$target" in
|
||||||
|
/tmp/*|/dev/shm/*|/var/tmp/*|/run/*|memfd:*|*"/memfd:"*) ;;
|
||||||
|
*) continue ;;
|
||||||
|
esac
|
||||||
|
path=${line% -> *}; path=${path##* } # -> /proc/PID/exe
|
||||||
|
pid=${path#/proc/}; pid=${pid%%/*}
|
||||||
|
read -r comm < "/proc/$pid/comm" 2>/dev/null || comm="?"
|
||||||
|
printf 'CRITICAL\tdeleted_exe\tkey=del:%s pid=%s comm=%s exe=[%s]\n' \
|
||||||
|
"$pid" "$pid" "$comm" "$target"
|
||||||
|
done < <(ls -l /proc/[0-9]*/exe 2>/dev/null)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Listener baseline: set of "port/comm" present at baseline time.
|
||||||
|
build_listener_baseline() {
|
||||||
|
ss -H -tlnp 2>/dev/null | awk '{
|
||||||
|
n=split($4,a,":"); port=a[n];
|
||||||
|
comm="?"; if (match($0,/"[^"]+"/)) comm=substr($0,RSTART+1,RLENGTH-2);
|
||||||
|
print port"/"comm
|
||||||
|
}' | sort -u > "$LOG_DIR/.listener-baseline"
|
||||||
|
}
|
||||||
|
|
||||||
|
detect_new_listener() {
|
||||||
|
[ -r "$LOG_DIR/.listener-baseline" ] || return 0
|
||||||
|
local line port comm key
|
||||||
|
while IFS= read -r line; do
|
||||||
|
port=$(awk '{n=split($4,a,":"); print a[n]}' <<< "$line")
|
||||||
|
comm="?"; [[ "$line" =~ \"([^\"]+)\" ]] && comm="${BASH_REMATCH[1]}"
|
||||||
|
key="$port/$comm"
|
||||||
|
grep -qxF "$key" "$LOG_DIR/.listener-baseline" && continue
|
||||||
|
case " $LISTENER_ALLOW_PORTS " in *" $port "*) continue ;; esac
|
||||||
|
local addr; addr=$(awk '{print $4}' <<< "$line")
|
||||||
|
printf 'HIGH\tnew_listener\tkey=lis:%s new listening socket %s by comm=%s\n' \
|
||||||
|
"$key" "$addr" "$comm"
|
||||||
|
done < <(ss -H -tlnp 2>/dev/null)
|
||||||
|
}
|
||||||
|
|
||||||
|
build_suid_baseline() {
|
||||||
|
find / -xdev \( -perm -4000 -o -perm -2000 \) -type f 2>/dev/null \
|
||||||
|
| sort -u > "$LOG_DIR/.suid-baseline"
|
||||||
|
}
|
||||||
|
|
||||||
|
detect_new_suid() {
|
||||||
|
[ -r "$LOG_DIR/.suid-baseline" ] || return 0
|
||||||
|
local f
|
||||||
|
while IFS= read -r f; do
|
||||||
|
grep -qxF "$f" "$LOG_DIR/.suid-baseline" && continue
|
||||||
|
local sev=HIGH why="new SUID/SGID binary"
|
||||||
|
for d in $SUID_HOT_DIRS; do
|
||||||
|
case "$f" in "$d"/*) sev=CRITICAL; why="SUID/SGID binary in writable dir"; break ;; esac
|
||||||
|
done
|
||||||
|
printf '%s\tnew_suid\tkey=suid:%s %s: %s\n' "$sev" "$f" "$why" "$f"
|
||||||
|
done < <(find / -xdev \( -perm -4000 -o -perm -2000 \) -type f 2>/dev/null)
|
||||||
|
}
|
||||||
|
|
||||||
|
detect_persistence() {
|
||||||
|
local since="$1" f
|
||||||
|
# -newermt @epoch matches files modified strictly after that time.
|
||||||
|
while IFS= read -r f; do
|
||||||
|
[ -n "$f" ] || continue
|
||||||
|
printf 'HIGH\tpersistence\tkey=persist:%s persistence file modified: %s\n' "$f" "$f"
|
||||||
|
done < <(find $WATCH_PERSISTENCE -newermt "@$since" \( -type f -o -type l \) 2>/dev/null)
|
||||||
|
}
|
||||||
|
|
||||||
|
detect_egress() {
|
||||||
|
local line addr port comm pid
|
||||||
|
while IFS= read -r line; do
|
||||||
|
# ss -Hntp output: ... peer_addr:port users:(("comm",pid=NNN,fd=N))
|
||||||
|
local raddr; raddr=$(awk '{print $5}' <<< "$line")
|
||||||
|
addr=${raddr%:*}; port=${raddr##*:}
|
||||||
|
addr=${addr#[}; addr=${addr%]}
|
||||||
|
[[ "$line" =~ \"([^\"]+)\",pid=([0-9]+) ]] || continue
|
||||||
|
comm="${BASH_REMATCH[1]}"; pid="${BASH_REMATCH[2]}"
|
||||||
|
case " $INTERPRETERS " in *" $comm "*) ;; *) continue ;; esac
|
||||||
|
is_public_ip "$addr" || continue
|
||||||
|
ip_in_cidrs "$addr" "$EGRESS_ALLOW_CIDRS" && continue
|
||||||
|
printf 'HIGH\tsuspicious_egress\tkey=egr:%s:%s pid=%s comm=%s -> %s:%s (interpreter to public IP)\n' \
|
||||||
|
"$pid" "$addr" "$pid" "$comm" "$addr" "$port"
|
||||||
|
done < <(ss -H -tnp state established 2>/dev/null)
|
||||||
|
}
|
||||||
|
|
||||||
|
run_detectors() {
|
||||||
|
local since="$1" do_suid="${2:-0}"
|
||||||
|
[ "$DET_REVERSE_SHELL" = 1 ] && detect_reverse_shell
|
||||||
|
[ "$DET_LD_PRELOAD" = 1 ] && detect_ld_preload
|
||||||
|
[ "$DET_DELETED_EXE" = 1 ] && detect_deleted_exe
|
||||||
|
[ "$DET_EGRESS" = 1 ] && detect_egress
|
||||||
|
# New-listener / new-suid / persistence only after the grace window so we
|
||||||
|
# don't alert on everything that existed at startup.
|
||||||
|
if [ $(( $(date +%s) - START_TIME )) -ge "$BASELINE_GRACE" ]; then
|
||||||
|
[ "$DET_NEW_LISTENER" = 1 ] && detect_new_listener
|
||||||
|
[ "$DET_PERSISTENCE" = 1 ] && detect_persistence "$since"
|
||||||
|
# SUID scan walks the whole filesystem, so it runs on a slow cadence
|
||||||
|
# (do_suid is decided by the caller), not every sweep.
|
||||||
|
[ "$DET_NEW_SUID" = 1 ] && [ "$do_suid" = 1 ] && detect_new_suid
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Per-signature incident-response guidance (the security analogue of
|
||||||
|
# freeze-watcher's classify_snapshot diagnoses).
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
signature_response() {
|
||||||
|
case "$1" in
|
||||||
|
reverse_shell)
|
||||||
|
echo "A shell/interpreter has a network socket wired to its stdin/stdout — the canonical reverse-shell signature. RESPONSE: identify the peer IP, kill the pid, inspect parent process and how it was spawned (web/db service = RCE), preserve /proc/<pid>/ before killing if you can." ;;
|
||||||
|
ld_preload_global)
|
||||||
|
echo "/etc/ld.so.preload injects a library into EVERY dynamically-linked process — a classic userland rootkit persistence mechanism. RESPONSE: capture the named .so, hash it, check package ownership (pacman -Qo), assume full host compromise until cleared." ;;
|
||||||
|
ld_preload_proc)
|
||||||
|
echo "A running process has LD_PRELOAD pointing at a writable/temp path — function-hooking, often to hide files/processes or intercept credentials. RESPONSE: capture the .so, inspect the process tree, determine who set the env var." ;;
|
||||||
|
deleted_exe)
|
||||||
|
echo "A process is executing from a deleted or memfd-backed binary — fileless malware that left no file on disk. RESPONSE: dump /proc/<pid>/exe to recover the binary BEFORE killing, capture maps and sockets." ;;
|
||||||
|
new_listener)
|
||||||
|
echo "A listening socket appeared that wasn't present at baseline — possible backdoor/bind shell. RESPONSE: identify the binary, confirm it's an expected service, check for matching inbound connections." ;;
|
||||||
|
new_suid)
|
||||||
|
echo "A new SUID/SGID binary appeared (critical if in a writable dir) — a privilege-escalation persistence trick. RESPONSE: verify package ownership; an unowned SUID binary in /tmp or /home is almost never legitimate." ;;
|
||||||
|
persistence)
|
||||||
|
echo "A persistence-relevant file (cron, systemd unit, authorized_keys, shell rc) was modified. RESPONSE: diff against backup/version control, review the change, check surrounding auth logs for who made it." ;;
|
||||||
|
suspicious_egress)
|
||||||
|
echo "An interpreter is holding an outbound connection to a public IP — possible C2 beacon or data exfil. RESPONSE: resolve/geolocate the peer, check reputation, inspect the process and its parentage." ;;
|
||||||
|
*) echo "Review the captured process, socket, and file context manually." ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Snapshot capture
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
notify_users() {
|
||||||
|
[ -z "$NOTIFY_USERS" ] && return 0
|
||||||
|
local body="$1" IFS=,
|
||||||
|
for user in $NOTIFY_USERS; do
|
||||||
|
user=$(echo "$user" | tr -d '[:space:]'); [ -z "$user" ] && continue
|
||||||
|
local uid; uid=$(id -u "$user" 2>/dev/null) || continue
|
||||||
|
sudo -u "$user" \
|
||||||
|
DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$uid/bus" \
|
||||||
|
notify-send -u "$NOTIFY_URGENCY" -a "enodia-sentinel" \
|
||||||
|
"⚠ Enodia Sentinel alert" "$body" 2>/dev/null &
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
prune_snapshots() {
|
||||||
|
if [ "${MAX_SNAPSHOT_AGE_DAYS:-0}" -gt 0 ]; then
|
||||||
|
find "$LOG_DIR" -maxdepth 1 -name 'alert-*.log' -type f \
|
||||||
|
-mtime "+$MAX_SNAPSHOT_AGE_DAYS" -delete 2>/dev/null
|
||||||
|
fi
|
||||||
|
if [ "${MAX_SNAPSHOTS:-0}" -gt 0 ]; then
|
||||||
|
local files
|
||||||
|
files=$(ls -1t "$LOG_DIR"/alert-*.log 2>/dev/null | tail -n "+$((MAX_SNAPSHOTS+1))")
|
||||||
|
[ -n "$files" ] && echo "$files" | xargs -r rm -f
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Extract the pids referenced in the triggering alerts so we can deep-dive them.
|
||||||
|
pids_from_alerts() {
|
||||||
|
grep -oE 'pid=[0-9]+' <<< "$1" | cut -d= -f2 | sort -un
|
||||||
|
}
|
||||||
|
|
||||||
|
capture_pid_detail() {
|
||||||
|
local pid="$1"
|
||||||
|
local d="/proc/$pid"
|
||||||
|
[ -d "$d" ] || { echo " pid $pid: gone"; return; }
|
||||||
|
echo "### pid $pid"
|
||||||
|
echo " comm: $(cat "$d/comm" 2>/dev/null)"
|
||||||
|
echo " exe: $(readlink "$d/exe" 2>/dev/null)"
|
||||||
|
echo " cwd: $(readlink "$d/cwd" 2>/dev/null)"
|
||||||
|
echo " cmdline: $(tr '\0' ' ' < "$d/cmdline" 2>/dev/null)"
|
||||||
|
local ppid; ppid=$(awk '/^PPid:/{print $2}' "$d/status" 2>/dev/null)
|
||||||
|
echo " ppid: $ppid ($(cat "/proc/$ppid/comm" 2>/dev/null))"
|
||||||
|
echo " uid: $(awk '/^Uid:/{print $2}' "$d/status" 2>/dev/null) gid: $(awk '/^Gid:/{print $2}' "$d/status" 2>/dev/null)"
|
||||||
|
echo " LD_PRELOAD env: $(tr '\0' '\n' 2>/dev/null < "$d/environ" | sed -n 's/^LD_PRELOAD=//p')"
|
||||||
|
echo " open fds (socket/file targets):"
|
||||||
|
local fd t
|
||||||
|
for fd in "$d"/fd/*; do
|
||||||
|
t=$(readlink "$fd" 2>/dev/null) || continue
|
||||||
|
printf ' %s -> %s\n' "${fd##*/}" "$t"
|
||||||
|
done | head -40
|
||||||
|
}
|
||||||
|
|
||||||
|
capture_execve_bpftrace() {
|
||||||
|
command -v bpftrace >/dev/null 2>&1 || { echo "bpftrace not installed"; return; }
|
||||||
|
echo "## bpftrace execve trace (3s) — short-lived processes polling can miss"
|
||||||
|
timeout 4 bpftrace -e '
|
||||||
|
tracepoint:syscalls:sys_enter_execve {
|
||||||
|
printf("%-8d %-16s %s\n", pid, comm, str(args->filename));
|
||||||
|
}
|
||||||
|
interval:s:3 { exit(); }
|
||||||
|
' 2>/dev/null | tail -60
|
||||||
|
}
|
||||||
|
|
||||||
|
capture_snapshot() {
|
||||||
|
local alerts="$1"
|
||||||
|
local final="$LOG_DIR/alert-$(date +%Y%m%d-%H%M%S).log"
|
||||||
|
local sigs; sigs=$(awk -F'\t' '{print $2}' <<< "$alerts" | sort -u)
|
||||||
|
local top_sev; top_sev=$(awk -F'\t' '{print $1}' <<< "$alerts" \
|
||||||
|
| grep -m1 -E 'CRITICAL' || awk -F'\t' 'NR==1{print $1}' <<< "$alerts")
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "=== ENODIA SENTINEL ALERT ==="
|
||||||
|
echo "Time: $(date -Iseconds)"
|
||||||
|
echo "Host: $(hostname)"
|
||||||
|
echo "Severity: ${top_sev:-HIGH}"
|
||||||
|
echo
|
||||||
|
echo "## Triggering detections"
|
||||||
|
awk -F'\t' '{printf " [%s] %s — %s\n", $1, $2, $3}' <<< "$alerts"
|
||||||
|
echo
|
||||||
|
echo "## Response guidance"
|
||||||
|
while IFS= read -r s; do
|
||||||
|
[ -n "$s" ] || continue
|
||||||
|
echo " • $s:"
|
||||||
|
echo " $(signature_response "$s")"
|
||||||
|
done <<< "$sigs"
|
||||||
|
echo
|
||||||
|
echo "## Flagged process detail"
|
||||||
|
local pids; pids=$(pids_from_alerts "$alerts")
|
||||||
|
if [ -n "$pids" ]; then
|
||||||
|
for p in $pids; do capture_pid_detail "$p"; echo; done
|
||||||
|
else
|
||||||
|
echo " (no specific pid in alerts)"
|
||||||
|
fi
|
||||||
|
echo "## Process tree"
|
||||||
|
ps -eo pid,ppid,user,etimes,pcpu,pmem,stat,comm --sort=-pcpu 2>/dev/null | head -40
|
||||||
|
echo
|
||||||
|
echo "## All established + listening TCP (ss -tanp)"
|
||||||
|
ss -tanp 2>/dev/null | head -80
|
||||||
|
echo
|
||||||
|
echo "## /etc/ld.so.preload"
|
||||||
|
if [ -s /etc/ld.so.preload ]; then cat /etc/ld.so.preload; else echo " (empty — good)"; fi
|
||||||
|
echo
|
||||||
|
echo "## Recently modified watched files (last 1h)"
|
||||||
|
find $WATCH_PERSISTENCE -newermt "@$(( $(date +%s) - 3600 ))" \
|
||||||
|
\( -type f -o -type l \) -printf ' %TY-%Tm-%Td %TH:%TM %p\n' 2>/dev/null | head -40
|
||||||
|
echo
|
||||||
|
echo "## Recently loaded kernel modules (tail of lsmod)"
|
||||||
|
lsmod 2>/dev/null | head -15
|
||||||
|
echo
|
||||||
|
echo "## Logged-in users / recent logins"
|
||||||
|
who 2>/dev/null; echo "--"; last -n 5 2>/dev/null | head -5
|
||||||
|
echo
|
||||||
|
echo "## Recent auth events (authpriv, 10m)"
|
||||||
|
journalctl --since "10 minutes ago" --facility=authpriv --no-pager 2>/dev/null | tail -20
|
||||||
|
echo
|
||||||
|
if [ "${CAPTURE_EXECVE_BPFTRACE:-0}" = 1 ]; then
|
||||||
|
capture_execve_bpftrace
|
||||||
|
fi
|
||||||
|
} > "$final" 2>&1
|
||||||
|
|
||||||
|
chmod 640 "$final" 2>/dev/null
|
||||||
|
|
||||||
|
local sig_list; sig_list=$(tr '\n' ',' <<< "$sigs" | sed 's/,$//;s/,/, /g')
|
||||||
|
echo "$(date -Iseconds) [${top_sev:-HIGH}] captured $final — signatures: ${sig_list}" \
|
||||||
|
>> "$LOG_DIR/events.log"
|
||||||
|
notify_users "${top_sev:-HIGH}: ${sig_list}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Entry points
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
case "${1:-}" in
|
||||||
|
--baseline)
|
||||||
|
build_listener_baseline; build_suid_baseline
|
||||||
|
echo "Baselines written to $LOG_DIR/.listener-baseline and .suid-baseline"
|
||||||
|
exit 0 ;;
|
||||||
|
--check)
|
||||||
|
# Re-arm immediately for a one-shot check
|
||||||
|
START_TIME=$((START_TIME - BASELINE_GRACE - 1))
|
||||||
|
[ -r "$LOG_DIR/.listener-baseline" ] || build_listener_baseline
|
||||||
|
[ -r "$LOG_DIR/.suid-baseline" ] || build_suid_baseline
|
||||||
|
out=$(run_detectors "$(( $(date +%s) - SAMPLE_INTERVAL ))" 1)
|
||||||
|
if [ -z "$out" ]; then echo "No alerts."; else
|
||||||
|
awk -F'\t' '{printf "[%s] %-18s %s\n", $1, $2, $3}' <<< "$out"
|
||||||
|
fi
|
||||||
|
exit 0 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# --- daemon loop ----------------------------------------------------------
|
||||||
|
echo "$(date -Iseconds) enodia-sentinel started (pid $$)" >> "$LOG_DIR/events.log"
|
||||||
|
build_listener_baseline
|
||||||
|
build_suid_baseline
|
||||||
|
last_suid_refresh=$START_TIME
|
||||||
|
prune_snapshots
|
||||||
|
last_scan=$START_TIME
|
||||||
|
last_suid_scan=0
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
now=$(date +%s)
|
||||||
|
|
||||||
|
# Decide whether this sweep includes the filesystem-wide SUID scan.
|
||||||
|
do_suid=0
|
||||||
|
if [ $(( now - last_suid_scan )) -ge "${SUID_SCAN_INTERVAL:-60}" ]; then
|
||||||
|
do_suid=1; last_suid_scan=$now
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Manual trigger (drop a word into $TRIGGER_FILE)
|
||||||
|
if [ -f "$TRIGGER_FILE" ]; then
|
||||||
|
rm -f "$TRIGGER_FILE"
|
||||||
|
capture_snapshot "$(printf 'HIGH\tmanual\tkey=manual manual capture requested')" &
|
||||||
|
fi
|
||||||
|
|
||||||
|
alerts=$(run_detectors "$last_scan" "$do_suid")
|
||||||
|
last_scan=$now
|
||||||
|
|
||||||
|
if [ -n "$alerts" ]; then
|
||||||
|
fresh=""
|
||||||
|
while IFS= read -r line; do
|
||||||
|
[ -n "$line" ] || continue
|
||||||
|
key=$(grep -oE 'key=[^ ]+' <<< "$line" | head -1)
|
||||||
|
key=${key:-$line}
|
||||||
|
prev=${last_alert[$key]:-0}
|
||||||
|
if [ $(( now - prev )) -ge "$COOLDOWN" ]; then
|
||||||
|
last_alert[$key]=$now
|
||||||
|
fresh+="$line"$'\n'
|
||||||
|
fi
|
||||||
|
done <<< "$alerts"
|
||||||
|
|
||||||
|
if [ -n "$fresh" ]; then
|
||||||
|
capture_snapshot "${fresh%$'\n'}" &
|
||||||
|
captures_since_prune=$((captures_since_prune+1))
|
||||||
|
if [ "$captures_since_prune" -ge 20 ]; then
|
||||||
|
prune_snapshots &
|
||||||
|
captures_since_prune=0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Periodically refresh the SUID baseline so legit installs don't alert forever
|
||||||
|
if [ $(( now - last_suid_refresh )) -ge "$SUID_REFRESH" ]; then
|
||||||
|
build_suid_baseline; last_suid_refresh=$now
|
||||||
|
fi
|
||||||
|
|
||||||
|
sleep "$SAMPLE_INTERVAL"
|
||||||
|
done
|
||||||
29
systemd/enodia-sentinel.service
Normal file
29
systemd/enodia-sentinel.service
Normal 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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue