tenseleyflow/shithub / 71a4f2b

Browse files

docs: describe runner sandbox posture

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
71a4f2b786e76a5a30420748f7f4ec9156cb9003
Parents
0a4cf06
Tree
ebfcead

3 changed files

StatusFile+-
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:
7676
 
7777
 *(Empty for now — first credit goes to the first reporter.)*
7878
 
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
+
79130
 ## Our threat model
80131
 
81132
 Published at
docs/internal/runbooks/actions-runner.mdmodified
@@ -71,6 +71,9 @@ default_image = "ghcr.io/shithub/runner-nix:1.0"
7171
 network = "bridge"
7272
 memory = "2g"
7373
 cpus = "2"
74
+seccomp_profile = "/etc/shithubd-runner/seccomp.json"
75
+user = "65534:65534"
76
+pids_limit = 512
7477
 ```
7578
 
7679
 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
99
 - The app database is migrated through S41d and the web API has
1010
   `auth.totp_key_b64` configured so job JWTs can be minted.
1111
 - 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.
1314
 - `bin/shithubd-runner` exists locally. `make build` builds both
1415
   `bin/shithubd` and `bin/shithubd-runner` with the same version ldflags.
1516
 - The default image has been loaded or published. Build it with:
@@ -55,6 +56,9 @@ shithub_runner_token=REPLACE_ME
5556
 shithub_runner_labels=self-hosted,linux,ubuntu-latest
5657
 shithub_runner_capacity=1
5758
 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
5862
 ```
5963
 
6064
 The role writes non-secret config to
@@ -78,6 +82,8 @@ The role:
7882
 - creates the `shithub-runner` system user and joins it to `docker`
7983
 - uploads `/usr/local/bin/shithubd-runner`
8084
 - renders `/etc/shithubd-runner/config.toml` and `runner.env`
85
+- installs the pinned seccomp profile at
86
+  `/etc/shithubd-runner/seccomp.json`
8187
 - installs `deploy/systemd/shithubd-runner.service`
8288
 - pulls the configured runner image
8389
 - enables and starts `shithubd-runner`
@@ -114,6 +120,29 @@ Expected state:
114120
 Repeat with `exit 1`; the check should complete with conclusion
115121
 `failure`.
116122
 
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
+
117146
 ## Rollback
118147
 
119148
 Stop the runner first so it does not claim new jobs: