Port the bash prototype to a structured, testable Python codebase while
preserving the same control loop and the seven detection signatures. The bash
implementation stays in src/ as the regression oracle.
Highlights:
- enodia_sentinel/ package (stdlib only — no runtime deps):
- detectors/ : one pure function per signature, detect(state, cfg)->Alerts
- system.py : SystemState — one cached /proc + ss snapshot per sweep,
fully injectable so detectors are unit-testable
- daemon.py : sweep loop, in-process cooldown dedup, threaded snapshot
capture, and a SUID filesystem scan moved OFF the loop
thread onto a slow background cadence
- snapshot.py: forensic text + JSON sidecar with per-signature IR guidance
- config.py : dataclass config via TOML + env overrides
- netutil.py : public-IP / CIDR logic via stdlib ipaddress
- tests/ : 25 stdlib-unittest cases (no root, no /proc, no ss needed)
- TOML config, launcher wrapper, Makefile (pip-free install), hardened
systemd unit (env-resolved ExecStart), updated PKGBUILD, rewritten README
Performance: per-sweep cost ~200 ms (shared cached state); the multi-second
SUID walk no longer blocks detection. scandir-based walk replaces os.walk.
Verified on Arch: all 7 detectors fire on red-team drills (reverse_shell,
ld_preload, deleted_exe, new_listener, new_suid confirmed live end-to-end),
no false positives on a clean sweep, 25/25 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
8.4 KiB
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
SystemStateper sweep. Detectors read shared, cached/proc/ssdata 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
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 alertalert-YYYYMMDD-HHMMSS.log— human-readable forensic snapshotalert-YYYYMMDD-HHMMSS.json— same data, structured (SIEM-ready)
Without installing
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:
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
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:
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:
- bpftrace tracepoints (now, optional) —
capture_execve_bpftrace = trueadds a liveexecvetrace to each snapshot. The gentle on-ramp to kernel tracing. - Event-driven detection —
bpftrace/BCC probes onsys_enter_execve,security_bprm_check, andtcp_connect, streamed to the daemon so a short-lived reverse shell can't slip between sweeps. - 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