From 4ee8f6af444fdccc773d9518cfc6927f828b8a76 Mon Sep 17 00:00:00 2001 From: mwiegand Date: Fri, 8 May 2026 16:47:30 +0200 Subject: [PATCH] =?UTF-8?q?refactor(deploy):=20rewrite=20left4me-script-sa?= =?UTF-8?q?ndbox=20to=20systemd-only=20=E2=80=94=20drop=20bwrap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the systemd-run --scope + bwrap composition with systemd-run in service-unit mode (--pipe --wait, transient .service unit). Same cgroup limits and walltime kill, plus the hardening directives that --scope units cannot carry: NoNewPrivileges, ProtectSystem=strict, ProtectHome, ProtectKernel{Tunables,Modules,Logs,ControlGroups}, RestrictNamespaces, RestrictAddressFamilies, RestrictSUIDSGID, LockPersonality, MemoryDenyWriteExecute, SystemCallFilter (seccomp), and an empty CapabilityBoundingSet (drops all caps). UID drop via User=/Group=. The TemporaryFileSystem="/etc /var/lib" pair is the gotcha: ProtectSystem=strict makes /var/lib *read-only* but visible, so the host DB at /var/lib/left4me/left4me.db (mode 0644) was readable from inside. Masking /var/lib with tmpfs hides the entire subtree; the BindPaths bind to /overlay is at a different path and unaffected. The Python side (ScriptBuilder, run_sandboxed_script, routes) is unchanged — same sudo-helper invocation, same argv shape. Loses PID-namespace isolation (no PrivatePID= directive in systemd). Host PIDs are visible via /proc and ps -ef but not signal-able due to UID mismatch — information disclosure only, not a privilege boundary. Smoke-tested on ckn@10.0.4.128 prior to this commit; all isolation invariants reproduced and the hardening directives provably blocked unshare(2), mount(2), personality(2), bpf(2), and sysctl writes. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../libexec/left4me/left4me-script-sandbox | 62 ++++++++--------- deploy/tests/test_deploy_artifacts.py | 67 ++++++++++++++++--- 2 files changed, 88 insertions(+), 41 deletions(-) diff --git a/deploy/files/usr/local/libexec/left4me/left4me-script-sandbox b/deploy/files/usr/local/libexec/left4me/left4me-script-sandbox index d317b5b..bbbf496 100755 --- a/deploy/files/usr/local/libexec/left4me/left4me-script-sandbox +++ b/deploy/files/usr/local/libexec/left4me/left4me-script-sandbox @@ -7,12 +7,16 @@ # absolute path to a bash file already written by the web app; # bind-mounted read-only at /script.sh inside the sandbox. # -# The script runs under bubblewrap inside a transient systemd scope so we get -# cgroup-v2 limits (memory / tasks / cpu) and a wallclock kill via -# RuntimeMaxSec. The sandbox drops to the unprivileged l4d2-sandbox UID; -# host filesystems are exposed read-only except /overlay (rw) and tmpfs -# /tmp + /run. Network namespace is *not* unshared — scripts must reach the -# public internet to download workshop / l4d2center / cedapug content. +# The script runs as a transient systemd .service with the full hardening +# surface: cgroup limits + walltime kill, NoNewPrivileges, ProtectSystem, +# ProtectHome, kernel-tunable / -module / -log protection, namespace +# restriction, address-family restriction, capability bounding (empty), +# seccomp filter (@system-service @network-io), MemoryDenyWriteExecute, +# LockPersonality, RestrictSUIDSGID. Network namespace is *not* restricted — +# scripts must reach the public internet to download workshop / l4d2center +# / cedapug content. PID namespace is shared with the host (no +# PrivatePID= directive in systemd); host PIDs are visible via /proc but +# not signal-able due to UID mismatch. set -euo pipefail [[ $# -eq 2 ]] || { echo "usage: $0