1// SPDX-License-Identifier: MIT
2
3package executor
4
5import (
6 "bytes"
7 "errors"
8 "os"
9 "path/filepath"
10
11 "encoding/base64"
12 "encoding/json"
13 "net/url"
14 "strings"
15 "testing"
16
17 "bnema.dev/ermite-actions/expr"
18 "github.com/bnema/zerowrap"
19)
20
21// jobSecretMasker is the masker of a job holding these secrets, built the way
22// Run does: the token first, then the secrets of the job.
23func jobSecretMasker(t *testing.T, secrets map[string]string) *masker {
24 t.Helper()
25 s, _, err := NewState(testGitHubContext("main"), "", "main", RunnerInfo{}, "")
26 if err != nil {
27 t.Fatal(err)
28 }
29 s.WithJobData(secrets, nil)
30 m := newMasker(s.Secrets()...)
31 registerSecrets(m, secrets)
32 return m
33}
34
35// A secret is masked as is and in the encodings of CI-2 (Worker.cs:158-171,
36// HostContext.cs:103-113): base64, JSON, URL.
37func TestJobSecretIsMaskedInItsEncodings(t *testing.T) {
38 const sec = `p@ss"w/rd é+1`
39 m := jobSecretMasker(t, map[string]string{"DB_PASSWORD": sec})
40 j, _ := json.Marshal(sec)
41 for name, text := range map[string]string{
42 "plain": "x " + sec + " y",
43 "base64": "Basic " + base64.StdEncoding.EncodeToString([]byte(sec)),
44 "json": "{\"k\":" + string(j) + "}",
45 "url": "?q=" + strings.ReplaceAll(url.QueryEscape(sec), "+", "%20"), // Uri.EscapeDataString
46 } {
47 got := m.mask(text)
48 if got == text || !strings.Contains(got, "***") {
49 t.Errorf("%s: %q was not masked: %q", name, text, got)
50 }
51 }
52 if got := m.mask("harmless"); got != "harmless" {
53 t.Errorf("%q", got)
54 }
55}
56
57// Worker.cs:158-171: the value is trimmed, and each non-empty trimmed line of a
58// multi-line value is registered too (split on CR and LF).
59func TestJobSecretLinesAndTrim(t *testing.T) {
60 m := jobSecretMasker(t, map[string]string{
61 "KEY": " \n-----BEGIN KEY-----\r\n line-two-secret \n\n-----END KEY-----\n ",
62 "PAD": " padded-secret \t\n",
63 "NONE": "",
64 "WS": " \r\n ",
65 })
66 for _, text := range []string{"-----BEGIN KEY-----", "line-two-secret", "-----END KEY-----", "padded-secret",
67 "-----BEGIN KEY-----\r\n line-two-secret \n\n-----END KEY-----"} {
68 if m.mask("a "+text+" b") == "a "+text+" b" {
69 t.Errorf("%q is not masked", text)
70 }
71 }
72 // empty and blank values register nothing: text is untouched
73 for _, text := range []string{"", " ", "\r\n", "plain text", "a b"} {
74 if got := m.mask(text); got != text {
75 t.Errorf("%q -> %q", text, got)
76 }
77 }
78 // the value as sent, with its padding, is masked as far as the trimmed one
79 if got := m.mask("v=padded-secret "); got != "v=*** " {
80 t.Errorf("%q", got)
81 }
82}
83
84func TestSecretsContext(t *testing.T) {
85 s, _, err := NewState(tokenContexts(), "", "main", RunnerInfo{}, "")
86 if err != nil {
87 t.Fatal(err)
88 }
89 s.WithJobData(map[string]string{"B": "2", "A": "1", "GITHUB_TOKEN": "forged", "EMPTY": ""}, nil)
90 d := s.secrets()
91 got := map[string]string{}
92 for _, p := range d.Pairs() {
93 got[p.Key] = str(d, p.Key)
94 }
95 if len(got) != 4 || got["GITHUB_TOKEN"] != testToken || got["A"] != "1" || got["B"] != "2" || got["EMPTY"] != "" {
96 t.Errorf("secrets context %v", got)
97 }
98 // without a token and without secrets the context is empty
99 s0, _, _ := NewState(testGitHubContext("main"), "", "main", RunnerInfo{}, "")
100 if s0.secrets().Len() != 0 {
101 t.Error("secrets context is not empty")
102 }
103}
104
105func TestVarsContext(t *testing.T) {
106 vars := map[string]string{"A": "from-job"}
107 // the contexts JSON has no vars: Job.vars is the fallback
108 s, _, _ := NewState(testGitHubContext("main"), "", "main", RunnerInfo{}, "")
109 s.WithJobData(nil, vars)
110 if got := dictOf(t, s.other["vars"]); got != "A=from-job" {
111 t.Errorf("fallback: %s", got)
112 }
113 // the contexts JSON has vars: it wins
114 withVars := strings.Replace(testGitHubContext("main"), `{"github"`, `{"vars":{"A":"from-contexts","B":"b"},"github"`, 1)
115 s2, _, err := NewState(withVars, "", "main", RunnerInfo{}, "")
116 if err != nil {
117 t.Fatal(err)
118 }
119 s2.WithJobData(nil, vars)
120 if got := dictOf(t, s2.other["vars"]); got != "A=from-contexts B=b" {
121 t.Errorf("contexts: %s", got)
122 }
123 // neither: an empty object
124 s3, _, _ := NewState(testGitHubContext("main"), "", "main", RunnerInfo{}, "")
125 s3.WithJobData(nil, nil)
126 if got := dictOf(t, s3.other["vars"]); got != "" {
127 t.Errorf("none: %s", got)
128 }
129}
130
131func TestJobSecretsEndToEnd(t *testing.T) {
132 needTools(t, "bash")
133 const (
134 pw = "hunter2-secret"
135 key = "-----BEGIN-----\nsecond-line-secret\n-----END-----"
136 )
137 yaml := `on: push
138jobs:
139 main:
140 runs-on: ubuntu-24.04
141 outputs:
142 leak: ${{ steps.s.outputs.v }}
143 leak64: ${{ steps.s.outputs.b }}
144 line: ${{ steps.s.outputs.l }}
145 fine: ${{ steps.s.outputs.w }}
146 steps:
147 - id: s
148 env:
149 PW: ${{ secrets.PW }}
150 KEY: ${{ secrets.KEY }}
151 V: ${{ vars.V }}
152 MISSING: ${{ secrets.NOPE }}
153 run: |
154 echo "pw=$PW"
155 echo "b64=$(printf %s "$PW" | base64)"
156 echo "line=$(printf %s "$KEY" | sed -n 2p)"
157 echo "v=$V missing=[$MISSING]"
158 echo "v=pre-$PW-post" >> "$GITHUB_OUTPUT"
159 echo "b=$(printf %s "$PW" | base64)" >> "$GITHUB_OUTPUT"
160 echo "l=second-line-secret" >> "$GITHUB_OUTPUT"
161 echo "w=$V" >> "$GITHUB_OUTPUT"
162`
163 rec, res := runLocal(t, yaml, "main", func(c *Config) {
164 c.Secrets = map[string]string{"PW": pw, "KEY": key}
165 c.Vars = map[string]string{"V": "visible"}
166 })
167 if res.Conclusion != ConclusionSuccess {
168 t.Fatalf("%+v", res)
169 }
170 var all []string
171 for _, s := range rec.steps {
172 all = append(all, s.Name, logOf(s))
173 }
174 joined := strings.Join(all, "\n")
175 for _, secret := range []string{pw, "second-line-secret", base64.StdEncoding.EncodeToString([]byte(pw))} {
176 if strings.Contains(joined, secret) {
177 t.Errorf("%q leaked in the log:\n%s", secret, joined)
178 }
179 }
180 for _, want := range []string{"pw=***", "line=***", "v=visible missing=[]"} {
181 if !strings.Contains(joined, want) {
182 t.Errorf("log lacks %q:\n%s", want, joined)
183 }
184 }
185 if len(res.Outputs) != 1 || res.Outputs["fine"] != "visible" {
186 t.Errorf("outputs %v", res.Outputs)
187 }
188 done := logOf(rec.step(t, CompleteJobName))
189 for _, n := range []string{"leak", "leak64", "line"} {
190 if w := "##[warning]Skip output '" + n + "' since it may contain secret."; !strings.Contains(done, w) {
191 t.Errorf("warning %q missing:\n%s", w, done)
192 }
193 }
194 if strings.Contains(done, "Skip output 'fine'") {
195 t.Error("fine output skipped")
196 }
197}
198
199func TestSecretsWithheldLine(t *testing.T) {
200 needTools(t, "bash")
201 yaml := wfHead + " steps:\n - run: echo hi\n"
202 with := setupLog(t, yaml, "j", func(c *Config) { c.SecretsWithheld = true })
203 if n := strings.Count(with, SecretsWithheldLine); n != 1 || SecretsWithheldLine != "Secrets are not available to this job: the run was started by a limited token." {
204 t.Errorf("line count %d:\n%s", n, with)
205 }
206 if without := setupLog(t, yaml, "j", nil); strings.Contains(without, "Secrets are not available") {
207 t.Errorf("printed without the flag:\n%s", without)
208 }
209}
210
211// dictOf renders a string dictionary as "K=v K=v" in order.
212func dictOf(t *testing.T, v expr.Value) string {
213 t.Helper()
214 d, ok := v.(*expr.Dictionary)
215 if !ok {
216 t.Fatalf("not a dictionary: %T", v)
217 }
218 var parts []string
219 for _, p := range d.Pairs() {
220 parts = append(parts, p.Key+"="+str(d, p.Key))
221 }
222 return strings.Join(parts, " ")
223}
224
225// The first lines of "Set up job" are reported before the contexts exist: the
226// secrets of the job are already in the masker then (GO-002). GitHub's
227// SecretMasker has no minimum length (SecretMasker.AddValue ignores only null
228// or empty values, SecretMasker.cs:78-84), so a one-character secret is masked.
229func TestSecretsAreMaskedFromTheFirstLine(t *testing.T) {
230 needTools(t, "bash")
231 yaml := wfHead + " steps:\n - run: echo hi\n"
232 rec, res := runLocal(t, yaml, "j", func(c *Config) {
233 c.Secrets = map[string]string{"ONE": "7", "LONG": "topsecretvalue"}
234 c.RunnerVersion = "v7.1-topsecretvalue"
235 })
236 if res.Conclusion != ConclusionSuccess {
237 t.Fatalf("%+v", res)
238 }
239 first := rec.steps[0].Log[0]
240 if first != "Current runner version: 'v***.1-***'" {
241 t.Errorf("first line %q", first)
242 }
243}
244
245// An early failure of "Set up job" (before the contexts are built) is masked in
246// the log and in the error of the job.
247func TestEarlySetupFailureIsMasked(t *testing.T) {
248 yaml := wfHead + " steps:\n - run: echo hi\n"
249 rec, res := runLocal(t, yaml, "no-such-job-S3CRET", func(c *Config) {
250 c.Secrets = map[string]string{"S": "S3CRET", "ONE": "-"}
251 })
252 if res.Conclusion != ConclusionFailure {
253 t.Fatalf("%+v", res)
254 }
255 all := logOf(rec.steps[0]) + "\n" + res.Error + "\n" + rec.result.Error
256 if strings.Contains(all, "S3CRET") || strings.Contains(all, "-") || !strings.Contains(all, "no***such***job***") {
257 t.Errorf("not masked:\n%s", all)
258 }
259}
260
261// The diagnostics of the runner (Config.Log) bypass the reporter: they are
262// masked by diag (GO-001). A path with a secret in a failing hashFiles reaches
263// the diagnostics through the error.
264func TestDiagnosticsAreMasked(t *testing.T) {
265 needTools(t, "bash")
266 const sec = "s3cr3t-dir-name"
267 yaml := `on: push
268jobs:
269 j:
270 runs-on: x
271 steps:
272 - run: ln -s missing ` + sec + `
273 - run: echo ${{ hashFiles('**') }}
274`
275 var logs bytes.Buffer
276 rec, res := runLocal(t, yaml, "j", func(c *Config) {
277 c.Secrets = map[string]string{"D": sec}
278 c.Log = zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &logs})
279 })
280 if res.Conclusion != ConclusionFailure {
281 t.Fatalf("%+v", res)
282 }
283 if !strings.Contains(logs.String(), "hashFiles failed") {
284 t.Fatalf("no diagnostic:\n%s", logs.String())
285 }
286 if strings.Contains(logs.String(), sec) || !strings.Contains(logs.String(), "/***: statat ***") {
287 t.Errorf("diagnostics not masked:\n%s", logs.String())
288 }
289 for _, s := range rec.steps {
290 if strings.Contains(logOf(s), sec) {
291 t.Errorf("job log leaks:\n%s", logOf(s))
292 }
293 }
294
295 // The helper masks message, error and fields.
296 logs.Reset()
297 j := &job{cfg: Config{Log: zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &logs})}}
298 j.masker.Store(newMasker(sec))
299 j.diagWarn(errors.New("open "+sec+": no"), "msg "+sec, "k", "v "+sec)
300 j.diagDebug(nil, "d "+sec)
301 if strings.Contains(logs.String(), sec) || strings.Count(logs.String(), "***") != 4 {
302 t.Errorf("helper: %s", logs.String())
303 }
304}
305
306// Config.Log is used only by diag: any other use would bypass the masker.
307func TestOnlyDiagUsesTheDiagnosticsLogger(t *testing.T) {
308 files, _ := filepath.Glob("*.go")
309 for _, f := range files {
310 if strings.HasSuffix(f, "_test.go") {
311 continue
312 }
313 b, _ := os.ReadFile(f)
314 n := strings.Count(string(b), "cfg.Log")
315 // job.go: the two uses are in diag.
316 if (f == "job.go" && n != 2) || (f != "job.go" && n != 0) {
317 t.Errorf("%s uses cfg.Log %d times: use diagWarn or diagDebug", f, n)
318 }
319 }
320}
321
322// The group prints what the server sent in Job.permissions, as GitHub's runner
323// prints system.github.token.permissions, and not what the workflow says.
324func TestSetUpJobPrintsTheServersTokenPermissions(t *testing.T) {
325 needTools(t, "bash")
326 group := func(log string) string {
327 _, rest, _ := strings.Cut(log, "##[group]GITHUB_TOKEN Permissions\n")
328 g, _, _ := strings.Cut(rest, "\n##[endgroup]")
329 return g
330 }
331 // The workflow asks for write-all; the server lowered it (an untrusted run).
332 yaml := "on: push\npermissions: write-all\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"
333 for _, tc := range []struct {
334 name string
335 levels map[string]string
336 want string
337 }{
338 {"lowered by the server", map[string]string{"contents": "read", "pull-requests": "read", "issues": "read", "metadata": "read"},
339 "Contents: read\nIssues: read\nMetadata: read\nPullRequests: read"},
340 {"write kept", map[string]string{"contents": "write", "metadata": "read", "packages": "read"}, "Contents: write\nMetadata: read\nPackages: read"},
341 {"none and id-token are not listed, metadata always is", map[string]string{"contents": "none", "id-token": "write", "actions": "read"},
342 "Actions: read\nMetadata: read"},
343 {"nothing at all is metadata only", map[string]string{}, "Metadata: read"},
344 {"a key the runner does not know", map[string]string{"made-up-key": "read"}, "MadeUpKey: read\nMetadata: read"},
345 } {
346 got := group(setupLog(t, yaml, "j", func(c *Config) { c.TokenPermissions = tc.levels }))
347 if got != tc.want {
348 t.Errorf("%s:\n%s\nwant:\n%s", tc.name, got, tc.want)
349 }
350 }
351 // A server that sends none: worked out from the workflow, as before.
352 got := group(setupLog(t, yaml, "j", nil))
353 if !strings.Contains(got, "Contents: write") || !strings.Contains(got, "Models: read") {
354 t.Errorf("fallback to the workflow:\n%s", got)
355 }
356}