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>
This commit is contained in:
parent
5d577b624f
commit
34bc09041a
13 changed files with 465 additions and 9 deletions
98
tests/test_tamper.py
Normal file
98
tests/test_tamper.py
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
# 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue