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

@ -6,4 +6,4 @@ captures a forensic snapshot with incident-response guidance whenever a known
attack signature appears. The Python re-architecture of the bash v0 prototype.
"""
__version__ = "0.6.0"
__version__ = "0.7.0"

View file

@ -69,6 +69,12 @@ def main(argv: list[str] | None = None) -> int:
fc.add_argument("--packages", action="store_true",
help="also verify package-owned files via pacman -Qkk")
sub.add_parser("pkgdb-check", help="check the package DB for out-of-band tampering")
pv = sub.add_parser("pkgdb-verify",
help="verify on-disk files against the signed cache packages")
pv.add_argument("--sample", type=int, default=0,
help="packages to verify (0 = config default; rotates)")
sub.add_parser("rootcheck",
help="anti-rootkit cross-view: hidden procs/modules/ports")
wd = sub.add_parser("watchdog",
help="poll a remote dashboard and push if Sentinel is silent")
wd.add_argument("--url", required=True, help="dashboard base URL")
@ -108,6 +114,10 @@ def main(argv: list[str] | None = None) -> int:
return 1
print("Package DB: consistent with the anchor (no out-of-band changes).")
return 0
if args.cmd == "pkgdb-verify":
return _cmd_pkgdb_verify(cfg, args.sample)
if args.cmd == "rootcheck":
return _cmd_rootcheck(cfg)
if args.cmd == "watchdog":
return _cmd_watchdog(cfg, args.url, args.token, args.max_age)
return _cmd_run(cfg)
@ -132,6 +142,35 @@ def _cmd_watchdog(cfg: Config, url: str, token: str, max_age: int) -> int:
return 0 if ok else 1
def _cmd_pkgdb_verify(cfg: Config, sample: int) -> int:
from . import pkgdb
if sample > 0:
cfg.pkgdb_pkgverify_sample = sample
sl = pkgdb.siglevel_alert()
if sl:
print(f"[CRITICAL] {sl.detail}")
if not pkgdb.keyring_present():
print("[WARN] pacman keyring not found — signature trust may be unestablished.")
alerts = [a for a in pkgdb.verify_alerts(cfg) if a.signature != "pacman_siglevel_disabled"]
for a in alerts:
print(f"[CRITICAL] {a.detail}")
if not sl and not alerts:
print(f"Package verify: sampled files match the signed cache packages "
f"(sample={cfg.pkgdb_pkgverify_sample}).")
return 1 if (sl or alerts) else 0
def _cmd_rootcheck(cfg: Config) -> int:
from . import rootcheck
alerts = list(rootcheck.run(cfg))
if not alerts:
print("Rootcheck: no hidden processes, modules, ports, or sniffers found.")
return 0
for a in sorted(alerts, key=lambda x: -x.severity):
print(f"[{a.severity}] {a.signature:<22} {a.detail}")
return 1
def _cmd_fim_check(cfg: Config, packages: bool) -> int:
from . import fim
sentinel = Sentinel(cfg)

View file

@ -71,6 +71,17 @@ class Config:
pkgdb_verify: bool = True # detect out-of-band package-DB edits
pkgdb_interval: int = 600 # seconds between DB integrity checks
heartbeat_max_age: int = 120 # heartbeat older than this = stale
# Layer 2 — verify on-disk files against the signed cache package (survives
# a rewritten checksum DB). Expensive, so it samples a rotating slice of
# packages each pass; off by default (needs the package cache populated).
pkgdb_pkgverify: bool = False
pkgdb_pkgverify_interval: int = 21600 # seconds between Layer-2 passes
pkgdb_pkgverify_sample: int = 40 # packages verified per pass (rotates)
# anti-rootkit (cross-view: ask the same question two ways, compare)
rootcheck_enabled: bool = True
rootcheck_interval: int = 300 # seconds between cross-view sweeps
rootcheck_pid_cap: int = 65536 # upper PID to brute-force via kill(0)
# eBPF on-ramp
capture_execve_bpftrace: bool = False

View file

@ -43,6 +43,13 @@ class Sentinel:
self._fim_pkg_thread: threading.Thread | None = None
self._last_fim_pkg = 0.0
self._last_pkgdb = 0.0
# Layer-2 package verification (rotating sample each pass).
self._pkgverify_thread: threading.Thread | None = None
self._last_pkgverify = 0.0
self._pkgverify_offset = 0
# Anti-rootkit cross-view sweep (off the loop thread; brute-forces PIDs).
self._rootcheck_thread: threading.Thread | None = None
self._last_rootcheck = 0.0
self._stop = threading.Event()
# -- baselines ---------------------------------------------------------
@ -158,6 +165,44 @@ class Sentinel:
for alert in pacman_verify_alerts():
self._on_exec_alert(alert)
# -- package signature verification (Layer 2, off the loop thread) ------
def _maybe_pkgdb_verify(self, now: float) -> None:
if not self.cfg.pkgdb_pkgverify:
return
if self._pkgverify_thread and self._pkgverify_thread.is_alive():
return
if (now - self._last_pkgverify) < self.cfg.pkgdb_pkgverify_interval:
return
self._last_pkgverify = now
self._pkgverify_thread = threading.Thread(
target=self._pkgdb_verify, daemon=True)
self._pkgverify_thread.start()
def _pkgdb_verify(self) -> None:
from . import pkgdb
for alert in pkgdb.verify_alerts(self.cfg, offset=self._pkgverify_offset):
self._on_exec_alert(alert)
# Advance the rotating window so the next pass covers a different slice.
self._pkgverify_offset += self.cfg.pkgdb_pkgverify_sample
# -- anti-rootkit cross-view (off the loop thread) ---------------------
def _maybe_rootcheck(self, now: float) -> None:
if not self.cfg.rootcheck_enabled:
return
if self._rootcheck_thread and self._rootcheck_thread.is_alive():
return
if (now - self._last_rootcheck) < self.cfg.rootcheck_interval:
return
self._last_rootcheck = now
self._rootcheck_thread = threading.Thread(
target=self._rootcheck, daemon=True)
self._rootcheck_thread.start()
def _rootcheck(self) -> None:
from . import rootcheck
for alert in rootcheck.run(self.cfg):
self._on_exec_alert(alert)
# -- one sweep ---------------------------------------------------------
def sweep(self, *, force_suid: bool = False) -> list[Alert]:
now = time.time()
@ -221,6 +266,8 @@ class Sentinel:
self._maybe_scan_fim(now)
self._maybe_pkg_verify(now)
self._maybe_check_pkgdb(now)
self._maybe_pkgdb_verify(now)
self._maybe_rootcheck(now)
alerts = self.sweep()
fresh = self.fresh_alerts(alerts, now)
if fresh:

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

View file

@ -0,0 +1,209 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""Anti-rootkit: detect things that are being hidden from the normal views.
The technique a rootkit uses to hide is also how you catch it: ask the same
question two different ways and compare the answers. A process the ``/proc``
listing omits but the scheduler still knows about; a loaded module absent from
``/proc/modules`` but live in ``/sys/module``; a listening port in
``/proc/net/tcp`` that ``ss`` won't show — each discrepancy is a hiding artifact.
Honest limits: this runs in user space, so it reliably catches userland
(LD_PRELOAD) rootkits and the common ``/proc``-hiding LKMs, but a kernel rootkit
that hooks *every* path (the scheduler, /proc, and /sys consistently) can still
evade it. It raises the bar and catches the common cases; it is not a guarantee
against a bespoke ring-0 implant. Pair it with the off-box dead-man's switch.
"""
from __future__ import annotations
import os
import re
import subprocess
from collections.abc import Iterator
from .alert import Alert, Severity
from .config import Config
from .system import SystemState
SID_HIDDEN_PROC = 100022
SID_HIDDEN_MODULE = 100023
SID_HIDDEN_PORT = 100024
SID_PROMISC = 100025
IFF_PROMISC = 0x100
# --- pure diff cores (unit-tested without a live system) ------------------
def find_hidden_pids(proc_pids: set[int], alive_pids: set[int]) -> set[int]:
"""PIDs the kernel confirms alive but that are missing from /proc."""
return alive_pids - proc_pids
def find_hidden_modules(proc_modules: set[str], sys_live: set[str]) -> set[str]:
"""Modules live in /sys/module but absent from /proc/modules."""
return sys_live - proc_modules
def find_hidden_ports(procnet_ports: set[int], ss_ports: set[int]) -> set[int]:
"""Listening ports in /proc/net/tcp that ss does not report."""
return procnet_ports - ss_ports
# --- system views ---------------------------------------------------------
def proc_pids() -> set[int]:
return {int(n) for n in os.listdir("/proc") if n.isdigit()}
def pid_max(cap: int) -> int:
try:
return min(int(open("/proc/sys/kernel/pid_max").read().strip()), cap)
except (OSError, ValueError):
return cap
def alive_pids(cap: int) -> set[int]:
"""Brute-force which PIDs exist via kill(pid, 0).
kill returns success or EPERM for a live PID, ESRCH (ProcessLookupError)
for a dead one. Goes through the process table, not the /proc readdir a
rootkit may have hooked.
"""
alive: set[int] = set()
for pid in range(1, pid_max(cap) + 1):
try:
os.kill(pid, 0)
alive.add(pid)
except PermissionError:
alive.add(pid) # exists, just not ours to signal
except (ProcessLookupError, OSError):
pass
return alive
def proc_modules() -> set[str]:
out: set[str] = set()
try:
with open("/proc/modules") as fh:
for line in fh:
out.add(line.split()[0])
except OSError:
pass
return out
def sys_live_modules() -> set[str]:
"""Modules under /sys/module whose initstate is 'live' (i.e. loaded, not
built-in built-ins have no initstate file)."""
out: set[str] = set()
base = "/sys/module"
try:
for m in os.listdir(base):
try:
with open(os.path.join(base, m, "initstate")) as fh:
if fh.read().strip() == "live":
out.add(m)
except OSError:
continue
except OSError:
pass
return out
def _procnet_listen_ports(path: str) -> set[int]:
ports: set[int] = set()
try:
with open(path) as fh:
next(fh, None) # header
for line in fh:
parts = line.split()
if len(parts) < 4 or parts[3] != "0A": # 0A = TCP_LISTEN
continue
local = parts[1] # HEX_ADDR:HEX_PORT
ports.add(int(local.rsplit(":", 1)[1], 16))
except OSError:
pass
return ports
def procnet_listen_ports() -> set[int]:
return _procnet_listen_ports("/proc/net/tcp") | _procnet_listen_ports("/proc/net/tcp6")
def ss_listen_ports(state: SystemState) -> set[int]:
ports: set[int] = set()
for s in state.listening_sockets():
tail = s.local.rsplit(":", 1)
if len(tail) == 2 and tail[1].isdigit():
ports.add(int(tail[1]))
return ports
def promiscuous_interfaces() -> list[str]:
out: list[str] = []
base = "/sys/class/net"
try:
for iface in os.listdir(base):
try:
with open(os.path.join(base, iface, "flags")) as fh:
flags = int(fh.read().strip(), 16)
if flags & IFF_PROMISC:
out.append(iface)
except (OSError, ValueError):
continue
except OSError:
pass
return out
# --- the check ------------------------------------------------------------
def run(cfg: Config, state: SystemState | None = None) -> Iterator[Alert]:
state = state or SystemState()
cap = cfg.rootcheck_pid_cap
# Hidden processes — re-verify each candidate to shake out exit races.
visible = proc_pids()
candidates = find_hidden_pids(visible, alive_pids(cap))
confirmed = []
for pid in candidates:
try:
os.kill(pid, 0)
except PermissionError:
pass
except OSError:
continue
if not os.path.exists(f"/proc/{pid}"):
confirmed.append(pid)
if confirmed:
yield Alert(
severity=Severity.CRITICAL, signature="rootkit_hidden_process",
key=f"rk:hidproc:{min(confirmed)}",
detail=("process(es) alive but hidden from /proc: "
+ ", ".join(map(str, sorted(confirmed)[:20]))),
pids=tuple(sorted(confirmed)[:20]),
sid=SID_HIDDEN_PROC, classtype="rootkit-hidden-process")
hidden_mods = find_hidden_modules(proc_modules(), sys_live_modules())
for m in sorted(hidden_mods):
yield Alert(
severity=Severity.CRITICAL, signature="rootkit_hidden_module",
key=f"rk:hidmod:{m}",
detail=f"kernel module live in /sys/module but hidden from /proc/modules: {m}",
sid=SID_HIDDEN_MODULE, classtype="rootkit-hidden-module")
hidden_ports = find_hidden_ports(procnet_listen_ports(), ss_listen_ports(state))
if hidden_ports:
yield Alert(
severity=Severity.HIGH, signature="rootkit_hidden_port",
key=f"rk:hidport:{min(hidden_ports)}",
detail=("listening port(s) in /proc/net/tcp not reported by ss "
"(tool may be hooked): " + ", ".join(map(str, sorted(hidden_ports)))),
sid=SID_HIDDEN_PORT, classtype="rootkit-hidden-port")
for iface in promiscuous_interfaces():
yield Alert(
severity=Severity.MEDIUM, signature="promiscuous_interface",
key=f"rk:promisc:{iface}",
detail=f"interface in promiscuous mode (possible sniffer): {iface}",
sid=SID_PROMISC, classtype="network-sniffer")