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
132
enodia_sentinel/pkgdb.py
Normal file
132
enodia_sentinel/pkgdb.py
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""Package-database integrity — guard the thing that FIM trusts.
|
||||
|
||||
``pacman -Qkk`` verifies files against the *local package database*. A root
|
||||
attacker can defeat that by editing a binary and rewriting its stored checksum
|
||||
in the DB. So we also watch the DB itself.
|
||||
|
||||
The defense: a legitimate change to ``/var/lib/pacman/local`` only ever happens
|
||||
during a logged pacman transaction. We anchor a fingerprint of the DB, refreshed
|
||||
*only* by the pacman PostTransaction hook, and cross-check ``pacman.log``. If the
|
||||
DB fingerprint changes with **no corresponding transaction**, the checksum
|
||||
database may have been tampered with to mask file modification — which is exactly
|
||||
the attack of "overwriting the hashes."
|
||||
|
||||
This is layer 1 (out-of-band detection). It does not stop an attacker who also
|
||||
runs `fim-update` or forges a transaction; for that you want the anchor stored
|
||||
off-box and verification against signed packages (see README hardening notes).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
from .alert import Alert, Severity
|
||||
from .config import Config
|
||||
|
||||
DB_DIR = "/var/lib/pacman/local"
|
||||
PACMAN_LOG = "/var/log/pacman.log"
|
||||
SID_PKGDB = 100021
|
||||
_SLOP = 120 # seconds of clock slop allowed between a transaction and the anchor
|
||||
|
||||
_TS_RE = re.compile(r"^\[([0-9T:+\-]+)\]")
|
||||
|
||||
|
||||
def db_fingerprint(db_dir: str = DB_DIR) -> str | None:
|
||||
"""Deterministic SHA-256 over every file in the local package DB."""
|
||||
h = hashlib.sha256()
|
||||
try:
|
||||
files = []
|
||||
for dp, _dn, fn in os.walk(db_dir):
|
||||
files.extend(os.path.join(dp, f) for f in fn)
|
||||
for p in sorted(files):
|
||||
h.update(p.encode("utf-8", "replace") + b"\0")
|
||||
with open(p, "rb") as fh:
|
||||
while chunk := fh.read(1 << 16):
|
||||
h.update(chunk)
|
||||
except OSError:
|
||||
return None
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def last_transaction_time(log: str = PACMAN_LOG) -> float | None:
|
||||
"""Epoch of the most recent pacman transaction, from the log."""
|
||||
ts = None
|
||||
try:
|
||||
with open(log, encoding="utf-8", errors="replace") as fh:
|
||||
for line in fh:
|
||||
if "transaction completed" in line or "transaction started" in line:
|
||||
m = _TS_RE.match(line)
|
||||
if m:
|
||||
ts = m.group(1)
|
||||
except OSError:
|
||||
return None
|
||||
if not ts:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(ts).timestamp()
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _anchor_path(cfg: Config):
|
||||
return cfg.log_dir / "pkgdb-anchor.json"
|
||||
|
||||
|
||||
def save_anchor(cfg: Config, fingerprint: str | None = None) -> None:
|
||||
fp = fingerprint or db_fingerprint()
|
||||
if fp is None:
|
||||
return
|
||||
cfg.log_dir.mkdir(parents=True, exist_ok=True)
|
||||
_anchor_path(cfg).write_text(json.dumps({"fingerprint": fp, "time": _now()}))
|
||||
|
||||
|
||||
def load_anchor(cfg: Config) -> dict | None:
|
||||
try:
|
||||
return json.loads(_anchor_path(cfg).read_text())
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _now() -> float:
|
||||
import time
|
||||
return time.time()
|
||||
|
||||
|
||||
def check(cfg: Config,
|
||||
_fingerprint=db_fingerprint,
|
||||
_last_tx=last_transaction_time) -> Alert | None:
|
||||
"""Return a tamper Alert if the DB changed outside a logged transaction.
|
||||
|
||||
Injectable fingerprint / last-transaction functions for testing.
|
||||
"""
|
||||
cur = _fingerprint()
|
||||
if cur is None:
|
||||
return None
|
||||
anchor = load_anchor(cfg)
|
||||
if anchor is None:
|
||||
save_anchor(cfg, cur) # first run establishes the anchor
|
||||
return None
|
||||
if cur == anchor.get("fingerprint"):
|
||||
return None # unchanged — fine
|
||||
|
||||
# The DB changed. Legitimate only if a pacman transaction occurred after the
|
||||
# anchor was taken (the hook normally refreshes the anchor in that case).
|
||||
last_tx = _last_tx()
|
||||
if last_tx is not None and last_tx >= anchor.get("time", 0) - _SLOP:
|
||||
save_anchor(cfg, cur) # legitimate upgrade; re-anchor
|
||||
return None
|
||||
|
||||
return Alert(
|
||||
severity=Severity.CRITICAL,
|
||||
signature="pkgdb_tamper",
|
||||
key="pkgdb:tamper",
|
||||
detail=("pacman local DB changed with no corresponding transaction — "
|
||||
"the package checksum database may have been altered to mask "
|
||||
"file tampering"),
|
||||
sid=SID_PKGDB,
|
||||
classtype="anti-tamper",
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue