Add `enodia-sentinel status [--json]` — a local health and alert
summary: daemon running state, heartbeat freshness, per-severity alert
counts, last alert time, and eBPF state. Reuses web.daemon_status (the
same data behind /api/status), so the CLI and dashboard never diverge.
Exit code reflects health (0 = running and fresh, 1 = down or stale), so
it doubles as a cron/monitoring probe. Three CLI tests drive it against a
temp log_dir; no daemon or root needed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add `enodia-sentinel posture check` — a config-hygiene audit that finds
the conditions making a host easy to attack, complementing the live
detectors. Checks: SSH root/password/empty-password login (Include-aware,
first-wins sshd resolution), passwordless and group/world-writable
sudoers, world-writable or non-root PATH directories, loose permissions
on sensitive files (/etc/shadow, /etc/passwd, ...), and downgraded
package-signature policy (reusing pkgdb.siglevel_alert).
Findings reuse the Alert type, so they serialize through the existing
JSON/triage pipeline (`posture check --json`). Each check is a pure
evaluator over injected content/stat facts plus a thin system reader, so
the 18 new tests need no root or live /etc. Posture is command-driven and
never runs in the daemon loop, per the v0.8 exit criterion.
SIDs 100040-100047 (classtype host-posture). Sample config and docs
(COMMAND_REFERENCE, OPERATIONS, ROADMAP, SPECIFICATION) updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add docs/ (SPECIFICATION, ROADMAP, OPERATIONS, COMMAND_REFERENCE,
THREAT_MODEL) describing the current v0.7 agent and the phased path from
local HIDS to a host security platform. Reframe README, package metadata,
and CLI/module descriptions from "intrusion-detection daemon" to "Linux
host security platform", and surface the v0.7 signed-package verify
(pkgdb_pkgverify*) and anti-rootkit (rootcheck_*) knobs in the sample
config. Also wrap the pacman.conf read in pkgdb.siglevel_alert in a
context manager to avoid a leaked file handle.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Closes the tamper-evidence loop with two trust anchors the attacker can't
forge from userland:
- pkgdb Layer 2: verify on-disk files against the .MTREE in the *signed*
cache package, surviving a rewritten local checksum DB. Rotating-sample
cadence keeps it affordable; flags pkg_signature_mismatch (sid 100027)
and SigLevel downgrades (sid 100026). Fixes parse_mtree, which required
type=file on every line and so matched nothing on real pacman MTREEs
(which use a /set type=file default with bare file entries).
- rootcheck: anti-rootkit cross-view — hidden processes, modules, ports,
and promiscuous interfaces, each caught by diffing two views of the
same state (sids 100022-100025).
Wired both through config, the daemon (off-loop slow cadence), and CLI
(pkgdb-verify, rootcheck). 14 new tests (95 total). Docs + version bump.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Answers two hard questions: "what guards the hashes FIM trusts?" and "how do we
make the sensor itself hard to silently disable?" — within the honest limit that
a root attacker who shares your privileges can't be fully stopped on-box, only
made loud.
- pkgdb.py: guards the package DB that `pacman -Qkk` trusts. Anchors a
fingerprint of /var/lib/pacman/local (refreshed ONLY by the pacman hook) and
cross-checks pacman.log; a DB change with no logged transaction is flagged
pkgdb_tamper (CRITICAL, sid 100021) — catches an attacker rewriting a stored
checksum to mask a modified binary. Verified end-to-end against a simulated
hash-overwrite.
- selfprotect.py: Sentinel's own binaries/config/units/hook are always in the
FIM watch set (self-integrity); a heartbeat is written each loop; an external
`watchdog` command polls a remote dashboard and pushes if the sensor goes
silent or unreachable — silence becomes the alarm.
- daemon: heartbeat + slow-cadence pkgdb check; DB anchor re-anchored in
build_fim_baseline so the pacman hook refreshes it after each transaction
- web: /api/status now reports heartbeat_age/stale; dashboard shows it
- cli: pkgdb-check, watchdog (--url/--token/--max-age, bypasses min-severity)
- config: pkgdb_verify/_interval, heartbeat_max_age
- README: threat model (trust-anchor problem) + hardening layers (immutability,
signed-package + external anchor); reframes "anti-rootkit" as tamper-evidence
- tests: +8 (DB fingerprint, anchor/transaction logic, pacman.log parse,
heartbeat + watchdog verdicts). 80/80 pass
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
A noisy IDS gets ignored. Adds explicit FP-handling built on provenance — a
binary owned by the package manager is almost certainly legitimate (the OSSEC
rootcheck / AIDE principle).
- provenance.py: package_owner()/is_package_owned() via pacman/dpkg/rpm, cached;
framed as confidence-raising, never proof-of-safety
- triage.py: triage_alert() labels each detection likely-FP vs review with a
reason (package-owned binary, loopback-only listener, allowlisted comm, …);
reverse_shell/egress/exec rules are ALWAYS review (provenance can't clear a
network shell); unattributable listeners are reviewed, not cleared
- cli: `enodia-sentinel triage` summarizes captured alerts and suggests
allowlist entries
- new_listener: listener_allow_comms + optional suppress_package_owned_listeners
gate (the best single knob for a desktop/seedbox running P2P apps)
- tests: +10 (provenance injected); README + config documented. 65/65 pass
Verified on a live seedbox: 12 detections, 11 auto-cleared as FP (qbittorrent /
nicotine / kdeconnectd / the dashboard itself), 1 correctly held for review.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both zero-dependency (stdlib http.server + urllib), consistent with the
project's no-dependency-tree stance for a security daemon.
Web dashboard (enodia_sentinel/web.py + static/dashboard.html):
- read-only JSON API over the log dir: /api/status, /api/alerts,
/api/alerts/<id>, /api/events
- bearer-token auth (constant-time; header or ?token=), required on non-
loopback binds, auto-generated + persisted (0600) when unset
- binds the host's Tailscale IP by default (auto-detected), reachable from the
tailnet but not the LAN/internet
- self-contained dark SPA: severity cards, live alert list, full snapshot
viewer; 10s auto-refresh
- path-traversal-safe alert lookup; `enodia-sentinel web` subcommand; daemon
now writes a pidfile so the dashboard can show live status
- hardened enodia-sentinel-web.service (read-only, no caps)
Phone push (enodia_sentinel/notify/):
- pluggable backends — ntfy, Pushover, generic webhook — each separating a pure
build() (unit-tested, no network) from send()
- a backend turns on when its config keys are set; pushes gated by
notify_min_severity; severity → per-service priority/tags
- fired from snapshot.capture on worker threads, errors swallowed
- desktop notify-send retained
Tests: +16 (9 web incl. a real-server 401/200 auth test, 7 notify request-build
cases). 55/55 pass. Live end-to-end verified: daemon → alert → API → page.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Closes polling's blind spot (processes that exit between sweeps) with a real
eBPF probe and a declarative, data-driven detection engine — inspired by Snort
(rule language, signature IDs) and OSSEC (host-IDS framing).
New events/ subpackage:
- bcc_source.py : eBPF C tracing execve (filename, argv[1..2], ppid, uid,
parent comm) over a perf buffer, loaded via bcc; lazy import
+ available()/try-except so it fails closed to poll-only when
bcc/root/BTF are absent — a broken probe never downs the daemon
- exec_event.py : the ExecEvent type
- rules.py : ExecRule (sid/msg/severity/classtype + path/exec/parent/argv
conditions) and ExecRuleEngine; 4 shipped rules (fileless exec
100001, reverse-shell argv 100002, web/DB→shell RCE 100003,
curl|sh 100004); operators add more via exec_rules_file TOML
- monitor.py : runs the source on a thread, routes events through the engine
Integration:
- daemon starts the monitor, shares a lock-guarded cooldown with the sweep
loop, and feeds event alerts into the same snapshot pipeline
- Alert gains Snort-style sid + classtype; retrofitted onto all 7 poll
detectors; snapshots and JSON now carry them
- config: ebpf_exec_monitor (default on, degrades), exec_rules_file
- systemd: opt-in ebpf.conf drop-in (relaxes MemoryDenyWriteExecute + widens
caps for bcc's JIT) so the base unit stays hardened for poll-only
- sentinel-redteam: ebpf_exec drill (short-lived /tmp exec + /dev/tcp argv the
poller can't see); footer now uses the Python CLI
Tests: +14 cases for the rule engine (each default rule match/non-match, rule
validation, parent-exclude). 39/39 pass. Graceful non-root degradation verified.
NOTE: the eBPF C follows bcc's execsnoop pattern but could not be run here
(BPF needs root); it wants a root smoke-test on a real host.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
Poll-based host intrusion-detection daemon modeled on freeze-watcher's
sample→trigger→snapshot→classify loop, with security signatures instead
of performance ones:
- reverse_shell (interpreter with a network socket on stdio)
- ld_preload (/etc/ld.so.preload or LD_PRELOAD in writable dirs)
- deleted_exe (process running from a deleted/memfd binary)
- new_listener (listening port absent from startup baseline)
- new_suid (new SUID/SGID binary; critical in writable dirs)
- persistence (cron/systemd/authorized_keys/rc-file changes)
- suspicious_egress (interpreter holding an outbound public connection)
Each capture writes a forensic snapshot with per-signature incident-
response guidance. Includes a safe, self-cleaning red-team harness
(sentinel-redteam), hardened systemd unit, config, Makefile, and Arch
packaging.
Tested on Arch: detectors fire on drills, no false positives on a clean
sweep, sweep cost optimized from ~15s to ~1s via batched syscalls and a
gated filesystem-wide SUID scan.
This bash implementation is retained as the regression oracle for the
forthcoming Python rewrite.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>