Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 143 additions & 0 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,143 @@ jobs:
at: /tmp/workspace
- run: ./.circleci/release.sh

# The sandbox tests (//tools/sandbox/test:sandbox_test) create user, mount,
# and network namespaces and mount within them. The docker executor denies
# mount inside the job container and doesn't expose any configuration to
# allow it. Instead, these workflows run the sandbox tests on machine
# executors with rootless podman.
#
# podman's default seccomp profile allows unprivileged namespace and mount
# syscalls, and the kernel gates them on CAP_SYS_ADMIN in the caller's user
# namespace, which the sandbox creates for itself, so no custom seccomp
# profile and no CAP_SYS_ADMIN are needed. The container only needs
# SETUID/SETGID (new*idmap writing multi-range maps) and SETFCAP (mapping
# parent uid 0 into a user namespace requires it, see user_namespaces(7)).
# unmask=ALL is required so a /proc can be mounted in the nested mount
# namespace (the kernel refuses while runtime /proc masks are in place).
sandbox-test-linux:
working_directory: ~/please
machine:
image: ubuntu-2404:current
resource_class: medium
steps:
- checkout
- run:
name: Set up rootless podman
command: |
sudo apt-get update
sudo apt-get install -y podman catatonit uidmap passt slirp4netns
grep -q "^$(id -un):" /etc/subuid || sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 "$(id -un)"
# The job shell has no systemd user session or D-Bus, so podman's
# default systemd cgroup manager fails to create transient scopes
# ("Interactive authentication required"); use cgroupfs instead.
mkdir -p ~/.config/containers
printf '[engine]\ncgroup_manager = "cgroupfs"\n' > ~/.config/containers/containers.conf
podman system migrate || true
- run:
name: Run sandbox tests
command: |
podman run --rm --init \
--security-opt unmask=ALL \
--cap-add SETUID --cap-add SETGID --cap-add SETFCAP \
-v "$PWD:/please" -w /please \
-e PLZ_ARGS="--profile ci" \
ghcr.io/thought-machine/please_ubuntu:20260318 \
sh -ec '
# Mirrors the tools/images Dockerfile changes; drop once new
# image tags containing them are published and referenced.
apt-get update && apt-get install -y uidmap
printf "root:0:65536\n" >> /etc/subuid
printf "root:0:65536\n" >> /etc/subgid
./pleasew -p -v2 test --rerun --test_results_file plz-out/results/sandbox/test_results.xml --log_file plz-out/log/sandbox_test.log //tools/sandbox/test:sandbox_test
'
- store_test_results:
path: plz-out/results
- store_artifacts:
path: plz-out/log

sandbox-test-linux-alt:
working_directory: ~/please
machine:
image: ubuntu-2404:current
resource_class: medium
steps:
- checkout
- run:
name: Set up rootless podman
command: |
sudo apt-get update
sudo apt-get install -y podman catatonit uidmap passt slirp4netns
grep -q "^$(id -un):" /etc/subuid || sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 "$(id -un)"
# The job shell has no systemd user session or D-Bus, so podman's
# default systemd cgroup manager fails to create transient scopes
# ("Interactive authentication required"); use cgroupfs instead.
mkdir -p ~/.config/containers
printf '[engine]\ncgroup_manager = "cgroupfs"\n' > ~/.config/containers/containers.conf
podman system migrate || true
- run:
name: Run sandbox tests
command: |
podman run --rm --init \
--security-opt unmask=ALL \
--cap-add SETUID --cap-add SETGID --cap-add SETFCAP \
-v "$PWD:/please" -w /please \
-e PLZ_ARGS="--profile ci-alt" \
ghcr.io/thought-machine/please_ubuntu_alt:20260318 \
sh -ec '
# Mirrors the tools/images Dockerfile changes; drop once new
# image tags containing them are published and referenced.
apt-get update && apt-get install -y uidmap
printf "root:0:65536\n" >> /etc/subuid
printf "root:0:65536\n" >> /etc/subgid
./pleasew -p -v2 test --rerun --test_results_file plz-out/results/sandbox/test_results.xml --log_file plz-out/log/sandbox_test.log //tools/sandbox/test:sandbox_test
'
- store_test_results:
path: plz-out/results
- store_artifacts:
path: plz-out/log

sandbox-test-alpine:
working_directory: ~/please
machine:
image: ubuntu-2404:current
resource_class: medium
steps:
- checkout
- run:
name: Set up rootless podman
command: |
sudo apt-get update
sudo apt-get install -y podman catatonit uidmap passt slirp4netns
grep -q "^$(id -un):" /etc/subuid || sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 "$(id -un)"
# The job shell has no systemd user session or D-Bus, so podman's
# default systemd cgroup manager fails to create transient scopes
# ("Interactive authentication required"); use cgroupfs instead.
mkdir -p ~/.config/containers
printf '[engine]\ncgroup_manager = "cgroupfs"\n' > ~/.config/containers/containers.conf
podman system migrate || true
- run:
name: Run sandbox tests
command: |
podman run --rm --init \
--security-opt unmask=ALL \
--cap-add SETUID --cap-add SETGID --cap-add SETFCAP \
-v "$PWD:/please" -w /please \
-e PLZ_ARGS="--profile ci --profile alpine --exclude no-musl" \
ghcr.io/thought-machine/please_alpine:20260318 \
sh -ec '
# Mirrors the tools/images Dockerfile changes; drop once new
# image tags containing them are published and referenced.
apk add --no-cache shadow-uidmap curl
printf "root:0:65536\n" >> /etc/subuid
printf "root:0:65536\n" >> /etc/subgid
./pleasew -p -v2 test --rerun --test_results_file plz-out/results/sandbox/test_results.xml --log_file plz-out/log/sandbox_test.log //tools/sandbox/test:sandbox_test
'
- store_test_results:
path: plz-out/results
- store_artifacts:
path: plz-out/log

# Runs a benchmarking test and records some performance results.
perf-test:
docker:
Expand All @@ -337,6 +474,9 @@ workflows:
- build-alpine
- build-linux
- build-linux-alt
- sandbox-test-linux
- sandbox-test-linux-alt
- sandbox-test-alpine
- build-darwin:
requires:
- build-alpine
Expand Down Expand Up @@ -366,6 +506,9 @@ workflows:
- build-darwin
- test-rex
- test-http-cache
- sandbox-test-linux
- sandbox-test-linux-alt
- sandbox-test-alpine
filters:
branches:
only: master
Expand Down
2 changes: 2 additions & 0 deletions .mailmap
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Grant Zvolsky <grant@zvolsky.org> <GrantZvolsky@gmail.com>
Grant Zvolsky <grant@zvolsky.org> <gzvolsky-gf@thoughtmachine.net>
6 changes: 5 additions & 1 deletion tools/images/alpine/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ FROM golang:1.26-alpine
LABEL org.opencontainers.image.authors="please <at> thoughtmachine <dot> net"
LABEL org.opencontainers.image.source=https://github.com/thought-machine/please

RUN apk add --no-cache git patch gcc g++ libc-dev bash libgcc xz protoc protobuf-dev perl-utils
RUN apk add --no-cache git patch gcc g++ libc-dev bash libgcc xz protoc protobuf-dev perl-utils shadow-uidmap curl

# Subordinate id delegation for sandbox tests
RUN printf 'root:0:65536\n' >> /etc/subuid && \
printf 'root:0:65536\n' >> /etc/subgid

# Ensure this is where we expect on the PATH
RUN ln -s /usr/local/go/bin/go /usr/local/bin/go
Expand Down
6 changes: 5 additions & 1 deletion tools/images/ubuntu/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,13 @@ RUN truncate -s0 /tmp/preseed.cfg; \
apt-get update && \
apt-get install -y python3 python3-dev python3-pip time \
curl unzip git locales pkg-config zlib1g-dev psmisc \
openssh-client ca-certificates && \
openssh-client ca-certificates uidmap && \
apt-get clean

# Subordinate id delegation for sandbox tests
RUN printf 'root:0:65536\n' >> /etc/subuid && \
printf 'root:0:65536\n' >> /etc/subgid

# Go - we want a specific package version here.
RUN curl -fsSL https://dl.google.com/go/go1.26.1.linux-amd64.tar.gz | tar -xzC /usr/local
RUN ln -s /usr/local/go/bin/go /usr/local/bin/go && ln -s /usr/local/go/bin/gofmt /usr/local/bin/gofmt
Expand Down
6 changes: 5 additions & 1 deletion tools/images/ubuntu_alt/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,13 @@ ENV DEBIAN_FRONTEND noninteractive
# Most dependencies; Python, Clang
RUN apt-get update && \
apt-get install -y python3 python3-dev python3-pip \
curl unzip git locales pkg-config zlib1g-dev clang && \
curl unzip git locales pkg-config zlib1g-dev clang uidmap && \
apt-get clean

# Subordinate id delegation for sandbox tests
RUN printf 'root:0:65536\n' >> /etc/subuid && \
printf 'root:0:65536\n' >> /etc/subgid

# Go
RUN curl -fsSL https://dl.google.com/go/go1.26.1.linux-amd64.tar.gz | tar -xzC /usr/local
RUN ln -s /usr/local/go/bin/go /usr/local/bin/go && ln -s /usr/local/go/bin/gofmt /usr/local/bin/gofmt
Expand Down
8 changes: 0 additions & 8 deletions tools/sandbox/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,6 @@ c_binary(
deps = [":sandbox"],
)

c_binary(
name = "nonet_sandbox",
srcs = ["nonet_main.c"],
static = (CONFIG.get("STATIC_SANDBOX") is not None),
visibility = ["PUBLIC"],
deps = [":sandbox"],
)

cc_test(
name = "sandbox_test",
srcs = ["sandbox_test.cc"],
Expand Down
92 changes: 92 additions & 0 deletions tools/sandbox/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# please_sandbox

> [!CAUTION]
> The Please Sandbox is not a security boundary. It is not designed to run untrusted or malicious
> code.

`please_sandbox` is a wrapper that allows running a given binary in Linux namespaces. By default, it
creates PID, IPC, UTS, user, mount and network namespaces. It also does a bunch of things on the
filesystem:

- if `TMP_DIR` is not set or not under `/tmp`, a tmpfs is mounted over `/tmp` and `TMPDIR` is set to
`/tmp`. If it is set, `$TMP_DIR` is bind mounted onto `/tmp/plz_sandbox`, which becomes the
working directory, and the root filesystem is remounted read-only;
- if `SANDBOX_DIRS` is set, we expect a comma-separated list of path that will be hidden with a
tmpfs;
- if `SANDBOX_FILE_MOUNTS` is set, we expect it to be set to a comma-separated list of key-value
pairs in the following format: `key:value`. Keys must point to existing paths and will be bind
mounted to the path given as value;
- if `SANDBOX_UID_MAP` and `SANDBOX_GID_MAP` are set (both are required if either is), we pass
these arguments to newuidmap/newgidmap to configure uid/gid mappings. The format is 1..n
space-delimited triples of [id lowerid count], see `man newuidmap`;

Mount and network namespaces can be disabled setting the `SHARE_MOUNT` and `SHARE_NETWORK`
environment variables to `1`. Remounting of `/proc` can be disabled by setting `MOUNT_PROC=0`,
e.g. for systems that don't allow mounting a full `/proc` from a new user namespace (see the
mount namespace notes below).

When the network namespace is used, the loopback interface is brought up with an additional IP
address, which defaults to 10.1.1.1. The `SANDBOX_LOCAL_IP` environment variable can be used
to change this IP, and setting it to the empty string disables the extra address entirely.

## Using the knobs with Please

Please invokes the tool configured in `[sandbox] tool` with the command to run as its arguments,
and sets `SHARE_NETWORK` and `SHARE_MOUNT` itself according to the rule being run. Remote
execution workers invoke the sandbox tool themselves and should set these variables explicitly,
since the binary's defaults apply when they are absent. To set the other knobs, or to override
Please's per-rule choices, point `tool` at a thin wrapper script:

```sh
#!/bin/sh
# please_sandbox, but without remounting /proc.
export MOUNT_PROC=0
exec /path/to/please_sandbox "$@"
```

```ini
[sandbox]
tool = /path/to/noproc_sandbox_wrapper
```

## UID/GID mapping in user namespace

By default, the sandbox only maps the real UID/GID of the user running the sandbox into the namespace.
However, `SANDBOX_UID_MAP` and `SANDBOX_GID_MAP` may be used to define arguments that are passed to
`new*idmap`.

The example below will map the real UID of the user running the sandbox to root and UIDs
from range [100000;165536) to [1;65536) in the child namespace (assuming your UID is 1000):

```bash
$ TMP_DIR=/tmp SANDBOX_UID_MAP="0 $UID 1 1 100000 65536" SANDBOX_GID_MAP="0 $(id -g) 1" please_sandbox cat /proc/self/uid_map
0 1000 1
1 100000 65536
```

## Capabilities and other requirements

### Namespaces

Historically, creating Mount, PID, IPC, UTS, and Network namespaces required the heavily overloaded
`CAP_SYS_ADMIN` capability on the host system. However, since Linux 3.8, unprivileged processes can
use User Namespaces to obtain local `CAP_SYS_ADMIN` privileges. This allows processes to create
Mount, PID, IPC, UTS, and Network namespaces without needing host-level root or `CAP_SYS_ADMIN`
privileges.

#### Mount namespace

In addition to the above, when enabling the mount namespace, the sandbox will remount /proc, so any
process that use it will have access to accurate information of the PID namespace (otherwise they'd
still have access to /proc from the parent namespace). There's however a specific edge case in the
Linux kernel, that prevents /proc from being remounted in a mount namespace, when the parent /proc
is not fully visible, [see this commit](https://github.com/torvalds/linux/commit/1b852bceb0d1).

This is an issue with most container runtimes (and therefore Kubernetes), as by default, they will
hide some part of /proc in a container to reduce attack surface, eg
[see this docker PR](https://github.com/docker/cli/pull/1808).
Comment on lines +79 to +87

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a little unclear what the conclusion of this section is? I remember the issue (remember bashing my head against it for a while) but is this something the sandbox works around, is it just a limitation of cases where it doesn't work, or do we intend to try to fix it in the future?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The conclusion is that users of the sandbox have two options: Either unmask proc (k8s pod procMount: Unmasked securityContext option, which is less of an issue when combined with hostUsers: false), or disable the /proc isolation, in which case the sandboxed process will be able to see (but not send signals to) processes outside the sandbox. I'll update the README to mention this.


Users of the sandbox have different options:
- unmask /proc (k8s pod `procMount: Unmasked` securityContext option combined with `hostUsers: false`),
- disable the /proc isolation (`MOUNT_PROC=0`), in which case the sandboxed process will be able to see (but not send signals to) processes outside the sandbox,
- disable the mount namespace entirely with `SHARE_MOUNT=1`.
8 changes: 6 additions & 2 deletions tools/sandbox/main.c
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// please_sandbox is a very small binary to implement sandboxing
// of tests (and possibly other build actions) via cgroups.
// of tests (and possibly other build actions) via namespaces.
// Essentially this is a very lightweight replacement for Docker
// where we would use it for tests to avoid port clashes etc.
//
Expand Down Expand Up @@ -27,5 +27,9 @@ int main(int argc, char* argv[]) {
const char* share_mount_env = getenv("SHARE_MOUNT");
const bool unshare_mount = share_mount_env == NULL || strcmp(share_mount_env, "1");

return contain(&argv[1], unshare_network, unshare_mount);
// /proc is remounted by default but it can be opted out if `MOUNT_PROC=0` env is set
const char* mount_proc_env = getenv("MOUNT_PROC");
const bool mount_proc = mount_proc_env == NULL || strcmp(mount_proc_env, "0");

return contain(&argv[1], unshare_network, unshare_mount, unshare_mount, mount_proc);
}
17 changes: 0 additions & 17 deletions tools/sandbox/nonet_main.c

This file was deleted.

Loading
Loading