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
104 lines
7.3 KiB
Markdown
104 lines
7.3 KiB
Markdown
# 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.py` under `.git/werkator/buildenv/home`
|
|
- **and** the host configures a shared `werkdock.gradleHome`
|
|
- **When** a pull-request target build runs in werkdock
|
|
- **Then** `/root` is a fresh, empty directory beside its fresh workspace, without the planted file
|
|
- **and** no shared Gradle home is mounted, and `GRADLE_USER_HOME` is not set to it
|
|
- **and** the home is removed with the workspace
|
|
- **And** every other build keeps the toolchain home and the shared Gradle home
|
|
|
|
##### Verified by
|
|
|
|
- [WerkdockBuildRunnerUnitTest](../../src/test/kotlin/de/hoennig/werkator/build/WerkdockBuildRunnerUnitTest.kt) `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](../../src/test/kotlin/de/hoennig/werkator/build/GitWorktreeWorkspacesIntegrationTest.kt) `a pull-request target build gets a fresh workspace apart from the branch's, without anything left behind`
|
|
- [WatcherPullRequestTargetUnitTest](../../src/test/kotlin/de/hoennig/werkator/watcher/WatcherPullRequestTargetUnitTest.kt) `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.socket` says so
|
|
- **And** every other build keeps the Gradle volume
|
|
|
|
##### Verified by
|
|
|
|
- [DockerBuildRunnerUnitTest](../../src/test/kotlin/de/hoennig/werkator/build/DockerBuildRunnerUnitTest.kt) `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.enabled` nor `werkdock.enabled` holds for it, with `builds.default` applied
|
|
- **or** it has `docker.socket: true`, also inherited from `builds.default`
|
|
- **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](../../src/test/kotlin/de/hoennig/werkator/config/PullRequestTargetConfigUnitTest.kt) `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](../../src/test/kotlin/de/hoennig/werkator/build/ProcessBuildRunnerUnitTest.kt) `never starts a pull-request target build on the host, which would share the host user's HOME`
|
|
- [PullRequestTargetBuildIntegrationTest](../../src/test/kotlin/de/hoennig/werkator/build/PullRequestTargetBuildIntegrationTest.kt) `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`; a `DOCKER_HOST` pointing at `/root/werkdock.sock` that the host's definition inherits from `builds.default` still 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.
|