test_release_script_generates_checksummed_artifacts hard-coded
`dirty == 1`, which only held while the worktree happened to be dirty. The
script derives `dirty` from actual git state and RELEASE_ALLOW_DIRTY is a
permission flag, not a forced value — so the test failed the moment the tree
was clean (all work committed). Compute the expected dirty flag the same way
the script does so the test is deterministic on both clean and dirty trees.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two gaps in the baseline-reconciliation feature:
- The dashboard claimed to summarise acknowledged drift, but renderIntegrity()
never rendered it. Add a `recon` chip to the integrity summary and a
Reconciliation state-card (accepted/ok/stale counts plus stale items).
- A read-only GET /api/integrity could write the store: list() flushed a
TTL-lapsed ok->stale transition to disk, violating the read-only dashboard
invariant. Add list(persist=False) and use it from the web summary path so
staleness is evaluated in memory without touching the file.
Covered by a new test asserting integrity_report() never rewrites the store.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implements the v0.9 roadmap item from the approved design spec. Operators
accept a specific FIM/package/listener/SUID drift item with a mandatory
reason; the ack suppresses that one alert only while the live state still
matches the recorded fingerprint. Content kinds (fim/pkgfile) re-alert on
further change; identity kinds (listener/suid) retire on TTL or revoke.
- reconcile.py: ReconcileStore (mtime-cached, fails closed on missing/corrupt
store), fingerprint builders, and the filter_alerts chokepoint.
- CLI: baseline accept/revoke/list with --reason/--expires/--force/--stale/--json.
- Wired at the daemon sweep + eBPF chokepoint, fim-check, and /api/integrity.
- RECONCILE_V1 schema, config knob, COMMAND_REFERENCE/SCHEMAS/OPERATIONS/ROADMAP
docs, and reconcile unit/CLI/integration tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Covers the v0.9 roadmap item: accept legitimate FIM/package/listener/SUID
drift with a mandatory reason and audited fingerprint-bound ack store.
Approach A selected: ReconcileStore with re-alert-on-fingerprint-change,
optional TTL, and stale tracking. Filter applied at daemon chokepoint,
CLI fim check, and dashboard API.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Close the remaining v0.8 posture bullet: flag enabled systemd units with
group/world-writable unit files (systemd_unit_writable, sid 100050) and
units whose ExecStart binary runs from a writable/unsafe path such as
/tmp, /dev/shm, /var/tmp, /home, or /run/user (systemd_exec_unsafe_path,
sid 100051).
- posture.systemd_findings/parse_systemctl_show/read_systemd_units, wired
into posture.run() behind the new posture_systemd config knob.
- read_systemd_units shells out to systemctl with timeouts and fails closed
on non-systemd hosts; the dashboard /api/posture surfaces findings for free.
- Unit tests for the parser and evaluator; TestRunIntegration patches the
new reader so run() never touches live systemctl.
- Docs: COMMAND_REFERENCE posture list, ROADMAP v0.8 bullet, RUNBOOKS
Runbook 2 triggers, config TOML knob.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Surface integrity and watchdog state in the read-only dashboard via a new
/api/integrity endpoint and integrity_report(): watchdog/heartbeat
verdict, FIM baseline and package-DB anchor freshness, pacman keyring and
SigLevel posture, and Sentinel's own self-integrity footprint. The
endpoint summarizes existing anchors and heartbeats only — it runs no live
FIM or package verification from the request path, keeping the dashboard
read-only.
Add the enodia.integrity.v1 schema (schemas.py + docs/SCHEMAS.md) with a
contract test, a new "Integrity" dashboard tab and summary metric, and
web tests for the report shape, schema contract, endpoint, and console
wiring. Docs updated; completes the v1.0 "dashboard to local console"
roadmap item.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Collapse related alerts into one incident, process-lineage first with a
time-window fallback. Each alert batch's flagged PIDs are walked up the
/proc PPid chain into a lineage set (excluding pid 0/1 so everything
doesn't correlate through init); batches whose lineage sets intersect —
sharing a process or a common ancestor like the web server or SSH
session — join the same incident. PID-less batches (FIM drift, package
tamper, hidden modules) fall back to the most recently active open
incident within incident_window.
snapshot.capture now computes lineage and records each snapshot into a
JSON incident index (incidents.json), writing incident_id into both the
report JSON (report level, so the per-alert schema is untouched) and the
text header. New commands:
incident list incidents newest-first, with signatures
incident show <id> summary + time-ordered snapshot timeline
incident export <id> JSON bundle: record + inlined snapshots
The lineage/assign cores are pure functions; record() serializes the
index under a lock (capture runs from sweep + eBPF threads) and is
best-effort so an index problem never loses a snapshot. 17 new tests
(lineage, assign, record, and an end-to-end capture→group→CLI path).
Config: incident_tracking / incident_window / incident_lineage_depth.
Docs + sample config updated; closes the last v0.8 roadmap item.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Red-team harness: every detector now has a named drill with an expected
signature/sid, shown by `sentinel-redteam --list`. Add the missing
`egress` drill (the one detector without one) — explicit-only, since it
is the sole drill that emits real outbound traffic. After firing, the
harness runs `enodia-sentinel check` and reports PASS/MISS per poll
detection, turning it into a shared regression check.
Add docs/RUNBOOKS.md: confirm/preserve/contain/recover playbooks for
reverse shells & egress, persistence, trojaned binaries / package
tampering, hidden listeners & rootkits, and sensor tampering — each tied
to the real signatures, sids, and commands. Linked from README and the
OPERATIONS alert workflow.
Both close the remaining smaller v0.8 roadmap items.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>