enodia-sentinal/enodia_sentinel/pkgdb.py
Luna 1de5e86fed Add signed-package verification and anti-rootkit cross-view (v0.7)
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>
2026-06-01 05:42:57 -07:00

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