mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-29 11:12:29 +00:00
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:
parent
ffba634d72
commit
0475dc0880
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
95
packages/secubox-antirootkit/api/policy.py
Normal file
95
packages/secubox-antirootkit/api/policy.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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-*"]
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
62
packages/secubox-antirootkit/tests/test_policy.py
Normal file
62
packages/secubox-antirootkit/tests/test_policy.py
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user