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