kadet-hub logokadet-hub

gVisor in CI: sandboxing untrusted code on GitHub Actions

How kadet-hub runs untrusted contributor code in CI inside a gVisor sandbox on GitHub Actions, what it adds over a plain container and what it misses.

If your CI executes code that contributors send you, a plain container is not a boundary. Run it under gVisor with the network off and a read-only root filesystem, keep a syscall trace, and treat everything else (timeouts, what you mount in, your tokens) as separate problems. This is what the kadet-hub gate does to compile contributor-supplied Kapitan generators, and the pieces transfer to any project that runs plugins, formatters, test code or build scripts from a pull request.

The problem

A pull request that adds a generator ships Python that the gate has to run to see what it produces. The same shape shows up anywhere CI runs contributor code: a linter plugin, a pytest suite, a Terraform provider, a code formatter. Once that code runs, in-language guardrails do not hold, because the attacker writes the code and picks the API.

Three defenses that look like confinement are not. Each of these runs with host Python 3.13 (pinned through uv), python -I:

A Python audit hook that blocks subprocess:

blocked: subprocess blocked by audit hook
via fork_exec

The hook rejects subprocess.run, then _posixsubprocess.fork_exec spawns the process directly. fork_exec never raises the subprocess.Popen audit event, so the hook does not see it. CPython’s own subprocess is the only thing that raises that event.

Blocking DNS, against a raw IP (192.0.2.1 is TEST-NET-1):

name lookup blocked: DNS blocked
raw-IP connect reached network, result: TimeoutError timed out

getaddrinfo is patched to refuse, and the connect() to a literal address leaves the host anyway. Name resolution was never the exfiltration path.

An rlimit and a private HOME:

HOME = $HOME
RLIMIT_NOFILE = (1048576, 1048576)
read /etc/passwd -> root:x:0:0:Super User:/root:/bin/bash

Pointing HOME at a scratch directory and capping file descriptors does nothing to stop a read of a path outside HOME. Resource limits bound resource use, not what the filesystem exposes.

What gVisor changes

gVisor (runsc) is a userspace kernel. The sandboxed process makes syscalls to gVisor, not to the host kernel, so the host attack surface the process can reach is small. Two options close the holes above. The runs below use the same scripts inside runsc --rootless --network=none run, in an OCI bundle whose root filesystem is python:3.13-slim exported read-only, with no host mounts.

With --network=none the sandbox has no interfaces. In this rootless bundle setup the network namespace from runsc spec already cuts it off, so the flag matters most where the namespace is shared:

name lookup blocked: DNS blocked
raw-IP connect reached network, result: OSError [Errno 101] Network is unreachable

The raw-IP connect now fails at once rather than reaching the network. For a compile that needs no network, this is the single highest-value flag.

A read-only minimal rootfs with no host mounts:

HOME = /root
RLIMIT_NOFILE = (1024, 1024)
read /etc/passwd -> root:x:0:0:root:/root:/bin/bash

The read resolves inside the container’s own filesystem (note the root GECOS field, where the host showed Super User). Host paths are gone: a probe in the same bundle gets FileNotFoundError for the host home and for host scratch directories, while a file bind-mounted at /mnt/secret is still readable. That last point matters: gVisor confines the filesystem to what you give it, so anything you mount in is in scope.

This is the kadet-hub sandbox/run shape. The container runs --network none --read-only --user 10001:10001 --cap-drop ALL --security-opt no-new-privileges with /tmp and /out as noexec tmpfs, the project mounted read-only, and no environment inherited from the runner.

The one gVisor does not stop, and the trace that does

gVisor is not an audit-hook sandbox. The fork_exec bypass runs inside it unchanged: the process is allowed to start a child. What catches it is the syscall trace. Run the same script under runsc --strace --strace-syscalls=execve,... --debug-log=... and the spawn is in the log:

python3 E execve(... /bin/echo, [... "/bin/echo", "via", "fork_exec"], ...)

The execve the audit hook never observed is right there, because gVisor is the kernel the process talks to and writes the trace outside the sandbox, where the code being judged cannot disable or forge it, and it follows child processes. The gate installs the runsc-trace runtime and fails a compile whose trace shows any execve other than the interpreter, Kapitan’s known probes and the declared binaries, any outbound connect, sendto or sendmsg to an AF_INET address, a read of a decoy file, and a few other syscalls. That rule set is SEC-10 in the gate spec. The design reasoning is in how kadet-hub works.

What gVisor does not cover

gVisor is a kernel boundary, not a policy. Four things stay your job.

CPU and wall-clock exhaustion. A sandboxed process can spin or allocate forever. gVisor does not time it out; you set a job timeout and a memory bound. The gate runs each compile under a step timeout and a tmpfs size cap.

Data you mount in. Everything inside the rootfs and every bind mount is reachable. Mount the contributor’s input read-only, mount nothing else, and never mount a checkout that carries secrets or a writable host path.

Your CI token. gVisor does not hide GITHUB_TOKEN or other secrets that the workflow puts in the environment or in a mounted file. The sandbox run should inherit no environment from the runner, which is why sandbox/run uses an explicit --env-file and the gate passes KUBECONFIG=/dev/null and an empty HELM_PLUGINS.

The workflow trigger. Run untrusted pull requests under pull_request, never pull_request_target. pull_request_target runs with the base repository’s secrets and a writable token, so a sandbox around the code does not help if the workflow itself hands it the keys. GitHub caps the permissions key for fork pull requests under pull_request; set permissions: {} anyway and add back only what a step needs.

Cost

Measured on one workstation, five runs each, same slim rootfs. A trivial python -c pass takes about 0.02 s native and about 0.12 s under gVisor; a bare sandbox boot (runsc do /bin/true) is about 0.07 s. So gVisor adds roughly 0.07 to 0.10 s of startup per run plus per-syscall overhead. For a CI compile that already takes seconds, the sandbox is not the bottleneck.

gVisor vs a plain Docker container (runc)

The runs so far are unconfined and under rootless runsc. To put gVisor directly against a plain container, the same three scripts also run under docker run with Docker’s default runtime (runc) and under docker run --runtime=runsc, every other option equal, so only the runtime changes. The script is piped in on stdin and nothing else is mounted. These were measured on the example repository’s CI, one ubuntu-24.04 runner, in run 39571252377.

Swapping runc for runsc changes nothing the three scripts can show. The fork_exec child runs under both, because neither runtime is an audit-hook sandbox. The raw-IP connect reaches the network and times out at 192.0.2.1 under both, because the default Docker network routes out either way; gVisor blocks it only with --network=none, as above. The read of /etc/passwd returns the image’s own file under both, because the image, not the runtime, is the filesystem the process sees.

What differs is the kernel the process talks to:

runc:  Linux 6.17.0-1022-azure
runsc: Linux 4.19.0-gvisor

Under runc the sandboxed code’s syscalls reach the runner’s own kernel, so a bug in that kernel is reachable from the code being judged. Under runsc they reach gVisor’s userspace kernel, and the host kernel surface the code can touch is small. That is what a plain container does not give you: the network and the spawn are bounded by flags and the trace, which you set on runc too, while the runtime is what moves the host kernel out of reach.

Cost of the swap on the same runner, five runs each: a python -c pass in the container is about 0.20 s under runc and about 0.27 s under runsc, so runsc adds roughly 0.07 s per run. The absolute figures are higher than the rootless runsc ... do numbers above because each docker run starts a container through the daemon, which dominates both runtimes.

This measures gVisor against Docker’s default runtime. It does not answer “gVisor vs Docker sandbox”, Docker’s Sandboxes product, or gVisor against microVM runtimes such as Firecracker or Kata, which would need microVM measurements this example does not take.

Decoy credentials

Detection beats hope for the untargeted credential stealer. The gate seeds the sandbox HOME with decoy ~/.aws/credentials, ~/.kube/config, ~/.ssh/id_ed25519 and a ~/.gnupg key, plus decoy AWS_SECRET_ACCESS_KEY, GITHUB_TOKEN and VAULT_TOKEN in the environment, each random per run. Two checks fire: the trace fails the run on any open of a decoy file, and the output is scanned for any decoy value in plain, base64 or hex form. A stealer that scrapes HOME and writes what it found into the generated manifests trips both. Code written specifically against this gate will avoid the decoys; the point is the untargeted collector, which is most of them.

A minimal GitHub Actions job

This installs runsc with a pinned release and a SHA-512 check (the digest and release are what kadet-hub pins), then runs the example rootless. Actions are pinned by commit SHA, permissions is empty, and there are no caches.

name: sandboxed-compile
on: pull_request
permissions: {}
jobs:
  run:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - name: Install gVisor
        env:
          GVISOR_RELEASE: "20261005"
          GVISOR_SHA512: 27fed731b432d6a39d908956623fd273979fda66b3ccf4b9c12413ea417c516e18a9dc3cced403fb28efe495d64dce4251f51cf8c4d9f4a72f9833bd96b6bb35
        run: |
          curl -fsSLo runsc.tar.zstd \
            "https://storage.googleapis.com/gvisor/releases/release/${GVISOR_RELEASE}/x86_64/gvisor.tar.zstd"
          echo "${GVISOR_SHA512}  runsc.tar.zstd" | sha512sum -c -
          # runsc starts its sidecars from gvisor-bin/ next to it: unpack everything
          sudo tar --zstd -xf runsc.tar.zstd -C /usr/local/bin
      - name: Allow unprivileged user namespaces
        # ubuntu-24.04 restricts them with AppArmor; rootless runsc fails
        # with "fork/exec /proc/self/exe: permission denied" otherwise
        run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
      - name: Run contributor code sandboxed
        run: runsc --rootless --network=none --ignore-cgroups do python3 -I pr-code.py

The example repository runs this setup weekly on ubuntu-24.04. Its first run failed without the sysctl step: Ubuntu 24.04 lets AppArmor restrict unprivileged user namespaces, and rootless runsc needs them. The step only changes the throwaway runner.

The gate itself goes further, running under Docker with the runsc-trace runtime so it can judge the syscall trace, which needs daemon configuration and root on the runner. The rootless runsc ... do form above needs neither and is enough to get the network and filesystem boundary in a plain job.

The escape scripts and a run script that reproduces every output above are in the example. For how this fits the rest of the registry, see kadet-hub generators as supply-chain artifacts.