v0.3.0 8f98fd3

secrets_test.go RawHistoryFolder

1// SPDX-License-Identifier: MIT
2
3package host
4
5import (
6	"context"
7	"fmt"
8	"go/ast"
9	"go/format"
10	"go/parser"
11	"go/token"
12	"maps"
13	"os"
14	"path/filepath"
15	"regexp"
16	"strings"
17	"testing"
18
19	"google.golang.org/protobuf/proto"
20
21	ermite "bnema.dev/ermite-api/gen/ermite"
22
23	"bnema.dev/ermite-runner/internal/execstream/execpb"
24)
25
26const (
27	secretValue  = "s3cr3t-value-DO-NOT-LOG"
28	secretValue2 = "-----BEGIN-----\nline-two-DO-NOT-LOG\n-----END-----"
29)
30
31func withSecrets(trusted bool) func(*harness) {
32	return func(h *harness) {
33		j := testJob("j1")
34		j.Secrets = map[string]string{"DB_PASSWORD": secretValue, "KEY": secretValue2}
35		j.Vars = map[string]string{"REGION": "eu"}
36		j.Trusted = trusted
37		h.srv.jobs = nil
38		h.srv.jobs = append(h.srv.jobs, j)
39	}
40}
41
42func runWithSecrets(t *testing.T, features []string, trusted bool) (*harness, *execpb.Run, string) {
43	t.Helper()
44	seenRun.Store(nil)
45	state := t.TempDir()
46	h := newHarness(t, resultsScript(nil))
47	h.runner.Cfg.StateDir = state
48	h.runner.Features = func(context.Context) ([]string, error) { return features, nil }
49	withSecrets(trusted)(h)
50	stop := h.run(t)
51	eventually(t, "CompleteJob", func() bool { return h.srv.completeCount() == 1 })
52	if err := stop(); err != nil {
53		t.Fatal(err)
54	}
55	return h, seenRun.Load(), state
56}
57
58func TestSecretsAndVarsReachTheExecutorOnly(t *testing.T) {
59	h, run, state := runWithSecrets(t, []string{FeatureSecrets, FeatureAnnotations}, true)
60	if run == nil {
61		t.Fatal("no Run frame")
62	}
63	vals := lastValues.Load()
64	if vals == nil || vals.secrets["DB_PASSWORD"] != secretValue || vals.secrets["KEY"] != secretValue2 || len(vals.secrets) != 2 {
65		t.Errorf("secrets not passed: %+v", vals)
66	}
67	if vals.vars["REGION"] != "eu" || run.Job.SecretsWithheld {
68		t.Errorf("vars %v withheld %v", vals.vars, run.Job.SecretsWithheld)
69	}
70	// The Run frame itself holds no secret.
71	if rb, _ := proto.Marshal(run); strings.Contains(string(rb), "DO-NOT-LOG") {
72		t.Error("the Run frame holds a secret")
73	}
74
75	// Nothing the host does prints the secrets: its log (debug level) and its
76	// state directory. (protobuf-go does not redact String(), so the guarantee
77	// is that no code formats the Job, the Run or the JobSpec; see the grep in
78	// TestNoCodePrintsTheJobMessages.)
79	if strings.Contains(h.logs.String(), "DO-NOT-LOG") {
80		t.Errorf("a secret is in the host log:\n%.500s", h.logs.String())
81	}
82	err := filepath.Walk(state, func(p string, info os.FileInfo, err error) error {
83		if err != nil || info.IsDir() {
84			return err
85		}
86		b, _ := os.ReadFile(p)
87		if strings.Contains(string(b), "DO-NOT-LOG") {
88			t.Errorf("a secret is in the state directory: %s", p)
89		}
90		return nil
91	})
92	if err != nil {
93		t.Fatal(err)
94	}
95}
96
97// The "not available" line only exists when the server has ci.secrets: an old
98// server does not send Job.trusted, which then reads false.
99func TestSecretsWithheldOnlyWithTheFeature(t *testing.T) {
100	for _, tc := range []struct {
101		name     string
102		features []string
103		trusted  bool
104		want     bool
105	}{
106		{"untrusted, server has ci.secrets", []string{FeatureSecrets}, false, true},
107		{"trusted, server has ci.secrets", []string{FeatureSecrets}, true, false},
108		{"untrusted, old server", []string{FeatureAnnotations}, false, false},
109		{"untrusted, features unreachable", nil, false, false},
110	} {
111		t.Run(tc.name, func(t *testing.T) {
112			_, run, _ := runWithSecrets(t, tc.features, tc.trusted)
113			if run == nil || run.Job.SecretsWithheld != tc.want {
114				t.Errorf("secrets_withheld = %v, want %v", run != nil && run.Job.SecretsWithheld, tc.want)
115			}
116		})
117	}
118}
119
120// protobuf-go prints map values in String(), so nothing may format a message
121// that holds secrets: the Job (ermite.Job, with Secrets) or the Run and JobSpec
122// that wrap it. This is a heuristic on names, not a proof: printLeaks looks,
123// in the sources of the host, the guest, execstream and the executor, for
124// formatting, logging, marshalling and String() calls that are given a value
125// named like those messages (job, j.job, r.job, spec, run, x.GetJob()), their
126// Secrets field or GetSecrets(). It does not follow a message through other
127// names or through values it was copied into. The real guarantees are the
128// tests that read the host log and the state directory
129// (TestSecretsAndVarsReachTheExecutorOnly) and the absence of secrets in the
130// Run frame.
131func TestNoCodePrintsTheJobMessages(t *testing.T) {
132	fset := token.NewFileSet()
133	for _, dir := range []string{".", "../guest", "../execstream", "../executor"} {
134		files, err := filepath.Glob(filepath.Join(dir, "*.go"))
135		if err != nil {
136			t.Fatal(err)
137		}
138		for _, file := range files {
139			if strings.HasSuffix(file, "_test.go") {
140				continue
141			}
142			src, err := os.ReadFile(file)
143			if err != nil {
144				t.Fatal(err)
145			}
146			for _, leak := range printLeaks(t, fset, file, src) {
147				t.Error(leak)
148			}
149		}
150	}
151}
152
153var (
154	// printers are the calls that render their arguments: fmt, loggers (zerolog
155	// events), the text and JSON encoders of messages.
156	printers = map[string]bool{"Errorf": true, "Printf": true, "Sprintf": true, "Fprintf": true, "Println": true, "Fprintln": true,
157		"Print": true, "Sprint": true, "Sprintln": true, "Interface": true, "Any": true, "Stringer": true, "Msgf": true, "Msg": true, "Str": true,
158		"Fatalf": true, "Logf": true, "Infof": true, "Warnf": true, "Format": true, "Marshal": true, "MarshalToString": true,
159		"Err": true, "Object": true, "Dict": true, "Strs": true}
160	// the messages by name
161	messageName = `(?:(?:j|r|rt)\.job|job|spec|run|j\.run|[A-Za-z_.]+\.GetJob\(\)|[A-Za-z_.]+\.Job)`
162	messageRe   = regexp.MustCompile(`^` + messageName + `$`)
163	// message.String(), and the secrets of a message or of anything
164	stringRe  = regexp.MustCompile(`^` + messageName + `\.String\(\)$`)
165	secretsRe = regexp.MustCompile(`\.(?:Secrets|GetSecrets\(\))$`)
166)
167
168func leaky(text string) bool {
169	return messageRe.MatchString(text) || stringRe.MatchString(text) || secretsRe.MatchString(text)
170}
171
172// printLeaks returns the places of src where a message with secrets, or its
173// secrets, is given to a printing call, or where String() is called on such a
174// message.
175func printLeaks(t *testing.T, fset *token.FileSet, name string, src []byte) []string {
176	t.Helper()
177	f, err := parser.ParseFile(fset, name, src, 0)
178	if err != nil {
179		t.Fatal(err)
180	}
181	text := func(e ast.Expr) string {
182		var b strings.Builder
183		_ = format.Node(&b, fset, e)
184		return b.String()
185	}
186	var out []string
187	report := func(e ast.Expr, what string) {
188		out = append(out, fmt.Sprintf("%s: %s: %s would print secrets", fset.Position(e.Pos()), what, text(e)))
189	}
190	// visit looks for leaky expressions inside an argument of a printing call. A
191	// field read of a message (j.job.JobId) is a scalar: the message is not
192	// descended into.
193	var visit func(e ast.Expr, what string)
194	visit = func(e ast.Expr, what string) {
195		if e == nil {
196			return
197		}
198		if leaky(text(e)) {
199			report(e, what)
200			return
201		}
202		switch x := e.(type) {
203		case *ast.SelectorExpr:
204			if !messageRe.MatchString(text(x.X)) {
205				visit(x.X, what)
206			}
207		case *ast.CallExpr:
208			if id, ok := x.Fun.(*ast.Ident); ok && (id.Name == "len" || id.Name == "cap") {
209				return // a count is not a secret
210			}
211			if sel, ok := x.Fun.(*ast.SelectorExpr); ok {
212				if !messageRe.MatchString(text(sel.X)) {
213					visit(sel.X, what)
214				}
215			} else {
216				visit(x.Fun, what)
217			}
218			for _, a := range x.Args {
219				visit(a, what)
220			}
221		case *ast.UnaryExpr:
222			visit(x.X, what)
223		case *ast.StarExpr:
224			visit(x.X, what)
225		case *ast.ParenExpr:
226			visit(x.X, what)
227		case *ast.BinaryExpr:
228			visit(x.X, what)
229			visit(x.Y, what)
230		case *ast.IndexExpr:
231			visit(x.X, what)
232		case *ast.CompositeLit:
233			for _, el := range x.Elts {
234				if kv, ok := el.(*ast.KeyValueExpr); ok {
235					visit(kv.Value, what)
236				} else {
237					visit(el, what)
238				}
239			}
240		}
241	}
242	ast.Inspect(f, func(n ast.Node) bool {
243		call, ok := n.(*ast.CallExpr)
244		if !ok {
245			return true
246		}
247		if stringRe.MatchString(text(call)) {
248			report(call, "String()")
249			return true
250		}
251		if sel, ok := call.Fun.(*ast.SelectorExpr); ok && printers[sel.Sel.Name] {
252			for _, a := range call.Args {
253				visit(a, sel.Sel.Name+"()")
254			}
255		}
256		return true
257	})
258	return out
259}
260
261// The guard finds what it is meant to find and leaves scalar fields alone.
262func TestPrintLeaksHeuristic(t *testing.T) {
263	positive := []string{
264		`fmt.Sprintf("%v", spec)`,
265		`fmt.Errorf("bad %+v", j.job)`,
266		`log.Info().Interface("job", j.job).Msg("x")`,
267		`log.Info().Any("run", run).Msg("x")`,
268		`_ = spec.String()`,
269		`_ = j.job.String()`,
270		`x := run.GetJob().String()`,
271		`log.Debug().Str("s", fmt.Sprint(j.job.Secrets))`,
272		`log.Debug().Interface("s", j.job.Secrets).Msg("x")`,
273		`log.Debug().Interface("s", job.GetSecrets()).Msg("x")`,
274		`fmt.Println(run.GetJob())`,
275		`fmt.Println("job", r.job)`,
276		`_ = prototext.Format(run)`,
277		`_, _ = json.Marshal(j.job)`,
278		`fmt.Println(strings.ToUpper(spec.String()))`,
279		`fmt.Println(&j.job)`,
280		`fmt.Println([]any{j.job})`,
281	}
282	negative := []string{
283		`fmt.Sprintf("%s", j.job.JobId)`,
284		`log.Info().Str("job_id", job.JobId).Int64("run_id", job.RunId).Msg("x")`,
285		`fmt.Errorf("job %s: %w", j.job.JobKey, err)`,
286		`log.Info().Str("k", run.GetJob().GetJobKey()).Msg("x")`,
287		`_ = fmt.Sprint(len(j.job.Secrets))`,
288		`h := JobHandler{Secrets: j.job.Secrets, Vars: j.job.Vars}`,
289		`_ = proto.Marshal(frame)`,
290		`fmt.Println(spec.GetRepo())`,
291		`_ = other.String()`,
292		`secrets := j.job.Secrets; _ = secrets`,
293	}
294	fset := token.NewFileSet()
295	for _, c := range positive {
296		if got := printLeaks(t, fset, "p.go", []byte("package p\nfunc f() {\n"+c+"\n}\n")); len(got) == 0 {
297			t.Errorf("not detected: %s", c)
298		}
299	}
300	for _, c := range negative {
301		if got := printLeaks(t, fset, "n.go", []byte("package p\nfunc f() {\n"+c+"\n}\n")); len(got) != 0 {
302			t.Errorf("false positive: %s: %v", c, got)
303		}
304	}
305}
306
307// Job.permissions reaches the executor in the Run frame when the server has
308// ci.secrets (it sent them); an old server's absent field stays unset, so that
309// the executor works them out from the workflow.
310func TestTokenPermissionsReachTheExecutor(t *testing.T) {
311	for _, tc := range []struct {
312		name     string
313		features []string
314		perms    *ermite.JobPermissions
315		want     map[string]string // nil: unset in the frame
316	}{
317		{"sent", []string{FeatureSecrets}, &ermite.JobPermissions{Levels: map[string]string{"contents": "write", "metadata": "read"}},
318			map[string]string{"contents": "write", "metadata": "read"}},
319		{"sent and empty", []string{FeatureSecrets}, &ermite.JobPermissions{}, map[string]string{}},
320		{"not sent", []string{FeatureSecrets}, nil, nil},
321		{"old server", []string{FeatureAnnotations}, &ermite.JobPermissions{Levels: map[string]string{"contents": "write"}}, nil},
322	} {
323		t.Run(tc.name, func(t *testing.T) {
324			seenRun.Store(nil)
325			h := newHarness(t, resultsScript(nil))
326			h.runner.Cfg.StateDir = t.TempDir()
327			h.runner.Features = func(context.Context) ([]string, error) { return tc.features, nil }
328			j := testJob("j1")
329			j.Permissions = tc.perms
330			h.srv.jobs = []*ermite.Job{j}
331			stop := h.run(t)
332			eventually(t, "CompleteJob", func() bool { return h.srv.completeCount() == 1 })
333			if err := stop(); err != nil {
334				t.Fatal(err)
335			}
336			run := seenRun.Load()
337			if run == nil {
338				t.Fatal("no Run frame")
339			}
340			got := run.Job.GetTokenPermissions()
341			switch {
342			case tc.want == nil && got != nil:
343				t.Errorf("unexpected permissions %v", got.GetLevels())
344			case tc.want != nil && (got == nil || !maps.Equal(got.GetLevels(), tc.want)):
345				t.Errorf("permissions %v, want %v", got.GetLevels(), tc.want)
346			}
347		})
348	}
349}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 11

↑↓ moveenter openesc close