A single bash script that runs a coding harness (pi by default) inside a rootless Ubuntu 24.04 container with only the current directory mounted at /project, an explicit egress allowlist and an explicit environment: nothing is passed in that was not named on the command line or in a config file. Network enforcement here is empirical rather than assumed (tests/spike_network.sh, docs/networking.md). The original design -- a sidecar proxy on an internal network -- does not work rootless on this host, so allow mode runs pasta with --map-host-loopback, installs an inet aisb ruleset inside the container that drops every egress path except the gate's, and relays through a squid gate bound to the host's loopback that resolves names itself and refuses anything unlisted. The entrypoint checks that the default-drop rule really exists before setpriv drops to uid 1000 with an empty capability set, and exits 77/78 rather than run unenforced. The Containerfile, the entrypoint, the squid config generator and the ruleset renderer all live in bin/ai-sandbox, and the image is tagged by content hash, so a policy change can never silently reuse a stale image. Tests are pure bash: 161 assertions start no container and touch no network, 256 run real ones, including proof that a raw socket, a stripped proxy env and DNS all stay blocked while an allowlisted host works over both http and CONNECT. LLM-Generated: true
85 lines
3.8 KiB
Bash
85 lines
3.8 KiB
Bash
#!/usr/bin/env bash
|
|
# tests/fixtures/egress_probe.sh -- runs *inside* the sandbox and reports, one
|
|
# marker per line, what is and is not reachable. Kept as a file so the outer test
|
|
# does not have to nest five layers of quoting.
|
|
#
|
|
# Markers start with ok_/BAD_ so the caller can grep for either.
|
|
set -uo pipefail
|
|
|
|
ALLOW="${AISB_PROBE_ALLOW:-example.com}"
|
|
OTHER="${AISB_PROBE_OTHER:-example.org}"
|
|
RAWIP="${AISB_PROBE_RAWIP:-1.1.1.1}"
|
|
GATE="${AISB_PROBE_GATE:-100.64.0.1}"
|
|
HOSTSVC="${AISB_PROBE_HOSTSVC:-18099}"
|
|
|
|
# 1. allowlisted destination, through the proxy
|
|
code="$(curl -m15 -sS -o /dev/null -w '%{http_code}' "http://$ALLOW" 2>/dev/null)"
|
|
[[ "$code" == 200 ]] && echo "ok_allowed_http=$code" || echo "BAD_allowed_http=$code"
|
|
|
|
# 2. allowlisted destination over https (CONNECT)
|
|
code="$(curl -m15 -sS -o /dev/null -w '%{http_code}' "https://$ALLOW" 2>/dev/null)"
|
|
[[ "$code" == 200 ]] && echo "ok_allowed_https=$code" || echo "BAD_allowed_https=$code"
|
|
|
|
# 3. non-allowlisted domain: the gate must answer 403
|
|
code="$(curl -m10 -sS -o /dev/null -w '%{http_code}' "http://$OTHER" 2>/dev/null)"
|
|
[[ "$code" == 403 ]] && echo "ok_gate_denied_domain=$code" || echo "BAD_gate_denied_domain=$code"
|
|
# a denied CONNECT is refused by the proxy: curl reports 000/502, never 200
|
|
code="$(curl -m10 -sS -o /dev/null -w '%{http_code}' "https://$OTHER" 2>/dev/null)"
|
|
[[ "$code" != 200 ]] && echo "ok_gate_denied_https=$code" || echo "BAD_gate_denied_https=$code"
|
|
|
|
# 4. same, but pretending the caller ignores the proxy env: the netfilter ruleset
|
|
# is what stops it. Anything other than a connection failure is a leak.
|
|
nop () { env -u http_proxy -u https_proxy -u all_proxy -u HTTP_PROXY -u HTTPS_PROXY -u ALL_PROXY "$@"; }
|
|
if nop curl -m8 -sS -o /dev/null "http://$OTHER" 2>/dev/null; then
|
|
echo "BAD_direct_egress=domain"
|
|
else
|
|
echo "ok_direct_egress_blocked=domain"
|
|
fi
|
|
if nop curl -m8 -sS -o /dev/null "http://$RAWIP" 2>/dev/null; then
|
|
echo "BAD_direct_egress=rawip"
|
|
else
|
|
echo "ok_direct_egress_blocked=rawip"
|
|
fi
|
|
|
|
# 5. a raw socket (no proxy semantics at all) must not connect either
|
|
python3 - "$RAWIP" "$GATE" <<'PY'
|
|
import socket, sys
|
|
rawip, gate = sys.argv[1], sys.argv[2]
|
|
for label, host, port in (("rawip", rawip, 80), ("cdnish", "93.184.216.34", 80), ("dns", "8.8.8.8", 53)):
|
|
s = socket.socket(); s.settimeout(6)
|
|
try:
|
|
s.connect((host, port)); print(f"BAD_raw_socket={label}")
|
|
except Exception as e:
|
|
print(f"ok_raw_socket_blocked={label}:{type(e).__name__}")
|
|
finally:
|
|
s.close()
|
|
PY
|
|
|
|
# 6. an unrelated service on the host's loopback must be unreachable through the
|
|
# mapped address: only the gate's own port is routable there. Tested without
|
|
# the proxy, because a proxied request dies at the squid and would exit 0 on a
|
|
# 403 -- which proves the proxy works, not that the route is closed.
|
|
if nop curl -m6 -sS -o /dev/null "http://$GATE:$HOSTSVC/" 2>/dev/null; then
|
|
echo "BAD_host_service_reachable=$HOSTSVC"
|
|
else
|
|
echo "ok_host_service_blocked=$HOSTSVC"
|
|
fi
|
|
|
|
# 7. DNS: gate mode keeps it shut, so nothing can resolve names behind the proxy
|
|
if getent hosts "$OTHER" > /dev/null 2>&1; then
|
|
echo "BAD_dns_resolved=$OTHER"
|
|
else
|
|
echo "ok_dns_blocked=$OTHER"
|
|
fi
|
|
|
|
# 8. who are we, and can we touch the ruleset?
|
|
echo "uid=$(id -u)"
|
|
echo "capeff=$(grep CapEff /proc/self/status | cut -f2)"
|
|
if nft list table inet aisb > /dev/null 2>&1; then echo "BAD_rules_readable"; else echo "ok_rules_hidden"; fi
|
|
if nft delete table inet aisb > /dev/null 2>&1; then echo "BAD_rules_deleted"; else echo "ok_rules_locked"; fi
|
|
if unshare -n true > /dev/null 2>&1; then echo "BAD_new_netns"; else echo "ok_netns_locked"; fi
|
|
|
|
# 9. and after all that, enforcement is still in place
|
|
code="$(curl -m10 -sS -o /dev/null -w '%{http_code}' "http://$OTHER" 2>/dev/null)"
|
|
[[ "$code" == 403 ]] && echo "ok_still_enforced=$code" || echo "BAD_still_enforced=$code"
|