runner: secrets with newlines reach the container !362

merged merged by cmc on 2026-09-10 03:08 UTC · krz/gitbay:multiline-secrets into main

3 files changed, +65 −4

Layout: unified · split

.gitbay/wiki/Threat-Model.org +5 −1
@@ -219,4 +219,8 @@ assume has been checked.
219- A build's secrets are environment variables inside its container, so 219- A build's secrets are environment variables inside its container, so
220 they are visible to =podman inspect= as the runner's user — the same 220 they are visible to =podman inspect= as the runner's user — the same
221 user that already holds them in memory. They reach podman through a 221 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) {
162 t.Fatal("idle worker did not stop") 162 t.Fatal("idle worker did not stop")
163 } 163 }
164} 164}
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,
115 // file outside the workspace holds them instead — outside because the 115 // file outside the workspace holds them instead — outside because the
116 // workspace is bind mounted, and a file of secrets sitting in the 116 // workspace is bind mounted, and a file of secrets sitting in the
117 // checkout is one `cat` from a build's own log. 117 // 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)
118 envFile := filepath.Join(r.workdir, fmt.Sprintf("env-%d", j.ID)) 124 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 {
120 fmt.Fprintf(sink, "preparing the build environment: %v\n", err) 126 fmt.Fprintf(sink, "preparing the build environment: %v\n", err)
121 return false 127 return false
122 } 128 }
@@ -144,7 +150,9 @@ func (r *runner) runStepsPodman(j job, dir string, env []string, sink io.Writer,
144 args := append(r.podmanGlobal(), "run", "--detach", "--rm", "--pull=never", "--cgroups=disabled") 150 args := append(r.podmanGlobal(), "run", "--detach", "--rm", "--pull=never", "--cgroups=disabled")
145 args = append(args, 151 args = append(args,
146 "--name", name, 152 "--name", name,
147 "--env-file", envFile, 153 "--env-file", envFile)
154 args = append(args, inheritArgs(inherit)...)
155 args = append(args,
148 "--volume", dir+":/workspace:rw", 156 "--volume", dir+":/workspace:rw",
149 // The build home holds the tool caches (Go modules, the sonar 157 // The build home holds the tool caches (Go modules, the sonar
150 // scanner) that must outlive a build; HOME in env points at it. 158 // 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,
156 "--entrypoint", "sh", 164 "--entrypoint", "sh",
157 image, "-c", "sleep infinity") 165 image, "-c", "sleep infinity")
158 start := exec.Command(podman, args...) 166 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...)
160 intoCgroup(start, cgroupFD) 168 intoCgroup(start, cgroupFD)
161 if out, err := start.CombinedOutput(); err != nil { 169 if out, err := start.CombinedOutput(); err != nil {
162 // A missing image lands here, and it is the common case worth 170 // A missing image lands here, and it is the common case worth
@@ -226,6 +234,31 @@ func (r *runner) podmanHome() string {
226 return "/var/lib/gitbay-runner" 234 return "/var/lib/gitbay-runner"
227} 235}
228 236
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
229// writeEnvFile writes KEY=VALUE lines for podman --env-file, readable 262// writeEnvFile writes KEY=VALUE lines for podman --env-file, readable
230// only by this user. Values containing a newline are refused rather than 263// only by this user. Values containing a newline are refused rather than
231// silently truncated: the format has no escape for one, and a secret that 264// silently truncated: the format has no escape for one, and a secret that