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>
102 lines
3.4 KiB
Python
102 lines
3.4 KiB
Python
# 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()
|