Add run-confined skill
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
This commit is contained in:
1 parent
5f9527a8f0
commit
ab335b3061
2 files changed
+281
No files matched your search
Executable
+214
@@ -0,0 +1,214 @@
|
||||
#!/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[@]}"
|
||||
Reference in new issue
Block a user