Add file integrity monitoring (Tripwire-style), auto-refreshed via pacman hook

Detects binary/config tampering by content hash — catching a malicious swap
even when mtime is preserved (the gap in mtime-based persistence checks). Two
engines split by file ownership:

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-05-31 22:18:42 -07:00
parent 07f5261d59
commit 5d577b624f
12 changed files with 467 additions and 3 deletions

View file

@ -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"

View file

@ -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

View file

@ -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":

View file

@ -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:

181
enodia_sentinel/fim.py Normal file
View file

@ -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",
)