docs: describe runner sandbox posture
Authored by
mfwolffe <wolffemf@dukes.jmu.edu>
- SHA
71a4f2b786e76a5a30420748f7f4ec9156cb9003- Parents
-
0a4cf06 - Tree
ebfcead
71a4f2b
71a4f2b786e76a5a30420748f7f4ec9156cb90030a4cf06
ebfcead| Status | File | + | - |
|---|---|---|---|
| M |
SECURITY.md
|
51 | 0 |
| M |
docs/internal/runbooks/actions-runner.md
|
3 | 0 |
| M |
docs/internal/runbooks/runner-deploy.md
|
30 | 1 |
SECURITY.mdmodified@@ -76,6 +76,57 @@ Reporters who responsibly disclosed accepted findings: | ||
| 76 | 76 | |
| 77 | 77 | *(Empty for now — first credit goes to the first reporter.)* |
| 78 | 78 | |
| 79 | +## Actions runner sandbox | |
| 80 | + | |
| 81 | +Workflow authors with repository write access are treated as untrusted | |
| 82 | +code execution users. `shithubd-runner` executes each `run:` step in a | |
| 83 | +fresh Docker or Podman container with these defaults: | |
| 84 | + | |
| 85 | +- read-only container root filesystem | |
| 86 | +- writable, executable `/tmp` tmpfs capped at 1 GiB | |
| 87 | +- writable `/workspace` bind mount for step-to-step job state | |
| 88 | +- `--cap-drop=ALL` with only `DAC_OVERRIDE`, `SETGID`, and `SETUID` | |
| 89 | + added back | |
| 90 | +- `--security-opt=no-new-privileges` | |
| 91 | +- pinned seccomp profile at `/etc/shithubd-runner/seccomp.json` | |
| 92 | +- `--user 65534:65534` | |
| 93 | +- PID, file-descriptor, process, CPU, memory, and log-size caps | |
| 94 | + | |
| 95 | +The writable `/workspace` mount is deliberate. The v1 engine starts one | |
| 96 | +container per step, so checkout/build outputs need a host-backed job | |
| 97 | +workspace to survive into later steps. The root filesystem remains | |
| 98 | +read-only; writeable job state is confined to the per-job workspace that | |
| 99 | +the runner sweeps after completion. | |
| 100 | + | |
| 101 | +`DAC_OVERRIDE` is the load-bearing concession that lets the non-root | |
| 102 | +container user write to the bind-mounted workspace owned by the runner | |
| 103 | +host user. It is not a general privilege grant: `CAP_SYS_ADMIN` is not | |
| 104 | +present, no-new-privileges is set, and the default seccomp profile still | |
| 105 | +filters dangerous syscalls. | |
| 106 | + | |
| 107 | +Root containers are opt-in per job through an explicit shithub-only | |
| 108 | +permissions key: | |
| 109 | + | |
| 110 | +```yaml | |
| 111 | +permissions: | |
| 112 | + shithub-runner-root: write | |
| 113 | +``` | |
| 114 | + | |
| 115 | +Broad `write-all` permissions do not imply root. This escape hatch is | |
| 116 | +for trusted maintenance workflows that need package-manager behavior | |
| 117 | +inside the container. Prefer a prebuilt runner image instead. | |
| 118 | + | |
| 119 | +The runner host remains trusted infrastructure because Docker socket | |
| 120 | +access is equivalent to host-root in ordinary Docker deployments. Do | |
| 121 | +not run `shithubd-runner` on the web/database host. | |
| 122 | + | |
| 123 | +Runner job JWTs are signed from an HKDF subkey derived from | |
| 124 | +`auth.totp_key_b64` with label `actions-runner-jwt-v1`. To rotate the | |
| 125 | +runner JWT root, rotate `auth.totp_key_b64`, restart web and worker | |
| 126 | +processes, then restart runners so fresh claims use the new signer. | |
| 127 | +Existing in-flight job JWTs are short-lived and single-use; let them | |
| 128 | +expire or cancel the affected jobs before completing the rotation. | |
| 129 | + | |
| 79 | 130 | ## Our threat model |
| 80 | 131 | |
| 81 | 132 | Published at |
docs/internal/runbooks/actions-runner.mdmodified@@ -71,6 +71,9 @@ default_image = "ghcr.io/shithub/runner-nix:1.0" | ||
| 71 | 71 | network = "bridge" |
| 72 | 72 | memory = "2g" |
| 73 | 73 | cpus = "2" |
| 74 | +seccomp_profile = "/etc/shithubd-runner/seccomp.json" | |
| 75 | +user = "65534:65534" | |
| 76 | +pids_limit = 512 | |
| 74 | 77 | ``` |
| 75 | 78 | |
| 76 | 79 | The config path defaults to `/etc/shithubd-runner/config.toml`. |
docs/internal/runbooks/runner-deploy.mdmodified@@ -9,7 +9,8 @@ for an already-installed runner lives in [actions-runner.md](./actions-runner.md | ||
| 9 | 9 | - The app database is migrated through S41d and the web API has |
| 10 | 10 | `auth.totp_key_b64` configured so job JWTs can be minted. |
| 11 | 11 | - Docker is installed on the runner host and the `docker` group exists. |
| 12 | - S41e narrows the sandbox; S41d runner hosts must be treated as trusted. | |
| 12 | + The runner process needs Docker socket access; treat the host itself | |
| 13 | + as trusted even though individual step containers are sandboxed. | |
| 13 | 14 | - `bin/shithubd-runner` exists locally. `make build` builds both |
| 14 | 15 | `bin/shithubd` and `bin/shithubd-runner` with the same version ldflags. |
| 15 | 16 | - The default image has been loaded or published. Build it with: |
@@ -55,6 +56,9 @@ shithub_runner_token=REPLACE_ME | ||
| 55 | 56 | shithub_runner_labels=self-hosted,linux,ubuntu-latest |
| 56 | 57 | shithub_runner_capacity=1 |
| 57 | 58 | shithub_runner_default_image=ghcr.io/shithub/runner-nix:1.0 |
| 59 | +shithub_runner_seccomp_profile=/etc/shithubd-runner/seccomp.json | |
| 60 | +shithub_runner_container_user=65534:65534 | |
| 61 | +shithub_runner_pids_limit=512 | |
| 58 | 62 | ``` |
| 59 | 63 | |
| 60 | 64 | The role writes non-secret config to |
@@ -78,6 +82,8 @@ The role: | ||
| 78 | 82 | - creates the `shithub-runner` system user and joins it to `docker` |
| 79 | 83 | - uploads `/usr/local/bin/shithubd-runner` |
| 80 | 84 | - renders `/etc/shithubd-runner/config.toml` and `runner.env` |
| 85 | +- installs the pinned seccomp profile at | |
| 86 | + `/etc/shithubd-runner/seccomp.json` | |
| 81 | 87 | - installs `deploy/systemd/shithubd-runner.service` |
| 82 | 88 | - pulls the configured runner image |
| 83 | 89 | - enables and starts `shithubd-runner` |
@@ -114,6 +120,29 @@ Expected state: | ||
| 114 | 120 | Repeat with `exit 1`; the check should complete with conclusion |
| 115 | 121 | `failure`. |
| 116 | 122 | |
| 123 | +Sandbox smoke checks: | |
| 124 | + | |
| 125 | +```yaml | |
| 126 | +name: sandbox-smoke | |
| 127 | +on: push | |
| 128 | +jobs: | |
| 129 | + smoke: | |
| 130 | + runs-on: ubuntu-latest | |
| 131 | + steps: | |
| 132 | + - run: id -u | |
| 133 | + - run: test "$(id -u)" = "65534" | |
| 134 | + - run: if mkdir /etc/shithub-smoke 2>/dev/null; then exit 1; fi | |
| 135 | + - run: if mount -t tmpfs tmpfs /mnt 2>/dev/null; then exit 1; fi | |
| 136 | +``` | |
| 137 | + | |
| 138 | +Expected state: | |
| 139 | + | |
| 140 | +- the UID check prints `65534` | |
| 141 | +- writing under `/etc` fails because the root filesystem is read-only | |
| 142 | +- `mount` fails because the container does not have `CAP_SYS_ADMIN` | |
| 143 | +- step logs and systemd journal include the configured image, network, | |
| 144 | + CPU/memory limits, PID limit, container user, and seccomp profile | |
| 145 | + | |
| 117 | 146 | ## Rollback |
| 118 | 147 | |
| 119 | 148 | Stop the runner first so it does not claim new jobs: |