fix(antirootkit): broad execve audit rule + alert-only enforce policy + deploy fixes (ref #915)

On-hardware validation (gk2, aarch64) revealed the v1 exec-detection didn't
fire and had an unsafe blast radius. Fixes:

- audit: replace targeted `-w <dir> -p x` watches (which watch the dir inode,
  NOT execs of files within it — 0 records on real execs) with a broad
  `-a always,exit -F arch=b64 -S execve -k sbx_exec` rule. `-F dir=` does not
  filter execve by target path either (both verified on the raw audit log).
  ~150 exec/s on a populated board, lost=0.
- api/policy.py (new): enforce flag (default FALSE = alert-only 'process
  scanner') + jail_dirs scope. Broad detection would otherwise auto-jail
  legitimate non-dpkg SecuBox binaries and cut their egress. Alert-only logs
  + alerts every flagged exec but never jails; enforce=true jails only flagged
  execs under a jail_dir. Fail-safe: corrupt/missing config => alert-only.
- daemon: ExecLog now persists only FLAGGED (non-dpkg) execs, not the ~150/s
  legitimate ones (would balloon to millions of rows/day, zero forensic value).
  jail_fn receives the whole ExecEvent so policy can gate on the exe path.
- unit: SupplementaryGroups=adm so the daemon can actually read
  /var/log/audit/audit.log (0640 root:adm) — without it the scanner is blind.
- ctl nft-load: mkdir the slice cgroup before `nft -f` (nft resolves the
  cgroupv2 path at load time; missing cgroup => load fails).
- packaging: nginx route -> /etc/nginx/secubox-routes.d (the dir the hub's
  webui.conf includes; secubox.d is inert on the admin vhost); postinst
  reloads nginx so the panel goes live.

89 tests (was 80): +test_policy.py, +make_jail_fn alert-only/enforce tests.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
This commit is contained in:
CyberMind-FR 2026-07-28 11:38:47 +02:00
parent ffba634d72
commit 0475dc0880
13 changed files with 320 additions and 46 deletions

View File

@ -39,6 +39,7 @@ from api.allowlist import load as load_allowlist
from api.cgroup import jail_pid
from api.dpkg_backing import is_backed_cached
from api.execlog import ExecLog
from api.policy import load_policy, should_jail
CONF_PATH = "/etc/secubox/antirootkit.toml"
DB_PATH = "/var/lib/secubox/antirootkit/execlog.db"
@ -114,35 +115,65 @@ def poll_once(fetch_fn, parse_fn, handle_fn, cursor):
def make_log_fn(log: ExecLog, alert_store: AlertStore):
"""Build the run_once `log_fn`: records every decision to the forensic
ExecLog (unchanged v1 behaviour) and, additionally, appends a
lightweight alert to the shared (SQLite-backed, cross-process)
alert_store whenever a process is jailed so GET /alerts, served by a
SEPARATE process (secubox-antirootkit.service, api/main.py), reflects
real anti-escape events instead of a permanently-empty stub. Alert
scoring is intentionally minimal here (the daemon has none of
heuristics.score()'s inputs — unit_flags, failed_count — cheaply on
hand); the durable, detailed record stays the ExecLog row itself.
"""Build the run_once `log_fn`: persists FLAGGED execs (verdict "jail" =
non-dpkg, non-allowlisted) to the forensic append-only ExecLog and appends
a lightweight alert to the shared (SQLite-backed, cross-process)
alert_store so GET /alerts, served by a SEPARATE process
(secubox-antirootkit.service, api/main.py), reflects real scanner hits.
"allow" decisions (dpkg-backed or allowlisted) are NOT persisted: under
the broad execve audit rule the daemon observes ~150 execs/s, almost all
legitimate, and recording every one would balloon the ExecLog to millions
of rows/day with zero forensic value. The ExecLog is the record of
SUSPICIOUS execs, not a full-system exec journal (auditd keeps the raw
trail). Alert scoring is intentionally minimal here (the daemon lacks
heuristics.score()'s inputs); the durable detail is the ExecLog row.
Note "jail" verdict means FLAGGED, not necessarily jailed: whether a
flagged exec is actually jailed is the enforcement policy's call
(api/policy, applied in main()'s jail_fn), independent of this record.
"""
def _log_fn(ev, verdict: str) -> None:
log.record(ev, verdict, pkg=None if verdict == "jail" else "dpkg")
if verdict == "jail":
alert = build_alert(ev, score=2, reasons=["non-dpkg-egress-jailed"])
alert_store.append(alert)
if verdict != "jail":
return
log.record(ev, verdict, pkg=None)
alert = build_alert(ev, score=2, reasons=["non-dpkg-exec-flagged"])
alert_store.append(alert)
return _log_fn
def make_jail_fn(enforce: bool, jail_dirs):
"""Build run_once's `jail_fn` from the enforcement policy.
Called (by run_once) with the whole ExecEvent for every FLAGGED exec.
Actually jails (cgroup + nft egress drop, via api.cgroup.jail_pid) ONLY
when policy says so enforce=true AND the executable under a jail_dir.
In the default alert-only mode this is a no-op: the exec was already
logged + alerted by log_fn, so the scanner still sees it, but no egress
is cut. This is what keeps enabling the broad execve rule safe on a
populated host full of legitimate non-dpkg binaries.
"""
def _jail_fn(ev) -> None:
if should_jail(ev.exe, enforce, jail_dirs):
jail_pid(ev.pid)
return _jail_fn
def main() -> None:
allow = load_allowlist(CONF_PATH)
enforce, jail_dirs = load_policy(CONF_PATH)
log = ExecLog(DB_PATH, check_same_thread=True)
alert_store = AlertStore(ALERTS_DB_PATH)
log_fn = make_log_fn(log, alert_store)
jail_fn = make_jail_fn(enforce, jail_dirs)
cursor = None
def handle(events) -> None:
execwatch.run_once(events, allow, is_backed_cached, jail_pid, log_fn)
execwatch.run_once(events, allow, is_backed_cached, jail_fn, log_fn)
while True:
cursor = poll_once(_ausearch_since, execwatch.parse_ausearch, handle, cursor)

View File

@ -97,7 +97,14 @@ def decide(ev: ExecEvent, allow: set, is_backed_fn) -> str:
def run_once(events, allow, is_backed_fn, jail_fn, log_fn) -> int:
"""Process a batch of ExecEvents; jail unknowns; return #jailed.
"""Process a batch of ExecEvents; hand flagged ones to jail_fn; return
the count handed over.
jail_fn is called with the whole ExecEvent (not just the pid) so the
caller's enforcement policy (api/policy: enforce flag + jail_dirs) can
decide, from the executable path, whether to actually jail. A jail_fn
that alert-only-mode passes may choose to do nothing; the count returned
still reflects how many events reached the "jail" verdict.
Fail-closed: if deciding an event raises (e.g. is_backed_fn blows up),
the event is treated as unknown ("jail") rather than silently skipped.
@ -112,6 +119,6 @@ def run_once(events, allow, is_backed_fn, jail_fn, log_fn) -> int:
d = "jail"
log_fn(ev, d)
if d == "jail" and ev.success:
jail_fn(ev.pid)
jail_fn(ev)
n += 1
return n

View File

@ -0,0 +1,95 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
"""
SecuBox-Deb :: antirootkit enforcement policy
Governs whether the daemon merely ALERTS on a flagged (non-dpkg,
non-allowlisted) exec or actually AUTO-JAILS it (cgroup + nft egress drop).
Rationale (verified on gk2, aarch64, audit 1.0.6, 2026-07-28): the only
auditd rule form that reliably records execs is a broad `-S execve` syscall
rule targeted `-w <dir> -p x` watches do NOT fire on execs of files *within*
the dir, and `-F dir= -S execve` does not filter execve by target path. So the
watcher sees EVERY exec (~150/s on a populated board) and userspace is the
only place scope can be enforced.
Because a populated SecuBox host runs many *legitimate* non-dpkg binaries
(secubox-* helpers in /usr/local, Go tools, ), auto-jailing every non-dpkg
exec would cut their egress and self-inflict an outage. Therefore:
* enforce = false (DEFAULT) -> alert-only "process scanner": flagged execs
are logged + alerted, never jailed. Zero egress risk. Use this to build
the allowlist from real observed traffic before turning on enforcement.
* enforce = true -> auto-jail, but ONLY for flagged execs whose
executable lives under one of `jail_dirs` (a deliberately narrow set of
locations where an unknown binary is genuinely suspicious). An exe outside
every jail_dir is still logged + alerted, never jailed.
"""
import tomllib
# Conservative default jail scope used when the TOML omits `policy.jail_dirs`.
# These are locations where an unknown, non-dpkg executable is genuinely
# suspicious. `/tmp`, `/dev/shm`, `/var/tmp`, `/run` should never host a
# legitimate long-lived binary; `/usr/local/bin`,`/usr/local/sbin`,`/opt` are
# where drop-in malware (e.g. #914 notwork) landed — legitimate SecuBox tools
# there must be covered by the allowlist before enforce is turned on.
DEFAULT_JAIL_DIRS = [
"/tmp",
"/dev/shm",
"/var/tmp",
"/run",
"/usr/local/bin",
"/usr/local/sbin",
"/opt",
"/usr/lib/jvm",
"/home",
]
def load_policy(toml_path: str) -> tuple[bool, list[str]]:
"""Load (enforce, jail_dirs) from the module TOML.
Fail-safe: any read/parse error, or a missing [policy] table, yields the
SAFE default (enforce=False, DEFAULT_JAIL_DIRS) a corrupt config must
never silently enable auto-jailing.
"""
try:
with open(toml_path, "rb") as fh:
data = tomllib.load(fh)
except Exception:
return (False, list(DEFAULT_JAIL_DIRS))
pol = data.get("policy", {})
enforce = bool(pol.get("enforce", False))
jail_dirs = pol.get("jail_dirs") or list(DEFAULT_JAIL_DIRS)
# normalise: strip trailing slashes so prefix matching is unambiguous
jail_dirs = [d.rstrip("/") for d in jail_dirs if isinstance(d, str) and d]
return (enforce, jail_dirs)
def under_jail_dir(exe: str, jail_dirs: list[str]) -> bool:
"""True if `exe` resides directly-or-transitively under a jail_dir.
Uses a path-boundary check ("/usr/local/bin/x" is under "/usr/local/bin"
but "/usr/local/binary" is NOT under "/usr/local/bin"). A None/empty exe
is treated as not-under (nothing to jail on).
"""
if not exe:
return False
for d in jail_dirs:
if exe == d or exe.startswith(d + "/"):
return True
return False
def should_jail(exe: str, enforce: bool, jail_dirs: list[str]) -> bool:
"""Whether a flagged exec should be AUTO-JAILED under the active policy.
Only reached for execs already decided as "jail" (non-dpkg,
non-allowlisted) by execwatch.decide(). Returns True iff enforcement is on
AND the executable is under the jail scope.
"""
return bool(enforce) and under_jail_dir(exe, jail_dirs)

View File

@ -1,8 +1,19 @@
# SecuBox anti-rootkit process-watch (exec surveillance) — ref #915
-w /usr/local/bin -p x -k sbx_exec
-w /usr/local/sbin -p x -k sbx_exec
-w /tmp -p x -k sbx_exec
-w /dev/shm -p x -k sbx_exec
-w /opt -p x -k sbx_exec
-w /usr/lib/jvm -p x -k sbx_exec
-w /home -p x -k sbx_exec
#
# WHY A BROAD execve SYSCALL RULE (not targeted -w dir -p x watches):
# Verified on gk2 (aarch64, kernel 6.x, audit 1.0.6, 2026-07-28):
# * `-w <dir> -p x` watches the directory INODE — it does NOT fire on
# execs of files *within* the dir. A malware dropped in /usr/local/bin
# and executed produced ZERO sbx_exec records.
# * `-a always,exit -S execve -F dir=<dir>` does NOT filter execve by the
# target executable's path either (0 records, confirmed on the raw log).
# The only form that reliably records every exec is a bare execve syscall
# rule. Scope is therefore enforced in USERSPACE by the daemon
# (api/execwatch.decide = dpkg-backing + allowlist; api/policy = enforce flag
# + jail_dirs), NOT by the audit rule. Volume on a populated board is
# ~150 exec/s with lost=0 (auditd backlog 8192 absorbs it); the daemon only
# persists the flagged (non-dpkg) minority to its append-only ExecLog.
#
# x86-64 drop-ins on this arm64 host (e.g. #914 notwork) run via binfmt/qemu,
# whose native aarch64 execve of the interpreter is caught by arch=b64.
-a always,exit -F arch=b64 -S execve -k sbx_exec

View File

@ -1,3 +1,15 @@
[policy]
# enforce=false = ALERT-ONLY "process scanner": flagged (non-dpkg,
# non-allowlisted) execs are logged + alerted but NEVER auto-jailed. This is
# the SAFE default — a broad execve audit rule (see conf/99-sbx-procwatch.rules)
# means the daemon sees every exec, and many legitimate SecuBox binaries are
# non-dpkg; auto-jailing them would cut their egress. Build the allowlist from
# real observed traffic first, THEN set enforce=true.
enforce = false
# When enforce=true, auto-jail a flagged exec ONLY if its executable lives
# under one of these dirs (omit to use api/policy.DEFAULT_JAIL_DIRS).
jail_dirs = ["/tmp", "/dev/shm", "/var/tmp", "/run", "/usr/local/bin", "/usr/local/sbin", "/opt", "/usr/lib/jvm", "/home"]
[allowlist]
exec_paths = ["/usr/local/bin/certbot", "/usr/local/bin/jws", "/usr/local/bin/acme-*"]

View File

@ -56,6 +56,14 @@ case "$1" in
systemctl start secubox-antirootkit.service || true
systemctl enable sbx-antirootkitd.service || true
systemctl start sbx-antirootkitd.service || true
# Reload nginx so the /antirootkit panel + API route (installed into
# /etc/nginx/secubox-routes.d, which the hub's webui.conf includes)
# become live. Only reload if the config still tests clean, and never
# fail the install on a pre-existing unrelated nginx error.
if command -v nginx > /dev/null 2>&1 && nginx -t > /dev/null 2>&1; then
systemctl reload nginx 2>/dev/null || true
fi
;;
esac

View File

@ -46,10 +46,14 @@ override_dh_auto_install:
install -m 644 $(CURDIR)/conf/antirootkit.toml \
$(CURDIR)/debian/secubox-antirootkit/etc/secubox/antirootkit.toml
# Install nginx route (dedicated socket, no aggregator dependency)
install -d $(CURDIR)/debian/secubox-antirootkit/etc/nginx/secubox.d
# Install nginx route into secubox-routes.d — the dir the hub's webui.conf
# actually `include`s inside the admin server block. /etc/nginx/secubox.d
# is NOT included by the admin vhost, so a route dropped there is inert
# (panel returns nothing). The route holds server-context `location`
# blocks (API -> dedicated /run/secubox/antirootkit.sock, + static panel).
install -d $(CURDIR)/debian/secubox-antirootkit/etc/nginx/secubox-routes.d
install -m 644 $(CURDIR)/nginx/antirootkit.conf \
$(CURDIR)/debian/secubox-antirootkit/etc/nginx/secubox.d/
$(CURDIR)/debian/secubox-antirootkit/etc/nginx/secubox-routes.d/
# Install web panel
install -d $(CURDIR)/debian/secubox-antirootkit/usr/share/secubox/www/antirootkit

View File

@ -13,7 +13,14 @@ case "$verb" in
# move the pid into the slice's cgroup
mkdir -p /sys/fs/cgroup/sbx-untrusted.slice
echo "$pid" > /sys/fs/cgroup/sbx-untrusted.slice/cgroup.procs ;;
nft-load) exec nft -f /usr/share/secubox-antirootkit/secubox-antiescape.nft ;;
nft-load)
# nft resolves the cgroupv2 path in the rule ("sbx-untrusted.slice")
# at LOAD time — if /sys/fs/cgroup/sbx-untrusted.slice does not exist
# yet the load fails ("cgroupv2 path fails: No such file or directory").
# The static slice's cgroup is only created lazily (first jail), so
# create it here first (mkdir in the unified cgroupfs = create cgroup).
mkdir -p /sys/fs/cgroup/sbx-untrusted.slice
exec nft -f /usr/share/secubox-antirootkit/secubox-antiescape.nft ;;
quarantine-prep)
[[ $# -eq 2 ]] || { echo "usage: $0 quarantine-prep <path>" >&2; exit 2; }
path="${2:?path}"; [[ -n "$path" ]] || exit 2

View File

@ -7,6 +7,11 @@ Requires=auditd.service
Type=simple
User=secubox-antirootkit
Group=secubox-antirootkit
# The daemon reads the audit trail via `ausearch -k sbx_exec`, which reads
# /var/log/audit/audit.log (mode 0640 root:adm). Without adm membership the
# read returns nothing and the scanner is silently blind. `adm` grants
# read-only access to system logs — the daemon never writes them.
SupplementaryGroups=adm
# NoNewPrivileges=no: the daemon jails unbacked execs via
# `sudo -n secubox-antirootkitctl jail <pid>` (api/cgroup.py), scoped by the
# exact-path sudoers rule shipped in sudoers/secubox-antirootkit. NNP=yes

View File

@ -106,7 +106,9 @@ def test_make_log_fn_does_not_alert_on_allow(tmp_path):
ev = execwatch.ExecEvent(pid=1, ppid=1, uid=0, exe="/usr/bin/yacy", argv=[], success=True)
log_fn(ev, "allow")
assert log.count() == 1
# new policy: allow (dpkg/allowlisted) is NOT persisted — ExecLog is the
# record of SUSPICIOUS execs only (broad execve rule => ~150 execs/s)
assert log.count() == 0
assert astore.recent() == []
@ -124,10 +126,45 @@ def test_daemon_jails_unknown_proc_and_alert_names_its_exe(tmp_path):
jailed = []
def handle(events):
execwatch.run_once(events, set(), lambda p: False, jailed.append, log_fn)
execwatch.run_once(events, set(), lambda p: False, lambda ev: jailed.append(ev.pid), log_fn)
poll_once(lambda c: EV1, execwatch.parse_ausearch, handle, None)
assert jailed == [999]
names = [a["exe"] for a in astore.recent()]
assert "/tmp/x" in names
def test_make_jail_fn_alert_only_is_noop():
"""Default alert-only mode (enforce=False): jail_fn must NOT jail, even
for an exe under a jail_dir. Proven by jail_pid never being called."""
from api.daemon import make_jail_fn
from api import execwatch
called = []
import api.daemon as dmod
orig = dmod.jail_pid
dmod.jail_pid = lambda pid: called.append(pid)
try:
jf = make_jail_fn(enforce=False, jail_dirs=["/tmp"])
jf(execwatch.ExecEvent(pid=5, ppid=1, uid=0, exe="/tmp/evil", argv=[]))
assert called == []
finally:
dmod.jail_pid = orig
def test_make_jail_fn_enforce_jails_in_scope_only():
from api.daemon import make_jail_fn
from api import execwatch
called = []
import api.daemon as dmod
orig = dmod.jail_pid
dmod.jail_pid = lambda pid: called.append(pid)
try:
jf = make_jail_fn(enforce=True, jail_dirs=["/tmp"])
jf(execwatch.ExecEvent(pid=5, ppid=1, uid=0, exe="/tmp/evil", argv=[]))
jf(execwatch.ExecEvent(pid=6, ppid=1, uid=0, exe="/usr/sbin/legit", argv=[]))
assert called == [5] # only the in-scope /tmp exec jailed
finally:
dmod.jail_pid = orig

View File

@ -31,7 +31,7 @@ def test_run_once_jails_and_logs():
e = ew.ExecEvent(pid=7, ppid=1, uid=0, exe="/tmp/x", argv=[], success=True)
jailed = []
logged = []
n = ew.run_once([e], set(), lambda p: False, jailed.append, lambda ev, d: logged.append(d))
n = ew.run_once([e], set(), lambda p: False, lambda ev: jailed.append(ev.pid), lambda ev, d: logged.append(d))
assert n == 1 and jailed == [7] and logged == ["jail"]
@ -76,7 +76,7 @@ def test_run_once_fail_closed_on_raise():
jailed = []
logged = []
n = ew.run_once(
[e1, e2], set(), flaky_is_backed, jailed.append, lambda ev, d: logged.append(d)
[e1, e2], set(), flaky_is_backed, lambda ev: jailed.append(ev.pid), lambda ev, d: logged.append(d)
)
assert n == 2
assert jailed == [101, 102]

View File

@ -105,22 +105,17 @@ def test_rules_installs_nft_sudoers_and_audit_files():
assert "secubox-antirootkitctl" in rules
def test_audit_rule_file_targeted_watches():
def test_audit_rule_is_broad_execve_syscall():
# Targeted `-w <dir> -p x` watches do NOT fire on execs of files *within*
# the dir (verified on gk2 aarch64); the only rule form that reliably
# records every exec is a broad execve syscall rule. Scope is enforced in
# userspace (api/policy). See conf/99-sbx-procwatch.rules header.
rules = _read("conf/99-sbx-procwatch.rules")
assert "-p x" in rules
assert "sbx_exec" in rules
for path in (
"/usr/local/bin",
"/usr/local/sbin",
"/tmp",
"/dev/shm",
"/opt",
"/usr/lib/jvm",
# /home matches heuristics.SUSPECT_DIRS — without a watch here a
# /home payload would be flagged-if-seen but never actually seen.
"/home",
):
assert path in rules
active = [ln for ln in rules.splitlines() if ln.strip() and not ln.lstrip().startswith("#")]
assert any("sbx_exec" in ln and "-S execve" in ln and "arch=b64" in ln for ln in active)
assert all("always,exit" in ln for ln in active)
# the broken directory-inode watches must be gone from the ACTIVE rules
assert not any(ln.lstrip().startswith("-w ") or "-p x" in ln for ln in active)
def test_compat_level_13():

View File

@ -0,0 +1,62 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
"""Tests for api.policy — enforce flag + jail_dirs scope, fail-safe loading."""
from api.policy import DEFAULT_JAIL_DIRS, load_policy, should_jail, under_jail_dir
def test_load_missing_file_is_safe_default(tmp_path):
enforce, dirs = load_policy(str(tmp_path / "nope.toml"))
assert enforce is False
assert dirs == DEFAULT_JAIL_DIRS
def test_load_no_policy_table_is_safe_default(tmp_path):
f = tmp_path / "a.toml"
f.write_text("[allowlist]\nexec_paths = []\n")
enforce, dirs = load_policy(str(f))
assert enforce is False
assert dirs == DEFAULT_JAIL_DIRS
def test_load_enforce_true_and_custom_dirs(tmp_path):
f = tmp_path / "a.toml"
f.write_text('[policy]\nenforce = true\njail_dirs = ["/tmp", "/opt/"]\n')
enforce, dirs = load_policy(str(f))
assert enforce is True
# trailing slash normalised away for unambiguous prefix matching
assert dirs == ["/tmp", "/opt"]
def test_corrupt_toml_never_enables_enforcement(tmp_path):
f = tmp_path / "bad.toml"
f.write_text("this is not = valid = toml [[[")
enforce, dirs = load_policy(str(f))
assert enforce is False
assert dirs == DEFAULT_JAIL_DIRS
def test_under_jail_dir_boundary():
dirs = ["/usr/local/bin", "/tmp"]
assert under_jail_dir("/usr/local/bin/evil", dirs) is True
assert under_jail_dir("/tmp/x", dirs) is True
# boundary: /usr/local/binary is NOT under /usr/local/bin
assert under_jail_dir("/usr/local/binary", dirs) is False
assert under_jail_dir("/usr/bin/curl", dirs) is False
assert under_jail_dir(None, dirs) is False
def test_should_jail_alert_only_never_jails():
# enforce=False => even an exe squarely under a jail_dir is not jailed
assert should_jail("/tmp/evil", enforce=False, jail_dirs=["/tmp"]) is False
def test_should_jail_enforce_respects_scope():
dirs = ["/tmp", "/usr/local/bin"]
assert should_jail("/tmp/evil", True, dirs) is True
assert should_jail("/usr/local/bin/notwork", True, dirs) is True
# enforce on, but exe outside every jail_dir => still not jailed
assert should_jail("/usr/sbin/legit-nondpkg-tool", True, dirs) is False