diff --git a/.github/workflows/session-image.yml b/.github/workflows/session-image.yml index 2e38759b..5c8ff3cf 100644 --- a/.github/workflows/session-image.yml +++ b/.github/workflows/session-image.yml @@ -7,7 +7,10 @@ on: - .dockerignore - images/session/** - scripts/session-image-*.sh + - scripts/session-image-security-policy-test.py - internal/driver/image*test.go + - testdata/session-security/** + - Makefile - .github/workflows/session-image.yml workflow_dispatch: @@ -31,7 +34,45 @@ jobs: env: RAINIER_SESSION_IMAGE: rainier-session:qualify run: go test ./internal/driver -run '^(TestSessionImage|TestImageSmoke)' -count=1 + # Chromium must use its own sandbox; enable user namespaces on this disposable CI host. + - name: Enable Chromium sandbox user namespaces + run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + # This public snapshot exercises the browser boundary without requiring + # a cross-repository token. Rainier Cloud owns and qualifies its runtime + # policy independently; the fixture's hashes and exact rules are checked + # before the profile is loaded. + - name: Validate the public browser policy fixture + run: make session-image-security-policy + - name: Load the reviewed Chromium AppArmor policy + run: sudo apparmor_parser -r testdata/session-security/rainier-codex-bwrap.apparmor - name: Functional smoke with no network or credentials + env: + SECCOMP: ${{ github.workspace }}/testdata/session-security/codex-bwrap-seccomp-docker-27.5.1.json + APPARMOR: rainier-codex-bwrap + id: image-smoke + continue-on-error: true run: make session-image-smoke SESSION_IMAGE=rainier-session:qualify + # A Chromium sandbox failure can be a host-level denial after the + # browser has exited. Emit only coarse category flags; raw kernel audit + # records must stay on the runner and never enter public logs. + - name: Summarize host security denials + if: steps.image-smoke.outcome == 'failure' + run: | + if sudo dmesg --color=never 2>/dev/null | grep -Eqi 'apparmor.*DENIED'; then + echo apparmor-denial-observed + else + echo no-apparmor-denial-observed + fi + if sudo dmesg --color=never 2>/dev/null | grep -Eqi 'seccomp|SECCOMP'; then + echo seccomp-denial-observed + else + echo no-seccomp-denial-observed + fi + exit 1 + - name: Browser end to end in a fresh project + env: + SECCOMP: ${{ github.workspace }}/testdata/session-security/codex-bwrap-seccomp-docker-27.5.1.json + APPARMOR: rainier-codex-bwrap + run: make session-image-browser-e2e SESSION_IMAGE=rainier-session:qualify - name: Image size run: docker image inspect -f '{{.Size}} bytes; {{.Architecture}}' rainier-session:qualify diff --git a/Dockerfile b/Dockerfile index 631543df..7e8eb798 100644 --- a/Dockerfile +++ b/Dockerfile @@ -54,6 +54,18 @@ ARG CLAUDE_CODE_VERSION=2.1.263 ARG POSTGRES_MAJOR=17 ARG PGDG_KEY_FINGERPRINT=B97B0AFCAA1A47F044F244A07FCC7D46ACCC4CF8 +# The browser baseline. A project runs ITS OWN Playwright — nothing Playwright +# is installed globally in this image, deliberately — so what is pinned here is +# the browser that Playwright launches, and the Playwright version it is the +# right browser for. A project on that version downloads nothing; a project on +# another version installs its own revision into the workspace cache beside it. +# See images/session/browsers.sh, whose checksums are the actual pin, and +# docs/session-image.md for the supported set and for what other versions do. +ARG PLAYWRIGHT_VERSION=1.63.0 +ARG CHROMIUM_VERSION=153.0.8010.12 +ARG CHROMIUM_REVISION=1243 +ARG PLAYWRIGHT_FFMPEG_REVISION=1011 + # --- the pinned upstream toolchain, verified before it is extracted ---------- FROM ${BASE_IMAGE} AS toolchain ARG TARGETARCH @@ -233,6 +245,77 @@ RUN set -eu; \ COPY images/session/services/ /usr/local/bin/ RUN chmod 0755 /usr/local/bin/rainier-pg /usr/local/bin/rainier-redis +# --- browser testing: the shared libraries, the fonts, and one Chromium ------ +# +# `npx playwright install --with-deps chromium` is the line every project's CI +# runs, and its --with-deps half is an `apt-get install` as root. This image +# installs no escalation path and never will, the rootfs is read-only, and the +# egress allowlist carries no package archive — so that half has to be a +# build-time layer or a session cannot run a browser test at all. This is that +# layer. +# +# The package list is Playwright's own `debian12-x64` chromium dependency set +# (packages/playwright-core/src/server/registry/nativeDeps.ts), named here in +# full rather than resolved by the tool, because the tool needs root to read it +# and a session has none. Sixteen of these are missing from the base image and +# each one is a `chrome-headless-shell: error while loading shared libraries` +# at somebody's first test run. +# +# The fonts are not decoration. A Chromium with no fonts renders every glyph as +# a box, which turns a screenshot into a useless artifact and a text-measuring +# assertion into a flake. fonts-liberation is the metric-compatible Arial / +# Times / Courier set Chrome for Testing expects, fonts-dejavu-core covers +# Latin, Greek and Cyrillic, and fonts-noto-color-emoji is what an emoji in a +# product's UI renders as. CJK is deliberately absent — fonts-wqy-zenhei and +# fonts-ipafont-gothic are ~35 MiB for a script most suites never assert on; +# see docs/session-image.md. +# +# Xvfb is deliberately absent too: this image runs headless browsers only, and +# an X server would be dead weight plus a socket in every session. +RUN set -eu; \ + apt-get update; \ + dpkg-query -W -f='${Package}\n' | sort > /tmp/packages.before; \ + apt-get install -y --no-install-recommends \ + libasound2 libatk-bridge2.0-0 libatk1.0-0 libatspi2.0-0 \ + libcairo2 libcups2 libdbus-1-3 libdrm2 libgbm1 libglib2.0-0 \ + libnspr4 libnss3 libpango-1.0-0 \ + libx11-6 libxcb1 libxcomposite1 libxdamage1 libxext6 libxfixes3 \ + libxkbcommon0 libxrandr2 \ + fontconfig libfontconfig1 libfreetype6 \ + fonts-liberation fonts-dejavu-core fonts-noto-color-emoji; \ + rm -rf /var/lib/apt/lists/*; \ + fc-cache -f >/dev/null; \ + dpkg-query -W -f='${Package}\t${Installed-Size}\n' | sort \ + | awk -F'\t' 'NR==FNR { had[$1] = 1; next } \ + !($1 in had) { n++; kb += $2; added[$1] = $2 } \ + END { printf "%d packages, %d KiB installed\n", n, kb; \ + for (p in added) printf "%8d KiB %s\n", added[p], p }' \ + /tmp/packages.before - \ + | { read -r first; echo "$first"; sort -rn; } > /usr/local/share/rainier-browser-size.txt; \ + rm -f /tmp/packages.before; \ + chmod 0644 /usr/local/share/rainier-browser-size.txt + +# The browser itself, checksum-verified before extraction and laid out exactly +# where a project's Playwright looks. Root-owned under /usr/local/lib for the +# same reason the agents are: a session user who could rewrite the browser +# binary could rewrite what every later test run executes. +ARG TARGETARCH +ARG PLAYWRIGHT_VERSION +ARG CHROMIUM_VERSION +ARG CHROMIUM_REVISION +ARG PLAYWRIGHT_FFMPEG_REVISION +COPY images/session/browsers.sh /tmp/browsers.sh +RUN TARGETARCH="${TARGETARCH}" PLAYWRIGHT_VERSION="${PLAYWRIGHT_VERSION}" \ + CHROMIUM_VERSION="${CHROMIUM_VERSION}" CHROMIUM_REVISION="${CHROMIUM_REVISION}" \ + PLAYWRIGHT_FFMPEG_REVISION="${PLAYWRIGHT_FFMPEG_REVISION}" \ + /tmp/browsers.sh && rm /tmp/browsers.sh + +# The helper that links that baseline into the cache a project's Playwright +# reads. Root-owned in /usr/local/bin beside rainier-pg and rainier-redis; it +# installs nothing, downloads nothing and needs no privilege. +COPY images/session/browsers/ /usr/local/bin/ +RUN chmod 0755 /usr/local/bin/rainier-browsers + # The pinned upstream releases from the toolchain stage. Root-owned, under # /usr/local, which the session user cannot write — see the prefix note below. COPY --from=toolchain /opt/toolchain/go /usr/local/go @@ -358,6 +441,7 @@ ENV PATH="/opt/rainier-env/bin:${PATH}" \ GOPATH=/workspace/.gopath \ GOTMPDIR=/workspace/.cache/go-tmp \ XDG_CACHE_HOME=/workspace/.cache \ + PLAYWRIGHT_BROWSERS_PATH=/workspace/.cache/ms-playwright \ npm_config_cache=/workspace/.cache/npm \ npm_config_update_notifier=false \ PIP_CACHE_DIR=/workspace/.cache/pip \ @@ -366,6 +450,39 @@ ENV PATH="/opt/rainier-env/bin:${PATH}" \ PYTHONDONTWRITEBYTECODE=1 \ DISABLE_AUTOUPDATER=1 +# The browser baseline, linked into the cache a project's Playwright reads. +# +# PLAYWRIGHT_BROWSERS_PATH above is /workspace/.cache/ms-playwright, which is +# both writable and exactly where Playwright would have looked anyway +# ($XDG_CACHE_HOME/ms-playwright). Building the links HERE, into the image's +# own /workspace, means docker copies them onto a freshly created workspace +# volume at session creation: no entrypoint work, no first-run copy of a +# quarter of a gigabyte, and nothing on the volume but symlinks and two empty +# marker files. The payload stays on the read-only rootfs, out of checkpoints, +# archives and `rainier pull`. +# +# The seed's chown is -h, and the layout it produces is why the driver's own +# volume initializer is still correct. GNU chown -R traverses -P by default and +# lchown()s a symlink rather than its target (verified against coreutils 9.1), +# so `chown -R 1000:1000 /workspace` — which is exactly what +# internal/driver.initVolumeScript runs, as root with CAP_CHOWN and a READ-ONLY +# rootfs — walks over these links without touching the browser they point at +# and without failing on a filesystem it cannot write. A -L or --dereference +# there would do both: fail the init job with EROFS, and, on any host where it +# did not, hand the session user the root-owned binary it is about to execute. +# -h here says that out loud, and the assertions below are what actually holds +# it: the binary is still root's, and the cache still reaches it. +RUN set -eu; \ + /usr/local/bin/rainier-browsers link; \ + chown -Rh 1000:1000 /workspace/.cache; \ + bin=$(find /usr/local/lib/rainier-browsers -name chrome-headless-shell -type f); \ + [ -n "$bin" ] || { echo "no browser baseline was installed" >&2; exit 1; }; \ + [ "$(stat -c %u "$bin")" = 0 ] || { \ + echo "the browser baseline is owned by $(stat -c %U "$bin"), not root; the workspace chown followed a symlink" >&2; exit 1; }; \ + link=/workspace/.cache/ms-playwright/chromium_headless_shell-${CHROMIUM_REVISION}/$(basename "$(dirname "$bin")"); \ + [ -L "$link" ] && [ -x "$link/chrome-headless-shell" ] || { \ + echo "the workspace cache does not resolve to the baseline through $link" >&2; exit 1; } + COPY --from=build /out/sessiond /usr/local/bin/sessiond # sessiond as PID 1; RAINIER_DIAL/RAINIER_SESSION injected by the driver select diff --git a/Makefile b/Makefile index 705a1de9..7e9bdf2a 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: test build demo e2e verify module-path protocols control session-image session-image-smoke session-image-verify +.PHONY: test build demo e2e verify module-path protocols control session-image session-image-security-policy session-image-smoke session-image-browser-e2e session-image-verify DOCKER ?= docker SESSION_IMAGE ?= rainier-session:smoke @@ -41,6 +41,12 @@ control: session-image: $(DOCKER) build $(BUILD_ARGS) -t "$(SESSION_IMAGE)" . +# session-image-security-policy checks the public, test-only policy snapshot +# used by image qualification. Hosted Rainier Cloud owns and qualifies its +# runtime copy independently; core CI must not need a cross-repository token. +session-image-security-policy: + python3 scripts/session-image-security-policy-test.py + # session-image-smoke does the part `--version` cannot: it builds, runs, # installs and serves inside containers wearing the driver's real restrictions # — uid 1000, read-only rootfs, noexec /tmp, no network at all. See the header @@ -48,7 +54,15 @@ session-image: session-image-smoke: DOCKER="$(DOCKER)" ./scripts/session-image-smoke.sh "$(SESSION_IMAGE)" -session-image-verify: session-image session-image-smoke +# session-image-browser-e2e is the browser half, and the one step in image +# qualification that is deliberately allowed a network: it stages a sample +# project that has never been in the image, installs its locked dependencies +# from the registry, and then runs its Playwright suite twice with no network +# at all. See the header of the script. +session-image-browser-e2e: + DOCKER="$(DOCKER)" ./scripts/session-image-browser-e2e.sh "$(SESSION_IMAGE)" + +session-image-verify: session-image session-image-smoke session-image-browser-e2e -verify: module-path protocols control test build +verify: module-path protocols control session-image-security-policy test build go vet ./... diff --git a/docs/session-image.md b/docs/session-image.md index 6e54a04f..3a689ef8 100644 --- a/docs/session-image.md +++ b/docs/session-image.md @@ -30,6 +30,7 @@ Pinned by version in the `Dockerfile`, and by SHA-256 in | **Python** | `python3`, `venv`, `pip`, and `uv`/`uvx` | | **Shell** | `bash`, GNU coreutils, findutils, grep, sed, gawk, diffutils, `patch` | | **Search and data** | `ripgrep`, `jq` | +| **Browser testing** | The shared libraries and fonts Chromium needs, and one pinned Chrome for Testing headless shell with Playwright's ffmpeg, preinstalled and linked into the workspace browser cache — see [Browser testing](#browser-testing) | | **Databases** | PostgreSQL 17 client *and server* (`psql`, `initdb`, `pg_ctl`, `pg_dump`, `createdb`, `pg_isready`, …), SQLite 3 (`sqlite3`), Redis (`redis-server`, `redis-cli`) — installed, never started; see [Local services](#local-services-a-developer-starts) | | **Network** | `curl`, `wget`, CA certificates, `openssl`, `nc` | | **Archives** | `tar`, `gzip`, `bzip2`, `xz-utils`, `zip`, `unzip` | @@ -275,6 +276,239 @@ server at all is the thing this change exists to fix. If the pull cost review decides 239 MiB per runner boot is too much, the source build is the conversation to have, and the breakdown on the run is where it starts. +## Browser testing + +`npx playwright install --with-deps chromium` is the line every project's CI +runs, and it is the line a session cannot: `--with-deps` is `apt-get install` +as root, and a session has no escalation path, a read-only rootfs, and no +package archive on its egress allowlist. So the image carries the two halves +that command would have installed — the **shared libraries and fonts**, as an +ordinary build-time apt layer, and **one browser**, checksum-pinned beside the +rest of the toolchain — and a fresh session runs a project's Playwright suite +with no setup step and, on the supported version, no download at all. + +What it deliberately does **not** carry is Playwright itself. See +[Version matching](#version-matching-what-is-ready-to-run-and-what-is-not). + +### The ready-to-run baseline + +| | | +|---|---| +| Playwright the baseline matches | **1.63.x** (`@playwright/test` or `playwright`) | +| Browser | Chrome for Testing **153.0.8010.12**, `chromium-headless-shell` revision **1243** | +| Also preinstalled | Playwright's `ffmpeg` revision 1011, which is what `video:` recording uses | +| Where it lives | `/usr/local/lib/rainier-browsers`, root-owned, on the read-only rootfs | +| Where Playwright looks | `PLAYWRIGHT_BROWSERS_PATH=/workspace/.cache/ms-playwright` | + +```sh +# In a project with @playwright/test in its lockfile: +npm ci +npx playwright test # no `playwright install`, no network, no root + +rainier-browsers status # what is installed, where, and what resolves +rainier-browsers path # the cache directory Playwright reads +``` + +`rainier-browsers` is a root-owned shell script in `/usr/local/bin`, like +`rainier-pg` and `rainier-redis`. It installs nothing, downloads nothing and +needs no privilege. + +**Only the headless shell.** Playwright launches `chromium-headless-shell` for +`headless: true` and the full Chrome for Testing build only for `headless: +false` or an explicit `channel: 'chromium'`. This image has no X server and no +Xvfb, so `headless: false` cannot run in it whatever is installed, and the full +build is another ~393 MiB extracted for one channel setting. A project that +wants it runs `npx playwright install chromium`, which writes into the +workspace cache and needs only `cdn.playwright.dev`. + +### How the baseline and the workspace cache meet + +The baseline is on the **read-only rootfs**, because a session user who could +rewrite the browser binary could rewrite what every later test run executes. +Playwright's cache has to be **writable**, because a project pinned to a +different Playwright installs its own revision there and must win. The two meet +through links: + +``` +/workspace/.cache/ms-playwright/chromium_headless_shell-1243/ + INSTALLATION_COMPLETE real file, writable + DEPENDENCIES_VALIDATED real file, writable (Playwright rewrites it every 30 days) + .rainier-baseline says this entry is the image's, not the project's + chrome-headless-shell-linux64 -> /usr/local/lib/rainier-browsers/... +``` + +Those links are built into the image's own `/workspace`, so **docker copies +them onto a freshly created workspace volume** when the session is created. +There is no entrypoint work, no first-run copy of a quarter of a gigabyte, and +nothing on the volume but symlinks and two empty files — the payload stays on +the rootfs, out of checkpoints, archives and `rainier pull`. + +The two markers are real files rather than links because Playwright rewrites +`DEPENDENCIES_VALIDATED` after every successful host-requirements check and +re-runs that check when the file is older than thirty days; a failed write +there would cost an `ldd` sweep on every launch, forever. + +`PLAYWRIGHT_BROWSERS_PATH` is set explicitly even though it is exactly what +Playwright would compute on its own from `XDG_CACHE_HOME`, so the path is a +property of this image rather than of a default that could move. + +### Version matching: what is ready to run, and what is not + +**Nothing Playwright is installed globally, deliberately.** A global +`playwright` on `PATH` is what a bare `npx playwright` finds, and it would +drive a browser revision the project never pinned. The version that runs a +project's tests is the version in the project's own lockfile, always. The image +installs browsers; the project installs Playwright. + +Playwright pins a browser revision **per minor release** — 1.61 is 1228, 1.62 +is 1234, 1.63 is 1243 — and patch releases keep their minor's revision. So: + +| The project pins | What happens | +|---|---| +| `1.63.x` | The baseline is used. Nothing is downloaded; the suite runs offline. | +| Any other version | Playwright reports `Executable doesn't exist at …` and names `npx playwright install`, which downloads that version's revision (~114 MiB) into `/workspace/.cache/ms-playwright` beside the baseline. It needs `cdn.playwright.dev`. The download survives suspend and resume, so it is paid once per workspace. | +| `channel: 'chrome'` or `'msedge'` | Not supported and will not be: those are Google's and Microsoft's branded builds, installed from their own apt archives as root. | +| `channel: 'chromium'`, or `headless: false` | Needs the full Chrome for Testing build (`npx playwright install chromium`), and `headless: false` needs a display this image does not have. | + +`npm ci` never downloads a browser: Playwright's npm packages carry no install +script, so acquiring a browser is always an explicit `playwright install`. + +**Do not run `npx playwright install --with-deps`.** The `--with-deps` half +needs root and will fail; the dependencies it wants are already installed. Plain +`npx playwright install` is the supported form. + +A `playwright install` prunes browser directories no linked Playwright asks +for, which for a project on another version means it removes the baseline's +links. That is correct behaviour and it only ever removes links — the payload is +on the rootfs. `rainier-browsers link` puts them back. + +### Firefox and WebKit are not supported + +Only Chromium is. `npx playwright install firefox` or `webkit` will download +the browser and then fail to launch, because their shared libraries are not in +this image: Firefox additionally needs GTK 3, `libdbus-glib`, `libavcodec` and +an X client stack, and WebKit needs four GStreamer plugin sets, `libsoup3`, +`libenchant`, EGL/GLES and more — together several hundred megabytes of +packages, for browsers whose engines this platform's own suites do not target. +`npx playwright install-deps` cannot supply them from inside a session at all. + +A project that needs cross-browser coverage runs it somewhere else. Adding +either engine here is a bounded change to the Dockerfile and a real size +review; it has not been made. + +### Sandboxing, and what is actually isolating the browser + +"Chromium sandbox" describes two layers that work together in a hosted +session. + +**The project must request Chromium's own sandbox.** Playwright defaults +`chromiumSandbox` to `false`, so a project that wants the browser sandbox must +set it explicitly. Rainier's browser qualification projects do this for every +Chromium launch. The image and driver never add `--no-sandbox`, and the image +contract tests reject that flag in executable qualification code. + +**The container remains the outer boundary.** A session still runs as uid 1000 +with `no-new-privileges`, a read-only rootfs, a noexec `/tmp`, its own network +namespace, no host mount, no Docker socket, and the hosted runner's seccomp and +AppArmor profiles. Chromium's sandbox is an additional process boundary inside +that container; it does not replace the container boundary. + +**Hosted Rainier admits only the namespace operations Chromium needs.** The +Cloud security profile permits Chromium's exact `clone(CLONE_NEWUSER|SIGCHLD)` +and `unshare(CLONE_NEWUSER|CLONE_NEWNS)` forms, the x86_64 clone shape used by +its safe-empty-directory helper, plus the AppArmor `userns` permission. The +hosted browser qualification runs the real web suite under those profiles and +fails if Chromium cannot initialize its sandbox. A local +Docker host with stricter policies must load an equivalent reviewed profile; +Rainier never falls back to `--no-sandbox`. Core keeps a public, test-only +snapshot of this boundary in `testdata/session-security/`; its structural test +and image workflow no longer need to check out Rainier Cloud. The runtime +policy remains Cloud-owned and is qualified independently. + +**No broad privilege is needed.** The session does not use `--privileged`, +`--cap-add`, `seccomp=unconfined`, `apparmor=unconfined`, host networking, +host IPC, a wider mount, or a debugging socket. Playwright's +`--remote-debugging-pipe` uses file descriptors rather than a listening port, +and each launch receives a fresh profile under the session's temporary +filesystem. + +### `/dev/shm` is 64 MiB, and that is fine here + +Docker's default, and the driver does not change it. Chromium is famous for +crashing in containers with a small `/dev/shm` — and Playwright passes +`--disable-dev-shm-usage` on **every** Chromium launch, which moves those +allocations to `/tmp`, a per-container tmpfs bounded by half of RAM. A suite +driven by Playwright is unaffected. A tool that launches Chromium itself +without that flag can still exhaust it; pass the flag rather than asking for a +wider container. + +### Fonts + +`fonts-liberation` (metric-compatible with Arial, Times and Courier, which is +what Chrome for Testing expects), `fonts-dejavu-core` (Latin, Greek, Cyrillic) +and `fonts-noto-color-emoji`. A browser with no fonts renders every glyph as a +box, which makes a screenshot artifact useless and a text-measuring assertion a +flake, so this is a rendering dependency rather than a nicety — the smoke +measures ten 100px Arial capital Ms and requires the ~833px that Liberation +gives, which a DejaVu fallback (791px) would fail. + +**CJK is deliberately absent.** `fonts-wqy-zenhei` and `fonts-ipafont-gothic` +are ~35 MiB for a script most suites never assert on. A suite that needs it +should say so; adding it is a bounded change to the Dockerfile. + +### What this costs + +Measured by the build and reported by the smoke on every qualification run, so +read it off the run rather than off this page: + +On the qualified candidate (linux/amd64, the default pinned base; run +[34378290786](https://github.com/tokencanopy/rainier/actions/runs/34378290786)): + +| | | +|---|---| +| Shared libraries and fonts | **26 packages, 30,744 KiB (≈30 MiB)** | +| The browser payload | **272,100 KiB (≈266 MiB)** under `/usr/local/lib/rainier-browsers` | +| Whole image, with it | **2,725,950,441 bytes, 28 layers** — up from 2,416,401,380 and 22 layers, so **+295 MiB and +6 layers** | +| Compressed, in the pull | ~117 MiB (`chrome-headless-shell-linux64.zip` 114.3 MiB + ffmpeg 2.3 MiB), plus the apt layer | +| Per session, on the workspace volume | two directories of symlinks and empty marker files — kilobytes | +| Startup cost | none: docker copies the links when it creates the volume, and no entrypoint step touches them | + +Read the first two off the run rather than off this table, which is one build +old the moment it is written: the apt layer diffs its own package set and +writes `/usr/local/share/rainier-browser-size.txt`, +`images/session/browsers.sh` appends the browser payload to the same file, and +the smoke reports both as workflow notices on the pull request being approved. + +Dropping the browser would return ~296 MiB; adding the full Chrome for Testing +build would cost ~393 MiB more. Both are one line of `images/session/browsers.sh` +and a checksum, and the [rollout runbook](https://github.com/tokencanopy/rainier-cloud/blob/main/docs/runbooks/default-environment-rollout.md) +step 2 is where the pull cost is reviewed against them. + +A project that pins another Playwright pays ~114 MiB of download once per +workspace, onto the volume, where it survives suspend and resume. + +### Egress + +One host, for both artifacts: + +| Host | What needs it | +|---|---| +| `cdn.playwright.dev` | `npx playwright install` for any browser or revision the image does not carry, including the full Chrome for Testing build | +| `playwright.download.prss.microsoft.com` | Playwright's documented fallback mirror; only tried when the first fails | +| `registry.npmjs.org` | `npm ci` of the project's own Playwright, like any other dependency | + +Nothing is needed at all for a project on the pinned version: the baseline is +in the image and the suite runs on `--network none`. Which of these an +environment gets is a control-plane decision and not this image's to make. + +**A test web server has to be on loopback.** A session's `http_proxy` points at +the egress proxy and its `no_proxy` carries `localhost` and `127.0.0.1`, which +Chromium reads. A dev server on `127.0.0.1` — which is what Playwright's +`webServer` starts and what Vite, Next and the rest bind by default — is +reached directly. A suite that instead addressed the container by its own +hostname or its non-loopback address would send the request to the egress proxy +and be refused; bind and address loopback. + ## Why this base image, and not a Dev Containers one The choice was between a pinned Debian/Ubuntu **Dev Containers base image** and @@ -396,6 +630,7 @@ survivable: go test ./internal/driver/ -run TestSession # the contract, no docker needed make session-image # build it make session-image-smoke # what --version cannot tell you +make session-image-browser-e2e # a real Playwright project, twice ``` `internal/driver/image_contract_test.go` reads the `Dockerfile` and the @@ -427,7 +662,28 @@ all. The shell of those two helpers is separately exercised against stub binaries in `internal/driver/image_services_test.go`, which needs no docker and catches the behavioural failures (a stop that waits on the wrong thing, a server bound to the wrong interface, a cluster created with the wrong locale) -that reading the script does not. Every probe runs in a container wearing the +that reading the script does not. The shell of `rainier-browsers` is exercised +the same way in `internal/driver/image_browser_test.go`, including the case +that matters most — a `link` that would overwrite a browser the project +installed itself. + +It also runs the browser: it checks that the baseline is root-owned and +unwritable, that a freshly created workspace volume already carries the cache +links, that the preinstalled build is the one the `Dockerfile` pins, that every +shared library resolves, that a page renders and screenshots at 1280x800 and at +390x844, that Arial lays out at Liberation's metrics rather than a fallback's, +and that no browser process survives the run. Chromium's own sandbox is +asserted when qualification loads the reviewed seccomp and AppArmor fixture; +the smoke script fails closed if Chromium exits without its sandbox. Which +policy a production host applies remains a host property and is qualified by +Rainier Cloud separately. + +`scripts/session-image-browser-e2e.sh` is the third piece and the only one with +a network, deliberately: it stages a sample project that has never been in the +image, `npm ci`s its locked dependencies from the registry, and then runs its +Playwright suite twice on `--network none` — a real navigation, assertions on +real layout at a desktop and a phone viewport, a screenshot, and a deliberate +failure whose trace, screenshot and video it then goes looking for. Every probe runs in a container wearing the driver's own restrictions with **no network at all**, and nothing is relaxed to make a check pass — a check that cannot pass under the real contract is reporting a real defect in the image. @@ -541,3 +797,22 @@ useful local diagnostics, not qualification of the shipping AMD64 image. Authenticated agent workloads and cold dependency downloads under hosted egress policy remain a separate no-setup environment gate on approved canary capacity; never replace a runner holding active work to obtain that evidence. + + +### Browser cache recovery after an image change + +After restoring a workspace onto a different browser-image revision, run +`rainier-browsers link`. It links the new baseline and invalidates completion +markers for Rainier-owned revisions whose image-local payload no longer exists. +The project's normal `npx playwright install` can then fetch an older pinned +revision again; project-installed browser directories are preserved. This is an +explicit recovery step for existing volumes, not an automatic image migration. +`PLAYWRIGHT_BROWSERS_PATH=0` uses Playwright's package-local cache and is not +managed by this helper; use the project's installer for that mode. + +The session-image CI runs `make session-image-security-policy` and +`make session-image-browser-e2e` in addition to its offline image checks. The +sample project explicitly enables `chromiumSandbox: true`, and the image job +loads the public test-only seccomp and AppArmor snapshot. Cloud's hosted browser +qualification runs the web suite under its independently reviewed runtime +profiles. Both checks are release gates for the supported browser path. diff --git a/images/session/browser-sample/package-lock.json b/images/session/browser-sample/package-lock.json new file mode 100644 index 00000000..ce4eed62 --- /dev/null +++ b/images/session/browser-sample/package-lock.json @@ -0,0 +1,60 @@ +{ + "name": "rainier-browser-sample", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "rainier-browser-sample", + "version": "0.0.0", + "devDependencies": { + "@playwright/test": "1.63.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/images/session/browser-sample/package.json b/images/session/browser-sample/package.json new file mode 100644 index 00000000..204c061f --- /dev/null +++ b/images/session/browser-sample/package.json @@ -0,0 +1,13 @@ +{ + "name": "rainier-browser-sample", + "private": true, + "version": "0.0.0", + "description": "The sample project scripts/session-image-browser-e2e.sh qualifies the session image with: a loopback web server, a real Chromium navigation at two viewports, and the artifacts a failure has to leave behind.", + "scripts": { + "serve": "node server.js", + "test": "playwright test" + }, + "devDependencies": { + "@playwright/test": "1.63.0" + } +} diff --git a/images/session/browser-sample/playwright.config.js b/images/session/browser-sample/playwright.config.js new file mode 100644 index 00000000..d6ff496b --- /dev/null +++ b/images/session/browser-sample/playwright.config.js @@ -0,0 +1,59 @@ +// The sample suite's configuration, and deliberately an ordinary one: nothing +// here is Rainier-specific, because the point of the qualification is that a +// project's own unmodified Playwright configuration works in a session. +// +// In particular there is no `channel`, no `executablePath`, or launch +// argument. The browser sandbox is required for this baseline. Playwright resolves the browser +// from PLAYWRIGHT_BROWSERS_PATH, which the image points at the workspace +// cache; the image seeds that cache with links to its pinned baseline, so this +// runs with nothing downloaded and no network at all. +const { defineConfig, devices } = require('@playwright/test') + +const PORT = Number(process.env.PORT || 8973) +const baseURL = `http://127.0.0.1:${PORT}` + +module.exports = defineConfig({ + testDir: './tests', + // CI here means "this is a qualification run": no `.only` may slip through, + // and one worker, because the thing being measured is the image rather than + // the host's core count. + forbidOnly: !!process.env.CI, + workers: 1, + retries: 0, + reporter: [['list'], ['html', { open: 'never' }]], + // The artifacts a failure has to leave behind, and the reason ffmpeg is in + // the image: a trace to open, a screenshot to look at, and a video of the + // run. All three are retained only on failure, so a green run writes almost + // nothing to the workspace volume. + use: { + baseURL, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, + projects: [ + { + name: 'desktop', + use: { ...devices['Desktop Chrome'], viewport: { width: 1280, height: 800 }, launchOptions: { chromiumSandbox: true } }, + }, + { + name: 'phone', + // A real phone descriptor, not just a narrow window: device scale + // factor, touch, and the mobile user agent all change what the page + // does, and a layout assertion that ignored them would be measuring + // something nobody has. + use: { ...devices['Pixel 7'], launchOptions: { chromiumSandbox: true } }, + }, + ], + // Playwright starts and stops this itself, which is half of what the + // qualification is checking: a session must not be left with a listener on + // loopback after the suite exits. + webServer: { + command: 'node server.js', + url: `${baseURL}/healthz`, + reuseExistingServer: false, + timeout: 60_000, + stdout: 'pipe', + stderr: 'pipe', + }, +}) diff --git a/images/session/browser-sample/server.js b/images/session/browser-sample/server.js new file mode 100644 index 00000000..1444478b --- /dev/null +++ b/images/session/browser-sample/server.js @@ -0,0 +1,78 @@ +// The loopback web server the sample suite drives. Deliberately node's own +// http and nothing else: a session image qualification must not depend on a +// second package resolving, and the point of the exercise is the browser. +// +// It binds 127.0.0.1 inside the session's own network namespace, which is the +// only interface a test server should ever be on. Nothing outside the session +// container can reach it even if the container has egress. +const http = require('node:http') + +const PORT = Number(process.env.PORT || 8973) + +// One page, written out here rather than read from disk, so the served bytes +// and the assertions live next to each other. Everything it names is +// synthetic: no account, workspace or session is behind any of it. +const page = ` + + + + + Rainier browser sample + + + +

Sessions

+
+

Signed in as sample@rainier.test on runner.invalid.

+
+

box1

running

+

box2

suspended

+

box3

stopped

+
+

Resumed 0 times.

+ +
+ + + +` + +const server = http.createServer((req, res) => { + if (req.url === '/healthz') { + res.writeHead(200, { 'content-type': 'text/plain' }) + res.end('ok') + return + } + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }) + res.end(page) +}) + +// Loopback only, explicitly. A server that bound 0.0.0.0 would be reachable +// from anything sharing this network namespace, and "nothing does" is a +// property of the deployment rather than of this file. +server.listen(PORT, '127.0.0.1', () => { + console.log(`sample server on http://127.0.0.1:${PORT}`) +}) + +for (const signal of ['SIGINT', 'SIGTERM']) { + process.on(signal, () => server.close(() => process.exit(0))) +} diff --git a/images/session/browser-sample/tests/artifacts.spec.js b/images/session/browser-sample/tests/artifacts.spec.js new file mode 100644 index 00000000..849262e9 --- /dev/null +++ b/images/session/browser-sample/tests/artifacts.spec.js @@ -0,0 +1,14 @@ +// A failing test, run on purpose by scripts/session-image-browser-e2e.sh with +// RAINIER_BROWSER_SMOKE_FAIL=1, because "a failure emits a trace, a screenshot +// and a video" is a claim about the image and the configuration together and +// cannot be checked by a suite that only ever passes. +// +// It is skipped in every other run, so an ordinary `npx playwright test` in +// this directory is green. +const { test, expect } = require('@playwright/test') + +test('deliberately fails so the run has artifacts to emit', async ({ page }) => { + test.skip(process.env.RAINIER_BROWSER_SMOKE_FAIL !== '1', 'artifact probe; set RAINIER_BROWSER_SMOKE_FAIL=1') + await page.goto('/') + await expect(page.getByRole('heading', { level: 1 })).toHaveText('this heading does not exist') +}) diff --git a/images/session/browser-sample/tests/session.spec.js b/images/session/browser-sample/tests/session.spec.js new file mode 100644 index 00000000..d12dd911 --- /dev/null +++ b/images/session/browser-sample/tests/session.spec.js @@ -0,0 +1,88 @@ +// What a session has to be able to do out of the box: reach a loopback server +// it started itself, drive a real Chromium, assert on what rendered, and write +// a screenshot somebody can look at. Everything it names is synthetic. +const { test, expect } = require('@playwright/test') +const fs = require('node:fs') +const path = require('node:path') + +test('renders the page and asserts on what the browser actually laid out', async ({ page }, testInfo) => { + await page.goto('/') + await expect(page).toHaveTitle('Rainier browser sample') + await expect(page.getByRole('heading', { name: 'Sessions', level: 1 })).toBeVisible() + await expect(page.getByText('sample@rainier.test')).toBeVisible() + + // A real layout question, which is the only kind worth a browser: the cards + // are a three-column grid on a desktop viewport and a single column on a + // phone. jsdom cannot answer this. + const cards = page.locator('.card') + await expect(cards).toHaveCount(3) + const boxes = await cards.evaluateAll((els) => els.map((el) => el.getBoundingClientRect().top)) + const sameRow = boxes.every((top) => Math.abs(top - boxes[0]) < 1) + expect(sameRow).toBe(testInfo.project.name === 'desktop') + + // And a real interaction: a click that has to reach the page and run its + // handler, not a synthetic event dispatched into a DOM implementation. + await page.getByRole('button', { name: 'Resume' }).click() + await expect(page.locator('#count')).toHaveText('1') + + const screenshot = testInfo.outputPath(`${testInfo.project.name}.png`) + await page.screenshot({ path: screenshot, fullPage: true }) + expect(fs.statSync(screenshot).size).toBeGreaterThan(1000) + await testInfo.attach(`${testInfo.project.name} screenshot`, { path: screenshot, contentType: 'image/png' }) +}) + +test('has no horizontal overflow at its own viewport', async ({ page }) => { + await page.goto('/') + const overflow = await page.evaluate(() => { + const root = document.documentElement + return root.scrollWidth - root.clientWidth + }) + expect(overflow).toBeLessThanOrEqual(0) +}) + +// The version-matching evidence, asserted rather than described. A +// preinstalled browser is only worth anything if the project own Playwright is +// what picked it, from the path the image advertises, at the revision that +// Playwright version pins. If any of those three stops being true this test +// says which one. +// +// It reads /proc rather than asking Playwright for a path, because +// `chromium.executablePath()` answers for the full Chrome for Testing build +// while `headless: true` actually launches chrome-headless-shell. The running +// process is the only unambiguous answer to "which binary is this". +test('runs the preinstalled baseline, resolved by the project own Playwright', async ({ browser, page }) => { + await page.goto('/') + + const browsersPath = process.env.PLAYWRIGHT_BROWSERS_PATH + expect(browsersPath, 'PLAYWRIGHT_BROWSERS_PATH').toBeTruthy() + + const running = fs + .readdirSync('/proc') + .filter((entry) => /^\d+$/.test(entry)) + .map((pid) => { + try { + return fs.readlinkSync(`/proc/${pid}/exe`) + } catch { + return null + } + }) + .filter((exe) => exe && /chrome-headless-shell|chrome-linux/.test(exe)) + + expect(running.length, 'a browser process').toBeGreaterThan(0) + + // /proc//exe is already resolved, so this is the real file behind the + // workspace cache link — which is how the test tells "the image preinstalled + // it" apart from "this workspace downloaded it". Both are legitimate; only + // the first is what a fresh session is being qualified for. + const exe = running[0] + expect(exe.startsWith('/usr/local/lib/rainier-browsers/'), `browser executable ${exe}`).toBe(true) + + // The revision directory is the one this project own Playwright pins, which + // is the whole of "the versions match". + const registry = require( + path.join(path.dirname(require.resolve('playwright-core')), 'browsers.json'), + ) + const pinned = registry.browsers.find((b) => b.name === 'chromium-headless-shell') + expect(exe).toContain(`chromium_headless_shell-${pinned.revision}`) + expect(browser.version()).toBe(pinned.browserVersion) +}) diff --git a/images/session/browsers.sh b/images/session/browsers.sh new file mode 100755 index 00000000..682454c3 --- /dev/null +++ b/images/session/browsers.sh @@ -0,0 +1,188 @@ +#!/usr/bin/env bash +# Installs the session image's pinned browser baseline: the Chromium build that +# a project's own Playwright launches for `headless: true`, plus Playwright's +# ffmpeg for video recording. +# +# WHY A BASELINE AT ALL. A session has no sudo, a read-only rootfs, and an +# egress allowlist that does not carry a package archive, so `npx playwright +# install --with-deps` — the line every project's CI uses — cannot work here: +# its apt half needs root. The shared-library half of that command is therefore +# in the Dockerfile as an ordinary build-time apt layer, and the browser half is +# here. Between them a fresh session runs a project's Playwright tests with no +# setup step, and with no download at all when the project's Playwright agrees +# with the pin below. +# +# WHY ONLY THE HEADLESS SHELL. Playwright launches `chromium-headless-shell` +# for `headless: true` and the full Chrome for Testing build only for +# `headless: false` or an explicit `channel: 'chromium'`. This image has no X +# server and no Xvfb, so `headless: false` cannot run in it whatever is +# installed, and the full build's extra 393 MiB would buy one channel setting. +# A project that wants it runs `npx playwright install chromium`, which writes +# into the workspace cache and needs only cdn.playwright.dev. See +# docs/session-image.md. +# +# WHAT IS PINNED. The Chrome for Testing version and the two Playwright +# revisions are ARGs in the Dockerfile; the SHA-256 of every archive is in the +# table below and is checked before a single byte is extracted. Every URL names +# its version — none of them resolves "the latest" at build time — so one +# commit builds one browser, and a version bump that forgets its checksum fails +# the build rather than installing something unreviewed. +# +# WHAT IS NOT INSTALLED. No `playwright` or `@playwright/test` npm package, +# globally or otherwise. A global Playwright would shadow nothing at require() +# time but would absolutely be picked up by a bare `npx playwright`, and the +# version that drives a project's tests has to be the one in the project's own +# lockfile. This script installs browsers; the project installs Playwright. +set -euo pipefail + +: "${TARGETARCH:?TARGETARCH must be set (docker sets it from the build platform)}" +: "${CHROMIUM_VERSION:?}" "${CHROMIUM_REVISION:?}" "${PLAYWRIGHT_FFMPEG_REVISION:?}" +: "${PLAYWRIGHT_VERSION:?}" + +# Same guard as the toolchain script: docker reports TARGETARCH from the BUILD +# platform while dpkg reports the truth about the IMAGE, and a browser for the +# other architecture is an "exec format error" at somebody's first test run +# rather than a failed build. +image_arch=$(dpkg --print-architecture) +if [ "$image_arch" != "$TARGETARCH" ]; then + echo "the base image is ${image_arch} but the build targets ${TARGETARCH}: pass --build-arg BASE_IMAGE=, or build with --platform linux/${image_arch}" >&2 + exit 1 +fi + +# Playwright's own registry layout, which is the whole point: these directory +# and executable names are what packages/playwright-core/src/server/registry +# computes from the browser name and revision, so a project's Playwright finds +# the baseline by looking where it always looks. They are asserted after the +# extraction rather than trusted. +case "$TARGETARCH" in +amd64) + SHELL_DIR=chrome-headless-shell-linux64 + SHELL_ZIP="chrome-headless-shell-linux64.zip" + SHELL_SHA=a9da028861a0cf789ff25c2fed45f5f1aaf969ed9247835b6a7821a4f7af9d1d + CFT_PLATFORM=linux64 + FFMPEG_ZIP=ffmpeg-linux.zip + FFMPEG_SHA=ebc74fc5b94830176a3c2914ae96bd8bc7f6a91f4f33890230f84a172ee61ccc + ;; +arm64) + SHELL_DIR=chrome-headless-shell-linux-arm64 + SHELL_ZIP="chrome-headless-shell-linux-arm64.zip" + SHELL_SHA=d433c45172c7836e38124fe545f767b02210bfb43a6262f08a297473a8e91c99 + CFT_PLATFORM=linux-arm64 + FFMPEG_ZIP=ffmpeg-linux-arm64.zip + FFMPEG_SHA=2628c03f05318ff812c8c9baaf207dea2ddf53e818c0dc936714b0fbe3afb009 + ;; +*) + echo "no pinned browser baseline for TARGETARCH=${TARGETARCH}; the session image supports amd64 and arm64" >&2 + exit 1 + ;; +esac + +PREFIX=${RAINIER_BROWSERS_PREFIX:-/usr/local/lib/rainier-browsers} +SHELL_HOME="$PREFIX/chromium_headless_shell-${CHROMIUM_REVISION}" +FFMPEG_HOME="$PREFIX/ffmpeg-${PLAYWRIGHT_FFMPEG_REVISION}" + +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT + +# fetch +fetch() { + local url=$1 want=$2 out=$3 + curl --fail --location --retry 3 --retry-delay 2 --max-time 900 \ + --proto '=https' --tlsv1.2 --output "$out" "$url" + echo "${want} ${out}" | sha256sum --check --status \ + || { echo "checksum mismatch for ${url}" >&2; exit 1; } +} + +mkdir -p "$SHELL_HOME" "$FFMPEG_HOME" + +# Chrome for Testing, from Playwright's CDN and at the exact build Playwright +# ${PLAYWRIGHT_VERSION} pins. cdn.playwright.dev serves the Chrome for Testing +# builds under a plain /builds/cft path and Playwright's own artifacts under +# /dbazure/download/playwright — one host for both, which is the only host a +# session needs allowlisted to install a different browser later. +fetch "https://cdn.playwright.dev/builds/cft/${CHROMIUM_VERSION}/${CFT_PLATFORM}/${SHELL_ZIP}" \ + "$SHELL_SHA" "$work/shell.zip" +unzip -q "$work/shell.zip" -d "$SHELL_HOME" + +fetch "https://cdn.playwright.dev/dbazure/download/playwright/builds/ffmpeg/${PLAYWRIGHT_FFMPEG_REVISION}/${FFMPEG_ZIP}" \ + "$FFMPEG_SHA" "$work/ffmpeg.zip" +unzip -q "$work/ffmpeg.zip" -d "$FFMPEG_HOME" + +# The two markers Playwright writes after a successful install and a successful +# host-requirements check. Both are created here because this baseline is on +# the read-only rootfs at runtime: without INSTALLATION_COMPLETE a project's +# `playwright install` would decide the browser is absent and download it +# again, and without DEPENDENCIES_VALIDATED every launch would re-run the ldd +# sweep and then silently fail to record that it passed. +# +# DEPENDENCIES_VALIDATED is honest here and nowhere else: the shared libraries +# it stands for are installed in the layer above this one, by the same build, +# and are asserted by ldd below. +for home in "$SHELL_HOME" "$FFMPEG_HOME"; do + : > "$home/INSTALLATION_COMPLETE" + : > "$home/DEPENDENCIES_VALIDATED" +done + +SHELL_BIN="$SHELL_HOME/$SHELL_DIR/chrome-headless-shell" +FFMPEG_BIN="$FFMPEG_HOME/ffmpeg-linux" + +# The layout Playwright will look for, asserted rather than assumed: an +# upstream archive that renamed its top-level directory would otherwise ship an +# image whose baseline is invisible to the thing that is supposed to find it. +[ -x "$SHELL_BIN" ] || { echo "the Chromium archive did not contain ${SHELL_DIR}/chrome-headless-shell" >&2; exit 1; } +[ -x "$FFMPEG_BIN" ] || { echo "the ffmpeg archive did not contain ffmpeg-linux" >&2; exit 1; } + +# Every shared library the browser needs has to resolve in THIS image. ldd is +# the same check `playwright install` runs, and running it here means a missing +# apt line is a failed build rather than a developer's first test run failing +# with "error while loading shared libraries". Playwright's own version of this +# check reported "validation passed" against an image with sixteen of them +# missing, so this one reads ldd directly. +missing=$(ldd "$SHELL_BIN" 2>/dev/null | awk '/not found/ { print $1 }' | sort -u) +if [ -n "$missing" ]; then + echo "the browser baseline is missing shared libraries this image does not install:" >&2 + echo "$missing" >&2 + exit 1 +fi + +# It also has to actually start. --version is the cheapest execution that +# proves the dynamic linker, the CPU baseline and the file mode all agree, and +# it must report the Chrome for Testing build this script was told to install. +got=$("$SHELL_BIN" --version 2>&1 || true) +case "$got" in + *"$CHROMIUM_VERSION"*) ;; + *) echo "the installed browser reports '${got}', not ${CHROMIUM_VERSION}" >&2; exit 1 ;; +esac + +# What a reader holding only a digest needs in order to answer "which browser, +# which Playwright, and where". Read by /usr/local/bin/rainier-browsers and by +# scripts/session-image-smoke.sh; a target list is not evidence. +cat > /usr/local/share/rainier-browsers.json <> /usr/local/share/rainier-browser-size.txt +chmod 0644 /usr/local/share/rainier-browser-size.txt diff --git a/images/session/browsers/rainier-browsers b/images/session/browsers/rainier-browsers new file mode 100755 index 00000000..166a43bf --- /dev/null +++ b/images/session/browsers/rainier-browsers @@ -0,0 +1,150 @@ +#!/bin/sh +# rainier-browsers — the session image's preinstalled browser baseline, and the +# link between it and the cache a project's own Playwright reads. +# +# The baseline lives root-owned under /usr/local/lib/rainier-browsers, on the +# read-only rootfs. Playwright looks for browsers in +# $PLAYWRIGHT_BROWSERS_PATH, which this image sets to +# /workspace/.cache/ms-playwright — writable, on the volume, and the same place +# Playwright would have chosen on its own from $XDG_CACHE_HOME. `link` fills +# that cache with directories whose payload is a symlink into the baseline, so +# a fresh session launches the pinned browser with nothing downloaded, and a +# project that pins a different Playwright still installs its own revision +# beside it and wins. +# +# The image runs `link` at build time, and docker copies the result onto a +# freshly created workspace volume, so an ordinary session never has to run +# this. It is here for the two cases that are not ordinary: a workspace volume +# created before this image existed, and a `playwright install` that pruned the +# links as unused (it does that to any browser directory no linked Playwright +# asks for, which is the correct behaviour and only ever removes the links). +# +# It installs nothing, downloads nothing, needs no privilege, and never touches +# a browser directory a project installed itself. +set -eu + +PREFIX=${RAINIER_BROWSERS_PREFIX:-/usr/local/lib/rainier-browsers} +MANIFEST=/usr/local/share/rainier-browsers.json +# The marker that says a browser directory in the cache is a link to the +# baseline rather than something a project downloaded. Without it `link` would +# have to guess, and guessing wrong means deleting somebody's install. +OURS=.rainier-baseline + +usage() { + cat <<'USAGE' +usage: rainier-browsers + + status what is installed, where, and what a project's Playwright resolves + link (re)create the baseline's entries in the Playwright browser cache + path print the Playwright browser cache directory + version print the pinned Playwright version and Chromium build + +The baseline is read-only. A project that needs another browser or another +revision runs its own `npx playwright install`, which writes into the cache +`path` prints and needs cdn.playwright.dev. +USAGE +} + +registry_dir() { + if [ "${PLAYWRIGHT_BROWSERS_PATH:-}" = 0 ]; then + echo "PLAYWRIGHT_BROWSERS_PATH=0 selects a package-local cache; use the project Playwright installer or unset this override" >&2 + return 2 + fi + if [ -n "${PLAYWRIGHT_BROWSERS_PATH:-}" ]; then + printf '%s\n' "$PLAYWRIGHT_BROWSERS_PATH" + else + printf '%s/ms-playwright\n' "${XDG_CACHE_HOME:-$HOME/.cache}" + fi +} + +playwright_version() { + [ -r "$MANIFEST" ] || { printf 'unknown\n'; return 0; } + sed -n 's/.*"playwrightVersion": *"\([^"]*\)".*/\1/p' "$MANIFEST" | head -1 +} + +# A cache entry is the baseline's to manage only while it still says so. A +# project's own install replaces the whole directory, marker included. +is_ours() { [ -f "$1/$OURS" ]; } + +link_one() { + src=$1 dst=$2 + if [ -e "$dst" ] && ! is_ours "$dst"; then + printf 'skip %s (installed by this workspace, not the baseline)\n' "$dst" + return 0 + fi + mkdir -p "$dst" + printf '%s\n' "$src" > "$dst/$OURS" + # Playwright's two markers. They are ordinary writable files in the cache + # rather than symlinks into the read-only baseline, because Playwright + # rewrites DEPENDENCIES_VALIDATED every thirty days and a failed write there + # costs an ldd sweep on every launch afterwards. + : > "$dst/INSTALLATION_COMPLETE" + : > "$dst/DEPENDENCIES_VALIDATED" + for entry in "$src"/*; do + name=${entry##*/} + case "$name" in + INSTALLATION_COMPLETE|DEPENDENCIES_VALIDATED|"$OURS"|'*') continue ;; + esac + [ -n "$dst" ] && [ -n "$name" ] || { echo "invalid browser cache entry" >&2; return 1; } + rm -rf -- "${dst:?}/${name:?}" + ln -s "$entry" "$dst/$name" + done + printf 'link %s -> %s\n' "$dst" "$src" +} + +cmd_link() { + [ -d "$PREFIX" ] || { echo "no browser baseline at $PREFIX" >&2; exit 1; } + reg=$(registry_dir) + mkdir -p "$reg" + # An upgraded image may have retired a revision whose cache links survived + # on the workspace volume. Playwright skips download when this marker exists, + # even if the linked payload is gone. Invalidate only our stale markers; + # leave project installs and any other workspace files untouched. + for dst in "$reg"/*; do + [ -d "$dst" ] && [ ! -L "$dst" ] && is_ours "$dst" || continue + source_path=$(cat "$dst/$OURS") + if [ ! -d "$source_path" ]; then + rm -f "$dst/INSTALLATION_COMPLETE" "$dst/DEPENDENCIES_VALIDATED" + printf 'repair %s (retired baseline; project installer may download it)\n' "$dst" + fi + done + for src in "$PREFIX"/*; do + [ -d "$src" ] || continue + link_one "$src" "$reg/${src##*/}" + done +} + +cmd_status() { + reg=$(registry_dir) + printf 'playwright pin %s\n' "$(playwright_version)" + printf 'baseline %s\n' "$PREFIX" + printf 'browser cache %s\n' "$reg" + [ -d "$PREFIX" ] || { echo "the baseline is missing" >&2; exit 1; } + for src in "$PREFIX"/*; do + [ -d "$src" ] || continue + name=${src##*/} + state=absent + if [ -d "$reg/$name" ]; then + if is_ours "$reg/$name"; then state=linked; else state="installed by this workspace"; fi + fi + printf '%-40s %s\n' "$name" "$state" + done + # The one thing a version table cannot tell you: whether the binary in the + # cache runs in this container, as this user, right now. + for exe in "$reg"/*/*/chrome-headless-shell; do + [ -x "$exe" ] || continue + printf 'runs %s (%s)\n' "$("$exe" --version 2>&1 | head -1)" "$exe" + done +} + +case "${1:-}" in + status) cmd_status ;; + link) cmd_link ;; + path) registry_dir ;; + version) + printf 'playwright %s\n' "$(playwright_version)" + sed -n 's/.*"browserVersion": *"\([^"]*\)".*/chromium \1/p' "$MANIFEST" 2>/dev/null | head -1 + ;; + -h|--help|help|'') usage ;; + *) usage >&2; exit 2 ;; +esac diff --git a/internal/driver/image_browser_test.go b/internal/driver/image_browser_test.go new file mode 100644 index 00000000..bc61d663 --- /dev/null +++ b/internal/driver/image_browser_test.go @@ -0,0 +1,452 @@ +package driver + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// Browser testing is the one developer capability whose usual installation +// instruction — `npx playwright install --with-deps` — cannot run in a Rainier +// session at all: its --with-deps half is `sudo apt-get install`, and there is +// no sudo, no writable rootfs and no package archive on the egress allowlist. +// So the shared libraries are a build-time layer, the browser is a +// checksum-pinned artifact beside the rest of the toolchain, and neither is +// something a session can be asked to acquire later. +// +// These read the Dockerfile and images/session/browsers.sh as text, for the +// same reason the rest of image_contract_test.go does: this is the half of the +// contract that can be checked on a machine with no docker, which is where +// Dockerfile edits get made. scripts/session-image-smoke.sh runs the browser. + +func sessionBrowsersScript(t *testing.T) string { + t.Helper() + b, err := os.ReadFile("../../images/session/browsers.sh") + if err != nil { + t.Fatalf("reading the session image's browser install script: %v", err) + } + return string(b) +} + +// TestSessionImageInstallsTheBrowserDependencies: Playwright's own +// debian12-x64 chromium dependency list, named in the Dockerfile because the +// tool that knows it needs root to act on it. Sixteen of these are absent from +// the base image, and each absence is the same failure — "error while loading +// shared libraries" on somebody's first test run — which no version check and, +// measured against this base, not even Playwright's own host-requirements +// validation reports. +func TestSessionImageInstallsTheBrowserDependencies(t *testing.T) { + df := sessionDockerfile(t) + // packages/playwright-core/src/server/registry/nativeDeps.ts, deps + // ["debian12-x64"].chromium, in full. + for _, pkg := range []string{ + "libasound2", "libatk-bridge2.0-0", "libatk1.0-0", "libatspi2.0-0", + "libcairo2", "libcups2", "libdbus-1-3", "libdrm2", "libgbm1", "libglib2.0-0", + "libnspr4", "libnss3", "libpango-1.0-0", + "libx11-6", "libxcb1", "libxcomposite1", "libxdamage1", "libxext6", + "libxfixes3", "libxkbcommon0", "libxrandr2", + } { + if !regexp.MustCompile(`(?m)(^|\s)` + regexp.QuoteMeta(pkg) + `(\s|\\|$)`).MatchString(df) { + t.Errorf("the session image no longer installs %q; Chromium will not load", pkg) + } + } + // A browser with no fonts renders every glyph as a box, which makes a + // screenshot artifact useless and a text-measuring assertion a flake. This + // is a rendering dependency, not a nicety. + for _, font := range []string{"fontconfig", "fonts-liberation", "fonts-dejavu-core", "fonts-noto-color-emoji"} { + if !strings.Contains(df, font) { + t.Errorf("the session image installs no %q; a Chromium with no fonts renders tofu and its screenshots are worthless", font) + } + } +} + +// TestSessionImageInstallsNoGlobalPlaywright is the version-matching rule, and +// it is a prohibition rather than a pin. +// +// The version that drives a project's tests has to be the version in that +// project's lockfile: Playwright's client and its browser revision are one +// artifact, and a `playwright` on PATH from somewhere else is picked up by a +// bare `npx playwright` and silently drives the wrong one. The image therefore +// installs BROWSERS and no Playwright at all. +func TestSessionImageInstallsNoGlobalPlaywright(t *testing.T) { + df := sessionDockerfile(t) + for _, bad := range []string{ + "npm install --global playwright", "npm install -g playwright", + "@playwright/test", "npm install --global --no-audit --no-fund \"playwright", + } { + if strings.Contains(df, bad) { + t.Errorf("the Dockerfile contains %q; a globally installed Playwright hijacks `npx playwright` from the project's own pinned one", bad) + } + } + if regexp.MustCompile(`npm install [^\n]*\bplaywright\b`).MatchString(df) { + t.Error("the Dockerfile npm-installs Playwright; only the browsers belong in the image") + } +} + +// TestSessionImagePinsOneBrowserBaseline: the browser is an upstream download +// like Go or Codex, and it is pinned the same way — a version in the URL and a +// SHA-256 in the repository, checked before extraction. A baseline that +// resolved "the latest Chromium" would change under a digest that is supposed +// to describe it, and would stop matching the Playwright version it is the +// right browser for. +func TestSessionImagePinsOneBrowserBaseline(t *testing.T) { + df := sessionDockerfile(t) + sh := sessionBrowsersScript(t) + + for _, arg := range []string{ + "PLAYWRIGHT_VERSION", "CHROMIUM_VERSION", "CHROMIUM_REVISION", "PLAYWRIGHT_FFMPEG_REVISION", + } { + m := regexp.MustCompile(`(?m)^ARG ` + arg + `=(\S+)$`).FindStringSubmatch(df) + if m == nil { + t.Errorf("the Dockerfile declares no %s; the browser baseline would not be pinned to anything", arg) + continue + } + if strings.Contains(m[1], "latest") { + t.Errorf("%s is %q", arg, m[1]) + } + } + + if !strings.Contains(sh, "sha256sum --check") { + t.Error("the browser install script never verifies a checksum") + } + // Two artifacts for each of two architectures. + if sums := regexp.MustCompile(`[A-Z_]+SHA=([0-9a-f]{64})`).FindAllString(sh, -1); len(sums) < 4 { + t.Errorf("the browser script records %d checksums; it fetches two artifacts for each of two architectures", len(sums)) + } + if regexp.MustCompile(`\|\s*(ba)?sh(\s|$)`).MatchString(sh) { + t.Error("the browser script pipes a download into a shell") + } + for _, moving := range []string{"/latest/download", "releases/latest", "@latest"} { + if strings.Contains(sh, moving) { + t.Errorf("the browser script resolves %q at build time instead of naming a version", moving) + } + } + // The check that costs nothing at build time and saves the failure that is + // hardest to act on from inside a session: a browser whose libraries are + // not in this image. + if !strings.Contains(sh, "not found") || !strings.Contains(sh, "ldd ") { + t.Error("the browser script does not ldd the installed browser; a missing apt line would ship as a runtime failure instead of a failed build") + } + // And that it starts, reporting the build it was told to install. + if !strings.Contains(sh, "--version") || !strings.Contains(sh, "$CHROMIUM_VERSION") { + t.Error("the browser script never runs the browser it installed or checks which build it is") + } + for _, arch := range []string{"amd64", "arm64"} { + if !strings.Contains(sh, arch) { + t.Errorf("the browser script has no %s row", arch) + } + } +} + +// TestSessionImageBrowserCacheLivesOnTheWorkspace: the same rule as every +// other cache, and one extra that is a security boundary rather than a +// convenience. +// +// Playwright reads PLAYWRIGHT_BROWSERS_PATH, which has to be writable — a +// project pinned to a different Playwright installs its own revision there and +// must win. The BASELINE that path points into is root-owned on the read-only +// rootfs, because a session user who could rewrite the browser binary could +// rewrite what every later test run executes. The two meet through symlinks, +// and a `chown -R` that dereferenced them would hand the browser to the +// session user, which is why the seed's chown is -h. +func TestSessionImageBrowserCacheLivesOnTheWorkspace(t *testing.T) { + df := sessionDockerfile(t) + + m := regexp.MustCompile(`(?m)^\s*PLAYWRIGHT_BROWSERS_PATH=(\S+)`).FindStringSubmatch(df) + if m == nil { + t.Fatal("the image sets no PLAYWRIGHT_BROWSERS_PATH; Playwright would fall back to a path this image has not qualified") + } + if !strings.HasPrefix(m[1], workspaceMount+"/") { + t.Errorf("PLAYWRIGHT_BROWSERS_PATH is %s, which is not on the writable %s volume; a project could not install its own browser revision", m[1], workspaceMount) + } + + if !strings.Contains(df, "/usr/local/lib/rainier-browsers") { + t.Error("the browser baseline is not under /usr/local/lib; it must be root-owned like sessiond and the agents") + } + if strings.Contains(df, "/opt/rainier-env/browsers") || strings.Contains(df, "chown -R 1000:1000 /usr/local/lib") { + t.Error("the browser baseline is in, or given to, the session-writable prefix") + } + if !strings.Contains(df, "chown -Rh 1000:1000 /workspace/.cache") { + t.Error("the browser cache seed is chowned without -h; a recursive chown that dereferences the seed's symlinks gives the session user the root-owned browser it is about to execute") + } + // The other half of the same fact, and the one that would take the fleet + // down rather than merely weaken it. The initializer chowns the whole + // freshly copied volume as root with a READ-ONLY rootfs; the volume now + // contains symlinks onto that rootfs. GNU chown -R does not dereference + // (it traverses -P and lchown()s the link), so this works. -L or + // --dereference would fail the init job with EROFS on every session + // create, and would give the session user the browser binary on any host + // where it did not. + if strings.Contains(initWorkspaceScript, "-L") || strings.Contains(initWorkspaceScript, "--dereference") { + t.Errorf("the volume initializer dereferences symlinks (%q); the workspace seed points at the read-only rootfs", initWorkspaceScript) + } + if !strings.Contains(initWorkspaceScript, "chown -R "+sessionUser) { + t.Errorf("the volume initializer no longer chowns the workspace as expected: %q", initWorkspaceScript) + } + if !strings.Contains(df, "rainier-browsers link") { + t.Error("the image never links the baseline into the browser cache; a fresh session would download a browser it already has") + } + if !strings.Contains(df, "chmod 0755 /usr/local/bin/rainier-browsers") { + t.Error("rainier-browsers is not installed root-owned and executable in /usr/local/bin") + } +} + +// TestSessionImageDoesNotDisableBrowserSafety: nothing in the image may turn +// off a check on the user's behalf. PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS +// would hide exactly the missing-library failure this image exists to prevent, +// PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD would take away a project's ability to +// install the version it actually pins, and a download host baked into the +// image would silently redirect where a browser comes from. +func TestSessionImageDoesNotDisableBrowserSafety(t *testing.T) { + df := sessionDockerfile(t) + for _, v := range []string{ + "PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS", + "PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD", + "PLAYWRIGHT_CHROMIUM_DOWNLOAD_HOST", + "PLAYWRIGHT_DOWNLOAD_HOST", + } { + if regexp.MustCompile(`(?m)^\s*` + v + `=`).MatchString(df) { + t.Errorf("the image sets %s; a session must keep both the check and the choice", v) + } + } + // The image ships no launch flags at all, and in particular does not ship + // the one that turns Chromium's own sandbox off for everybody. Whether a + // suite enables Chromium's sandbox is the suite's call (Playwright's own + // default is chromiumSandbox: false); it is not the image's to make. + if strings.Contains(df, "--no-sandbox") { + t.Error("the Dockerfile names --no-sandbox; the image does not choose a project's browser launch flags") + } + for _, path := range []string{"../../scripts/session-image-smoke.sh", "../../scripts/session-image-browser-e2e.sh", "../../images/session/browser-sample/playwright.config.js"} { + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading browser safety contract %s: %v", path, err) + } + for _, line := range strings.Split(string(b), "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, "//") { + continue + } + if strings.Contains(line, "--no-sandbox") { + t.Errorf("%s names --no-sandbox in executable code; browser qualification must fail closed", path) + } + } + } +} + +// TestDockerGrantsNoBrowserPrivilege: the isolation half of the same change. +// The usual answers to "Chromium will not start in a container" are a +// privileged container, an added capability, seccomp=unconfined, the host's +// network, or a wider host mount. None of them is here, and a browser baseline +// in the image is not a reason for any of them to arrive later: the driver +// runs the container that runs the browser with exactly the restrictions it +// ran before. +func TestDockerGrantsNoBrowserPrivilege(t *testing.T) { + d := &Docker{opts: DockerOpts{Label: "rainier.session", Network: "rainier-int"}} + args := strings.Join(d.runArgs(Spec{SessionID: "sess_browser"}, "img"), " ") + for _, forbidden := range []string{ + "--privileged", + "--cap-add", + "seccomp=unconfined", + "apparmor=unconfined", + "--network host", + "--device", + "--ipc=host", + "--ipc host", + "/dev/shm:", + } { + if strings.Contains(args, forbidden) { + t.Errorf("runArgs contains %q; nothing about running a browser justifies relaxing the session's isolation", forbidden) + } + } + // The restrictions runArgs actually applies to a session with no setup + // script. --cap-drop is deliberately NOT in this list: the driver does not + // pass it to the session container today (only to the volume initializer), + // and a test that asserted it would be asserting the documentation rather + // than the code. See docs/session-image.md. + for _, required := range []string{"--user " + sessionUser, "no-new-privileges", "--read-only", "--tmpfs /tmp"} { + if !strings.Contains(args, required) { + t.Errorf("runArgs no longer contains %q", required) + } + } +} + +// --- the helper, run rather than read --------------------------------------- + +func browsersHelper(t *testing.T) string { + t.Helper() + p, err := filepath.Abs("../../images/session/browsers/rainier-browsers") + if err != nil { + t.Fatal(err) + } + return p +} + +// fakeBaseline is a browser baseline as far as the helper can see it: one +// browser directory holding a payload directory, and Playwright's two markers. +func fakeBaseline(t *testing.T) string { + t.Helper() + prefix := t.TempDir() + payload := filepath.Join(prefix, "chromium_headless_shell-1243", "chrome-headless-shell-linux64") + if err := os.MkdirAll(payload, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(payload, "chrome-headless-shell"), []byte("#!/bin/sh\necho 'Chromium 153.0.8010.12'\n"), 0o755); err != nil { + t.Fatal(err) + } + for _, marker := range []string{"INSTALLATION_COMPLETE", "DEPENDENCIES_VALIDATED"} { + if err := os.WriteFile(filepath.Join(prefix, "chromium_headless_shell-1243", marker), nil, 0o644); err != nil { + t.Fatal(err) + } + } + return prefix +} + +func TestSessionImageBrowserHelperLinksTheBaselineIntoTheCache(t *testing.T) { + prefix := fakeBaseline(t) + cache := filepath.Join(t.TempDir(), "ms-playwright") + run := runHelper(t, browsersHelper(t), t.TempDir(), []string{ + "RAINIER_BROWSERS_PREFIX=" + prefix, + "PLAYWRIGHT_BROWSERS_PATH=" + cache, + }, "link") + if run.status != 0 { + t.Fatalf("link exited %d: %s", run.status, run.out) + } + dir := filepath.Join(cache, "chromium_headless_shell-1243") + // Playwright decides a browser is installed by the presence of this file, + // and re-validates its dependencies every thirty days by rewriting the + // other. Both are real files in the writable cache rather than symlinks + // into the read-only baseline, or that rewrite fails and every launch pays + // for an ldd sweep it cannot record the result of. + for _, marker := range []string{"INSTALLATION_COMPLETE", "DEPENDENCIES_VALIDATED"} { + info, err := os.Lstat(filepath.Join(dir, marker)) + if err != nil { + t.Fatalf("%s: %v", marker, err) + } + if info.Mode()&os.ModeSymlink != 0 { + t.Errorf("%s is a symlink into the read-only baseline; Playwright has to be able to rewrite it", marker) + } + } + // And the payload is a link, not a copy: a quarter of a gigabyte per + // session volume is the thing this arrangement exists to avoid. + payload := filepath.Join(dir, "chrome-headless-shell-linux64") + info, err := os.Lstat(payload) + if err != nil { + t.Fatal(err) + } + if info.Mode()&os.ModeSymlink == 0 { + t.Fatal("the browser payload was copied into the workspace cache instead of linked") + } + if _, err := os.Stat(filepath.Join(payload, "chrome-headless-shell")); err != nil { + t.Fatalf("the linked payload does not resolve to an executable: %v", err) + } +} + +func TestSessionImageBrowserHelperIsIdempotent(t *testing.T) { + prefix := fakeBaseline(t) + cache := filepath.Join(t.TempDir(), "ms-playwright") + env := []string{"RAINIER_BROWSERS_PREFIX=" + prefix, "PLAYWRIGHT_BROWSERS_PATH=" + cache} + for i := 0; i < 2; i++ { + if run := runHelper(t, browsersHelper(t), t.TempDir(), env, "link"); run.status != 0 { + t.Fatalf("link %d exited %d: %s", i, run.status, run.out) + } + } + entries, err := os.ReadDir(filepath.Join(cache, "chromium_headless_shell-1243")) + if err != nil { + t.Fatal(err) + } + // The marker, Playwright's two, and one payload link. A second run that + // stacked links or nested a link inside its own target would show here. + if len(entries) != 4 { + names := []string{} + for _, e := range entries { + names = append(names, e.Name()) + } + t.Fatalf("a second link produced %v", names) + } +} + +// A project that installs its own browser owns that directory. `link` has to +// leave it alone even though the name collides: overwriting it would delete a +// download the project's own lockfile asked for, and would put the baseline's +// revision behind a name that means a different one. +func TestSessionImageBrowserHelperLeavesAProjectInstallAlone(t *testing.T) { + prefix := fakeBaseline(t) + cache := filepath.Join(t.TempDir(), "ms-playwright") + mine := filepath.Join(cache, "chromium_headless_shell-1243", "chrome-headless-shell-linux64") + if err := os.MkdirAll(mine, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(mine, "chrome-headless-shell"), []byte("mine"), 0o755); err != nil { + t.Fatal(err) + } + run := runHelper(t, browsersHelper(t), t.TempDir(), []string{ + "RAINIER_BROWSERS_PREFIX=" + prefix, + "PLAYWRIGHT_BROWSERS_PATH=" + cache, + }, "link") + if run.status != 0 { + t.Fatalf("link exited %d: %s", run.status, run.out) + } + b, err := os.ReadFile(filepath.Join(mine, "chrome-headless-shell")) + if err != nil || string(b) != "mine" { + t.Fatalf("the project's own install was replaced: %q, %v", string(b), err) + } + if !strings.Contains(run.out, "skip") { + t.Errorf("link did not report that it skipped the project's own install: %s", run.out) + } +} + +// The path a project's Playwright will read has to be the path this helper +// reports, or the helper is describing a different machine. +func TestSessionImageBrowserHelperReportsThePathPlaywrightReads(t *testing.T) { + cache := filepath.Join(t.TempDir(), "ms-playwright") + run := runHelper(t, browsersHelper(t), t.TempDir(), []string{"PLAYWRIGHT_BROWSERS_PATH=" + cache}, "path") + if got := strings.TrimSpace(run.out); got != cache { + t.Fatalf("path = %q, want %q", got, cache) + } + // With no override, Playwright computes $XDG_CACHE_HOME/ms-playwright. + run = runHelper(t, browsersHelper(t), t.TempDir(), []string{"XDG_CACHE_HOME=/workspace/.cache"}, "path") + if got := strings.TrimSpace(run.out); got != "/workspace/.cache/ms-playwright" { + t.Fatalf("path with only XDG_CACHE_HOME = %q", got) + } +} + +func TestSessionImageBrowserHelperInvalidatesRetiredBaseline(t *testing.T) { + prefix := fakeBaseline(t) + cache := filepath.Join(t.TempDir(), "cache") + env := []string{"RAINIER_BROWSERS_PREFIX=" + prefix, "PLAYWRIGHT_BROWSERS_PATH=" + cache} + if run := runHelper(t, browsersHelper(t), t.TempDir(), env, "link"); run.status != 0 { + t.Fatal(run.out) + } + old := filepath.Join(cache, "chromium_headless_shell-1243") + // A newer image no longer carries the old read-only payload. + if err := os.RemoveAll(filepath.Join(prefix, "chromium_headless_shell-1243")); err != nil { + t.Fatal(err) + } + project := filepath.Join(cache, "chromium_headless_shell-1200") + if err := os.MkdirAll(project, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(project, "INSTALLATION_COMPLETE"), nil, 0644); err != nil { + t.Fatal(err) + } + if run := runHelper(t, browsersHelper(t), t.TempDir(), env, "link"); run.status != 0 { + t.Fatal(run.out) + } + if _, err := os.Stat(filepath.Join(old, "INSTALLATION_COMPLETE")); !os.IsNotExist(err) { + t.Fatalf("retired payload still marked installed: %v", err) + } + if _, err := os.Stat(filepath.Join(project, "INSTALLATION_COMPLETE")); err != nil { + t.Fatalf("project install changed: %v", err) + } +} + +func TestSessionImageBrowserHelperRejectsPackageLocalCache(t *testing.T) { + for _, command := range []string{"path", "link", "status"} { + run := runHelper(t, browsersHelper(t), t.TempDir(), []string{"PLAYWRIGHT_BROWSERS_PATH=0", "RAINIER_BROWSERS_PREFIX=" + fakeBaseline(t)}, command) + if run.status == 0 || !strings.Contains(run.out, "package-local") { + t.Fatalf("%s: exit=%d output=%s", command, run.status, run.out) + } + } +} diff --git a/scripts/session-image-browser-e2e.sh b/scripts/session-image-browser-e2e.sh new file mode 100755 index 00000000..2fed0209 --- /dev/null +++ b/scripts/session-image-browser-e2e.sh @@ -0,0 +1,193 @@ +#!/usr/bin/env bash +# scripts/session-image-browser-e2e.sh — can a developer run their project's +# Playwright tests in a fresh session, with nothing set up first? +# +# scripts/session-image-smoke.sh answers the image half of that offline: the +# browser is present, root-owned, linked into the cache and able to render. +# This answers the whole of it, through the tool a developer actually uses, on +# a project that has never been in this image: install locked dependencies, +# start a loopback web server, drive a real Chromium at a desktop and a phone +# viewport, assert on what it laid out, write a screenshot, leave a trace and a +# video behind when a test fails, and leave no process behind when it does not. +# Then do it again, because "works once" and "works" are different claims. +# +# The restrictions are the driver's, copied from internal/driver.runArgs: uid +# 1000, no-new-privileges, a read-only rootfs, a noexec tmpfs on /tmp, docker's +# 64 MiB /dev/shm, a workspace volume, and no host mount of any kind. Nothing +# here is relaxed to make a step pass. +# +# The ONE difference from the smoke, and it is deliberate: `npm ci` gets a +# network, because installing a project's locked dependencies IS a network +# operation and pretending otherwise would test nothing. Every step after it — +# both test runs and the failure run — is back on --network none, which is what +# makes "the preinstalled browser needed no download" an observation rather +# than a hope. +# +# Usage: scripts/session-image-browser-e2e.sh [image] +# Env: DOCKER= STEP_TIMEOUT= KEEP=1 +# SECCOMP= APPARMOR= +# Exit: 0 every check passed, 1 a check failed, 2 setup or usage error. +set -uo pipefail + +IMAGE=${1:-rainier-session:smoke} +DOCKER=${DOCKER:-docker} +STEP_TIMEOUT=${STEP_TIMEOUT:-600} +SECCOMP=${SECCOMP:-} +APPARMOR=${APPARMOR:-} + +SECURITY_OPTS=() +if [ -n "$SECCOMP" ]; then + [ -r "$SECCOMP" ] || { echo "no seccomp profile at $SECCOMP" >&2; exit 2; } + SECURITY_OPTS+=(--security-opt "seccomp=$SECCOMP") +fi +if [ -n "$APPARMOR" ]; then + SECURITY_OPTS+=(--security-opt "apparmor=$APPARMOR") +fi + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +source "$SCRIPT_DIR/session-image-checks.sh" + +SAMPLE_DIR="$SCRIPT_DIR/../images/session/browser-sample" +[ -f "$SAMPLE_DIR/package-lock.json" ] \ + || { echo "no sample project at $SAMPLE_DIR" >&2; exit 2; } +command -v "$DOCKER" >/dev/null 2>&1 || { echo "no docker executable ($DOCKER); set DOCKER=" >&2; exit 2; } +"$DOCKER" image inspect "$IMAGE" >/dev/null 2>&1 \ + || { echo "image $IMAGE is not present; build it first (make session-image)" >&2; exit 2; } + +SUFFIX=$(od -An -tx1 -N6 /dev/urandom | tr -d ' \n') +WS_VOL="rainier-browser-ws-$SUFFIX" + +cleanup() { + [ "${KEEP:-0}" = 1 ] && return 0 + "$DOCKER" volume rm -f "$WS_VOL" >/dev/null 2>&1 + return 0 +} +trap cleanup EXIT + +# The driver's own volume preparation, unchanged: root with CAP_CHOWN and +# nothing else, no network, the image's entrypoint never running. This is also +# the step that copies the image's /workspace — the seeded browser cache +# included — onto the fresh volume, which is the mechanism the whole +# no-download claim rests on. +"$DOCKER" volume create "$WS_VOL" >/dev/null || { echo "could not create the workspace volume" >&2; exit 2; } +"$DOCKER" run --rm --network none --user 0:0 \ + --security-opt no-new-privileges --cap-drop ALL --cap-add CHOWN --read-only \ + -v "$WS_VOL:/workspace" --entrypoint sh "$IMAGE" \ + -c 'mkdir -p /workspace/.rainier && chown -R 1000:1000 /workspace' >/dev/null \ + || { echo "could not initialize the workspace volume" >&2; exit 2; } + +# step — one session-shaped container. The network argument +# is the only thing that varies between the install step and every other one, +# and it is spelled out at each call site rather than defaulted, because which +# steps are allowed to reach the internet is the interesting part of this file. +step() { + local network=$1 program=$2 + "$DOCKER" run --rm \ + --network "$network" \ + --user 1000:1000 \ + --security-opt no-new-privileges \ + "${SECURITY_OPTS[@]}" \ + --cap-drop ALL \ + --read-only \ + --tmpfs /tmp \ + --memory 3g --pids-limit 1024 \ + -v "$WS_VOL:/workspace" \ + -w /workspace/browser-sample \ + -e CI=1 \ + --entrypoint timeout "$IMAGE" -k 10 "$STEP_TIMEOUT" /bin/bash -c "set -uo pipefail +$program" 2>&1 +} +offline() { step none "$1"; } +online() { step bridge "$1"; } + +echo "== $IMAGE" +echo +echo "-- a project that has never been in this image" + +# Staged as the session user through a pipe rather than a bind mount: a host +# mount is exactly what a session does not get, and a qualification that used +# one would be qualifying a different container. +if ! tar -C "$SCRIPT_DIR/../images/session" -c browser-sample \ + | "$DOCKER" run --rm -i --user 1000:1000 --network none \ + --security-opt no-new-privileges --cap-drop ALL \ + -v "$WS_VOL:/workspace" -w /workspace \ + --entrypoint tar "$IMAGE" -x; then + echo "could not stage the sample project onto the workspace volume" >&2 + exit 2 +fi + +check "the sample project arrived with a lockfile and no node_modules" "staged" ' + test -f package-lock.json || { echo "no lockfile"; exit 1; } + test -d node_modules && { echo "NODE_MODULES-PRESENT"; exit 1; } + echo staged' offline + +check "npm ci installs the project locked dependencies" "playwright-installed" ' + npm ci --no-audit --no-fund >/dev/null 2>&1 || { echo "npm ci failed"; exit 1; } + test -x node_modules/.bin/playwright || { echo "no playwright in node_modules"; exit 1; } + # The version that runs has to be the one the lockfile pins, not one the + # image supplied: `npx playwright` resolves the project binary first, and + # there is no global Playwright in this image for it to fall back to. + node -p "require(\"playwright-core/package.json\").version" + echo playwright-installed' online + +# From here on: no network at all. A step that needed a download would fail, +# which is the whole point of preinstalling the browser. +# The default reporters from the sample project own configuration, so the HTML +# report a developer opens is written by the same run that is being checked. +check "the suite runs offline, at a desktop and a phone viewport, with nothing downloaded" "6 passed" ' + npx playwright test 2>&1 | tail -30' offline + +check "it runs a second time in the same workspace, still offline" "6 passed" ' + npx playwright test --reporter=list 2>&1 | tail -30' offline + +check "nothing was downloaded into the browser cache" "cache-unchanged" ' + # Every entry the cache carries is still one of the baseline links. A real + # directory here would mean the project had to fetch a browser, which is a + # legitimate thing for a project on another Playwright to do and exactly what + # this image exists to make unnecessary for the pinned one. + for dir in "$PLAYWRIGHT_BROWSERS_PATH"/*/; do + [ -f "$dir/.rainier-baseline" ] || { echo "UNEXPECTED $dir"; exit 1; } + done + echo cache-unchanged' offline + +check "the browser and the web server both exit with the suite" "nothing-left" ' + browsers_alive() { + for p in /proc/[0-9]*; do + case "$(readlink "$p/exe" 2>/dev/null)" in *chrome-headless-shell*) return 0 ;; esac + done + return 1 + } + npx playwright test --reporter=line >/dev/null 2>&1 || { echo "the suite failed"; exit 1; } + sleep 1 + browsers_alive && { echo BROWSER-STILL-RUNNING; exit 1; } + # Playwright started the web server and owns stopping it. A listener left on + # loopback would collide with the next run in the same workspace, which is + # the failure a suspend-and-resume workflow hits first. + ss -ltn 2>/dev/null | grep -q ":8973" && { echo SERVER-STILL-LISTENING; ss -ltnp; exit 1; } + echo nothing-left' offline + +check "a failing test leaves a trace, a screenshot and a video behind" "artifacts-ok" ' + rm -rf test-results + RAINIER_BROWSER_SMOKE_FAIL=1 npx playwright test artifacts.spec.js --reporter=line >/dev/null 2>&1 + test -d test-results || { echo "no test-results directory"; exit 1; } + for want in "trace.zip" "test-failed-1.png" ".webm"; do + find test-results -name "*$want*" -size +0 | grep -q . || { + echo "MISSING $want"; find test-results -type f | head -20; exit 1; } + done + echo artifacts-ok' offline + +check "the report a developer opens was written to the workspace" "report-ok" ' + test -s playwright-report/index.html || { echo "no HTML report"; exit 1; } + echo report-ok' offline + +# What the run cost, on the record beside what it proved. +FOOTPRINT=$(offline ' + du -sh node_modules 2>/dev/null | cut -f1 | tr -d "\n" + printf " node_modules; " + du -sh "$PLAYWRIGHT_BROWSERS_PATH" 2>/dev/null | cut -f1 | tr -d "\n" + printf " browser cache on the volume\n"' | tail -1) +printf 'note workspace footprint: %s\n' "$FOOTPRINT" +note "browser workspace footprint" "$FOOTPRINT" + +printf '\n%d passed, %d failed\n' "$PASS" "$FAIL" +[ "$FAIL" -eq 0 ] || exit 1 diff --git a/scripts/session-image-checks.sh b/scripts/session-image-checks.sh index 7369f6fb..8537559e 100644 --- a/scripts/session-image-checks.sh +++ b/scripts/session-image-checks.sh @@ -1,6 +1,35 @@ #!/usr/bin/env bash -# Small assertion boundary shared by the container smoke and its shell tests. -# The caller supplies probe (the execution boundary), ok and bad (reporting). +# Small assertion boundary shared by the image qualification scripts and their +# shell tests. The caller supplies probe (the execution boundary); reporting is +# here, because two qualification scripts annotating a pull request in two +# slightly different ways is how one of them quietly stops annotating at all. +# A caller that wants different reporting redefines ok and bad after sourcing. + +PASS=0 FAIL=0 + +# In GitHub Actions the job log is the only record of a failed qualification, +# and it is not always reachable from wherever the fix is being made — a +# session's egress allowlist does not carry the Actions log host, for one. +# Emitting each failure as a workflow annotation puts the check's name and its +# detail on the pull request itself, where the check status already is. Inert +# outside Actions, and it reports; it never changes what passes. +# A workflow command's PROPERTIES are comma-separated and colon-terminated, so +# a title carrying either has to be escaped or it truncates the annotation — +# and most check names contain a comma. The message half only has to survive +# the newline. +wf_title() { printf '%s' "$1" | sed 's/%/%25/g; s/\r/%0D/g; s/:/%3A/g; s/,/%2C/g'; } +wf_body() { printf '%s' "${1:-}" | cut -c1-2000 | sed 's/%/%25/g; s/\r/ /g' | awk '{printf "%s%%0A", $0}'; } +note() { + [ "${GITHUB_ACTIONS:-}" = true ] || return 0 + printf '::notice title=%s::%s\n' "$(wf_title "$1")" "$(wf_body "$2")" +} +annotate() { + [ "${GITHUB_ACTIONS:-}" = true ] || return 0 + printf '::error title=%s::%s\n' "$(wf_title "$1")" "$(wf_body "${2:-}")" +} +ok() { PASS=$((PASS+1)); printf 'ok %s\n' "$1"; } +bad() { FAIL=$((FAIL+1)); printf 'FAIL %s\n' "$1"; [ $# -gt 1 ] && printf ' %s\n' "$2"; annotate "$1" "${2:-}"; return 0; } + check() { local name=$1 want=$2 prog=$3 runner=${4:-probe} match=${5:-contains} out status=0 matched=1 out=$("$runner" "$prog") || status=$? diff --git a/scripts/session-image-security-policy-test.py b/scripts/session-image-security-policy-test.py new file mode 100644 index 00000000..0d403fa2 --- /dev/null +++ b/scripts/session-image-security-policy-test.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python3 +"""Validate the public, test-only browser sandbox policy fixture.""" + +import copy +import hashlib +import json +import pathlib +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +SECCOMP = ROOT / "testdata/session-security/codex-bwrap-seccomp-docker-27.5.1.json" +APPARMOR = ROOT / "testdata/session-security/rainier-codex-bwrap.apparmor" + +UNSHARE_USER = 0x10000000 +UNSHARE_CHROMIUM = 0x10020000 +CLONE_CHROMIUM_USER = 0x10000011 +CLONE_CHROMIUM_ZYGOTE = 0x70000011 +CLONE_CHROMIUM_ZYGOTE_NO_NET = 0x30000011 +CLONE_CHROMIUM_PID = 0x20000011 +# Chromium's safe-empty-dir helper uses the x86_64 clone optimization before +# chrooting its short-lived child. Keep this exact non-namespace shape rather +# than widening the Docker clone rule. +CLONE_CHROMIUM_CHROOT = 0x00084311 +CLONE_WITH_NETWORK = 0x78020011 +CLONE_WITHOUT_NETWORK = 0x38020011 +ENOSYS = 38 + +DOCKER_CANONICAL_SHA256 = ( + "885442dc08f21f8d60f99ea43d59af88b1c529103815fe24bbf9ce998d3a609d" +) +SECCOMP_CANONICAL_SHA256 = ( + "4e43265c398ab8e93118568ff37749abd8d367dc6a01419dfa1d1efecd3bb636" +) +APPARMOR_SHA256 = ( + "53f78e768ee56099b764661c58b569504e39ecc45b2b3fb0dffd13ea329eb431" +) + + +def rule(names, action, *, args=None, includes=None, errno=None, comment=None): + result = {"names": names, "action": action} + if args is not None: + result["args"] = args + if includes is not None: + result["includes"] = includes + if errno is not None: + result["errnoRet"] = errno + if comment is not None: + result["comment"] = comment + return result + + +def exact_clone(value): + return [{"index": 0, "value": value, "op": "SCMP_CMP_EQ"}] + + +class SessionImageSecurityPolicyTest(unittest.TestCase): + def test_seccomp_is_default_deny_and_only_allows_pinned_namespace_shapes(self): + profile = json.loads(SECCOMP.read_text()) + self.assertEqual(profile["defaultAction"], "SCMP_ACT_ERRNO") + + rainier = [ + item + for item in profile["syscalls"] + if item.get("comment", "").startswith("RAINIER:") + ] + amd64 = {"arches": ["amd64", "x32"]} + self.assertEqual( + rainier, + [ + rule( + ["unshare"], + "SCMP_ACT_ALLOW", + args=exact_clone(UNSHARE_USER), + includes=amd64, + comment=( + "RAINIER: Codex 0.153.4 Bubblewrap creates its initial " + "user namespace with unshare(CLONE_NEWUSER)." + ), + ), + rule( + ["clone"], + "SCMP_ACT_ALLOW", + args=exact_clone(CLONE_CHROMIUM_ZYGOTE), + includes=amd64, + comment=( + "RAINIER: Chromium namespace sandbox launches its zygote " + "with CLONE_NEWUSER|CLONE_NEWPID|CLONE_NEWNET|SIGCHLD." + ), + ), + rule( + ["clone3"], + "SCMP_ACT_ERRNO", + errno=ENOSYS, + comment=( + "RAINIER: Chromium requires clone3 to return ENOSYS so " + "libc falls back to flag-inspectable clone(2)." + ), + ), + rule( + ["clone"], + "SCMP_ACT_ALLOW", + args=exact_clone(CLONE_CHROMIUM_ZYGOTE_NO_NET), + includes=amd64, + comment=( + "RAINIER: Chromium namespace sandbox fallback zygote shape " + "with CLONE_NEWUSER|CLONE_NEWPID|SIGCHLD." + ), + ), + rule( + ["clone"], + "SCMP_ACT_ALLOW", + args=exact_clone(CLONE_CHROMIUM_PID), + includes=amd64, + comment=( + "RAINIER: Chromium gives each renderer its own PID namespace " + "with CLONE_NEWPID|SIGCHLD after the zygote enters its " + "private user namespace." + ), + ), + rule( + ["clone"], + "SCMP_ACT_ALLOW", + args=exact_clone(CLONE_CHROMIUM_USER), + includes=amd64, + comment=( + "RAINIER: Chromium sandbox probes unprivileged user " + "namespaces with clone(CLONE_NEWUSER|SIGCHLD)." + ), + ), + rule( + ["clone"], + "SCMP_ACT_ALLOW", + args=exact_clone(CLONE_CHROMIUM_CHROOT), + includes=amd64, + comment=( + "RAINIER: Chromium's safe-empty-dir helper uses the " + "x86_64 CLONE_FS|CLONE_VM|CLONE_VFORK|CLONE_SETTLS|SIGCHLD " + "shape before chroot." + ), + ), + rule( + ["unshare"], + "SCMP_ACT_ALLOW", + args=exact_clone(UNSHARE_CHROMIUM), + includes=amd64, + comment=( + "RAINIER: Chromium sandbox creates its user and mount " + "namespaces with unshare(CLONE_NEWUSER|CLONE_NEWNS)." + ), + ), + rule( + ["clone"], + "SCMP_ACT_ALLOW", + args=exact_clone(CLONE_WITH_NETWORK), + includes=amd64, + comment=( + "RAINIER: Codex 0.153.4 Bubblewrap clone shape with a " + "private network namespace." + ), + ), + rule( + ["clone"], + "SCMP_ACT_ALLOW", + args=exact_clone(CLONE_WITHOUT_NETWORK), + includes=amd64, + comment=( + "RAINIER: Codex 0.153.4 Bubblewrap clone shape without " + "a private network namespace." + ), + ), + rule( + ["mount", "pivot_root", "umount2"], + "SCMP_ACT_ALLOW", + comment=( + "RAINIER: Bubblewrap constructs and discards a filesystem " + "only inside the child mount namespace." + ), + ), + rule( + ["chroot"], + "SCMP_ACT_ALLOW", + comment=( + "RAINIER: Chromium's safe-empty-dir helper chroots only " + "after entering its private user namespace; the kernel " + "still requires CAP_SYS_CHROOT there." + ), + ), + rule( + ["setns"], + "SCMP_ACT_ALLOW", + comment=( + "RAINIER: Chromium's namespace sandbox may join its " + "private user, PID, network, and mount namespaces after " + "creation; namespace ownership still limits targets." + ), + ), + ], + ) + + unconditional = { + name + for item in profile["syscalls"] + if item["action"] == "SCMP_ACT_ALLOW" + and not item.get("args") + and not item.get("includes") + and not item.get("excludes") + for name in item["names"] + } + self.assertTrue( + {"mount", "pivot_root", "umount2", "chroot", "setns"} <= unconditional + ) + self.assertTrue( + {"clone3", "sethostname", "setdomainname", "unshare"}.isdisjoint( + unconditional + ) + ) + + docker_default = copy.deepcopy(profile) + x86_64 = next( + arch + for arch in docker_default["archMap"] + if arch["architecture"] == "SCMP_ARCH_X86_64" + ) + x86_64["subArchitectures"].insert(0, "SCMP_ARCH_X86") + docker_default["syscalls"] = [ + item + for item in profile["syscalls"] + if not item.get("comment", "").startswith("RAINIER:") + ] + canonical = json.dumps( + docker_default, sort_keys=True, separators=(",", ":") + ).encode() + self.assertEqual(hashlib.sha256(canonical).hexdigest(), DOCKER_CANONICAL_SHA256) + full = json.dumps(profile, sort_keys=True, separators=(",", ":")).encode() + self.assertEqual(hashlib.sha256(full).hexdigest(), SECCOMP_CANONICAL_SHA256) + + def test_apparmor_keeps_docker_denials_and_limits_namespace_writes(self): + profile = APPARMOR.read_text() + self.assertEqual(hashlib.sha256(profile.encode()).hexdigest(), APPARMOR_SHA256) + self.assertIn("profile rainier-codex-bwrap", profile) + self.assertIn(" userns,", profile) + self.assertIn(" mount,", profile) + self.assertIn(" pivot_root,", profile) + self.assertNotIn("deny mount", profile) + self.assertIn("@{PROC}/self/{uid_map,gid_map,setgroups} rw,", profile) + self.assertIn("@{PROC}/[0-9]*/{uid_map,gid_map,setgroups} rw,", profile) + self.assertNotIn("setgroup?*", profile) + self.assertIn("setgroup[^s]*", profile) + self.assertIn("deny @{PROC}/self/", profile) + self.assertIn("deny @{PROC}/sysrq-trigger rwklx,", profile) + self.assertIn("deny @{PROC}/kcore rwklx,", profile) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/session-image-smoke.sh b/scripts/session-image-smoke.sh index 70e0ddd6..ad0c4a58 100755 --- a/scripts/session-image-smoke.sh +++ b/scripts/session-image-smoke.sh @@ -24,42 +24,32 @@ # # Usage: scripts/session-image-smoke.sh [image] (default rainier-session:smoke) # Env: DOCKER= PROBE_TIMEOUT= KEEP=1 +# SECCOMP= APPARMOR= # Exit: 0 every check passed, 1 a check failed, 2 setup or usage error. set -uo pipefail IMAGE=${1:-rainier-session:smoke} DOCKER=${DOCKER:-docker} PROBE_TIMEOUT=${PROBE_TIMEOUT:-240} +SECCOMP=${SECCOMP:-} +APPARMOR=${APPARMOR:-} + +SECURITY_OPTS=() +if [ -n "$SECCOMP" ]; then + [ -r "$SECCOMP" ] || { echo "no seccomp profile at $SECCOMP" >&2; exit 2; } + SECURITY_OPTS+=(--security-opt "seccomp=$SECCOMP") +fi +if [ -n "$APPARMOR" ]; then + SECURITY_OPTS+=(--security-opt "apparmor=$APPARMOR") +fi + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +source "$SCRIPT_DIR/session-image-checks.sh" command -v "$DOCKER" >/dev/null 2>&1 || { echo "no docker executable ($DOCKER); set DOCKER=" >&2; exit 2; } "$DOCKER" image inspect "$IMAGE" >/dev/null 2>&1 \ || { echo "image $IMAGE is not present; build it first (make session-image)" >&2; exit 2; } -PASS=0 FAIL=0 - -# In GitHub Actions the job log is the only record of a failed qualification, -# and it is not always reachable from wherever the fix is being made — a -# session's egress allowlist does not carry the Actions log host, for one. -# Emitting each failure as a workflow annotation puts the check's name and its -# detail on the pull request itself, where the check status already is. Inert -# outside Actions, and it reports; it never changes what passes. -# A workflow command's PROPERTIES are comma-separated and colon-terminated, so -# a title carrying either has to be escaped or it truncates the annotation — -# and most of the check names below contain a comma. The message half only has -# to survive the newline. -wf_title() { printf '%s' "$1" | sed 's/%/%25/g; s/\r/%0D/g; s/:/%3A/g; s/,/%2C/g'; } -wf_body() { printf '%s' "${1:-}" | cut -c1-2000 | sed 's/%/%25/g; s/\r/ /g' | awk '{printf "%s%%0A", $0}'; } -note() { - [ "${GITHUB_ACTIONS:-}" = true ] || return 0 - printf '::notice title=%s::%s\n' "$(wf_title "$1")" "$(wf_body "$2")" -} -annotate() { - [ "${GITHUB_ACTIONS:-}" = true ] || return 0 - printf '::error title=%s::%s\n' "$(wf_title "$1")" "$(wf_body "${2:-}")" -} -ok() { PASS=$((PASS+1)); printf 'ok %s\n' "$1"; } -bad() { FAIL=$((FAIL+1)); printf 'FAIL %s\n' "$1"; [ $# -gt 1 ] && printf ' %s\n' "$2"; annotate "$1" "${2:-}"; return 0; } - SUFFIX=$(od -An -tx1 -N6 /dev/urandom | tr -d ' \n') WS_VOL="rainier-smoke-ws-$SUFFIX" HOME_VOL="rainier-smoke-agents-$SUFFIX" @@ -101,6 +91,7 @@ probe() { --network none \ --user 1000:1000 \ --security-opt no-new-privileges \ + "${SECURITY_OPTS[@]}" \ --cap-drop ALL \ --read-only \ --tmpfs /tmp \ @@ -127,6 +118,7 @@ probe_setup() { --network none \ --user 1000:1000 \ --security-opt no-new-privileges \ + "${SECURITY_OPTS[@]}" \ --cap-drop ALL \ --tmpfs /tmp \ --memory 3g --pids-limit 1024 \ @@ -138,14 +130,18 @@ $(declare -f brokered_gh_probe) $1" 2>&1 } -SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -source "$SCRIPT_DIR/session-image-checks.sh" CLAUDE_VERSION=$(sed -n 's/^ARG CLAUDE_CODE_VERSION=//p' "$SCRIPT_DIR/../Dockerfile") CODEX_VERSION=$(sed -n 's/^ARG CODEX_VERSION=//p' "$SCRIPT_DIR/../Dockerfile") # Read, not hardcoded: a check that names 17 while the image builds an 18 does # not fail, it stops asking the question. PG_MAJOR=$(sed -n 's/^ARG POSTGRES_MAJOR=//p' "$SCRIPT_DIR/../Dockerfile") [[ "$PG_MAJOR" =~ ^[0-9]+$ ]] || { echo "missing or invalid POSTGRES_MAJOR pin" >&2; exit 2; } +CHROMIUM_VERSION=$(sed -n 's/^ARG CHROMIUM_VERSION=//p' "$SCRIPT_DIR/../Dockerfile") +CHROMIUM_REVISION=$(sed -n 's/^ARG CHROMIUM_REVISION=//p' "$SCRIPT_DIR/../Dockerfile") +PLAYWRIGHT_PIN=$(sed -n 's/^ARG PLAYWRIGHT_VERSION=//p' "$SCRIPT_DIR/../Dockerfile") +[[ "$CHROMIUM_VERSION" =~ ^[0-9]+(\.[0-9]+)+$ ]] || { echo "missing or invalid CHROMIUM_VERSION pin" >&2; exit 2; } +[[ "$CHROMIUM_REVISION" =~ ^[0-9]+$ ]] || { echo "missing or invalid CHROMIUM_REVISION pin" >&2; exit 2; } +[[ "$PLAYWRIGHT_PIN" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "missing or invalid PLAYWRIGHT_VERSION pin" >&2; exit 2; } for version in "$CLAUDE_VERSION" "$CODEX_VERSION"; do [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "missing or invalid agent version pin" >&2; exit 2; } done @@ -556,6 +552,177 @@ check "every service kept its durable state on the workspace volume, and none on test -e /var/lib/postgresql/'"$PG_MAJOR"'/main && { echo CLUSTER-ON-ROOTFS; exit 1; } echo service-state-ok' +echo +echo "-- browser testing" + +# `npx playwright install --with-deps` is what every project's CI runs and what +# a session cannot: its --with-deps half is an apt install as root, and a +# session has no escalation path, a read-only rootfs and no package archive on +# its allowlist. So the shared libraries are a build-time layer and the browser +# is a checksum-pinned artifact, and if either is wrong there is no in-session +# repair — which is why these are checks rather than documentation. +# +# Everything below runs in the ordinary probe: uid 1000, read-only rootfs, +# noexec /tmp, docker's 64 MiB /dev/shm, and NO NETWORK AT ALL. A browser that +# needed to download anything on first use would fail here, which is the point: +# a fresh session has to be able to run a test suite offline. +# +# The direct invocations below intentionally omit --no-sandbox. The sample +# Playwright project requires chromiumSandbox=true, so these probes must prove +# the packaged browser can use its own sandbox under the session policy. + +BROWSER_FIXTURE=' + b=$(find -L "$PLAYWRIGHT_BROWSERS_PATH" -maxdepth 3 -type f -name chrome-headless-shell 2>/dev/null | head -1) + [ -n "$b" ] || { echo "no chrome-headless-shell under $PLAYWRIGHT_BROWSERS_PATH"; exit 1; } + d=$(mktemp -d -p /workspace) || exit 1 + cat > "$d/page.html" <pending + +

Rainier browser smoke

+MMMMMMMMMM + + +HTML + SERVER_URL=http://127.0.0.1:8974/page.html + python3 -m http.server 8974 --bind 127.0.0.1 --directory "$d" >/dev/null 2>&1 & + server=$! + cleanup_server() { kill "$server" >/dev/null 2>&1 || true; wait "$server" 2>/dev/null || true; } + trap cleanup_server EXIT + for _ in 1 2 3 4 5 6 7 8 9 10; do + curl -fsS "$SERVER_URL" >/dev/null 2>&1 && break + kill -0 "$server" >/dev/null 2>&1 || { echo "browser fixture server exited"; exit 1; } + sleep 0.1 + done + curl -fsS "$SERVER_URL" >/dev/null 2>&1 || { echo "browser fixture server not ready"; exit 1; } + # The flags Playwright passes, and nothing else: --disable-dev-shm-usage is + # in chromiumSwitches for every launch, which is why docker default 64 MiB + # /dev/shm is enough for a Playwright suite. + # Keep a misbehaving sandbox child from holding the whole image qualification + # job open. The browser is the process under test; timeout only bounds it and + # does not add a flag that changes the Chromium sandbox mode. + render() { timeout -k 5 30 "$b" --disable-dev-shm-usage --disable-gpu --disable-breakpad \ + --user-data-dir="$d/profile" "$@" 2>&1; } + png_size() { python3 -c "import struct,sys; d=open(sys.argv[1],\"rb\").read(24); w,h=struct.unpack(\">II\", d[16:24]); print(w,h)" "$1"; } +' + +check "no Playwright is installed globally, so a project's own pin is the one that runs" "no-global-playwright" ' + if command -v playwright >/dev/null 2>&1; then echo "GLOBAL-PLAYWRIGHT $(command -v playwright)"; exit 1; fi + if command -v playwright-core >/dev/null 2>&1; then echo GLOBAL-PLAYWRIGHT-CORE; exit 1; fi + if grep -qi "playwright" /usr/local/share/rainier-npm-global.json; then echo GLOBAL-PLAYWRIGHT-PACKAGE; exit 1; fi + echo no-global-playwright' + +check "the browser baseline is root-owned and the session user cannot rewrite it" "baseline-held" ' + b=$(find /usr/local/lib/rainier-browsers -type f -name chrome-headless-shell | head -1) + [ -n "$b" ] || { echo "no browser baseline in the image"; exit 1; } + [ "$(stat -c %U "$b")" = root ] || { echo "the browser is owned by $(stat -c %U "$b")"; exit 1; } + if echo x > "$b" 2>/dev/null; then echo BROWSER-WRITABLE; exit 1; fi + echo baseline-held' + +check "a fresh workspace volume already carries the browser cache Playwright reads" "cache-linked" ' + [ "$PLAYWRIGHT_BROWSERS_PATH" = /workspace/.cache/ms-playwright ] \ + || { echo "PLAYWRIGHT_BROWSERS_PATH=$PLAYWRIGHT_BROWSERS_PATH"; exit 1; } + dir=$PLAYWRIGHT_BROWSERS_PATH/chromium_headless_shell-'"$CHROMIUM_REVISION"' + # The two files Playwright reads before it decides a browser is installed and + # whether its dependencies still need checking. Both have to be writable + # files on the volume, not links onto the read-only rootfs. + for m in INSTALLATION_COMPLETE DEPENDENCIES_VALIDATED; do + [ -f "$dir/$m" ] || { echo "no $m in $dir"; exit 1; } + [ -L "$dir/$m" ] && { echo "$m is a link onto the read-only rootfs"; exit 1; } + : > "$dir/$m" || { echo "$m is not writable"; exit 1; } + done + exe=$(find -L "$dir" -type f -name chrome-headless-shell | head -1) + [ -x "$exe" ] || { echo "the cache does not resolve to an executable browser"; exit 1; } + echo cache-linked' + +check "the preinstalled browser is exactly the build the Dockerfile pins" "$CHROMIUM_VERSION" ' + '"$BROWSER_FIXTURE"' + "$b" --version' + +check "every shared library the browser needs resolves in this image" "libs-resolved" ' + '"$BROWSER_FIXTURE"' + missing=$(ldd "$b" 2>/dev/null | awk "/not found/ { print \$1 }" | sort -u) + [ -z "$missing" ] || { echo "MISSING $missing"; exit 1; } + echo libs-resolved' + +check "the browser renders a page and writes a desktop-viewport screenshot, offline" "1280 800" ' + '"$BROWSER_FIXTURE"' + render --screenshot="$d/desktop.png" --window-size=1280,800 "$SERVER_URL" >/dev/null + [ -s "$d/desktop.png" ] || { echo "no screenshot was written"; exit 1; } + file "$d/desktop.png" | grep -q "PNG image" || { echo "not a PNG"; exit 1; } + png_size "$d/desktop.png"' + +check "the same page at a phone viewport produces a phone-sized screenshot" "390 844" ' + '"$BROWSER_FIXTURE"' + render --screenshot="$d/phone.png" --window-size=390,844 "$SERVER_URL" >/dev/null + [ -s "$d/phone.png" ] || { echo "no screenshot was written"; exit 1; } + png_size "$d/phone.png"' + +# A browser with no fonts still renders: it falls back to whatever it can find +# and lays text out with the wrong metrics, so a screenshot is boxes and a +# width assertion is a flake. Ten Arial capital Ms at 100px are 833px wide by +# the font, and Liberation Sans is metric-compatible with Arial by design — +# DejaVu, the usual fallback, gives 791. So the number below is a check that +# fontconfig resolved Arial to the font this image installed FOR that, not +# merely that some font exists. +check "Arial resolves to a metric-compatible font and text lays out at its real width" "font-metrics-ok" ' + '"$BROWSER_FIXTURE"' + fc-match Arial | grep -qi liberation || { echo "fc-match Arial = $(fc-match Arial)"; exit 1; } + fc-list | grep -qi emoji || { echo "no emoji font"; exit 1; } + w=$(render --dump-dom "$SERVER_URL" | sed -n "s/.*\([0-9]*\)<\/title>.*/\1/p" | head -1) + [ -n "$w" ] || { echo "the page did not report a measured width"; exit 1; } + [ "$w" -ge 800 ] && [ "$w" -le 870 ] || { echo "ten 100px Arial Ms measured ${w}px, not ~833"; exit 1; } + echo font-metrics-ok' + +check "the browser leaves no process behind after it exits" "no-browser-left" ' + '"$BROWSER_FIXTURE"' + # By resolved executable rather than by command line: the shell running this + # check has the string "chrome-headless-shell" in its own argv, so a pgrep -f + # would match itself and never pass. A zombie has no /proc/pid/exe, which is + # the right answer too: sessiond is PID 1 in a real session, and it reaps. + browsers_alive() { + for p in /proc/[0-9]*; do + case "$(readlink "$p/exe" 2>/dev/null)" in *chrome-headless-shell*) return 0 ;; esac + done + return 1 + } + render --screenshot="$d/x.png" --window-size=800,600 "$SERVER_URL" >/dev/null + for _ in 1 2 3 4 5 6 7 8 9 10; do + browsers_alive || break + sleep 0.5 + done + if browsers_alive; then + echo "BROWSER-STILL-RUNNING"; ps -eo pid,ppid,comm | head -20; exit 1 + fi + echo no-browser-left' + +check "rainier-browsers reports the cache a project's Playwright will read" "linked" ' + rainier-browsers path | grep -qx /workspace/.cache/ms-playwright || { echo "path = $(rainier-browsers path)"; exit 1; } + rainier-browsers status' + +# The sandbox-enabled launch is a required check; a browser that cannot +# initialize its own sandbox exits nonzero. +SANDBOX_OUTPUT=$(probe ' + '"$BROWSER_FIXTURE"' + timeout -k 5 30 "$b" --disable-dev-shm-usage --disable-gpu --disable-breakpad --user-data-dir="$d/p2" --dump-dom "$SERVER_URL" >/dev/null 2>&1 + st=$? + printf "exit=%s\n" "$st" + exit "$st" +') +SANDBOX_STATUS=$? +printf 'note chromium own-sandbox under the driver restrictions: exit=%s\n' "$SANDBOX_STATUS" +note "chromium own-sandbox status" "exit=$SANDBOX_STATUS" +if [ "$SANDBOX_STATUS" -eq 0 ]; then + ok "Chromium starts with its own sandbox" +else + bad "Chromium starts with its own sandbox" "exit=$SANDBOX_STATUS" +fi + +BROWSER_SIZE=$(probe 'cat /usr/local/share/rainier-browser-size.txt 2>/dev/null | head -1; grep -h "browser payload" /usr/local/share/rainier-browser-size.txt 2>/dev/null' | tr '\n' '; ') +printf 'note browser layer: %s\n' "$BROWSER_SIZE" +note "browser layer size" "$BROWSER_SIZE" + echo echo "-- git, gh and the rest of the shell toolkit" check "git makes a commit" "git-ok" ' diff --git a/testdata/session-security/README.md b/testdata/session-security/README.md new file mode 100644 index 00000000..a4532046 --- /dev/null +++ b/testdata/session-security/README.md @@ -0,0 +1,33 @@ +# Browser sandbox policy fixture + +These files are a public, test-only snapshot used by the session-image +qualification job. They let the core repository exercise the same kind of +Chromium user-namespace boundary as a hosted session without checking out a +Rainier Cloud repository or requiring a cross-repository GitHub token. + +The seccomp file starts from Docker Engine 27.5.1's default profile and adds +only the exact syscall shapes needed by Codex's Bubblewrap and Chromium's +namespace sandbox, including Chromium's x86_64 safe-empty-directory clone +helper, its `chroot` syscall, Chromium's namespace `setns` operation, and its exact +renderer `clone(CLONE_NEWPID|SIGCHLD)` shape. The +kernel still requires `CAP_SYS_CHROOT` +inside the private user namespace. The AppArmor file keeps Docker's device, procfs, sysfs, and kernel +denials while admitting Bubblewrap's namespace setup and Chromium's three +namespace-map writes. Both policies remain default-deny; this fixture +must never be used as a production host policy. + +The runtime policy is owned and qualified by Rainier Cloud independently. If +either policy changes, update this snapshot deliberately, preserve the hashes +in `scripts/session-image-security-policy-test.py`, and make the corresponding +Cloud policy change in its own review. The core workflow must remain able to +run with only this public repository. + +Pinned fixture invariants: + +- Docker base profile: 27.5.1; canonical JSON SHA-256: `885442dc08f21f8d60f99ea43d59af88b1c529103815fe24bbf9ce998d3a609d` +- Full Rainier seccomp canonical JSON SHA-256: `4e43265c398ab8e93118568ff37749abd8d367dc6a01419dfa1d1efecd3bb636` +- AppArmor profile SHA-256: `53f78e768ee56099b764661c58b569504e39ecc45b2b3fb0dffd13ea329eb431` + +The canonical JSON hash is calculated with sorted keys and compact separators; +the raw file hash is intentionally not part of the contract because harmless +formatting changes should not alter the policy identity. diff --git a/testdata/session-security/codex-bwrap-seccomp-docker-27.5.1.json b/testdata/session-security/codex-bwrap-seccomp-docker-27.5.1.json new file mode 100644 index 00000000..a623df86 --- /dev/null +++ b/testdata/session-security/codex-bwrap-seccomp-docker-27.5.1.json @@ -0,0 +1,1043 @@ +{ + "defaultAction": "SCMP_ACT_ERRNO", + "defaultErrnoRet": 1, + "archMap": [ + { + "architecture": "SCMP_ARCH_X86_64", + "subArchitectures": [ + "SCMP_ARCH_X32" + ] + }, + { + "architecture": "SCMP_ARCH_AARCH64", + "subArchitectures": [ + "SCMP_ARCH_ARM" + ] + }, + { + "architecture": "SCMP_ARCH_MIPS64", + "subArchitectures": [ + "SCMP_ARCH_MIPS", + "SCMP_ARCH_MIPS64N32" + ] + }, + { + "architecture": "SCMP_ARCH_MIPS64N32", + "subArchitectures": [ + "SCMP_ARCH_MIPS", + "SCMP_ARCH_MIPS64" + ] + }, + { + "architecture": "SCMP_ARCH_MIPSEL64", + "subArchitectures": [ + "SCMP_ARCH_MIPSEL", + "SCMP_ARCH_MIPSEL64N32" + ] + }, + { + "architecture": "SCMP_ARCH_MIPSEL64N32", + "subArchitectures": [ + "SCMP_ARCH_MIPSEL", + "SCMP_ARCH_MIPSEL64" + ] + }, + { + "architecture": "SCMP_ARCH_S390X", + "subArchitectures": [ + "SCMP_ARCH_S390" + ] + }, + { + "architecture": "SCMP_ARCH_RISCV64", + "subArchitectures": null + } + ], + "syscalls": [ + { + "names": [ + "accept", + "accept4", + "access", + "adjtimex", + "alarm", + "bind", + "brk", + "cachestat", + "capget", + "capset", + "chdir", + "chmod", + "chown", + "chown32", + "clock_adjtime", + "clock_adjtime64", + "clock_getres", + "clock_getres_time64", + "clock_gettime", + "clock_gettime64", + "clock_nanosleep", + "clock_nanosleep_time64", + "close", + "close_range", + "connect", + "copy_file_range", + "creat", + "dup", + "dup2", + "dup3", + "epoll_create", + "epoll_create1", + "epoll_ctl", + "epoll_ctl_old", + "epoll_pwait", + "epoll_pwait2", + "epoll_wait", + "epoll_wait_old", + "eventfd", + "eventfd2", + "execve", + "execveat", + "exit", + "exit_group", + "faccessat", + "faccessat2", + "fadvise64", + "fadvise64_64", + "fallocate", + "fanotify_mark", + "fchdir", + "fchmod", + "fchmodat", + "fchmodat2", + "fchown", + "fchown32", + "fchownat", + "fcntl", + "fcntl64", + "fdatasync", + "fgetxattr", + "flistxattr", + "flock", + "fork", + "fremovexattr", + "fsetxattr", + "fstat", + "fstat64", + "fstatat64", + "fstatfs", + "fstatfs64", + "fsync", + "ftruncate", + "ftruncate64", + "futex", + "futex_requeue", + "futex_time64", + "futex_wait", + "futex_waitv", + "futex_wake", + "futimesat", + "getcpu", + "getcwd", + "getdents", + "getdents64", + "getegid", + "getegid32", + "geteuid", + "geteuid32", + "getgid", + "getgid32", + "getgroups", + "getgroups32", + "getitimer", + "getpeername", + "getpgid", + "getpgrp", + "getpid", + "getppid", + "getpriority", + "getrandom", + "getresgid", + "getresgid32", + "getresuid", + "getresuid32", + "getrlimit", + "get_robust_list", + "getrusage", + "getsid", + "getsockname", + "getsockopt", + "get_thread_area", + "gettid", + "gettimeofday", + "getuid", + "getuid32", + "getxattr", + "inotify_add_watch", + "inotify_init", + "inotify_init1", + "inotify_rm_watch", + "io_cancel", + "ioctl", + "io_destroy", + "io_getevents", + "io_pgetevents", + "io_pgetevents_time64", + "ioprio_get", + "ioprio_set", + "io_setup", + "io_submit", + "ipc", + "kill", + "landlock_add_rule", + "landlock_create_ruleset", + "landlock_restrict_self", + "lchown", + "lchown32", + "lgetxattr", + "link", + "linkat", + "listen", + "listxattr", + "llistxattr", + "_llseek", + "lremovexattr", + "lseek", + "lsetxattr", + "lstat", + "lstat64", + "madvise", + "map_shadow_stack", + "membarrier", + "memfd_create", + "memfd_secret", + "mincore", + "mkdir", + "mkdirat", + "mknod", + "mknodat", + "mlock", + "mlock2", + "mlockall", + "mmap", + "mmap2", + "mprotect", + "mq_getsetattr", + "mq_notify", + "mq_open", + "mq_timedreceive", + "mq_timedreceive_time64", + "mq_timedsend", + "mq_timedsend_time64", + "mq_unlink", + "mremap", + "msgctl", + "msgget", + "msgrcv", + "msgsnd", + "msync", + "munlock", + "munlockall", + "munmap", + "name_to_handle_at", + "nanosleep", + "newfstatat", + "_newselect", + "open", + "openat", + "openat2", + "pause", + "pidfd_open", + "pidfd_send_signal", + "pipe", + "pipe2", + "pkey_alloc", + "pkey_free", + "pkey_mprotect", + "poll", + "ppoll", + "ppoll_time64", + "prctl", + "pread64", + "preadv", + "preadv2", + "prlimit64", + "process_mrelease", + "pselect6", + "pselect6_time64", + "pwrite64", + "pwritev", + "pwritev2", + "read", + "readahead", + "readlink", + "readlinkat", + "readv", + "recv", + "recvfrom", + "recvmmsg", + "recvmmsg_time64", + "recvmsg", + "remap_file_pages", + "removexattr", + "rename", + "renameat", + "renameat2", + "restart_syscall", + "rmdir", + "rseq", + "rt_sigaction", + "rt_sigpending", + "rt_sigprocmask", + "rt_sigqueueinfo", + "rt_sigreturn", + "rt_sigsuspend", + "rt_sigtimedwait", + "rt_sigtimedwait_time64", + "rt_tgsigqueueinfo", + "sched_getaffinity", + "sched_getattr", + "sched_getparam", + "sched_get_priority_max", + "sched_get_priority_min", + "sched_getscheduler", + "sched_rr_get_interval", + "sched_rr_get_interval_time64", + "sched_setaffinity", + "sched_setattr", + "sched_setparam", + "sched_setscheduler", + "sched_yield", + "seccomp", + "select", + "semctl", + "semget", + "semop", + "semtimedop", + "semtimedop_time64", + "send", + "sendfile", + "sendfile64", + "sendmmsg", + "sendmsg", + "sendto", + "setfsgid", + "setfsgid32", + "setfsuid", + "setfsuid32", + "setgid", + "setgid32", + "setgroups", + "setgroups32", + "setitimer", + "setpgid", + "setpriority", + "setregid", + "setregid32", + "setresgid", + "setresgid32", + "setresuid", + "setresuid32", + "setreuid", + "setreuid32", + "setrlimit", + "set_robust_list", + "setsid", + "setsockopt", + "set_thread_area", + "set_tid_address", + "setuid", + "setuid32", + "setxattr", + "shmat", + "shmctl", + "shmdt", + "shmget", + "shutdown", + "sigaltstack", + "signalfd", + "signalfd4", + "sigprocmask", + "sigreturn", + "socketcall", + "socketpair", + "splice", + "stat", + "stat64", + "statfs", + "statfs64", + "statx", + "symlink", + "symlinkat", + "sync", + "sync_file_range", + "syncfs", + "sysinfo", + "tee", + "tgkill", + "time", + "timer_create", + "timer_delete", + "timer_getoverrun", + "timer_gettime", + "timer_gettime64", + "timer_settime", + "timer_settime64", + "timerfd_create", + "timerfd_gettime", + "timerfd_gettime64", + "timerfd_settime", + "timerfd_settime64", + "times", + "tkill", + "truncate", + "truncate64", + "ugetrlimit", + "umask", + "uname", + "unlink", + "unlinkat", + "utime", + "utimensat", + "utimensat_time64", + "utimes", + "vfork", + "vmsplice", + "wait4", + "waitid", + "waitpid", + "write", + "writev" + ], + "action": "SCMP_ACT_ALLOW" + }, + { + "names": [ + "process_vm_readv", + "process_vm_writev", + "ptrace" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "minKernel": "4.8" + } + }, + { + "names": [ + "socket" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 40, + "op": "SCMP_CMP_NE" + } + ] + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 0, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 8, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 131072, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 131080, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 4294967295, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "sync_file_range2", + "swapcontext" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "arches": [ + "ppc64le" + ] + } + }, + { + "names": [ + "arm_fadvise64_64", + "arm_sync_file_range", + "sync_file_range2", + "breakpoint", + "cacheflush", + "set_tls" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "arches": [ + "arm", + "arm64" + ] + } + }, + { + "names": [ + "arch_prctl" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "arches": [ + "amd64", + "x32" + ] + } + }, + { + "names": [ + "modify_ldt" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "arches": [ + "amd64", + "x32", + "x86" + ] + } + }, + { + "names": [ + "s390_pci_mmio_read", + "s390_pci_mmio_write", + "s390_runtime_instr" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "arches": [ + "s390", + "s390x" + ] + } + }, + { + "names": [ + "riscv_flush_icache" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "arches": [ + "riscv64" + ] + } + }, + { + "names": [ + "open_by_handle_at" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_DAC_READ_SEARCH" + ] + } + }, + { + "names": [ + "unshare" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 268435456, + "op": "SCMP_CMP_EQ" + } + ], + "comment": "RAINIER: Codex 0.153.4 Bubblewrap creates its initial user namespace with unshare(CLONE_NEWUSER).", + "includes": { + "arches": [ + "amd64", + "x32" + ] + } + }, + { + "names": [ + "clone" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 1879048209, + "op": "SCMP_CMP_EQ" + } + ], + "comment": "RAINIER: Chromium namespace sandbox launches its zygote with CLONE_NEWUSER|CLONE_NEWPID|CLONE_NEWNET|SIGCHLD.", + "includes": { + "arches": [ + "amd64", + "x32" + ] + } + }, + { + "names": [ + "clone3" + ], + "action": "SCMP_ACT_ERRNO", + "errnoRet": 38, + "comment": "RAINIER: Chromium requires clone3 to return ENOSYS so libc falls back to flag-inspectable clone(2)." + }, + { + "names": [ + "clone" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 805306385, + "op": "SCMP_CMP_EQ" + } + ], + "comment": "RAINIER: Chromium namespace sandbox fallback zygote shape with CLONE_NEWUSER|CLONE_NEWPID|SIGCHLD.", + "includes": { + "arches": [ + "amd64", + "x32" + ] + } + }, + { + "names": [ + "clone" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 536870929, + "op": "SCMP_CMP_EQ" + } + ], + "comment": "RAINIER: Chromium gives each renderer its own PID namespace with CLONE_NEWPID|SIGCHLD after the zygote enters its private user namespace.", + "includes": { + "arches": [ + "amd64", + "x32" + ] + } + }, + { + "names": [ + "clone" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 268435473, + "op": "SCMP_CMP_EQ" + } + ], + "comment": "RAINIER: Chromium sandbox probes unprivileged user namespaces with clone(CLONE_NEWUSER|SIGCHLD).", + "includes": { + "arches": [ + "amd64", + "x32" + ] + } + }, + { + "names": [ + "clone" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 541457, + "op": "SCMP_CMP_EQ" + } + ], + "comment": "RAINIER: Chromium's safe-empty-dir helper uses the x86_64 CLONE_FS|CLONE_VM|CLONE_VFORK|CLONE_SETTLS|SIGCHLD shape before chroot.", + "includes": { + "arches": [ + "amd64", + "x32" + ] + } + }, + { + "names": [ + "unshare" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 268566528, + "op": "SCMP_CMP_EQ" + } + ], + "comment": "RAINIER: Chromium sandbox creates its user and mount namespaces with unshare(CLONE_NEWUSER|CLONE_NEWNS).", + "includes": { + "arches": [ + "amd64", + "x32" + ] + } + }, + { + "names": [ + "clone" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 2013397009, + "op": "SCMP_CMP_EQ" + } + ], + "comment": "RAINIER: Codex 0.153.4 Bubblewrap clone shape with a private network namespace.", + "includes": { + "arches": [ + "amd64", + "x32" + ] + } + }, + { + "names": [ + "clone" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 939655185, + "op": "SCMP_CMP_EQ" + } + ], + "comment": "RAINIER: Codex 0.153.4 Bubblewrap clone shape without a private network namespace.", + "includes": { + "arches": [ + "amd64", + "x32" + ] + } + }, + { + "names": [ + "mount", + "pivot_root", + "umount2" + ], + "action": "SCMP_ACT_ALLOW", + "comment": "RAINIER: Bubblewrap constructs and discards a filesystem only inside the child mount namespace." + }, + { + "names": [ + "bpf", + "clone", + "clone3", + "fanotify_init", + "fsconfig", + "fsmount", + "fsopen", + "fspick", + "lookup_dcookie", + "mount", + "mount_setattr", + "move_mount", + "open_tree", + "perf_event_open", + "quotactl", + "quotactl_fd", + "setdomainname", + "sethostname", + "setns", + "syslog", + "umount", + "umount2", + "unshare" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_ADMIN" + ] + } + }, + { + "names": [ + "clone" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 2114060288, + "op": "SCMP_CMP_MASKED_EQ" + } + ], + "excludes": { + "caps": [ + "CAP_SYS_ADMIN" + ], + "arches": [ + "s390", + "s390x" + ] + } + }, + { + "names": [ + "clone" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 1, + "value": 2114060288, + "op": "SCMP_CMP_MASKED_EQ" + } + ], + "comment": "s390 parameter ordering for clone is different", + "includes": { + "arches": [ + "s390", + "s390x" + ] + }, + "excludes": { + "caps": [ + "CAP_SYS_ADMIN" + ] + } + }, + { + "names": [ + "clone3" + ], + "action": "SCMP_ACT_ERRNO", + "errnoRet": 38, + "excludes": { + "caps": [ + "CAP_SYS_ADMIN" + ] + } + }, + { + "names": [ + "reboot" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_BOOT" + ] + } + }, + { + "names": [ + "chroot" + ], + "action": "SCMP_ACT_ALLOW", + "comment": "RAINIER: Chromium's safe-empty-dir helper chroots only after entering its private user namespace; the kernel still requires CAP_SYS_CHROOT there." + }, + { + "names": [ + "setns" + ], + "action": "SCMP_ACT_ALLOW", + "comment": "RAINIER: Chromium's namespace sandbox may join its private user, PID, network, and mount namespaces after creation; namespace ownership still limits targets." + }, + { + "names": [ + "chroot" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_CHROOT" + ] + } + }, + { + "names": [ + "delete_module", + "init_module", + "finit_module" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_MODULE" + ] + } + }, + { + "names": [ + "acct" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_PACCT" + ] + } + }, + { + "names": [ + "kcmp", + "pidfd_getfd", + "process_madvise", + "process_vm_readv", + "process_vm_writev", + "ptrace" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_PTRACE" + ] + } + }, + { + "names": [ + "iopl", + "ioperm" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_RAWIO" + ] + } + }, + { + "names": [ + "settimeofday", + "stime", + "clock_settime", + "clock_settime64" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_TIME" + ] + } + }, + { + "names": [ + "vhangup" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_TTY_CONFIG" + ] + } + }, + { + "names": [ + "get_mempolicy", + "mbind", + "set_mempolicy", + "set_mempolicy_home_node" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_NICE" + ] + } + }, + { + "names": [ + "syslog" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYSLOG" + ] + } + }, + { + "names": [ + "bpf" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_BPF" + ] + } + }, + { + "names": [ + "perf_event_open" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_PERFMON" + ] + } + } + ] +} diff --git a/testdata/session-security/rainier-codex-bwrap.apparmor b/testdata/session-security/rainier-codex-bwrap.apparmor new file mode 100644 index 00000000..920a193c --- /dev/null +++ b/testdata/session-security/rainier-codex-bwrap.apparmor @@ -0,0 +1,52 @@ +#include <tunables/global> + +# Docker 27.5.1's docker-default profile with its blanket mount denial +# replaced by an allow rule. The session still has no capability in the +# container's original user namespace; this rule lets Bubblewrap use the +# capability it receives only inside the child user and mount namespaces. +profile rainier-codex-bwrap flags=(attach_disconnected,mediate_deleted) { + #include <abstractions/base> + + network, + capability, + # Chromium needs unprivileged user namespaces for its setuid-free sandbox. + # Keep this explicit; no host namespace or privilege escalation is granted. + userns, + file, + mount, + pivot_root, + umount, + signal (receive) peer=unconfined, + signal (receive) peer=runc, + signal (receive) peer=crun, + signal (send,receive) peer=rainier-codex-bwrap, + + # Keep Docker's direct-proc write denial, except for the three namespace + # maps Chromium must write after creating its private user namespace. The + # complement is spelled out because AppArmor deny rules override allows. + deny @{PROC}/{[^gsu]*,g[^i]*,gi[^d]*,gid[^_]*,gid_[^m]*,gid_m[^a]*,gid_ma[^p]*,gid_map?*,s[^e]*,se[^t]*,set[^g]*,setg[^r]*,setgr[^o]*,setgro[^u]*,setgrou[^p]*,setgroup[^s]*,u[^i]*,ui[^d]*,uid[^_]*,uid_[^m]*,uid_m[^a]*,uid_ma[^p]*,uid_map?*} w, + deny @{PROC}/{[^1-9],[^1-9][^0-9],[^1-9s][^0-9y][^0-9s],[^1-9s][^0-9][^0-9][^0-9/]*,s[^ey][^0-9][^0-9/]*,se[^l][^0-9/]*,sel[^f][^0-9/]*,self[^0-9/][^0-9/]*}/** w, + deny @{PROC}/sys/[^k]** w, + deny @{PROC}/sys/kernel/{?,??,[^s][^h][^m]**} w, + deny @{PROC}/sysrq-trigger rwklx, + deny @{PROC}/kcore rwklx, + + deny /sys/[^f]*/** wklx, + deny /sys/f[^s]*/** wklx, + deny /sys/fs/[^c]*/** wklx, + deny /sys/fs/c[^g]*/** wklx, + deny /sys/fs/cg[^r]*/** wklx, + deny /sys/firmware/** rwklx, + deny /sys/devices/virtual/powercap/** rwklx, + deny /sys/kernel/security/** rwklx, + + # Chromium writes these three files while mapping the uid/gid in its + # private user namespace. Keep the allowance limited to proc namespace maps; + # the broader proc write denials below remain in force. + deny @{PROC}/self/{[^gsu]*,g[^i]*,gi[^d]*,gid[^_]*,gid_[^m]*,gid_m[^a]*,gid_ma[^p]*,gid_map?*,s[^e]*,se[^t]*,set[^g]*,setg[^r]*,setgr[^o]*,setgro[^u]*,setgrou[^p]*,setgroup[^s]*,u[^i]*,ui[^d]*,uid[^_]*,uid_[^m]*,uid_m[^a]*,uid_ma[^p]*,uid_map?*} w, + deny @{PROC}/self/{[^gsu]*,g[^i]*,gi[^d]*,gid[^_]*,gid_[^m]*,gid_m[^a]*,gid_ma[^p]*,gid_map?*,s[^e]*,se[^t]*,set[^g]*,setg[^r]*,setgr[^o]*,setgro[^u]*,setgrou[^p]*,setgroup[^s]*,u[^i]*,ui[^d]*,uid[^_]*,uid_[^m]*,uid_m[^a]*,uid_ma[^p]*,uid_map?*}/** w, + @{PROC}/self/{uid_map,gid_map,setgroups} rw, + @{PROC}/[0-9]*/{uid_map,gid_map,setgroups} rw, + + ptrace (trace,read,tracedby,readby) peer=rainier-codex-bwrap, +}