Addresses the "no regression protection" gap called out in Security notes: everything there was hand-verified at a point in time, with nothing to catch a future change silently breaking one of those properties again - which had already happened once (the ipset -exist bug hit twice, on two different ipsets, before being caught). entrypoint.sh gets a SANDBOX_TEST_MODE hook: run the real DNS-proxy/ firewall/logging setup exactly as normal, then exec `sleep infinity` as node instead of nvim (which needs a real tty). Lets the suite attach to a live, fully-initialized container and assert against it, rather than reimplementing the setup sequence in the test file where it could drift out of sync with the real thing. test/static.bats: fast, no Docker required - sudo/sudoers absence, every ipset add is duplicate-safe, no docker.sock/host-credential mounts, the ~/.claude read-only overlay lines are present, claude-sandbox-allow's input validation. test/runtime.bats: builds the image, boots a container in test mode, asserts IPv6 is blocked, DNS/IP allowlist enforcement in both directions, GitHub read-only, no working sudo/iptables/init-firewall.sh as node, capabilities are actually stripped (not just absent because docker exec doesn't have them to begin with), ulogd2 runs unprivileged, and the dynamic-ipset CDN-rotation fix actually re-syncs on a fresh DNS answer. Container/network names are derived from bats' own BATS_FILE_TMPDIR rather than $$ (caught via a real failure: bats reruns each file's top-level code in a fresh subshell per test, so $$ differs across setup_file/tests/ teardown_file, silently pointing them at different, nonexistent containers - several tests "passed" only because asserting failure against a missing container also happens to return nonzero). BATS_FILE_TMPDIR is guaranteed stable across the whole file's run, verified directly before relying on it. All 22 tests pass cleanly against the current sandbox.
84 lines
3.9 KiB
Bash
Executable File
84 lines
3.9 KiB
Bash
Executable File
#!/bin/bash
|
|
set -euo pipefail
|
|
|
|
# Derive the Gitea API hostname from GITEA_API (a full URL) so it can be
|
|
# firewall-allowlisted alongside GITEA_SSH_HOST.
|
|
gitea_api_host=""
|
|
if [ -n "${GITEA_API:-}" ]; then
|
|
gitea_api_host=$(echo "$GITEA_API" | sed -E 's#^[a-zA-Z]+://([^/]+).*#\1#')
|
|
fi
|
|
|
|
# Same for LOKI_URL (also a full URL) - Loki's host needs to be reachable
|
|
# for log-shipper.sh to actually deliver anything.
|
|
loki_host=""
|
|
if [ -n "${LOKI_URL:-}" ]; then
|
|
loki_host=$(echo "$LOKI_URL" | sed -E 's#^[a-zA-Z]+://##; s#[:/].*##')
|
|
fi
|
|
|
|
extra_domains="${GITEA_SSH_HOST:-} ${gitea_api_host} ${loki_host} ${ALLOWED_DOMAINS:-}"
|
|
|
|
if [ "${ENABLE_FIREWALL:-1}" = "1" ]; then
|
|
# dns-proxy.sh must run first: it repoints /etc/resolv.conf at a local
|
|
# dnsmasq that only resolves allowlisted names, so init-firewall.sh's own
|
|
# dig calls (and everything afterward) go through the same restriction.
|
|
EXTRA_ALLOWED_DOMAINS="${extra_domains}" /usr/local/bin/dns-proxy.sh
|
|
EXTRA_ALLOWED_DOMAINS="${extra_domains}" /usr/local/bin/init-firewall.sh
|
|
|
|
# NFLOG groups only exist once init-firewall.sh has run, so ulogd2 (the
|
|
# NFLOG consumer) and the Loki shipper start after it. Both are
|
|
# best-effort visibility on top of the enforcement above, not required
|
|
# for it - log-shipper.sh itself no-ops if LOKI_URL isn't set. Runs as
|
|
# its own unprivileged `ulogd` user (opens the NFLOG socket and output
|
|
# files first, then drops), not root - it's parsing headers from
|
|
# attacker-influenceable packets (whatever a compromised session sends),
|
|
# so a bug in it shouldn't be a path back to root in the container.
|
|
#
|
|
# `ulogd -d` daemonizes immediately and always exits 0 from this shell's
|
|
# point of view, regardless of whether the daemon itself goes on to
|
|
# start successfully - so `set -e` above won't catch a failure here (nor
|
|
# should it: logging is best-effort, not something worth failing the
|
|
# whole container over). Check separately so a failure is at least
|
|
# visible instead of silently producing no logs.
|
|
/usr/sbin/ulogd -d -u ulogd -c /etc/ulogd-sandbox.conf
|
|
sleep 0.5
|
|
if ! pgrep -x ulogd >/dev/null; then
|
|
echo "WARNING: ulogd2 failed to start - connection logging will be unavailable (see /var/log/ulogd.log)" >&2
|
|
fi
|
|
/usr/local/bin/log-shipper.sh &
|
|
fi
|
|
|
|
# A normal login/ssh session always chowns the allocated pty to the logging
|
|
# in user; this container's entrypoint skips that (there's no login/sshd
|
|
# involved), leaving the tty root-owned. nvim's clipboard fallback needs to
|
|
# write to it directly as node, so fix the ownership up here, before
|
|
# dropping privileges.
|
|
if [ -t 1 ]; then
|
|
chown node "$(tty)" 2>/dev/null || true
|
|
fi
|
|
|
|
# Point git's github.com credential helper at `gh`, which reads GH_TOKEN -
|
|
# only possible now, not at image build time, since that's a runtime
|
|
# secret. Combined with the firewall restricting github.com to port 443
|
|
# only, this is what makes GitHub read-only from inside the sandbox: no
|
|
# write-capable credential for it, and SSH (which could use the forwarded
|
|
# agent's real key) can't reach it at all. Skipped if GH_TOKEN isn't set -
|
|
# github.com HTTPS still works anonymously for public repos either way.
|
|
if [ -n "${GH_TOKEN:-}" ]; then
|
|
gosu node gh auth setup-git
|
|
fi
|
|
|
|
# Test harness hook (docker/claude-sandbox/test/): everything above this
|
|
# point is the real startup sequence - DNS proxy, firewall, IPv6 block,
|
|
# logging, privilege-drop prep. Skip execing nvim (which needs a real tty)
|
|
# so the suite can `docker exec` into a live, fully-initialized container
|
|
# and assert against it, exercising the actual code path instead of a
|
|
# reimplementation of it that could drift out of sync.
|
|
if [ "${SANDBOX_TEST_MODE:-0}" = "1" ]; then
|
|
exec gosu node sleep infinity
|
|
fi
|
|
|
|
# Drop root -> node for the actual session; node has no sudo/setuid path
|
|
# back to root after this, so it can't re-run init-firewall.sh itself to
|
|
# widen the allowlist mid-session.
|
|
exec gosu node nvim "$@"
|