Commit 3f7409ca2e

3f7409ca2ec172548260540b975e4a1c021e2185

parent: b4a2206ec2

Verified · cmc ci/build: success ci/sonar: success ci/test: success ci/vuln: success

cmc <hello@cleberg.net> · 2026-09-06 18:44 UTC

runner, wiki: construct the step environment instead of inheriting it

A step got os.Environ() — the runner service's whole environment — plus
the build's variables. It now gets PATH, HOME, LANG, CI, the GITBAY_*
variables and its secrets, and nothing else. HOME is the workspace, so a
build cannot read the runner's dotfiles.

Ref #144

Layout: unified · split

.gitbay/wiki/Threat-Model.org +5 −1
@@ -128,7 +128,11 @@ runner, polling over SSH, clones the commit and runs its steps.
128 repository's secrets — unless the head came from another repository. 128 repository's secrets — unless the head came from another repository.
129 A merge request from a fork is built in the target as untrusted, with 129 A merge request from a fork is built in the target as untrusted, with
130 no secrets, so a stranger's branch cannot read the target's deploy 130 no secrets, so a stranger's branch cannot read the target's deploy
131 credentials. 131 credentials. The step environment is *constructed*, not inherited: a
132 build gets =PATH=, =HOME=, =LANG=, =CI=, its own variables and its
133 secrets, and nothing the operator set on the service. =HOME= is the
134 workspace, so a build cannot read the runner's =.netrc=, =.npmrc= or
135 =.gitconfig=, where tools keep credentials.
132- *Where it runs.* Steps run as the runner's own user on the runner 136- *Where it runs.* Steps run as the runner's own user on the runner
133 host, with no container; the systemd drop-in adds =NoNewPrivileges=, 137 host, with no container; the systemd drop-in adds =NoNewPrivileges=,
134 =ProtectSystem=full= and the kernel and cgroup protections. =-repos= 138 =ProtectSystem=full= and the kernel and cgroup protections. =-repos=
cmd/gitbay-runner/env_test.go added +76
@@ -0,0 +1,76 @@
1package main
2
3import (
4 "os"
5 "strings"
6 "testing"
7)
8
9// A step's environment is constructed, not inherited: repository content
10// must not see what the operator set on the runner service (#144).
11func TestStepEnvDoesNotInherit(t *testing.T) {
12 t.Setenv("GITBAY_RUNNER_TOKEN", "a-secret-the-service-was-given")
13 t.Setenv("AWS_SECRET_ACCESS_KEY", "also-not-for-builds")
14
15 env := stepEnv(job{Repo: "alice/app", SHA: "abc", Ref: "main", Job: "test"}, "/tmp/ws")
16
17 for _, e := range env {
18 if strings.HasPrefix(e, "GITBAY_RUNNER_TOKEN=") || strings.HasPrefix(e, "AWS_SECRET_ACCESS_KEY=") {
19 t.Errorf("the runner's own environment reached a build step: %q", e)
20 }
21 }
22 want := map[string]string{
23 "CI": "true", "GITBAY_REPO": "alice/app", "GITBAY_SHA": "abc",
24 "GITBAY_REF": "main", "GITBAY_JOB": "test",
25 // HOME is the workspace so a build cannot read the runner's
26 // dotfiles, where tools keep credentials.
27 "HOME": "/tmp/ws",
28 }
29 got := map[string]string{}
30 for _, e := range env {
31 k, v, _ := strings.Cut(e, "=")
32 got[k] = v
33 }
34 for k, v := range want {
35 if got[k] != v {
36 t.Errorf("%s = %q, want %q", k, got[k], v)
37 }
38 }
39 if got["PATH"] == "" {
40 t.Error("PATH is empty; a step could not find any tool")
41 }
42}
43
44// Secrets are passed through when the server sent them, which it does
45// only for a trusted build.
46func TestStepEnvCarriesSecrets(t *testing.T) {
47 env := stepEnv(job{Secrets: map[string]string{"TOKEN": "s3cret"}}, "/tmp/ws")
48 if !containsEnv(env, "TOKEN=s3cret") {
49 t.Error("a trusted build's secret did not reach the step")
50 }
51 env = stepEnv(job{}, "/tmp/ws")
52 for _, e := range env {
53 if strings.HasPrefix(e, "TOKEN=") {
54 t.Errorf("a secret appeared with none sent: %q", e)
55 }
56 }
57}
58
59// PATH falls back rather than leaving a step unable to find anything.
60func TestStepEnvPathFallback(t *testing.T) {
61 old := os.Getenv("PATH")
62 os.Unsetenv("PATH")
63 defer os.Setenv("PATH", old)
64 if env := stepEnv(job{}, "/tmp/ws"); !containsEnv(env, "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin") {
65 t.Errorf("no PATH fallback: %v", env)
66 }
67}
68
69func containsEnv(env []string, want string) bool {
70 for _, e := range env {
71 if e == want {
72 return true
73 }
74 }
75 return false
76}
cmd/gitbay-runner/main.go +39 −6
@@ -304,15 +304,12 @@ func (r *runner) run(j job) bool {
304 } 304 }
305 } 305 }
306 306
307 env := stepEnv(j, dir)
307 for _, step := range j.Steps { 308 for _, step := range j.Steps {
308 fmt.Fprintf(sink, "$ %s\n", step) 309 fmt.Fprintf(sink, "$ %s\n", step)
309 cmd := exec.Command(toolpath.Look("sh"), "-c", step) 310 cmd := exec.Command(toolpath.Look("sh"), "-c", step)
310 cmd.Dir = dir 311 cmd.Dir = dir
311 cmd.Env = append(os.Environ(), 312 cmd.Env = env
312 "GITBAY_REPO="+j.Repo, "GITBAY_SHA="+j.SHA, "GITBAY_REF="+j.Ref, "GITBAY_JOB="+j.Job, "CI=true")
313 for name, value := range j.Secrets {
314 cmd.Env = append(cmd.Env, name+"="+value)
315 }
316 cmd.Stdout, cmd.Stderr = sink, sink 313 cmd.Stdout, cmd.Stderr = sink, sink
317 if ok, why := runStep(cmd, deadline); !ok { 314 if ok, why := runStep(cmd, deadline); !ok {
318 fmt.Fprintf(sink, "%s\n", why) 315 fmt.Fprintf(sink, "%s\n", why)
@@ -322,7 +319,43 @@ func (r *runner) run(j job) bool {
322 return true 319 return true
323} 320}
324 321
325// ssh runs one control command against the server and returns stdout. 322// stepEnv builds the environment a build step runs with. It is
323// constructed, not inherited: os.Environ() would hand repository content
324// the runner's entire environment, including anything an operator set on
325// the service (#144).
326//
327// HOME is the workspace, not the runner's home. Tools read credentials
328// out of dotfiles — .netrc, .npmrc, .gitconfig — and a build has no
329// business finding the runner's. It also means a build's caches land in
330// the workspace and go away with it.
331//
332// PATH is the one thing carried over: without it a step cannot find the
333// tools the host was provisioned with.
334func stepEnv(j job, dir string) []string {
335 path := os.Getenv("PATH")
336 if path == "" {
337 path = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
338 }
339 env := []string{
340 "PATH=" + path,
341 "HOME=" + dir,
342 "LANG=C.UTF-8",
343 "CI=true",
344 "GITBAY_REPO=" + j.Repo,
345 "GITBAY_SHA=" + j.SHA,
346 "GITBAY_REF=" + j.Ref,
347 "GITBAY_JOB=" + j.Job,
348 }
349 // The server sends secrets only for a trusted build — a merge request
350 // head from a fork arrives with none — so this loop is empty exactly
351 // when it should be.
352 for name, value := range j.Secrets {
353 env = append(env, name+"="+value)
354 }
355 return env
356}
357
358// ssh runs one control command against the server and returns stdout.// ssh runs one control command against the server and returns stdout.
326func (r *runner) ssh(stdin io.Reader, args ...string) (string, error) { 359func (r *runner) ssh(stdin io.Reader, args ...string) (string, error) {
327 cmd := exec.Command(toolpath.Look("ssh"), append(append(r.sshOpts, r.remote), args...)...) 360 cmd := exec.Command(toolpath.Look("ssh"), append(append(r.sshOpts, r.remote), args...)...)
328 if stdin != nil { 361 if stdin != nil {