Add two optional windowed frontends over the same local state the TUI and web console read: `enodia-sentinel gui` (stdlib tkinter) and `enodia-sentinel gui-qt` (PySide6, new `[qt]` extra). Both share `gui/model.py`, a GUI-free presentation model over `tui.collect_model`, so every formatter is unit-testable without a display server. Only `app.py` and `qt_app.py` import GUI libraries, enforced by import-guard tests mirroring the tray applet's boundary. Tabs cover status, alerts, incidents, posture, integrity, response plans, and the event tail, plus daemon-control buttons via systemctl. Response plans are display-only; the GUIs never execute containment commands. Core runtime dependencies stay empty. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JX86xeoBJVBb16qHkDf53K
37 KiB
Enodia Sentinel
A Linux IDS/IPS/EDR platform: intrusion detection, explicit prevention and containment workflows, endpoint detection and response, file/package integrity, anti-rootkit cross-checks, tamper-evidence, forensic snapshots, push alerts, and a read-only operator console.
The current daemon continuously runs detectors over live system state — processes, sockets, file descriptors, the SUID inventory, sensitive files, the package database, and rootkit-hiding artifacts — then 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, what changed on disk, whether the package database was tampered with, and what to do next."
Product direction
Enodia Sentinel is an IDS, IPS, and EDR system. IDS-style signatures identify suspicious host behavior, IPS workflows turn those findings into explicit containment/prevention plans, and EDR features preserve evidence for triage, investigation, and recovery. Automatic inline blocking is intentionally gated behind reviewed response workflows rather than silent remediation.
| Function | Current capability | Direction |
|---|---|---|
| Detect | Poll detectors + eBPF exec/syscall/host-event rules, first-seen rarity, and incident correlation | More live event sources and correlation rules |
| Prevent | Posture checks + dry-run containment plans | Audited, explicit --apply workflows |
| Verify | FIM, package DB anchor, signed-package checks, local hash-chain records | External anchors and signed evidence |
| Investigate | Text/JSON snapshots, incidents, dashboard, triage | Richer timelines and evidence export |
| Respond | Persisted dry-run response plans | Audited containment and recovery execution |
| Assure | Heartbeat, watchdog, self-integrity, rootcheck | Fleet health and attestation-ready anchors |
Project docs:
- Documentation index — versioned entry point for operator, product, schema, and roadmap documents.
- Specification — product model, current scope, target platform shape, data model, and acceptance criteria.
- Roadmap — phased work from local sensor to incident, response, fleet, and assurance layers.
- Operations guide — install checks, health checks, alert workflow, baseline hygiene, and evidence export.
- Packaging guide — Arch package layout plus Debian/RPM source-install paths.
- JSON schemas — stable v1 contracts for alerts, incidents, status, integrity/hash-chain state, response plans, response audit records, and reconciliation acks.
- IR runbooks — confirm/preserve/contain/recover playbooks per alert: reverse shells, persistence, trojaned binaries, rootkits, tampering.
- Rule reference — generated event-rule documentation: SID, signature, classtype, match fields, expected false positives, and drills.
- Fleet collector design — optional v1.2 collector boundaries for small multi-host deployments.
- Suricata assimilation design — forward-looking plan for host-scoped Suricata model adoption, Go migration, sessions, EVE-style events, and triggered forensic packet capture.
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.
The migration also has an opt-in Go validation sidecar under go-agent/.
It emits the same enodia.event.v1 envelope, has fixture parity for all current
poll detectors plus exec/syscall/typed-host rules and correlation, and uses
embedded cilium/ebpf CO-RE probes for the live event paths. A separate
hardened systemd unit writes JSONL to journald, retains a bounded copy of the
event stream plus baseline state and an atomic heartbeat under
/var/lib/enodia-sentinel-go, and fails open to polling if a probe cannot load.
It also writes bounded enodia.alert.snapshot.v1 JSON/text pairs with truthful
status counts and a private Python-compatible enodia.incident.v1 index using
process-lineage/time-window grouping. It does not replace, install over, or
write state for the Python daemon; Python remains authoritative for richer
forensic enrichment, assurance, notifications, management consumers, and the
remaining subsystem parity.
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) |
input_snooper |
A non-allowlisted process holding /dev/input, /dev/uinput, or HID raw devices open |
Keyloggers have to read keystroke/event devices somewhere; expected compositors/remappers are tunable |
credential_access |
A non-allowlisted process with shadow files, private SSH keys, browser stores, or secret profiles open | Credential harvesters have to open the material they steal; legitimate auth/keyring/browser readers are tunable |
stealth_network |
Raw, SCTP, DCCP, packet, MPTCP, TIPC, XDP, or vsock activity | Covert channels often avoid ordinary TCP/UDP paths; expected network managers/sniffers are tunable |
memory_obfuscation |
Executable anonymous/memfd/deleted mappings, RWX pages, mapped process-hiding libraries, or executable .so mappings from writable runtime paths |
Encrypted/packed payloads still need executable memory after decrypting; hide/injection libraries must be mapped to hook tools |
new_listener |
A listening port absent from the startup baseline | Bind shells/backdoors have to listen somewhere |
first_public_destination / first_listener_port |
New per-process network destinations and listening ports after the initial rarity baseline | C2 and backdoors often introduce network edges that were not normal for that process name |
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.
Incident correlation adds higher-confidence SIDs, such as multi-stage web-RCE
behavior, without hiding the underlying raw alerts.
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 exec 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). The syscall stream adds short-lived memory/anti-analysis
coverage for RWX mprotect/mmap (100060/100061), memfd_create
(100062), sensitive ptrace (100063), seccomp hardening (100064),
cross-process memory access (100065), and memory locking (100066). Typed
host-event fixtures now cover tcp_connect, starting with interpreter egress
to unusual public ports (100067) while live network event capture remains a
v1.1 roadmap item. Operators add custom exec rules via exec_rules_file
without touching code. They can inspect and test the active event rule set with:
enodia-sentinel rules list
enodia-sentinel rules show 100002
enodia-sentinel rules test event.json
enodia-sentinel rules docs
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 / rules / 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 HTTPS read-only management console + JSON API + auth
├── static/ the self-contained dashboard SPA
├── fim.py file integrity monitoring (hash baseline + pacman verify)
├── pkgdb.py package-DB integrity + signed-package verification
├── rootcheck.py anti-rootkit cross-view, module names, kernel/module taint
├── selfprotect.py self-integrity footprint + dead-man's-switch heartbeat
├── 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
├── bcc_syscall_source.py real eBPF syscall telemetry probe
├── exec_event.py the ExecEvent type
├── syscall_event.py the SyscallEvent type
├── rules.py Snort-style ExecRule engine + default rules
├── syscall_rules.py memory/anti-analysis SyscallRule engine
└── monitor.py runs the probe on a thread, routes events → rules
The parallel migration tree is intentionally isolated:
go-agent/
├── cmd/enodia-sentinel-go/ static JSONL validation agent
└── internal/ config · capture · baselines · rules · eBPF · health
Build or install the opt-in sidecar without changing the default service:
make build-go
sudo make install-go
sudo make enable-go
enodia-sentinel-go --health
See Go agent details and the packaging guide for its isolation and privilege contract.
Three 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). - Syscall event — optional eBPF telemetry catches short-lived memory and
anti-analysis actions such as RWX mappings,
memfd_create,ptrace, seccomp, cross-process memory access, and memory locking.
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 the core poll signatures 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.
Terminal TUI and completion
For SSH/tmux sessions, use the stdlib curses dashboard:
enodia-sentinel tui
# or, when installed from pyproject metadata:
enodia-sentinel-tui
The TUI mirrors the read-only management console for terminal operators:
status, recent alerts, incidents, posture findings, integrity/watchdog state,
incident timelines, event-rule metadata, event tail, and dry-run response-plan
previews. It includes plain-language view descriptions and a beginner workflow:
start at 1 Status, then use 3 Incidents, 4 Timeline, 9 Response Plans,
and 6 Integrity. The TUI displays evidence and suggested commands but does not
change the host.
Keys: 1 status, 2 alerts, 3 incidents, 4 timelines, 5 posture,
6 integrity, 7 rules, 8 events, 9 response plans, n/b next/back in
the beginner workflow, r refresh, / filter, : command mode, Tab complete
command names, q quit.
Shell completion is generated without extra dependencies:
enodia-sentinel completion bash > ~/.local/share/bash-completion/completions/enodia-sentinel
enodia-sentinel completion zsh > ~/.zfunc/_enodia-sentinel
Desktop GUI (optional)
A windowed desktop dashboard is available using only the stdlib tkinter
module (your distro may package it separately, e.g. python-tk or
python-tkinter). It is an optional frontend — the daemon and core agent
remain stdlib-only.
enodia-sentinel gui
# or, when installed from pyproject metadata:
enodia-sentinel-gui
The GUI reuses the same local state as the TUI and web console and adds daemon-control buttons: start, stop, restart, quick check, open dashboard, and periodic refresh. Tabs cover status, alerts, incidents, posture findings, integrity/watchdog state, dry-run response plans, and the event tail. Response plans are read-only; the GUI never executes containment commands.
Qt desktop GUI (optional)
A richer Qt6 desktop dashboard is available with the [qt] optional extra
(PySide6). It reuses the same data model as the tkinter GUI but adds a
modern native look, resizable table columns, a response-plan detail pane, and
threaded background refresh.
pip install 'enodia-sentinel[qt]'
enodia-sentinel gui-qt
# or:
enodia-sentinel-gui-qt
Like the tray applet and tkinter GUI, this is an optional frontend and does not change the core agent's zero-dependency design.
Desktop tray (optional)
A lightweight system-tray applet is available for desktop installs. It is an optional frontend — the daemon and core agent remain stdlib-only. Install the extra and launch it:
pip install 'enodia-sentinel[tray]'
enodia-sentinel-tray
The tray menu opens the local dashboard, starts/stops/restarts the daemon via
systemd, shows daemon status and alert count at a glance, and runs an on-demand
quick check (results shown as a desktop notification). Copy
packaging/enodia-sentinel-tray.desktop into ~/.config/autostart/ to launch
it on login.
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 # stdlib unittest suite, no runtime deps
make test-go # stdlib-only Go sidecar unit tests
make parity-go # shared fixture: Go output vs Python oracle
make check-go-service # systemd validation + packaging isolation tests
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 11 | the enabled detector list |
interpreters |
bash sh … | process names treated as shells |
egress_allow_cidrs |
[] | trusted public ranges (won't trip egress) |
input_snooper_allow_comms |
desktop input stack | comm names allowed to hold input devices |
credential_access_allow_comms |
auth/keyring/browsers | comm names allowed to read credential stores |
credential_access_extra_paths |
[] | extra exact paths or directory prefixes treated as secrets |
stealth_network_allow_comms |
network managers/sniffers | comm names allowed to own special protocol sockets |
stealth_network_allow_kinds |
[] | socket families to ignore entirely |
memory_obfuscation_allow_comms |
JIT runtimes/browsers | comm names allowed to own JIT-like executable anonymous mappings |
memory_obfuscation_allow_paths |
[] | mapped path prefixes allowed for suspicious map shapes |
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 |
ebpf_exec_monitor |
true | optional execve event monitor |
ebpf_syscall_monitor |
true | optional memory/anti-analysis syscall monitor |
notify_users |
[] | desktop notify-send targets |
pkgdb_pkgverify |
false | verify on-disk files against signed cache packages |
pkgdb_pkgverify_sample |
40 | packages verified per pass (rotates over time) |
rootcheck_enabled |
true | run the anti-rootkit cross-view sweep |
rootcheck_interval |
300 | seconds between cross-view sweeps |
Web dashboard
A read-only HTTPS management 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.
- TLS is mandatory. If
web_tls_cert/web_tls_keyare unset, Sentinel auto-generates a self-signed certificate underlog_dir. Add a browser exception for now, or point config at a local/private CA certificate. - Bearer-token auth (constant-time check); the token is auto-generated and
saved on first run and printed in the startup line. Open
https://<tailscale-ip>:8787/?token=…. - Read-only management: incidents, timelines, alert inventory, posture
findings, integrity/watchdog state, event rule atlas, event tail, and dry-run
response plans. No commands are executed from the browser. JSON
API at
/api/status,/api/incidents,/api/respond/plan/<id>,/api/posture,/api/integrity,/api/rules,/api/alerts,/api/alerts/<id>,/api/events. - Operator settings: the self-contained dashboard includes a Settings menu with persistent local themes: Console, Paper, Contrast, LGBTQ, Trans, Dracula, Solarized Dark, Solarized Light, and Twilight. Static dashboard tests guard the theme registry and core text/status color contrast across every palette.
CLI-generated response plans are saved for handoff/review under
<log_dir>/response-plans/, with a JSONL trail in
<log_dir>/response-audit.log. Dashboard plan previews stay read-only and do
not create artifacts. Plans also include pacman package-restore guidance
(pacman -Qo, signed-cache verification, trusted reinstall, and FIM re-anchor)
plus read-only recovery checks for package verification, rootcheck, persistence
re-checks, and off-host watchdog visibility.
enodia-sentinel respond apply <plan-ref> --dry-run reloads a saved plan,
prints the reviewed actions, and appends an audit record without executing
commands. Apply execution remains unsupported until the state-changing workflow
is separately designed and tested.
Alert snapshots include a best-effort enrichment block that annotates flagged processes and paths with package ownership, executable hashes, parent chains, remote IP classification, file metadata, recent watched writes, and local integrity/rootcheck anchor status.
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
Tamper-evidence & self-protection
An attacker's first move against a sensor is to disable or blind it — and on a box where they have root, any purely-local defense is ultimately defeatable (they share your privileges). Sentinel doesn't pretend otherwise. The goal is to make tampering evident by anchoring trust where the attacker has less control, and to make going silent loud.
Package-DB integrity. pacman -Qkk trusts the local package database — so a
root attacker can modify /usr/bin/ssh and rewrite its stored checksum, and
verification passes. Sentinel guards the DB itself: a legitimate change only
happens during a logged transaction, so it anchors a fingerprint of
/var/lib/pacman/local (refreshed only by the pacman hook) and cross-checks
pacman.log. A DB change with no corresponding transaction is flagged
pkgdb_tamper (CRITICAL, sid 100021) — directly catching the "overwrite the
hashes" attack.
Self-integrity. Sentinel's own binaries, config, systemd units, and pacman hook are always in the FIM watch set, so tampering with the watchdog trips the watchdog.
Dead-man's switch. The daemon writes a heartbeat every loop; the dashboard exposes its age. Run an external watcher on another host (over Tailscale) and silence becomes the alarm:
# on a SEPARATE machine — alerts you if the sensor or the whole box goes dark
enodia-sentinel watchdog --url https://100.x.x.x:8787 --token <T> --max-age 120 --insecure-tls
Hardening (the layers beyond on-box detection):
- Immutability —
chattr +iSentinel's binaries, config, baselines, and the pacman hook so even root must visibly clear the flag first. - ✅ Cryptographic anchor (done) — verify on-disk files against the signed package in the cache rather than the mutable DB; a maintainer's PGP signature is a root of trust the attacker doesn't hold. See Signed-package verification below.
- External anchor (next) — mirror the DB/FIM fingerprints off-box so a local attacker can't refresh the anchor to cover their tracks.
On "anti-rootkit": the robust version isn't stealth (fragile, and a genuine dual-use rootkit technique) — it's tamper-evidence. Don't hide; be un-hideable. The durable defenses move the trust anchor below or outside the attacker: signatures, external monitors, and ultimately the kernel (the eBPF roadmap) rather than userland the attacker can rewrite.
Signed-package verification (the independent anchor)
Layer 1 (above) catches an out-of-band DB edit. But a root attacker can do the
full job: modify /usr/bin/sshd, rewrite its hash in the local DB, and run
fim-update to re-anchor — defeating both pacman -Qkk and the DB-fingerprint
check, because every reference they're checked against is one they can rewrite.
The one reference they can't forge is the distro's signing key. Packages in
the cache are signed, and each carries a .MTREE manifest of per-file SHA-256
hashes. Layer 2 extracts that manifest from the cached package and compares
the on-disk files to it — a reference independent of the local DB:
enodia-sentinel pkgdb-verify # verify a rotating sample of packages
enodia-sentinel pkgdb-verify --sample 200 # verify more per run
A divergence is pkg_signature_mismatch (CRITICAL, sid 100027) — a trojaned
binary that a rewritten checksum DB would have hidden. It also flags a
SigLevel downgrade in pacman.conf (pacman_siglevel_disabled, CRITICAL,
sid 100026), since disabling signature checking is how an attacker would slip
an unsigned package past the anchor in the first place.
Verifying every package each pass is expensive (untar + hash every file), so it
runs on its own slow cadence (pkgdb_pkgverify_interval) and checks a rotating
sample (pkgdb_pkgverify_sample) per pass — over enough passes the whole
installed set is covered, and any single mismatch fires immediately. It's
off by default (needs the package cache populated); enable with pkgdb_pkgverify = true.
Anti-rootkit (cross-view detection)
A rootkit hides by lying to one view of the system — but the technique that hides it is also how you catch it: ask the same question two different ways and compare the answers. A discrepancy is the hiding artifact.
| Cross-check | Hidden thing it surfaces | sid |
|---|---|---|
kill(pid, 0) for every PID vs the /proc listing |
a process the kernel schedules but /proc omits |
100022 |
/proc process listing vs ps -e |
a process visible to the kernel but hidden from normal process tools | 100038 |
/sys/module (initstate=live) vs /proc/modules |
a loaded LKM hidden from the module list | 100023 |
/proc/net/tcp vs ss |
a listening port a hooked ss won't report |
100024 |
/sys/class/net/*/flags |
an interface in promiscuous mode (a sniffer) | 100025 |
| known LKM rootkit module names | Diamorphine/Reptile/Adore-style module artifacts | 100028 |
/sys/module/*/taint |
out-of-tree/proprietary/unsigned loaded modules needing review | 100029 |
/proc/sys/kernel/tainted |
global kernel taint: forced loads/unloads, unsigned modules, warnings | 100030 |
/proc/net/udp vs ss -u |
a UDP socket a hooked ss won't report |
100031 |
/proc/net/raw vs ss -w |
a raw socket protocol a hooked ss won't report |
100034 |
/proc/net/raw ICMP protocols |
persistent raw ICMP sockets used by knockers/sniffers | 100035 |
/proc/net special families vs ss |
SCTP/DCCP/packet/TIPC/XDP sockets a hooked ss won't report |
100037 |
enodia-sentinel rootcheck # one-shot cross-view scan
The daemon runs it on a slow background cadence (rootcheck_interval) and routes
any finding into the normal alert/snapshot/push pipeline. If you intentionally
run tainted vendor/DKMS modules, add exact names to rootcheck_module_allow.
Known rootkit module names are never suppressed by that allowlist.
Honest limits: this
runs in user space, so it reliably catches userland (LD_PRELOAD) rootkits and
the common /proc-hiding LKMs, but a kernel rootkit that hooks every path
consistently can still evade it. It raises the bar and catches the common cases;
it is not a guarantee against a bespoke ring-0 implant — pair it with the off-box
dead-man's switch. (Lineage: OSSEC's rootcheck / chkrootkit's cross-view idea.)
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 verification —
pacman -Qkkchecks 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 everypacman -Syu. A trojaned/usr/bin/sshsurfaces as a checksum mismatch (CRITICAL,sid 100020). - Hash baseline — a SHA-256 baseline of the files pacman doesn't track
(
/usr/local,/etcconfigs, systemd units, SSH keys). A pacmanPostTransactionhook runsfim-updateafter every upgrade, so legitimate package changes never alert — no manualtripwire --updateritual.
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
An EDR 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 (?)
...
Suggested TOML after review (Sentinel does not edit config):
# Triage suggestion for sid=100013 classtype=backdoor-listener.
# Add only after verifying this 'qbittorrent' activity is expected: package-owned listener.
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. When
triage can suggest tuning, it emits copyable TOML with the alert sid and
classtype in comments so the reason stays auditable.
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
grep 'eBPF syscall 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
The short version: Enodia grows from a local IDS/IPS/EDR agent into a host security platform: incident grouping, posture checks, response planning, richer eBPF telemetry, fleet health, external anchors, and eventually attestation-ready assurance.
See docs/ROADMAP.md for the release tracks and phased plan.
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.8-dev — expands security monitoring for Gonzalo/Peopleswar-style samples:
input_snooper catches direct keyboard/HID event access, credential_access
catches live credential harvesting against shadow files, SSH keys, browser
stores, and secret profiles, stealth_network watches SCTP/DCCP/raw/packet and
other special protocol families, and rootcheck now covers raw ICMP plus hidden
SCTP/DCCP/packet-family sockets. Process-hiding coverage now includes /proc
vs ps cross-view checks and memory-map scanning for mapped hide libraries,
RWX/executable anonymous memory, and executable memfd/deleted mappings.
v0.7 — closes the tamper-evidence loop with the independent anchor:
signed-package verification (compares on-disk files to the .MTREE in the signed
cache package, surviving a rewritten checksum DB) plus a SigLevel-downgrade
check, and an anti-rootkit cross-view layer (hidden processes, modules,
TCP/UDP sockets, promiscuous interfaces, known LKM rootkit names, and
kernel/module taint — each caught by asking independent views and diffing the
answers).
v0.6 — adds tamper-evidence: out-of-band package-DB integrity (catches rewritten checksums), self-integrity of Sentinel's own footprint, and a dead-man's-switch heartbeat with an external watchdog.
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; now HTTPS-only) 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.