Security fix for pull-request target builds (PR#117): a target build now shares nothing that the repository's other builds can write. - werkdock: a fresh, empty home per build as `/root`; never `.git/werkator/buildenv/home`, never `werkdock.gradleHome`. - Docker: no shared Gradle volume, no Docker socket. - A target definition without a sandbox, or with `docker.socket: true`, is refused at start. Found in the AI-review trial on mih09: ordinary CI builds run PR code with the shared home read-write and could plant e.g. a `sitecustomize.py` that the next target build, which holds the host's secrets, would execute. Details: the PR-doc under `docs/prs/`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: mhoennig <michael@hoennig.de> Reviewed-on: #118
7.3 KiB
PR#118: Pull-Request Target Builds Share No Home, Cache or Socket
WARNING: This document describes only the change applied in this PR. It may already be outdated once the next PR is merged. Historic PR-documentation is not maintained along with new PRs — treat it as a snapshot, not as current documentation.
The Problem
PR#117 pinned the definition of a pull-request target build to the host, so a pull request cannot change its command or environment. But the build still shared state with the repository's other builds, and those run the code of every branch.
The werkdock runner bound the repository's toolchain home .git/werkator/buildenv/home as /root into every sandbox, read-write.
A pull request's own CI build could plant /root/.local/lib/python3.x/site-packages/sitecustomize.py, a .bashrc or a tool configuration there.
The next pull-request target build, which holds the host's secrets in werkdock.env, executed it.
The same home carried /root/werkdock.sock, and a werkdock.gradleHome reached the target build as well.
The Docker runner had the same class of issue: the per-repository Gradle cache volume, and the Docker socket where docker.socket is set.
A native target build would run in the host user's HOME, shared with everything else.
Non-Goals
- No change for builds that are not pull-request target builds.
- No protection of the images: a build with the Docker socket, or with werkdock's Docker-API socket, can still change images in the stores the target build starts from.
- No cache for target builds; they start empty every time.
The Scenarios
Feature: a pull-request target build shares nothing other builds write
Background
- A pull-request target build is a build definition with
trigger.onPullRequestTarget: true(PR#117). - Other builds are all other builds of the same repository; they run the code of the branches they build.
Scenario#118.01: in werkdock, a target build gets a fresh home of its own
So that nothing another build left in the toolchain home runs with the secrets.
- Given a pull request's CI build planted
sitecustomize.pyunder.git/werkator/buildenv/home- and the host configures a shared
werkdock.gradleHome
- and the host configures a shared
- When a pull-request target build runs in werkdock
- Then
/rootis a fresh, empty directory beside its fresh workspace, without the planted file- and no shared Gradle home is mounted, and
GRADLE_USER_HOMEis not set to it - and the home is removed with the workspace
- and no shared Gradle home is mounted, and
- And every other build keeps the toolchain home and the shared Gradle home
Verified by
- WerkdockBuildRunnerUnitTest
a pull-request target build binds a home of its own as /root and no shared Gradle home,a file planted in the shared toolchain home is not in the home of a pull-request target build,every other build keeps the repository's toolchain home and the shared Gradle home - GitWorktreeWorkspacesIntegrationTest
a pull-request target build gets a fresh workspace apart from the branch's, without anything left behind - WatcherPullRequestTargetUnitTest
the workspace and home of a finished pull-request target build are removed, a queued one's kept
werkdock is not installed on the development host, so the werkdock tests check the assembled werkdock run arguments and the directory bound as /root, not a running sandbox.
The two werkdock target tests and the Docker target test fail with the runner change reverted.
Scenario#118.02: in Docker, a target build gets no shared cache and no socket
So that no other build's cache content and no daemon reach the build that holds the secrets.
- Given a pull-request target build running in Docker
- When it starts
- Then the per-repository Gradle volume is neither prepared nor mounted
- and the Docker socket is not mounted, even where
docker.socketsays so
- and the Docker socket is not mounted, even where
- And every other build keeps the Gradle volume
Verified by
- DockerBuildRunnerUnitTest
a pull-request target build gets neither the shared Gradle volume nor the Docker socket, even when asked,every other build keeps the shared Gradle volume
Scenario#118.03: a target build without a sandbox, or with the socket, is refused at start
So that the configuration says what the build gets, and a host-native target build cannot exist.
- Given the host's configuration defines a pull-request target build
- When neither
docker.enablednorwerkdock.enabledholds for it, withbuilds.defaultapplied- or it has
docker.socket: true, also inherited frombuilds.default
- or it has
- Then the start is refused with a message naming the definition
- And a fragment or a committed file checked on its own is not refused, since its sandbox may come from the layers it joins
- And the native runner refuses such a build in any case
Verified by
- PullRequestTargetConfigUnitTest
a pull-request target build without a sandbox is refused at start, naming it,a pull-request target build with the Docker socket is refused, also when it inherits it,a fragment or committed file checked on its own may leave the sandbox to the layers it joins - ProcessBuildRunnerUnitTest
never starts a pull-request target build on the host, which would share the host user's HOME - PullRequestTargetBuildIntegrationTest
runs the base, reports on the head, and tells the build which pull request it judges
The Solution
BuildRunner.start takes pullRequestTarget, set by the executor for a build with a pull request.
WerkdockBuildRunner then binds GitWorktreeWorkspaces.pullRequestHomeOf(workspace) as /root and drops werkdock.gradleHome.
The home lies beside the fresh pull-request workspace (<workspace>.home); prepareFresh deletes it before each build and the watcher removes it with the workspace.
DockerBuildRunner mounts no Gradle volume and no socket; Gradle then writes into the container, which --rm removes.
ProcessBuildRunner refuses the build.
ConfigLoader.checkPullRequestTargetSandboxes refuses a target without a sandbox and one with docker.socket in the primary configuration, with builds.default applied.
Refusing rather than ignoring the socket keeps the configuration honest, as for the other target refusals.
Open Questions
- The runner sets no
DOCKER_HOST; aDOCKER_HOSTpointing at/root/werkdock.sockthat the host's definition inherits frombuilds.defaultstill reaches the target build, but the fresh home holds no socket, so it leads nowhere. - Images are shared: a build with the Docker socket, or with werkdock's Docker-API socket, may retag or replace the image a target build starts from. A target build on a host where any build has a socket trusts those builds with its image.
- Target builds start with empty caches; a review tool that downloads much pays for it on every run.