# 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 gzip import hashlib import json import os import re import subprocess 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" PACMAN_CONF = "/etc/pacman.conf" KEYRING_DIR = "/etc/pacman.d/gnupg" PKG_CACHE = "/var/cache/pacman/pkg" SID_PKGDB = 100021 SID_SIGLEVEL = 100026 SID_PKGVERIFY = 100027 _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", ) # ========================================================================== # Layer 2 — verification against signed packages (the independent anchor) # ========================================================================== # Layer 1 detects an *out-of-band* DB edit. But an attacker with root can edit # a binary, rewrite its hash in the local DB, AND run `fim-update` to re-anchor. # The only reference they cannot forge is the distro's signing key: packages and # repo databases are signed. So we verify on-disk files against the .MTREE inside # the *cached package* — independent of the local DB the attacker controls. def parse_global_siglevel(conf_text: str) -> str: """Return the [options] SigLevel value from pacman.conf text ('' if unset).""" section = "" value = "" for line in conf_text.splitlines(): line = line.split("#", 1)[0].strip() if line.startswith("[") and line.endswith("]"): section = line[1:-1].strip().lower() elif section == "options" and line.lower().startswith("siglevel"): value = line.split("=", 1)[1].strip() if "=" in line else "" return value def siglevel_insecure(value: str) -> bool: """True if signature verification is effectively disabled.""" tokens = value.replace(",", " ").split() return any(t in ("Never", "TrustAll") for t in tokens) def siglevel_alert(conf_path: str = PACMAN_CONF) -> Alert | None: try: with open(conf_path, encoding="utf-8", errors="replace") as fh: text = fh.read() except OSError: return None value = parse_global_siglevel(text) # pacman's compiled-in default ("Required DatabaseOptional") is secure, so an # unset SigLevel is fine; only an explicit downgrade is suspicious. if value and siglevel_insecure(value): return Alert( severity=Severity.CRITICAL, signature="pacman_siglevel_disabled", key="pkgdb:siglevel", detail=(f"pacman SigLevel is insecure ('{value}') — package signature " "verification is disabled, allowing unsigned/forged packages"), sid=SID_SIGLEVEL, classtype="anti-tamper") return None def keyring_present(keyring_dir: str = KEYRING_DIR) -> bool: return os.path.isfile(os.path.join(keyring_dir, "pubring.gpg")) # --- mtree extraction & comparison ---------------------------------------- _MTREE_SHA = re.compile(r"sha256digest=([0-9a-f]{64})") def parse_mtree(text: str) -> dict[str, str]: """Parse a pacman .MTREE into {absolute_path: sha256} for regular files. pacman emits a `/set type=file …` default and then bare path entries that *omit* `type=` for regular files (only dirs/links carry an explicit type). So a file is "has a sha256digest and isn't explicitly a non-file type" — we track the `/set` default and honor per-line overrides. """ out: dict[str, str] = {} set_type = "file" # pacman's /set default; updated by /set lines for line in text.splitlines(): line = line.strip() if line.startswith("/set "): for tok in line.split()[1:]: if tok.startswith("type="): set_type = tok[len("type="):] continue if not line.startswith("./"): continue # explicit per-line type overrides the /set default line_type = set_type for tok in line.split()[1:]: if tok.startswith("type="): line_type = tok[len("type="):] if line_type != "file": continue m = _MTREE_SHA.search(line) if not m: continue rel = line.split(None, 1)[0][1:] # './usr/bin/x' -> '/usr/bin/x' rel = rel.replace("\\040", " ") # mtree space escape out[rel] = m.group(1) return out def package_mtree(pkg_path: str) -> dict[str, str]: """Extract and parse the .MTREE from a cached package archive (via bsdtar).""" try: raw = subprocess.run(["bsdtar", "-xOqf", pkg_path, ".MTREE"], capture_output=True, timeout=60).stdout text = gzip.decompress(raw).decode("utf-8", "replace") except (OSError, subprocess.SubprocessError, gzip.BadGzipFile, ValueError): return {} return parse_mtree(text) def find_cached_package(name: str, version: str, cache: str = PKG_CACHE) -> str | None: import glob for ext in ("zst", "xz"): hits = glob.glob(os.path.join(cache, f"{name}-{version}-*.pkg.tar.{ext}")) if hits: return sorted(hits)[-1] return None def verify_package(name: str, version: str, root: str = "/", cache: str = PKG_CACHE) -> dict: """Compare a package's on-disk files to the hashes in its signed cache pkg. Returns {'pkg', 'cached', 'checked', 'mismatched':[(path, on_disk, expected)]}. A mismatch means the on-disk file differs from what the distro shipped — independent of the local DB, so it survives a rewritten checksum database. """ from .fim import hash_file result = {"pkg": f"{name}-{version}", "cached": None, "checked": 0, "mismatched": []} pkg = find_cached_package(name, version, cache) if not pkg: return result result["cached"] = pkg for path, expected in package_mtree(pkg).items(): full = os.path.join(root, path.lstrip("/")) if not os.path.isfile(full): continue result["checked"] += 1 actual = hash_file(full) if actual and actual != expected: result["mismatched"].append((path, actual, expected)) return result def installed_packages() -> list[tuple[str, str]]: """(name, version) for every installed package, via `pacman -Q`.""" try: out = subprocess.run(["pacman", "-Q"], capture_output=True, text=True, timeout=30).stdout except (OSError, subprocess.SubprocessError): return [] pkgs = [] for line in out.splitlines(): parts = line.split() if len(parts) == 2: pkgs.append((parts[0], parts[1])) return pkgs # --- the Layer-2 check ---------------------------------------------------- # Verifying every cached package on each pass is expensive (untar + hash every # file), so this runs on its own slow cadence and verifies a rotating *sample* # of packages per pass. Over enough passes the whole installed set is covered, # and any single mismatch fires immediately — an attacker can't know which slice # we'll check next. def _sample(items: list, n: int, offset: int) -> list: """A rotating window of `n` items starting at `offset` (wraps around).""" if n <= 0 or not items: return items n = min(n, len(items)) start = offset % len(items) return [items[(start + i) % len(items)] for i in range(n)] def verify_alerts(cfg: Config, *, _installed=installed_packages, _verify=verify_package, offset: int = 0) -> list[Alert]: """Layer 2: flag config that disables signing, and on-disk files that diverge from the distro's *signed* cache package. A mismatch here survives a rewritten local checksum DB — the reference is the maintainer's signature, not the mutable DB the attacker controls. The cache package itself is only trusted because pacman verified its signature on download (so we also flag SigLevel downgrades that would skip that). """ alerts: list[Alert] = [] sl = siglevel_alert() if sl: alerts.append(sl) pkgs = _installed() sample = _sample(pkgs, cfg.pkgdb_pkgverify_sample, offset) for name, version in sample: res = _verify(name, version) if res["mismatched"]: paths = ", ".join(p for p, _a, _e in res["mismatched"][:8]) alerts.append(Alert( severity=Severity.CRITICAL, signature="pkg_signature_mismatch", key=f"pkgverify:{name}", detail=(f"on-disk files of package '{res['pkg']}' differ from the " f"signed cache package — possible trojaned binary that a " f"rewritten checksum DB would hide: {paths}"), sid=SID_PKGVERIFY, classtype="anti-tamper")) return alerts