Commit dab7e0ad06

dab7e0ad069300115beb60a2538a8449a9bd8dbf

parent: 9cdda28d42

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

cmc <hello@cleberg.net> · 2026-09-10 02:58 UTC

runner: secrets with newlines reach the container through podman's environment

An env file has no escape for a newline, so a private key stored as a
secret failed the build before its first step. Such values are named
with --env NAME on the podman command line and valued in the podman
process's environment; the value stays off argv and out of the file.

Ref #184
.gitbay/wiki/Threat-Model.org +5 −1
@@ -219,4 +219,8 @@ assume has been checked.
219219- A build's secrets are environment variables inside its container, so
220220 they are visible to =podman inspect= as the runner's user — the same
221221 user that already holds them in memory. They reach podman through a
222 0600 env file rather than argv, since =/proc= is world-readable.
222 0600 env file rather than argv, since =/proc= is world-readable. A
223 value with a newline in it — a private key — cannot go in that file;
224 it is named on podman's command line with =--env NAME= and valued in
225 the runner-owned podman process's environment, so it never touches
226 argv either.
cmd/gitbay-runner/env_test.go +24
@@ -162,3 +162,27 @@ func TestServeStopsWhileIdle(t *testing.T) {
162162 t.Fatal("idle worker did not stop")
163163 }
164164}
165
166// A private key is a secret with newlines. An env file cannot carry one,
167// so such values reach the container through podman's own environment,
168// named on the command line and never valued there.
169func TestSplitEnvKeepsMultilineOutOfTheFile(t *testing.T) {
170 env := []string{"PATH=/bin", "KEY=-----BEGIN\nabc\n-----END", "TOKEN=s3cret", "CR=a\rb"}
171 file, inherit := splitEnv(env)
172 if len(file) != 2 || file[0] != "PATH=/bin" || file[1] != "TOKEN=s3cret" {
173 t.Fatalf("file env = %q", file)
174 }
175 if len(inherit) != 2 || inherit[0] != "KEY=-----BEGIN\nabc\n-----END" || inherit[1] != "CR=a\rb" {
176 t.Fatalf("inherited env = %q", inherit)
177 }
178 args := inheritArgs(inherit)
179 want := []string{"--env", "KEY", "--env", "CR"}
180 if strings.Join(args, " ") != strings.Join(want, " ") {
181 t.Fatalf("podman args = %q, want %q", args, want)
182 }
183 for _, a := range args {
184 if strings.Contains(a, "BEGIN") || strings.Contains(a, "a\rb") {
185 t.Fatalf("a secret's value reached argv: %q", a)
186 }
187 }
188}
cmd/gitbay-runner/isolate.go +36 −3
@@ -115,8 +115,14 @@ func (r *runner) runStepsPodman(j job, dir string, env []string, sink io.Writer,
115115 // file outside the workspace holds them instead — outside because the
116116 // workspace is bind mounted, and a file of secrets sitting in the
117117 // checkout is one `cat` from a build's own log.
118 //
119 // A value with a newline in it — a private key — cannot go in the
120 // file, which has no escape for one. Those are named on the command
121 // line with --env NAME and valued in the podman process's own
122 // environment, which podman copies into the container.
123 fileEnv, inherit := splitEnv(env)
118124 envFile := filepath.Join(r.workdir, fmt.Sprintf("env-%d", j.ID))
119 if err := writeEnvFile(envFile, env); err != nil {
125 if err := writeEnvFile(envFile, fileEnv); err != nil {
120126 fmt.Fprintf(sink, "preparing the build environment: %v\n", err)
121127 return false
122128 }
@@ -144,7 +150,9 @@ func (r *runner) runStepsPodman(j job, dir string, env []string, sink io.Writer,
144150 args := append(r.podmanGlobal(), "run", "--detach", "--rm", "--pull=never", "--cgroups=disabled")
145151 args = append(args,
146152 "--name", name,
147 "--env-file", envFile,
153 "--env-file", envFile)
154 args = append(args, inheritArgs(inherit)...)
155 args = append(args,
148156 "--volume", dir+":/workspace:rw",
149157 // The build home holds the tool caches (Go modules, the sonar
150158 // scanner) that must outlive a build; HOME in env points at it.
@@ -156,7 +164,7 @@ func (r *runner) runStepsPodman(j job, dir string, env []string, sink io.Writer,
156164 "--entrypoint", "sh",
157165 image, "-c", "sleep infinity")
158166 start := exec.Command(podman, args...)
159 start.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + r.podmanHome()}
167 start.Env = append([]string{"PATH=" + os.Getenv("PATH"), "HOME=" + r.podmanHome()}, inherit...)
160168 intoCgroup(start, cgroupFD)
161169 if out, err := start.CombinedOutput(); err != nil {
162170 // A missing image lands here, and it is the common case worth
@@ -226,6 +234,31 @@ func (r *runner) podmanHome() string {
226234 return "/var/lib/gitbay-runner"
227235}
228236
237// splitEnv separates the entries an env file can carry from those whose
238// value holds a newline, which podman must inherit from its environment.
239func splitEnv(env []string) (file, inherit []string) {
240 for _, e := range env {
241 if strings.ContainsAny(e, "\n\r") {
242 inherit = append(inherit, e)
243 } else {
244 file = append(file, e)
245 }
246 }
247 return file, inherit
248}
249
250// inheritArgs names each inherited variable for podman run: --env NAME
251// with no value makes podman take it from its own environment, so the
252// value never appears on a command line.
253func inheritArgs(inherit []string) []string {
254 var args []string
255 for _, e := range inherit {
256 name, _, _ := strings.Cut(e, "=")
257 args = append(args, "--env", name)
258 }
259 return args
260}
261
229262// writeEnvFile writes KEY=VALUE lines for podman --env-file, readable
230263// only by this user. Values containing a newline are refused rather than
231264// silently truncated: the format has no escape for one, and a secret that