# 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 (text **and** JSON) 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."* > **Two implementations, on purpose.** The project began as a bash prototype > (`src/sentinel.sh`, kept as the regression **oracle**) and was re-architected > into a zero-dependency Python package with a unit-test suite, structured > detectors, and JSON output. The bash version and the Python version share one > red-team harness, so every signature is exercised against both. ## 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 real EDRs are built on: | Signature | What it catches | Why it's hard to evade | |---|---|---| | `reverse_shell` | An interpreter with a **network socket on fd 0/1/2** | Interactive shells get a pty and daemons get unix sockets — a *network* socket on stdio is `nc -e` / `bash -i >& /dev/tcp/...` | | `ld_preload` | Non-empty `/etc/ld.so.preload`, or `LD_PRELOAD` into a writable dir | Injecting into processes needs the library to exist somewhere | | `deleted_exe` | A process running from a **deleted / `memfd:`** binary | Fileless malware deletes its dropper; the kernel still names the inode `(deleted)` | | `new_listener` | A listening port absent from the startup baseline | Bind shells/backdoors have to listen somewhere | | `new_suid` | A new SUID/SGID binary (critical in a writable dir) | A SUID `/tmp` binary is a textbook privesc trick | | `persistence` | Changes to cron, systemd units, `authorized_keys`, rc files | Persistence has to write somewhere that survives reboot | | `egress` | An interpreter with an established connection to a public IP | C2 beacons and exfil have to phone home | ## Architecture ``` enodia_sentinel/ ├── cli.py run / check / baseline / list-detectors ├── daemon.py sweep loop · cooldown dedup · backgrounded SUID scan ├── system.py SystemState — one cached snapshot of /proc + ss per sweep ├── snapshot.py forensic text+JSON capture · response guidance · retention ├── config.py dataclass config (TOML + env overrides) ├── netutil.py public-IP / CIDR logic (stdlib ipaddress) ├── alert.py Alert / Severity └── detectors/ one module per signature, each a pure function: detect(state, cfg) -> Iterable[Alert] ``` The loop is deliberately the same control flow as the bash prototype, but the state lives in real objects: ``` every sample_interval seconds: state = SystemState() # /proc + ss gathered once, cached alerts = run_all(detectors, state, cfg) fresh = drop alerts still within cooldown if fresh: snapshot.capture(fresh) # on a worker thread ``` Two design choices keep it fast and unobtrusive: - **One `SystemState` per sweep.** Detectors read shared, cached `/proc`/`ss` data instead of each shelling out — a sweep costs ~200 ms regardless of how many detectors run. - **The filesystem-wide SUID scan runs off the loop thread** on a slow cadence, so the multi-second walk never stalls live detection. Everything in `SystemState` is **injectable**, which is what makes the detectors unit-testable without root or a live system (see `tests/`). ## 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`, `deleted_exe`, `new_listener`, and `new_suid` in real time, each producing a `.log` + `.json` snapshot with response guidance. Output lives in `/var/log/enodia-sentinel/`: - `events.log` — one line per alert - `alert-YYYYMMDD-HHMMSS.log` — human-readable forensic snapshot - `alert-YYYYMMDD-HHMMSS.json` — same data, structured (SIEM-ready) ### Without installing ```bash make test # run the unit suite python3 -m enodia_sentinel.cli baseline # establish baselines python3 -m enodia_sentinel.cli check # run every detector once, print findings ``` No pip, no virtualenv, no dependencies — it's stdlib-only and installs as a plain package directory plus a launcher wrapper. ## The red-team harness `sentinel-redteam` is the demo and the integration test in one. It simulates each threat with **safe, clearly-labeled stand-ins** (everything tagged `enodia-drill`, auto-cleaned on exit), using a local Python TCP listener so no traffic ever leaves the host: ```bash sentinel-redteam --list # list drills sentinel-redteam reverse_shell new_suid # run specific ones HOLD=30 sentinel-redteam # keep artifacts alive 30s ``` It never touches your real dotfiles or `/etc/ld.so.preload`; the LD_PRELOAD drill only sets the env var on a throwaway process. ## Testing ```bash make test # 25 unit tests, stdlib unittest, no deps ``` Detectors are pure functions over an injectable `SystemState`, so tests build fake processes/sockets and assert on the alerts — no root, no `/proc`, no `ss`: ```python proc = FakeProc(pid=100, comm="bash", _stdio_inode=999) sock = Socket("ESTAB", "127.0.0.1:55", "9.9.9.9:443", 999, "bash", 100) state = SystemState(processes=[proc], sockets=[sock]) assert list(reverse_shell.detect(state, Config()))[0].signature == "reverse_shell" ``` ## Configuration Edit `/etc/enodia-sentinel.toml`, then `sudo systemctl restart enodia-sentinel.service`. Every key is optional. Highlights: | Key | Default | Purpose | |---|---|---| | `sample_interval` | 4 | seconds between sweeps | | `cooldown` | 60 | min seconds before re-alerting a signature | | `detectors` | all 7 | the enabled detector list | | `interpreters` | bash sh … | process names treated as shells | | `egress_allow_cidrs` | [] | trusted public ranges (won't trip egress) | | `suid_hot_dirs` | /tmp … | dirs where a SUID binary is CRITICAL | | `suid_scan_extra_dirs` | /tmp … | writable mounts always scanned (tmpfs-safe) | | `capture_execve_bpftrace` | false | add a bpftrace execve trace to snapshots | | `notify_users` | [] | desktop notify-send targets | ## 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: `ProtectSystem=strict` with the log dir as the only writable path, `ProtectHome=read-only`, `NoNewPrivileges`, `MemoryDenyWriteExecute`, `RestrictNamespaces`, 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 is **poll-based**: it sweeps `/proc` and `ss` every few seconds. Robust, dependency-light, and catches anything that lingers — but it can miss sub-second processes. The planned evolution: 1. **bpftrace tracepoints (now, optional)** — `capture_execve_bpftrace = true` adds a live `execve` trace to each snapshot. The gentle on-ramp to kernel tracing. 2. **Event-driven detection** — `bpftrace`/BCC probes on `sys_enter_execve`, `security_bprm_check`, and `tcp_connect`, streamed to the daemon so a short-lived reverse shell can't slip between sweeps. 3. **A libbpf + CO-RE agent (Go or Rust userland)** — the production EDR core: LSM hooks, ring-buffer event streaming, per-process lineage, tamper resistance. 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. ## Project status v0.2 — Python re-architecture of the bash prototype: 7 detectors, text+JSON forensic snapshots, backgrounded SUID scanning, 25-test unit suite, red-team harness, hardened systemd unit, Arch packaging. Zero runtime dependencies. Built and tested on Arch Linux. ## License MIT