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}