enodia-sentinal/tests/test_tamper.py
Luna 34bc09041a Add tamper-evidence: package-DB integrity, self-integrity, dead-man's switch
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>
2026-05-31 22:27:50 -07:00

98 lines
3.8 KiB
Python

# SPDX-License-Identifier: GPL-3.0-or-later
"""Tests for package-DB tamper detection and the dead-man's-switch watchdog."""
import tempfile
import time
import unittest
from pathlib import Path
from enodia_sentinel import pkgdb, selfprotect
from enodia_sentinel.alert import Severity
from enodia_sentinel.config import Config
def cfg_with_dir(tmp: Path) -> Config:
c = Config()
c.log_dir = tmp
return c
class TestPkgdbFingerprint(unittest.TestCase):
def test_fingerprint_changes_with_content(self):
with tempfile.TemporaryDirectory() as d:
db = Path(d)
(db / "pkgA").mkdir()
(db / "pkgA" / "desc").write_text("sha256 = AAAA")
f1 = pkgdb.db_fingerprint(str(db))
self.assertIsNotNone(f1)
(db / "pkgA" / "desc").write_text("sha256 = BBBB") # attacker rewrites hash
self.assertNotEqual(f1, pkgdb.db_fingerprint(str(db)))
class TestPkgdbCheck(unittest.TestCase):
def setUp(self):
self.d = tempfile.TemporaryDirectory()
self.cfg = cfg_with_dir(Path(self.d.name))
def tearDown(self):
self.d.cleanup()
def test_first_run_establishes_anchor(self):
v = pkgdb.check(self.cfg, _fingerprint=lambda: "FP1", _last_tx=lambda: None)
self.assertIsNone(v)
self.assertEqual(pkgdb.load_anchor(self.cfg)["fingerprint"], "FP1")
def test_unchanged_is_silent(self):
pkgdb.check(self.cfg, _fingerprint=lambda: "FP1", _last_tx=lambda: None)
v = pkgdb.check(self.cfg, _fingerprint=lambda: "FP1", _last_tx=lambda: None)
self.assertIsNone(v)
def test_change_with_no_transaction_is_tamper(self):
pkgdb.check(self.cfg, _fingerprint=lambda: "FP1", _last_tx=lambda: None)
v = pkgdb.check(self.cfg, _fingerprint=lambda: "FP2", _last_tx=lambda: None)
self.assertIsNotNone(v)
self.assertEqual(v.severity, Severity.CRITICAL)
self.assertEqual(v.sid, pkgdb.SID_PKGDB)
def test_change_with_recent_transaction_is_legit(self):
pkgdb.check(self.cfg, _fingerprint=lambda: "FP1", _last_tx=lambda: None)
future = time.time() + 10 # a transaction occurred after the anchor
v = pkgdb.check(self.cfg, _fingerprint=lambda: "FP2", _last_tx=lambda: future)
self.assertIsNone(v) # re-anchored, no alert
self.assertEqual(pkgdb.load_anchor(self.cfg)["fingerprint"], "FP2")
class TestPacmanLogParse(unittest.TestCase):
def test_last_transaction_time(self):
with tempfile.NamedTemporaryFile("w", suffix=".log", delete=False) as f:
f.write("[2026-05-30T10:00:00-0700] [ALPM] transaction completed\n")
f.write("[2026-05-31T12:34:56-0700] [ALPM] transaction completed\n")
f.write("[2026-05-31T12:35:00-0700] [ALPM] upgraded foo (1 -> 2)\n")
path = f.name
ts = pkgdb.last_transaction_time(path)
self.assertIsNotNone(ts)
Path(path).unlink()
class TestHeartbeatWatchdog(unittest.TestCase):
def test_heartbeat_roundtrip(self):
with tempfile.TemporaryDirectory() as d:
cfg = cfg_with_dir(Path(d))
selfprotect.write_heartbeat(cfg)
self.assertLess(selfprotect.heartbeat_age(cfg), 5)
def test_watchdog_verdicts(self):
ok, _ = selfprotect.watchdog_verdict({"running": True, "heartbeat_age": 5}, 120)
self.assertTrue(ok)
bad, msg = selfprotect.watchdog_verdict(None, 120)
self.assertFalse(bad)
self.assertIn("UNREACHABLE", msg)
bad2, _ = selfprotect.watchdog_verdict({"running": False}, 120)
self.assertFalse(bad2)
bad3, msg3 = selfprotect.watchdog_verdict(
{"running": True, "heartbeat_age": 9999}, 120)
self.assertFalse(bad3)
self.assertIn("STALE", msg3)
if __name__ == "__main__":
unittest.main()