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 5d577b624f Add file integrity monitoring (Tripwire-style), auto-refreshed via pacman hook
Detects binary/config tampering by content hash — catching a malicious swap
even when mtime is preserved (the gap in mtime-based persistence checks). Two
engines split by file ownership:

- fim.py hash baseline: SHA-256 (+ mode/uid/gid/size) of security-critical
  files the package manager doesn't track (/usr/local, /etc configs, systemd
  units, SSH keys). The baseline refreshes ONLY via fim-update / the pacman
  hook, so a flagged change stays flagged until acknowledged (Tripwire
  semantics). Alerts: fim_modified 100017 / fim_added 100018 / fim_removed 100019.
- package verification: `pacman -Qkk` checks package-owned binaries against the
  distro's own signed checksums — no baseline to maintain, implicitly current
  because the package DB updates on every upgrade. fim_pkg_modified 100020.

Auto-update on system updates: a pacman PostTransaction hook runs
`enodia-sentinel fim-update` after every install/upgrade/remove, so legitimate
package changes never alert — no manual `tripwire --update`.

- daemon: backgrounded FIM scan + optional pkg-verify on slow cadences, feeding
  the normal alert/snapshot/push pipeline; baseline loaded/built at startup
- cli: fim-baseline / fim-update / fim-check [--packages]
- config: fim_enabled, fim_paths, fim_scan_interval, fim_pkg_verify[_interval]
- packaging: ship + install the pacman hook (Makefile + PKGBUILD)
- tests: +7 (hashing, diff incl. mtime-preserving tamper, pacman -Qkk parse).
  72/72 pass. Verified end-to-end: a content swap with preserved mtime is caught.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:18:42 -07:00
config Add file integrity monitoring (Tripwire-style), auto-refreshed via pacman hook 2026-05-31 22:18:42 -07:00
enodia_sentinel Add file integrity monitoring (Tripwire-style), auto-refreshed via pacman hook 2026-05-31 22:18:42 -07:00
packaging Add file integrity monitoring (Tripwire-style), auto-refreshed via pacman hook 2026-05-31 22:18:42 -07:00
src Add event-driven eBPF execve layer with a Snort-style rule engine 2026-05-31 07:16:53 -07:00
systemd Add read-only web dashboard and phone push notifications 2026-05-31 16:18:00 -07:00
tests Add file integrity monitoring (Tripwire-style), auto-refreshed via pacman hook 2026-05-31 22:18:42 -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 Add file integrity monitoring (Tripwire-style), auto-refreshed via pacman hook 2026-05-31 22:18:42 -07:00
pyproject.toml Add file integrity monitoring (Tripwire-style), auto-refreshed via pacman hook 2026-05-31 22:18:42 -07:00
README.md Add file integrity monitoring (Tripwire-style), auto-refreshed via pacman hook 2026-05-31 22:18:42 -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

Every detection carries a stable sid and a classtype (à la Snort/Suricata), so it can be referenced, tuned, and tracked across revisions.

Event-driven detection (eBPF + a Snort-style rule engine)

Polling has a blind spot: a process that runs and exits between two sweeps is invisible to it. The event layer closes that gap. An eBPF probe (loaded with bcc) fires on every execve and hands each event to a declarative rule engine — the host-event analogue of Snort matching packets:

# a rule is data, not code — sid, msg, classtype, severity + conditions
sid        = 100002
msg        = "Reverse-shell command pattern in execve arguments"
severity   = "CRITICAL"
classtype  = "c2-reverse-shell"
argv_regex = "/dev/(tcp|udp)/| -i\\b| -e\\b| pty\\.spawn"

Shipped rules cover fileless execution from world-writable dirs (sid 100001), reverse-shell argv patterns (100002), web/DB services spawning a shell — webshell/RCE (100003), and curl|sh-style ingress tool transfer (100004). Operators add their own via exec_rules_file without touching code.

The layer is fail-safe: if bcc/root/BTF aren't available it logs the reason and the daemon runs poll-only — a broken probe can never take detection down. Lineage: the rule-driven engine + SIDs come from Snort; the host-IDS framing (and the queued FIM / hidden-process checks) from OSSEC.

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 (with Snort-style sid + classtype)
├── web.py            read-only dashboard: stdlib http server + JSON API + auth
├── static/           the self-contained dashboard SPA
├── fim.py            file integrity monitoring (hash baseline + pacman verify)
├── triage.py         false-positive classification
├── provenance.py     package-ownership lookups (pacman/dpkg/rpm)
├── detectors/        poll detectors — one module per signature, each a pure
│                       function: detect(state, cfg) -> Iterable[Alert]
├── notify/           outbound push — ntfy / Pushover / webhook backends
└── events/           event-driven layer (eBPF)
    ├── bcc_source.py   real eBPF execve probe loaded via bcc
    ├── exec_event.py   the ExecEvent type
    ├── rules.py        Snort-style ExecRule engine + default rules
    └── monitor.py      runs the probe on a thread, routes events → rules

Two complementary detection paths feed one Alert → snapshot pipeline:

  • Poll — every few seconds, sweep /proc/ss (catches anything lingering).
  • Event — eBPF fires on every execve, matched against the rule engine (catches processes that exit between sweeps).

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

Web dashboard

A read-only console, served by the stdlib http.server (no Flask, no JS framework, no CDN — one self-contained page):

enodia-sentinel web                      # serves on the Tailscale IP by default
# or as a service:
sudo systemctl enable --now enodia-sentinel-web
  • Bound to your Tailscale interface by default (auto-detected), so it's reachable from your phone/laptop on the tailnet but not the LAN or internet.
  • Bearer-token auth (constant-time check); the token is auto-generated and saved on first run and printed in the startup line. Open http://<tailscale-ip>:8787/?token=….
  • Read-only: severity cards, a live alert list, and the full forensic snapshot per alert. No actions, no writes — minimal attack surface for sensitive data. JSON API at /api/status, /api/alerts, /api/alerts/<id>, /api/events.

Phone push notifications

When an alert at/above notify_min_severity fires, Sentinel pushes to whichever backends you've configured (all via stdlib urllib, no SDKs):

Backend Enable by setting Notes
ntfy notify_ntfy_url + notify_ntfy_topic open-source, self-hostable, free apps
Pushover notify_pushover_token + _user polished, reliable
Webhook notify_webhook_url generic JSON POST (Discord/Slack/your own)

Severity maps to each service's priority (a CRITICAL is an urgent ntfy push / a high-priority Pushover). Sends happen on worker threads and swallow their own errors — a flaky notifier never stalls detection.

notify_min_severity = "HIGH"
notify_ntfy_url   = "https://ntfy.sh"
notify_ntfy_topic = "enodia-7Hq2x"      # keep this secret — it's the access control

File integrity monitoring (Tripwire-style)

Detects tampering with binaries and critical configs by content hash, so it catches a malicious swap even when the timestamp is preserved (the weakness of mtime-based checks). Two engines, split by who owns the file:

  • Package verificationpacman -Qkk checks package-owned binaries against the distro's own signed checksums. No baseline to maintain, and it's implicitly current because the package DB updates on every pacman -Syu. A trojaned /usr/bin/ssh surfaces as a checksum mismatch (CRITICAL, sid 100020).
  • Hash baseline — a SHA-256 baseline of the files pacman doesn't track (/usr/local, /etc configs, systemd units, SSH keys). A pacman PostTransaction hook runs fim-update after every upgrade, so legitimate package changes never alert — no manual tripwire --update ritual.
enodia-sentinel fim-baseline          # establish the baseline
enodia-sentinel fim-check             # report changes vs baseline
enodia-sentinel fim-check --packages  # also verify package-owned files

The baseline is only refreshed by fim-update / the pacman hook — a flagged change stays flagged until you acknowledge it, exactly like Tripwire. The daemon runs the scan on a slow background cadence (fim_scan_interval) and routes any change into the normal alert/snapshot/push pipeline (fim_modified sid 100017, fim_added 100018, fim_removed 100019).

False positives & triage

A host IDS that cries wolf gets ignored, so Sentinel ships explicit tooling to separate benign noise from real findings — built on provenance: a binary shipped by your package manager (pacman/dpkg/rpm) is overwhelmingly likely to be legitimate (the same idea behind OSSEC's rootcheck and AIDE).

enodia-sentinel triage     # classify captured alerts, suggest allowlist entries
12 distinct detections — 11 likely false-positive, 1 to review.

[FP  ] new_listener   x99  listener binary is package-owned (qbittorrent)
[FP  ] new_listener   x1   loopback-only listener (not externally reachable)
[REVIEW] new_listener x1   unrecognized listener *:1740 (?)
...
To suppress the false positives, add to your config:
  # listener_allow_comms += "qbittorrent"

Triage is deliberately conservative: reverse_shell, egress, and the eBPF exec rules are always flagged review (provenance can't clear a network shell), and any listener it can't attribute to a process is reviewed rather than cleared. Suppression is never automatic — you choose what to allowlist.

Knobs to quiet known-good activity:

Config Effect
listener_allow_comms never alert on listeners owned by these apps
listener_allow_ports never alert on these ports
suppress_package_owned_listeners drop new_listener when the binary is package-owned (best single knob for a desktop/seedbox)
egress_allow_cidrs trusted public ranges for the egress detector
suid_hot_dirs / exec_rules_file tune SUID criticality / add custom exec rules

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.

Enabling the eBPF monitor

The event layer needs python-bpfcc and privileges the hardened unit deliberately withholds (bcc JIT-compiles its programs, so it needs write+exec memory and CAP_BPF/CAP_PERFMON/CAP_SYS_ADMIN). Under the default unit the monitor simply fails closed and the daemon runs poll-only. To turn it on:

sudo pacman -S python-bpfcc
sudo install -Dm644 systemd/enodia-sentinel-ebpf.conf \
    /etc/systemd/system/enodia-sentinel.service.d/ebpf.conf
sudo systemctl daemon-reload && sudo systemctl restart enodia-sentinel
# confirm:  grep 'eBPF exec monitor' /var/log/enodia-sentinel/events.log

The drop-in relaxes MemoryDenyWriteExecute and widens the capability set — a conscious tradeoff documented in the file itself.

Roadmap

  1. bpftrace tracepoints (optional)capture_execve_bpftrace = true adds a live execve trace to each snapshot.
  2. Event-driven execve detection (done) — a real eBPF probe via bcc, feeding a Snort-style rule engine, so a short-lived process can't slip between sweeps.
  3. More event sourcestcp_connect (event-driven egress) and security_bprm_check (LSM) probes, plus per-process lineage tracking.
  4. A libbpf + CO-RE agent (Go or Rust) — the production EDR core: ring-buffer event streaming, tamper resistance, no runtime compiler.

The polling daemon isn't throwaway — it's the oracle: every signature is a test case the event layer must reproduce, and sentinel-redteam is the shared regression suite for both.

Project status

v0.5 — adds file integrity monitoring (SHA-256 baseline + pacman -Qkk verification, auto-refreshed by a pacman hook) and false-positive triage via package-ownership provenance.

v0.4 — adds a read-only web dashboard (stdlib server, Tailscale-bound, token-auth) and phone push (ntfy / Pushover / webhook), both zero-dependency.

v0.3 — adds the event-driven eBPF layer: a real bcc execve probe feeding a Snort-style declarative rule engine (4 default rules), stable signature IDs + classtypes on every detection, fail-safe degradation to poll-only, and an opt-in hardening drop-in. Inspired by Snort (rule engine, SIDs) and OSSEC (HIDS framing; FIM + hidden-process checks are next).

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.