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>
This commit is contained in:
Luna 2026-06-01 05:42:57 -07:00
parent 34bc09041a
commit 1de5e86fed
10 changed files with 766 additions and 5 deletions

View file

@ -18,10 +18,12 @@ 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
@ -29,7 +31,12 @@ 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:+\-]+)\]")
@ -130,3 +137,206 @@ def check(cfg: Config,
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