Closes the tamper-evidence loop with two trust anchors the attacker can't forge from userland: - pkgdb Layer 2: verify on-disk files against the .MTREE in the *signed* cache package, surviving a rewritten local checksum DB. Rotating-sample cadence keeps it affordable; flags pkg_signature_mismatch (sid 100027) and SigLevel downgrades (sid 100026). Fixes parse_mtree, which required type=file on every line and so matched nothing on real pacman MTREEs (which use a /set type=file default with bare file entries). - rootcheck: anti-rootkit cross-view — hidden processes, modules, ports, and promiscuous interfaces, each caught by diffing two views of the same state (sids 100022-100025). Wired both through config, the daemon (off-loop slow cadence), and CLI (pkgdb-verify, rootcheck). 14 new tests (95 total). Docs + version bump. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
342 lines
13 KiB
Python
342 lines
13 KiB
Python
# 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:
|
|
text = open(conf_path, encoding="utf-8", errors="replace").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
|