Untrusted installers (curl | bash, unvetted npx packages) run in a transient systemd user unit: $HOME unreadable, only ~/.local writable. The script is a copy of office-admin/tools/run-confined.sh; it aborts rather than run unconfined when AppArmor blocks user namespaces. Generated-by: Claude Opus 5.5
214 lines
7.8 KiB
Bash
Executable File
214 lines
7.8 KiB
Bash
Executable File
#!/bin/bash
|
|
# Run a command inside a one-shot, kernel-confined systemd USER unit.
|
|
#
|
|
# Usage: run-confined.sh -- cmd [args...]
|
|
#
|
|
# Everything after the first "--" is executed in a transient user service
|
|
# with strong sandboxing. Inside the unit:
|
|
# * $HOME is an unreadable tmpfs - ~/.ssh, ~/.config/gh, CI secrets
|
|
# do not exist / cannot be read. Denial by kernel namespace.
|
|
# * the only writable place is /tmp/confined, which is a bind mount of
|
|
# ~/.local, and $HOME points there. Installs for this machine land in
|
|
# ~/.local and stay there.
|
|
# * system dirs are read-only; /dev and /tmp are private.
|
|
#
|
|
# The unit is started by the systemd user manager, so the calling shell's
|
|
# environment is NOT inherited (no GH_TOKEN, SSH_AUTH_SOCK, etc.) unless
|
|
# explicitly passed via Environment= below.
|
|
#
|
|
# Requires the user manager to be able to create mount/user namespaces.
|
|
# Ubuntu 24.04 ships kernel.apparmor_restrict_unprivileged_userns=1 which
|
|
# blocks that; run-confined refuses to run unless the sandbox is proven
|
|
# effective by a wet test, so it never silently runs unconfined.
|
|
#
|
|
# TROUBLESHOOTING
|
|
# --------------
|
|
# On Ubuntu 24.04 the AppArmor userns boundary (
|
|
# kernel.apparmor_restrict_unprivileged_userns=1 , set by default) stops
|
|
# the systemd user manager from creating namespaces. systemd then SILENTLY
|
|
# skips sandbox setup rather than failing the unit, so the command still
|
|
# "runs" while the sandbox is off. Symptoms:
|
|
# * wet-test abort with a leak report that lists the full $HOME (incl.
|
|
# ~/.ssh), instead of an empty / "Permission denied" home.
|
|
# * "UTS namespace setup is prohibited (container manager?), ignoring
|
|
# namespace setup." in `journalctl --user -b`.
|
|
# * units die at once with status=218/CAPABILITIES + journal line
|
|
# "Failed to drop capabilities: Operation not permitted" when any of
|
|
# PrivateDevices/ProtectClock/ProtectHostname/ProtectKernel* is set.
|
|
# * unrelated sandboxes (firejail, unshare -m, podman rootless) fail too.
|
|
# None of this means the box is broken; it is a deliberate AppArmor
|
|
# containment boundary. To use namespaced sandboxes again, either
|
|
# temporarily with:
|
|
# sudo sysctl kernel.apparmor_restrict_unprivileged_userns=0
|
|
# or persist it across reboots with:
|
|
# echo 'kernel.apparmor_restrict_unprivileged_userns=0' \
|
|
# | sudo tee /etc/sysctl.d/60-userns.conf
|
|
#
|
|
# Some hardening properties need capset() / extra namespaces and are not
|
|
# available on every kernel; each is probed and dropped with a note.
|
|
set -euo pipefail
|
|
|
|
usage() {
|
|
sed -n '3,9p' "$0"
|
|
exit 2
|
|
}
|
|
|
|
[[ $# -ge 1 && $1 == "--" ]] || usage
|
|
shift
|
|
[[ $# -ge 1 ]] || usage
|
|
|
|
carve="/tmp/confined"
|
|
writerowed="$HOME/.local"
|
|
wet_marker=".run-confined-wet"
|
|
|
|
# Command args that reference files under $HOME are normally tab-completed
|
|
# on the host, but inside the unit $HOME is blanked and the carve lives at
|
|
# $carve. Rewrite such args so you type the host path and the unit still
|
|
# finds it. Pass the literal directory with "--preserve-paths" to opt out.
|
|
preserve_paths=0
|
|
for a in "$@"; do
|
|
[ "$a" = "--preserve-paths" ] && preserve_paths=1
|
|
done
|
|
|
|
map_path() {
|
|
case "$1" in
|
|
"$writerowed"/*) printf '%s/%s' "$carve" "${1#"$writerowed"/}" ;;
|
|
"$writerowed") printf '%s' "$carve" ;;
|
|
"$HOME"/*) printf '%s/%s' "$carve" "${1#"$HOME"/}" ;;
|
|
"$HOME") printf '%s' "$carve" ;;
|
|
*) printf '%s' "$1" ;;
|
|
esac
|
|
}
|
|
|
|
cmd=()
|
|
for a in "$@"; do
|
|
if [ "$a" = "--preserve-paths" ]; then
|
|
continue
|
|
elif [ "$preserve_paths" -eq 1 ]; then
|
|
cmd+=("$a")
|
|
else
|
|
cmd+=("$(map_path "$a")")
|
|
fi
|
|
done
|
|
[[ ${#cmd[@]} -ge 1 ]] || usage
|
|
|
|
# Node for the unit. No downloads, no checksum theater: the newest nvm
|
|
# v22 install is bound in READ-ONLY (so the unit gets node+npm but cannot
|
|
# tamper with ~/.nvm). Override with CONFINED_NODE=/path/to/node-dir (the
|
|
# dir that has bin/node). A standalone carve node is preferred if present.
|
|
unit_path="/usr/local/bin:/usr/bin:/bin"
|
|
bind_node_dir=""
|
|
node_target="/tmp/confined-node"
|
|
|
|
if [[ -x "$HOME/.local/share/pi-node/current/bin/node" ]]; then
|
|
unit_path="$HOME/.local/share/pi-node/current/bin:$unit_path"
|
|
elif [[ -n "${CONFINED_NODE:-}" && -x "$CONFINED_NODE/bin/node" ]]; then
|
|
bind_node_dir="$CONFINED_NODE"
|
|
unit_path="$node_target/bin:$unit_path"
|
|
else
|
|
newest_bin=$(ls -d "$HOME"/.nvm/versions/node/v22.*/bin 2>/dev/null | sort -V | tail -1)
|
|
if [[ -n "$newest_bin" && -x "$newest_bin/node" ]]; then
|
|
bind_node_dir="${newest_bin%/bin}"
|
|
unit_path="$node_target/bin:$unit_path"
|
|
fi
|
|
fi
|
|
|
|
base_props=(
|
|
"ProtectSystem=strict"
|
|
"ProtectHome=yes"
|
|
"BindPaths=$writerowed:$carve"
|
|
"PrivateTmp=yes"
|
|
"PrivateMounts=yes"
|
|
"ProtectKernelTunables=yes"
|
|
"ProtectControlGroups=yes"
|
|
"RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK"
|
|
"RestrictRealtime=yes"
|
|
"RestrictSUIDSGID=yes"
|
|
"LockPersonality=yes"
|
|
"NoNewPrivileges=yes"
|
|
"MemoryMax=1G"
|
|
"TasksMax=200"
|
|
"TimeoutStartSec=infinity"
|
|
)
|
|
|
|
# capset-dependent; known to fail under some kernels -> probe and drop.
|
|
optional_props=(
|
|
"PrivateDevices=yes"
|
|
"ProtectClock=yes"
|
|
"ProtectHostname=yes"
|
|
"ProtectKernelModules=yes"
|
|
"ProtectKernelLogs=yes"
|
|
)
|
|
|
|
probe_pass() {
|
|
timeout 10 systemd-run --user --wait -q --property="$1" -- /bin/true 2>/dev/null
|
|
}
|
|
|
|
# A real sandbox blanks $HOME (unreadable), exposes only the carve, and
|
|
# allows writes there. Concretely: $HOME/.ssh must not be listable, and a
|
|
# touch/rm round-trip must work inside the carve. Anything else means
|
|
# systemd silently skipped the namespace setup -> not confined.
|
|
sandbox_wet_test() {
|
|
timeout 20 systemd-run --user --wait -q \
|
|
--property="ProtectHome=yes" \
|
|
--property="BindPaths=$writerowed:$carve" \
|
|
--property="PrivateTmp=yes" \
|
|
--property="Environment=HOME=$carve" \
|
|
-- /bin/sh -c "ls -d \"\$HOME/.ssh\" >/dev/null 2>&1 && exit 1; touch \"$carve/$wet_marker\" && rm \"$carve/$wet_marker\"" 2>/dev/null
|
|
}
|
|
|
|
# Show what leaked, to diagnose an ineffective sandbox.
|
|
sandbox_leak_report() {
|
|
timeout 20 systemd-run --user --wait -P \
|
|
--property="ProtectHome=yes" \
|
|
--property="BindPaths=$writerowed:$carve" \
|
|
--property="PrivateTmp=yes" \
|
|
--property="Environment=HOME=$carve" \
|
|
-- /bin/sh -c 'echo "HOME listing (expect Permission denied):"; ls -A "$HOME" 2>&1; echo; echo ".ssh (expect cannot access):"; ls -d "$HOME/.ssh" 2>&1; echo; echo "carve:"; ls "$carve" 2>&1 | head -5' 2>/dev/null
|
|
}
|
|
|
|
props=("${base_props[@]}")
|
|
if [[ -n "$bind_node_dir" ]]; then
|
|
props+=("BindReadOnlyPaths=$bind_node_dir:$node_target")
|
|
fi
|
|
for p in "${optional_props[@]}"; do
|
|
if probe_pass "$p"; then
|
|
props+=("$p")
|
|
else
|
|
echo "note: dropping unsupported hardening property: $p" >&2
|
|
fi
|
|
done
|
|
|
|
if ! sandbox_wet_test; then
|
|
echo "abort: sandbox is not actually enforced on this kernel/session" >&2
|
|
echo " ~/.ssh must not exist inside the transient unit and the carve" >&2
|
|
echo " write must succeed; the wet test failed, so NOTHING ran." >&2
|
|
echo " This is the apparmor_restrict_unprivileged_userns=1 symptom on" >&2
|
|
echo " Ubuntu 24.04 (see the header for the full symptom list). Fix:" >&2
|
|
echo " sudo sysctl kernel.apparmor_restrict_unprivileged_userns=0" >&2
|
|
echo " or persist: echo 'kernel.apparmor_restrict_unprivileged_userns=0' |" >&2
|
|
echo " sudo tee /etc/sysctl.d/60-userns.conf" >&2
|
|
echo "Leak report from the probe unit follows:" >&2
|
|
sandbox_leak_report >&2 || true
|
|
exit 3
|
|
fi
|
|
|
|
unit_name="run-confined-$(date +%s%N)"
|
|
|
|
systemd_run_args=(
|
|
--user --wait -P
|
|
--unit="$unit_name"
|
|
--property="Type=oneshot"
|
|
--property="WorkingDirectory=$carve"
|
|
--property="Environment=HOME=$carve"
|
|
--property="Environment=PATH=$unit_path"
|
|
--property="Environment=TMPDIR=${TMPDIR:-/tmp}"
|
|
--property="Environment=TERM=vt100"
|
|
--property="Environment=npm_config_prefix=$carve"
|
|
--property="Environment=npm_config_cache=$carve/share/node/npm-cache"
|
|
)
|
|
for p in "${props[@]}"; do
|
|
systemd_run_args+=(--property="$p")
|
|
done
|
|
|
|
systemd-run "${systemd_run_args[@]}" -- "${cmd[@]}" |