Skip to content

Commit 5903bc4

Browse files
kim-emclaude
andcommitted
chore: temporary Namespace install-step probe
Checks whether TauCeti's bubblewrap install step, which loads an AppArmor profile under set -euo pipefail, survives on a privileged Namespace runner. The install body is copied verbatim from pr-build.yml. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent a7de08c commit 5903bc4

1 file changed

Lines changed: 219 additions & 0 deletions

File tree

‎.github/workflows/ns-probe.yml‎

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
name: namespace probe
2+
3+
# THROWAWAY. Before switching TauCeti's sandboxed jobs to Namespace runners, check the one
4+
# thing that would break every build: the install step loads an AppArmor profile under
5+
# `set -euo pipefail`, and Namespace runs jobs inside a container, where that may not work.
6+
# The install body below is copied verbatim from TauCeti pr-build.yml.
7+
#
8+
# Delete this workflow once the answer is recorded.
9+
10+
on:
11+
push:
12+
branches: [ns-probe]
13+
workflow_dispatch:
14+
15+
permissions:
16+
contents: read
17+
18+
jobs:
19+
install:
20+
strategy:
21+
fail-fast: false
22+
matrix:
23+
include:
24+
- label: privileged-namespace
25+
runner: ["nscloud-ubuntu-24.04-amd64-8x16-with-features", "namespace-features:container.privileged=true"]
26+
- label: github-hosted-control
27+
runner: "ubuntu-24.04"
28+
name: ${{ matrix.label }}
29+
runs-on: ${{ matrix.runner }}
30+
timeout-minutes: 20
31+
steps:
32+
- uses: actions/checkout@v4
33+
34+
- name: Environment, before anything runs
35+
run: |
36+
echo "container: $(systemd-detect-virt --container 2>/dev/null || echo none)"
37+
echo "apparmor_parser: $(command -v apparmor_parser || echo ABSENT)"
38+
echo "aa-enabled: $(aa-enabled 2>&1 || true)"
39+
echo "restrict_unprivileged_userns: $(sysctl -n kernel.apparmor_restrict_unprivileged_userns 2>/dev/null || echo '<unset>')"
40+
echo "unshare --user: $(unshare --user --map-root-user true 2>/dev/null && echo works || echo denied)"
41+
42+
# Verbatim from TauCeti pr-build.yml, including `set -euo pipefail`. If the AppArmor
43+
# load fails here, this is exactly how it would fail there.
44+
- name: Install bubblewrap (pinned + checksum) and self-test (fail closed)
45+
env:
46+
BWRAP_VER: 0.9.0-1build1
47+
BWRAP_DEB_SHA256: dde30d1f24da50446d647ed504ec8dc5a714f171974d054c70a589b48ba38b48
48+
BWRAP_SHA256: 3e33f8255e1411ada346db27028c25ef5ba4fa3327754a2321ea4c8732a3bc10
49+
run: |
50+
set -euo pipefail
51+
# Pinned Ubuntu package, verified twice: the .deb that was fetched, and the binary it
52+
# installed. A bare `apt-get install bubblewrap` would take whatever the archive holds on
53+
# the day, which is not a pin.
54+
#
55+
# Noble's RELEASE pocket, which is the part of the archive Ubuntu never rewrites: a
56+
# version published there stays published for the life of the release. The pocket
57+
# matters more than the suite. `0.9.0-1ubuntu0.1` in noble-security looks like the
58+
# better package, but security and updates entries are superseded and then deleted
59+
# when the next one lands, so pinning that would expire the first time Ubuntu ships a
60+
# bubblewrap fix. A development suite is worse again, deleted on the next upload.
61+
#
62+
# What we give up is the noble-security revision, whose only change is a backport
63+
# Flatpak needed for CVE-2024-42472. That is an enabling change for Flatpak rather
64+
# than a bubblewrap vulnerability fix, and nothing here uses Flatpak.
65+
#
66+
# 0.9.0 predates the fix for GHSA-pxhw-h44j-8pfx (2026-08-27, no CVE assigned yet),
67+
# where a symlink in attacker-controlled content redirects the files bwrap creates
68+
# while setting the sandbox up. That reaches these policies through exactly one path:
69+
# `.lake`, the only place they create a mount point inside a candidate checkout. The
70+
# "Normalise ... .lake" step above replaces it with a real directory before anything
71+
# reads or writes it, which removes the precondition rather than patching the symptom,
72+
# and closes the same hole for `mkdir -p` and the cache restore, neither of which the
73+
# upstream fix would have covered.
74+
#
75+
# So this pin is safe because of that step, not because 0.9.0 is fixed. Moving to
76+
# 0.12.0 when noble carries it would make the sandbox safe on its own and retire that
77+
# half of the step's job; the trusted-phase half stays either way. Until then the step
78+
# is load-bearing, and scripts/test_bwrap_pin.py checks it is still present and still
79+
# runs before anything that touches a candidate `.lake`.
80+
deb="bubblewrap_${BWRAP_VER}_amd64.deb"
81+
if ! curl -sSL --fail "https://archive.ubuntu.com/ubuntu/pool/main/b/bubblewrap/${deb}" -o bwrap.deb \
82+
&& ! curl -sSL --fail "https://security.ubuntu.com/ubuntu/pool/main/b/bubblewrap/${deb}" -o bwrap.deb; then
83+
echo "::error::bubblewrap ${BWRAP_VER} is gone from the Ubuntu pool. The sandbox cannot be installed, so no PR can build until the pin is moved."
84+
printf '%s\n' \
85+
'' \
86+
"WHAT HAPPENED: BWRAP_VER is pinned to ${BWRAP_VER} from noble's release pocket," \
87+
'which Ubuntu does not rewrite for the life of the release. Reaching this is' \
88+
'unexpected: an archive outage, a runner image no longer on 24.04, or 24.04 gone' \
89+
'end of life.' \
90+
'' \
91+
'HOW TO FIX IT. Take the version from a RELEASE pocket, never from -updates,' \
92+
'-security or a development suite: those are superseded and deleted, so a pin' \
93+
'to one expires and brings every required build down with it.' \
94+
'' \
95+
' suite=noble # or the release pocket of whatever image CI runs on' \
96+
' curl -s "https://archive.ubuntu.com/ubuntu/dists/$suite/main/binary-amd64/Packages.gz" \' \
97+
" | gunzip | awk '/^Package: bubblewrap\$/{f=1} f&&/^Version:/{print \$2; exit}'" \
98+
'' \
99+
'Download it and take both hashes:' \
100+
'' \
101+
' curl -sSLO "https://archive.ubuntu.com/ubuntu/pool/main/b/bubblewrap/bubblewrap_${ver}_amd64.deb"' \
102+
' sha256sum "bubblewrap_${ver}_amd64.deb" # BWRAP_DEB_SHA256' \
103+
' sudo dpkg -i "bubblewrap_${ver}_amd64.deb"' \
104+
' sha256sum /usr/bin/bwrap # BWRAP_SHA256' \
105+
'' \
106+
'Set BWRAP_VER, BWRAP_DEB_SHA256 and BWRAP_SHA256 in all three of' \
107+
'.github/workflows/{pr-build,pr-profile,nightly-verify}.yml. They must agree;' \
108+
'scripts/test_bwrap_pin.py fails if they do not.' \
109+
'' \
110+
'ON VERSIONS: 0.12.0 or newer is safe on its own. Below that, the' \
111+
'"Normalise ... .lake" step is what keeps it safe, because earlier bubblewrap' \
112+
'follows a symlink in a candidate checkout while creating the sandbox mount point' \
113+
'(GHSA-pxhw-h44j-8pfx). If you pin below 0.12.0, check that step still runs before' \
114+
'anything else touches .lake. Keep the step either way: the trusted-phase callers' \
115+
'follow the symlink themselves and no bubblewrap version fixes that.' \
116+
'' >&2
117+
exit 1
118+
fi
119+
if ! echo "${BWRAP_DEB_SHA256} bwrap.deb" | sha256sum -c -; then
120+
echo "::error::bubblewrap ${BWRAP_VER} downloaded but does not match BWRAP_DEB_SHA256. Ubuntu does not rewrite published packages, so either the pin is wrong or the download was tampered with. Re-derive the hashes rather than relaxing this check."
121+
exit 1
122+
fi
123+
sudo dpkg -i bwrap.deb >/dev/null
124+
if ! echo "${BWRAP_SHA256} /usr/bin/bwrap" | sha256sum -c -; then
125+
echo "::error::the installed /usr/bin/bwrap does not match BWRAP_SHA256, so the binary that would run is not the one that was vetted. Re-derive it with: sudo dpkg -i ${deb} && sha256sum /usr/bin/bwrap"
126+
exit 1
127+
fi
128+
rm -f bwrap.deb
129+
/usr/bin/bwrap --version
130+
131+
# Ubuntu 24.04 denies unprivileged user namespaces through AppArmor, so bwrap cannot build
132+
# its sandbox as shipped. Grant the capability to this one binary rather than clearing
133+
# kernel.apparmor_restrict_unprivileged_userns, which would lift it for everything on the
134+
# runner. Measured on ubuntu-24.04: without this, bwrap fails with
135+
# "loopback: Failed RTM_NEWADDR: Operation not permitted".
136+
printf '%s\n' \
137+
'abi <abi/4.0>,' \
138+
'include <tunables/global>' \
139+
'profile bwrap /usr/bin/bwrap flags=(unconfined) {' \
140+
' userns,' \
141+
' include if exists <local/bwrap>' \
142+
'}' | sudo tee /etc/apparmor.d/bwrap >/dev/null
143+
sudo apparmor_parser -r /etc/apparmor.d/bwrap
144+
145+
# Prove the sandbox enforces before trusting it with candidate code. bwrap fails closed
146+
# where landrun's --best-effort could degrade to no sandbox at all, but `--unshare-all`
147+
# expands to `--unshare-user-try`, which SKIPS the user namespace rather than failing
148+
# when it cannot be created, so the policies name `--unshare-user` explicitly and this
149+
# compares the resulting namespace against the host's.
150+
#
151+
# `env -i`: `--clearenv` clears the CHILD's environment, but bwrap keeps its own and is
152+
# PID 1 of the sandbox's PID namespace, so without this the launcher's environment stays
153+
# readable at /proc/1/environ from inside (containers/bubblewrap#725).
154+
probe() {
155+
env -i /usr/bin/bwrap --tmpfs / --ro-bind /usr /usr --ro-bind /etc /etc \
156+
--symlink usr/bin /bin --symlink usr/sbin /sbin \
157+
--symlink usr/lib /lib --symlink usr/lib64 /lib64 \
158+
--dev /dev --proc /proc --tmpfs /tmp --remount-ro / \
159+
--setenv PATH /usr/bin \
160+
--unshare-user --unshare-ipc --unshare-pid --unshare-net --unshare-uts \
161+
--unshare-cgroup --disable-userns --die-with-parent --new-session \
162+
-- /usr/bin/bash -c "$1"
163+
}
164+
# Canaries, so each check can fail for the right reason. Testing that a host path is
165+
# merely absent proves nothing if it never existed; these are known to exist outside.
166+
canary=$(mktemp -d); echo secret > "$canary/file"
167+
echo secret > /dev/shm/bwrap-host-canary-$$
168+
set +e
169+
probe 'echo ok' >/dev/null 2>&1; ok=$?
170+
probe 'echo x > /etc/bwrap-probe' >/dev/null 2>&1; wr=$?
171+
probe 'mkdir /bwrap-probe-dir' >/dev/null 2>&1; rootw=$?
172+
probe "cat $canary/file" >/dev/null 2>&1; hostro=$?
173+
probe "cat /dev/shm/bwrap-host-canary-$$" >/dev/null 2>&1; shm=$?
174+
probe 'curl -sS --max-time 8 https://example.com >/dev/null' >/dev/null 2>&1; net=$?
175+
probe 'unshare --user --map-root-user true' >/dev/null 2>&1; nest=$?
176+
host_ns=$(readlink /proc/self/ns/user)
177+
sb_ns=$(probe 'readlink /proc/self/ns/user' 2>/dev/null)
178+
sb_env=$(probe 'tr "\0" "\n" < /proc/1/environ | grep -c .' 2>/dev/null)
179+
set -e
180+
rm -rf "$canary" /dev/shm/bwrap-host-canary-$$
181+
echo "self-test: run=$ok etc-write=$wr root-write=$rootw host-read=$hostro shm=$shm" \
182+
"net=$net nested-userns=$nest pid1-env=$sb_env userns=$host_ns->$sb_ns"
183+
# Every one of these but `ok` must FAIL inside the sandbox.
184+
if [ "$ok" -ne 0 ] || [ "$wr" -eq 0 ] || [ "$rootw" -eq 0 ] || [ "$hostro" -eq 0 ] \
185+
|| [ "$shm" -eq 0 ] || [ "$net" -eq 0 ] || [ "$nest" -eq 0 ] \
186+
|| [ "$sb_env" != "0" ] || [ -z "$sb_ns" ] || [ "$sb_ns" = "$host_ns" ]; then
187+
echo "::error::bwrap not enforcing (run=$ok etc-write=$wr root-write=$rootw" \
188+
"host-read=$hostro shm=$shm net=$net nested-userns=$nest pid1-env=$sb_env" \
189+
"userns=$host_ns->$sb_ns); refusing to run candidate code"
190+
exit 1
191+
fi
192+
193+
- uses: leanprover/lean-action@v1
194+
with:
195+
auto-config: false
196+
build: false
197+
use-github-cache: false
198+
use-mathlib-cache: false
199+
200+
- name: Build a real project under the production policy
201+
run: |
202+
set -euo pipefail
203+
export PATH="$HOME/.elan/bin:$PATH"
204+
mkdir -p .lake
205+
env -i /usr/bin/bwrap --tmpfs / \
206+
--ro-bind /usr /usr --ro-bind /etc /etc \
207+
--symlink usr/bin /bin --symlink usr/sbin /sbin \
208+
--symlink usr/lib /lib --symlink usr/lib64 /lib64 \
209+
--dev /dev --proc /proc --tmpfs /tmp \
210+
--ro-bind "$HOME/.elan" "$HOME/.elan" \
211+
--ro-bind "$PWD" "$PWD" --bind "$PWD/.lake" "$PWD/.lake" \
212+
--remount-ro / \
213+
--setenv PATH "$HOME/.elan/bin:/usr/bin:/bin" \
214+
--setenv HOME "$HOME" --setenv CI true \
215+
--chdir "$PWD" \
216+
--unshare-user --unshare-ipc --unshare-pid --unshare-net --unshare-uts \
217+
--unshare-cgroup --disable-userns --die-with-parent --new-session \
218+
-- bash -euxo pipefail -c 'export TMPDIR="$PWD/.lake/tmp"; mkdir -p "$TMPDIR"; exec lake build'
219+
echo "BUILD_OK"

0 commit comments

Comments
 (0)