From 5d577b624f5891df53aa8aa862ee94d325fe871b Mon Sep 17 00:00:00 2001 From: Luna Date: Sun, 31 May 2026 22:18:42 -0700 Subject: [PATCH] Add file integrity monitoring (Tripwire-style), auto-refreshed via pacman hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Makefile | 2 + README.md | 34 ++++++ config/enodia-sentinel.toml | 12 ++ enodia_sentinel/__init__.py | 2 +- enodia_sentinel/cli.py | 36 ++++++ enodia_sentinel/config.py | 18 +++ enodia_sentinel/daemon.py | 60 ++++++++++ enodia_sentinel/fim.py | 181 +++++++++++++++++++++++++++++ packaging/PKGBUILD | 3 +- packaging/enodia-sentinel-fim.hook | 18 +++ pyproject.toml | 2 +- tests/test_fim.py | 102 ++++++++++++++++ 12 files changed, 467 insertions(+), 3 deletions(-) create mode 100644 enodia_sentinel/fim.py create mode 100644 packaging/enodia-sentinel-fim.hook create mode 100644 tests/test_fim.py diff --git a/Makefile b/Makefile index 7b9a252..df779e2 100644 --- a/Makefile +++ b/Makefile @@ -16,6 +16,7 @@ install: install -Dm755 src/sentinel-redteam $(DESTDIR)$(BINDIR)/sentinel-redteam install -Dm644 systemd/enodia-sentinel.service $(DESTDIR)$(SYSTEMDDIR)/enodia-sentinel.service install -Dm644 systemd/enodia-sentinel-web.service $(DESTDIR)$(SYSTEMDDIR)/enodia-sentinel-web.service + install -Dm644 packaging/enodia-sentinel-fim.hook $(DESTDIR)/etc/pacman.d/hooks/enodia-sentinel-fim.hook @if [ ! -e "$(DESTDIR)$(CONFDIR)/enodia-sentinel.toml" ]; then \ install -Dm644 config/enodia-sentinel.toml $(DESTDIR)$(CONFDIR)/enodia-sentinel.toml; \ echo "Installed default config at $(CONFDIR)/enodia-sentinel.toml"; \ @@ -32,6 +33,7 @@ uninstall: rm -f $(DESTDIR)$(BINDIR)/sentinel-redteam rm -f $(DESTDIR)$(SYSTEMDDIR)/enodia-sentinel.service rm -f $(DESTDIR)$(SYSTEMDDIR)/enodia-sentinel-web.service + rm -f $(DESTDIR)/etc/pacman.d/hooks/enodia-sentinel-fim.hook rm -f $(DESTDIR)$(CONFDIR)/enodia-sentinel.toml.new @echo "Uninstalled. Config and logs preserved." diff --git a/README.md b/README.md index cd07047..0df498e 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,9 @@ enodia_sentinel/ ├── 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 @@ -236,6 +239,33 @@ 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 verification** — `pacman -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. + +```bash +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 @@ -319,6 +349,10 @@ 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. diff --git a/config/enodia-sentinel.toml b/config/enodia-sentinel.toml index 103de66..f0095eb 100644 --- a/config/enodia-sentinel.toml +++ b/config/enodia-sentinel.toml @@ -55,6 +55,18 @@ watch_persistence = [ "/root/.ssh/authorized_keys", "/root/.bashrc", "/root/.profile", ] +# --- file integrity monitoring (FIM) ------------------------------------ +# SHA-256 baseline of security-critical files the package manager doesn't track +# (configs, /usr/local, systemd units, keys). A pacman PostTransaction hook runs +# `enodia-sentinel fim-update` after every upgrade, so package changes never +# alert. Package-owned binaries are instead verified against the distro's own +# checksums via `pacman -Qkk` (fim_pkg_verify). +fim_enabled = true +fim_scan_interval = 300 # seconds between hash scans +fim_paths = [] # [] = built-in critical set; or your own list +fim_pkg_verify = false # run `pacman -Qkk` (thorough but slow) +fim_pkg_verify_interval = 21600 # seconds between package verifications + # --- eBPF event layer ---------------------------------------------------- # Event-driven execve monitor: catches short-lived processes the poll loop # misses, matched against the Snort-style rule engine. Requires python-bpfcc diff --git a/enodia_sentinel/__init__.py b/enodia_sentinel/__init__.py index b196008..e8d6ed3 100644 --- a/enodia_sentinel/__init__.py +++ b/enodia_sentinel/__init__.py @@ -6,4 +6,4 @@ captures a forensic snapshot with incident-response guidance whenever a known attack signature appears. The Python re-architecture of the bash v0 prototype. """ -__version__ = "0.4.0" +__version__ = "0.5.0" diff --git a/enodia_sentinel/cli.py b/enodia_sentinel/cli.py index 67966d8..7c5e2a8 100644 --- a/enodia_sentinel/cli.py +++ b/enodia_sentinel/cli.py @@ -63,6 +63,11 @@ def main(argv: list[str] | None = None) -> int: sub.add_parser("list-detectors", help="list available detectors") sub.add_parser("web", help="serve the read-only dashboard") sub.add_parser("triage", help="classify captured alerts as likely-FP vs review") + sub.add_parser("fim-baseline", help="build the file-integrity baseline") + sub.add_parser("fim-update", help="refresh the FIM baseline (run by the pacman hook)") + fc = sub.add_parser("fim-check", help="scan monitored files and report changes") + fc.add_argument("--packages", action="store_true", + help="also verify package-owned files via pacman -Qkk") args = parser.parse_args(argv) cfg = Config.load(args.config) @@ -82,9 +87,40 @@ def main(argv: list[str] | None = None) -> int: return 0 if args.cmd == "triage": return _cmd_triage(cfg) + if args.cmd in ("fim-baseline", "fim-update"): + n = Sentinel(cfg).build_fim_baseline() + print(f"FIM baseline written: {n} files under {cfg.log_dir}") + return 0 + if args.cmd == "fim-check": + return _cmd_fim_check(cfg, args.packages) return _cmd_run(cfg) +def _cmd_fim_check(cfg: Config, packages: bool) -> int: + from . import fim + sentinel = Sentinel(cfg) + sentinel.load_fim_baseline() + current = fim.scan_paths(cfg.fim_path_list()) + d = fim.diff(sentinel.fim_baseline, current) + changed = len(d["added"]) + len(d["removed"]) + len(d["modified"]) + for path, changes in d["modified"]: + print(f"[MODIFIED] {path} ({', '.join(changes)})") + for path in d["added"]: + print(f"[ADDED] {path}") + for path in d["removed"]: + print(f"[REMOVED] {path}") + if not changed: + print("FIM: no changes against baseline.") + if packages: + print("\nVerifying package-owned files (pacman -Qkk, may take a while)…") + hits = fim.pacman_verify() + for pkg, path, reason in hits: + print(f"[PKG] {path} ({pkg}: {reason})") + if not hits: + print("Packages: all verified files match the distro checksums.") + return 1 if changed else 0 + + def _cmd_triage(cfg: Config) -> int: import json from collections import OrderedDict diff --git a/enodia_sentinel/config.py b/enodia_sentinel/config.py index a1db3e2..ff41724 100644 --- a/enodia_sentinel/config.py +++ b/enodia_sentinel/config.py @@ -61,6 +61,13 @@ class Config: ) watch_persistence: tuple[str, ...] = _DEFAULT_WATCH + # file integrity monitoring (FIM) + fim_enabled: bool = True + fim_paths: tuple[str, ...] = () # populated from fim.DEFAULT_FIM_PATHS + fim_scan_interval: int = 300 # seconds between hash scans + fim_pkg_verify: bool = False # run `pacman -Qkk` (slow; opt-in) + fim_pkg_verify_interval: int = 21600 # seconds between package verifications + # eBPF on-ramp capture_execve_bpftrace: bool = False # Event-driven eBPF execve monitor (catches short-lived processes the poll @@ -107,6 +114,17 @@ class Config: def suid_baseline(self) -> Path: return self.log_dir / "suid-baseline.json" + @property + def fim_baseline(self) -> Path: + return self.log_dir / "fim-baseline.json" + + def fim_path_list(self) -> tuple[str, ...]: + """Configured FIM paths, defaulting to the built-in critical set.""" + if self.fim_paths: + return self.fim_paths + from .fim import DEFAULT_FIM_PATHS + return DEFAULT_FIM_PATHS + # ---- loading --------------------------------------------------------- @classmethod def load(cls, path: str | os.PathLike | None = None) -> "Config": diff --git a/enodia_sentinel/daemon.py b/enodia_sentinel/daemon.py index 685af1d..5bbeefb 100644 --- a/enodia_sentinel/daemon.py +++ b/enodia_sentinel/daemon.py @@ -36,6 +36,12 @@ class Sentinel: self._suid_thread: threading.Thread | None = None self._last_suid_scan = 0.0 self._last_suid_baseline_refresh = self.start_time + # FIM: baseline + background scan state + self.fim_baseline: dict = {} + self._fim_thread: threading.Thread | None = None + self._last_fim_scan = 0.0 + self._fim_pkg_thread: threading.Thread | None = None + self._last_fim_pkg = 0.0 self._stop = threading.Event() # -- baselines --------------------------------------------------------- @@ -82,6 +88,57 @@ class Sentinel: self._last_suid_baseline_refresh = time.time() self._save(self.cfg.suid_baseline, sorted(self.suid_baseline)) + # -- FIM (off the loop thread) ----------------------------------------- + def build_fim_baseline(self) -> int: + from .fim import scan_paths + self.fim_baseline = scan_paths(self.cfg.fim_path_list()) + self.cfg.log_dir.mkdir(parents=True, exist_ok=True) + self.cfg.fim_baseline.write_text(json.dumps(self.fim_baseline)) + return len(self.fim_baseline) + + def load_fim_baseline(self) -> None: + try: + self.fim_baseline = json.loads(self.cfg.fim_baseline.read_text()) + except (OSError, ValueError): + self.fim_baseline = {} + if not self.fim_baseline: + self.build_fim_baseline() + + def _maybe_scan_fim(self, now: float) -> None: + if not self.cfg.fim_enabled: + return + if self._fim_thread and self._fim_thread.is_alive(): + return + if (now - self._last_fim_scan) < self.cfg.fim_scan_interval: + return + self._last_fim_scan = now + self._fim_thread = threading.Thread(target=self._scan_fim, daemon=True) + self._fim_thread.start() + + def _scan_fim(self) -> None: + from .fim import diff, diff_alerts, scan_paths + current = scan_paths(self.cfg.fim_path_list()) + # NB: the baseline is NOT updated here — only `fim-update` / the pacman + # hook refreshes it, so a change stays flagged until acknowledged. + for alert in diff_alerts(diff(self.fim_baseline, current)): + self._on_exec_alert(alert) + + def _maybe_pkg_verify(self, now: float) -> None: + if not self.cfg.fim_pkg_verify: + return + if self._fim_pkg_thread and self._fim_pkg_thread.is_alive(): + return + if (now - self._last_fim_pkg) < self.cfg.fim_pkg_verify_interval: + return + self._last_fim_pkg = now + self._fim_pkg_thread = threading.Thread(target=self._pkg_verify, daemon=True) + self._fim_pkg_thread.start() + + def _pkg_verify(self) -> None: + from .fim import pacman_verify_alerts + for alert in pacman_verify_alerts(): + self._on_exec_alert(alert) + # -- one sweep --------------------------------------------------------- def sweep(self, *, force_suid: bool = False) -> list[Alert]: now = time.time() @@ -132,6 +189,7 @@ class Sentinel: with open(self.cfg.events_log, "a") as fh: fh.write(f"{time.strftime('%FT%T%z')} enodia-sentinel started\n") self.build_baselines() + self.load_fim_baseline() snapshot.prune(self.cfg) self._start_exec_monitor() sweeps = 0 @@ -139,6 +197,8 @@ class Sentinel: now = time.time() if (now - self.start_time) >= self.cfg.baseline_grace: self._maybe_scan_suid(now) + self._maybe_scan_fim(now) + self._maybe_pkg_verify(now) alerts = self.sweep() fresh = self.fresh_alerts(alerts, now) if fresh: diff --git a/enodia_sentinel/fim.py b/enodia_sentinel/fim.py new file mode 100644 index 0000000..98bdece --- /dev/null +++ b/enodia_sentinel/fim.py @@ -0,0 +1,181 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +"""File integrity monitoring — the Tripwire/OSSEC-syscheck capability. + +Two engines: + +* **hash baseline** — SHA-256 (plus mode/uid/gid/size) of a curated set of + security-critical files that the package manager does *not* track + (``/usr/local``, ``/etc`` configs, systemd units, SSH keys). We own this + baseline and refresh it on demand (a pacman PostTransaction hook calls + ``fim-update`` after every upgrade, so legitimate package changes never + alert). + +* **package verification** — ``pacman -Qkk`` checks package-owned binaries + against the distro's own signed checksums. No baseline to maintain; it is + implicitly current because the package DB updates on every upgrade. + +Content hashing is the upgrade over mtime checks: it catches a file swapped out +for a malicious copy even when the timestamp is preserved. +""" +from __future__ import annotations + +import hashlib +import os +import stat +import subprocess +from collections.abc import Iterable, Iterator + +from .alert import Alert, Severity + +# SIDs 100017+ are FIM signatures. +SID_MODIFIED = 100017 +SID_ADDED = 100018 +SID_REMOVED = 100019 +SID_PKG = 100020 + +DEFAULT_FIM_PATHS = ( + "/usr/local/bin", "/usr/local/sbin", "/usr/local/lib", + "/etc/systemd/system", "/etc/ld.so.preload", "/etc/ld.so.conf", + "/etc/ld.so.conf.d", "/etc/pam.d", "/etc/ssh/sshd_config", + "/etc/sudoers", "/etc/sudoers.d", "/etc/profile", "/etc/profile.d", + "/etc/bash.bashrc", "/etc/crontab", "/etc/cron.d", "/etc/cron.daily", + "/etc/cron.hourly", "/etc/passwd", "/etc/shadow", "/etc/group", + "/root/.ssh/authorized_keys", "/root/.bashrc", "/root/.profile", +) + + +# --- hashing & scanning --------------------------------------------------- + +def hash_file(path: str, _bufsize: int = 1 << 16) -> str | None: + h = hashlib.sha256() + try: + with open(path, "rb") as fh: + while chunk := fh.read(_bufsize): + h.update(chunk) + except OSError: + return None + return h.hexdigest() + + +def _entry(path: str) -> dict | None: + try: + st = os.lstat(path) + except OSError: + return None + e = {"mode": stat.S_IMODE(st.st_mode), "uid": st.st_uid, + "gid": st.st_gid, "size": st.st_size} + if stat.S_ISLNK(st.st_mode): + try: + e["link"] = os.readlink(path) + except OSError: + e["link"] = "" + elif stat.S_ISREG(st.st_mode): + e["sha256"] = hash_file(path) + else: + return None # skip sockets/devices/fifos + return e + + +def scan_paths(paths: Iterable[str]) -> dict[str, dict]: + """Map path -> integrity entry for every regular file / symlink under paths.""" + out: dict[str, dict] = {} + for root in paths: + if os.path.isfile(root) or os.path.islink(root): + e = _entry(root) + if e: + out[root] = e + elif os.path.isdir(root): + for dirpath, _dirs, files in os.walk(root, onerror=lambda e: None): + for f in files: + p = os.path.join(dirpath, f) + e = _entry(p) + if e: + out[p] = e + return out + + +# --- diffing -------------------------------------------------------------- + +def diff(old: dict[str, dict], new: dict[str, dict]) -> dict[str, list]: + """Return {'added':[...], 'removed':[...], 'modified':[(path, changes)]}.""" + added = sorted(set(new) - set(old)) + removed = sorted(set(old) - set(new)) + modified = [] + for path in sorted(set(old) & set(new)): + o, n = old[path], new[path] + changes = [k for k in ("sha256", "mode", "uid", "gid", "link") + if o.get(k) != n.get(k)] + if changes: + modified.append((path, changes)) + return {"added": added, "removed": removed, "modified": modified} + + +def diff_alerts(d: dict[str, list]) -> Iterator[Alert]: + for path, changes in d["modified"]: + crit = "sha256" in changes or "mode" in changes + yield Alert( + severity=Severity.HIGH if crit else Severity.MEDIUM, + signature="fim_modified", + key=f"fim:mod:{path}", + detail=f"integrity change ({', '.join(changes)}): {path}", + sid=SID_MODIFIED, classtype="integrity-violation", + ) + for path in d["added"]: + yield Alert(Severity.MEDIUM, "fim_added", f"fim:add:{path}", + f"new file in monitored path: {path}", + sid=SID_ADDED, classtype="integrity-violation") + for path in d["removed"]: + yield Alert(Severity.MEDIUM, "fim_removed", f"fim:del:{path}", + f"monitored file removed: {path}", + sid=SID_REMOVED, classtype="integrity-violation") + + +# --- package verification (engine A) -------------------------------------- + +def parse_pacman_verify(output: str) -> list[tuple[str, str, str]]: + """Parse ``pacman -Qkk`` diagnostics into (package, path, reason). + + Only lines reporting an actual content/permission/ownership change are + returned; 'total files' summaries and 'No package owns' lines are ignored. + """ + out: list[tuple[str, str, str]] = [] + for line in output.splitlines(): + line = line.strip() + if "(" not in line or ")" not in line: + continue + reason = line[line.rfind("(") + 1:line.rfind(")")] + rl = reason.lower() + # Content/permission/ownership changes matter; a modification-time-only + # mismatch is benign noise (a `touch` doesn't alter the file). + if not any(k in rl for k in ("checksum", "sha", "size", + "permission", "ownership", "uid", "gid")): + continue + head = line[:line.rfind("(")].strip() + # head looks like "pkgname: /path/to/file" + if ": /" not in head: + continue + pkg, path = head.split(": /", 1) + pkg = pkg.replace("warning:", "").strip() + out.append((pkg, "/" + path.strip(), reason.strip())) + return out + + +def pacman_verify(timeout: int = 600) -> list[tuple[str, str, str]]: + """Run ``pacman -Qkk`` and return altered package-owned files (best-effort).""" + try: + r = subprocess.run(["pacman", "-Qkk"], capture_output=True, + text=True, timeout=timeout) + except (OSError, subprocess.SubprocessError): + return [] + return parse_pacman_verify(r.stdout + "\n" + r.stderr) + + +def pacman_verify_alerts(timeout: int = 600) -> Iterator[Alert]: + for pkg, path, reason in pacman_verify(timeout): + yield Alert( + severity=Severity.CRITICAL, + signature="fim_pkg_modified", + key=f"fim:pkg:{path}", + detail=f"package file altered ({pkg}: {reason}): {path}", + sid=SID_PKG, classtype="integrity-violation", + ) diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD index 1159da2..86cccfb 100644 --- a/packaging/PKGBUILD +++ b/packaging/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: Enodia pkgname=enodia-sentinel -pkgver=0.4.0 +pkgver=0.5.0 pkgrel=1 pkgdesc="Host intrusion-detection daemon — signature-based detection of reverse shells, LD_PRELOAD rootkits, fileless malware, and persistence tampering" arch=('any') @@ -23,6 +23,7 @@ package() { install -Dm755 src/sentinel-redteam "$pkgdir/usr/bin/sentinel-redteam" install -Dm644 systemd/enodia-sentinel.service "$pkgdir/usr/lib/systemd/system/enodia-sentinel.service" install -Dm644 systemd/enodia-sentinel-web.service "$pkgdir/usr/lib/systemd/system/enodia-sentinel-web.service" + install -Dm644 packaging/enodia-sentinel-fim.hook "$pkgdir/usr/share/libalpm/hooks/enodia-sentinel-fim.hook" install -Dm644 config/enodia-sentinel.toml "$pkgdir/etc/enodia-sentinel.toml" install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE" install -dm750 "$pkgdir/var/log/enodia-sentinel" diff --git a/packaging/enodia-sentinel-fim.hook b/packaging/enodia-sentinel-fim.hook new file mode 100644 index 0000000..b998eec --- /dev/null +++ b/packaging/enodia-sentinel-fim.hook @@ -0,0 +1,18 @@ +# Refresh the Enodia Sentinel file-integrity baseline after every package +# transaction, so legitimate package changes never trigger a FIM alert. +# Install as: /etc/pacman.d/hooks/enodia-sentinel-fim.hook +[Trigger] +Operation = Install +Operation = Upgrade +Operation = Remove +Type = Path +Target = usr/* +Target = etc/* +Target = boot/* +Target = opt/* + +[Action] +Description = Refreshing Enodia Sentinel file-integrity baseline... +When = PostTransaction +Exec = /usr/bin/env enodia-sentinel fim-update +Depends = python diff --git a/pyproject.toml b/pyproject.toml index 24a9eaf..bdde5a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "enodia-sentinel" -version = "0.4.0" +version = "0.5.0" description = "Host intrusion-detection daemon — signature-based detection of reverse shells, LD_PRELOAD rootkits, fileless malware, and persistence tampering" readme = "README.md" requires-python = ">=3.11" diff --git a/tests/test_fim.py b/tests/test_fim.py new file mode 100644 index 0000000..1b6632e --- /dev/null +++ b/tests/test_fim.py @@ -0,0 +1,102 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +"""Tests for file integrity monitoring: hashing, diff, alerts, pacman parse.""" +import os +import tempfile +import unittest +from pathlib import Path + +from enodia_sentinel import fim +from enodia_sentinel.alert import Severity + + +class TestHashAndScan(unittest.TestCase): + def setUp(self): + self.d = tempfile.TemporaryDirectory() + self.tmp = Path(self.d.name) + + def tearDown(self): + self.d.cleanup() + + def test_hash_stable_and_sensitive(self): + p = self.tmp / "f" + p.write_text("hello") + h1 = fim.hash_file(str(p)) + self.assertEqual(h1, fim.hash_file(str(p))) + p.write_text("hell0") + self.assertNotEqual(h1, fim.hash_file(str(p))) + + def test_scan_collects_entries(self): + (self.tmp / "a").write_text("x") + (self.tmp / "b").write_text("y") + scan = fim.scan_paths([str(self.tmp)]) + self.assertEqual(len(scan), 2) + self.assertIn("sha256", next(iter(scan.values()))) + + +class TestDiff(unittest.TestCase): + def setUp(self): + self.d = tempfile.TemporaryDirectory() + self.tmp = Path(self.d.name) + (self.tmp / "keep").write_text("keep") + (self.tmp / "gone").write_text("gone") + self.base = fim.scan_paths([str(self.tmp)]) + + def tearDown(self): + self.d.cleanup() + + def test_modified_detected_with_preserved_mtime(self): + p = self.tmp / "keep" + st = p.stat() + p.write_text("TAMPERED") + os.utime(p, (st.st_atime, st.st_mtime)) # preserve mtime + new = fim.scan_paths([str(self.tmp)]) + d = fim.diff(self.base, new) + self.assertIn((str(p), ["sha256"]), [(x, c) for x, c in d["modified"]]) + + def test_added_and_removed(self): + (self.tmp / "new").write_text("n") + (self.tmp / "gone").unlink() + d = fim.diff(self.base, fim.scan_paths([str(self.tmp)])) + self.assertIn(str(self.tmp / "new"), d["added"]) + self.assertIn(str(self.tmp / "gone"), d["removed"]) + + def test_alerts_have_sids(self): + (self.tmp / "x").write_text("x") + d = fim.diff(self.base, fim.scan_paths([str(self.tmp)])) + alerts = list(fim.diff_alerts(d)) + sids = {a.sid for a in alerts} + self.assertIn(fim.SID_ADDED, sids) + + +class TestPacmanParse(unittest.TestCase): + SAMPLE = """\ +warning: qbittorrent: /usr/bin/qbittorrent (SHA256 checksum mismatch) +openssh: /usr/bin/ssh (Permissions mismatch) +coreutils: 105 total files, 1 altered files +warning: No package owns /usr/local/bin/custom +bar: /etc/bar.conf (Modification time mismatch) +""" + + def test_parse(self): + hits = fim.parse_pacman_verify(self.SAMPLE) + paths = {p for _pkg, p, _r in hits} + self.assertIn("/usr/bin/qbittorrent", paths) + self.assertIn("/usr/bin/ssh", paths) + # mtime-only and summary/no-owner lines are ignored + self.assertNotIn("/etc/bar.conf", paths) + self.assertEqual(len(hits), 2) + + def test_pkg_alert_is_critical(self): + # exercise the alert builder via a monkeypatched verify + orig = fim.pacman_verify + fim.pacman_verify = lambda timeout=600: [("openssh", "/usr/bin/ssh", "SHA256 checksum mismatch")] + try: + alerts = list(fim.pacman_verify_alerts()) + finally: + fim.pacman_verify = orig + self.assertEqual(alerts[0].severity, Severity.CRITICAL) + self.assertEqual(alerts[0].sid, fim.SID_PKG) + + +if __name__ == "__main__": + unittest.main()