# 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", )