|
| 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