A host intrusion-detection daemon for Linux. Think of it as a watchdog for security and host stability purposes. https://enodiainformatics.com
Find a file
Luna 586f74b929 Relicense under GPL-3.0-or-later
Replace MIT with the full GNU GPLv3 text, update license metadata in
pyproject.toml (+ trove classifiers) and PKGBUILD, and add
SPDX-License-Identifier headers to all Python modules and shell scripts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 06:52:34 -07:00
config Re-architect Sentinel as a zero-dependency Python package 2026-05-31 01:50:50 -07:00
enodia_sentinel Relicense under GPL-3.0-or-later 2026-05-31 06:52:34 -07:00
packaging Relicense under GPL-3.0-or-later 2026-05-31 06:52:34 -07:00
src Relicense under GPL-3.0-or-later 2026-05-31 06:52:34 -07:00
systemd Re-architect Sentinel as a zero-dependency Python package 2026-05-31 01:50:50 -07:00
tests Relicense under GPL-3.0-or-later 2026-05-31 06:52:34 -07:00
.gitignore Re-architect Sentinel as a zero-dependency Python package 2026-05-31 01:50:50 -07:00
LICENSE Relicense under GPL-3.0-or-later 2026-05-31 06:52:34 -07:00
Makefile Re-architect Sentinel as a zero-dependency Python package 2026-05-31 01:50:50 -07:00
pyproject.toml Relicense under GPL-3.0-or-later 2026-05-31 06:52:34 -07:00
README.md Relicense under GPL-3.0-or-later 2026-05-31 06:52:34 -07:00

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

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

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:

  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 detectionbpftrace/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

GPL-3.0-or-later — see LICENSE.