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

102
tests/test_fim.py Normal file
View file

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