1// SPDX-License-Identifier: MIT
2
3package executor
4
5import (
6 "encoding/base64"
7 "strings"
8 "testing"
9 "time"
10)
11
12const testToken = "ghs_testtoken123"
13
14func tokenContexts() string {
15 return strings.Replace(testGitHubContext("main"), `"github":{`, `"github":{"token":"`+testToken+`",`, 1)
16}
17
18func TestMaskerForms(t *testing.T) {
19 m := newMasker(testToken)
20 for _, tc := range []struct{ name, text string }{
21 {"plain", "x " + testToken + " y"},
22 {"base64", "Authorization: Basic " + base64.StdEncoding.EncodeToString([]byte(testToken))},
23 {"base64, one byte of prefix", base64.StdEncoding.EncodeToString([]byte("u" + testToken))},
24 {"base64, two bytes of prefix", base64.StdEncoding.EncodeToString([]byte("us" + testToken))},
25 {"base64, three bytes of prefix", base64.StdEncoding.EncodeToString([]byte("usr" + testToken))},
26 } {
27 if got := m.mask(tc.text); strings.Contains(got, testToken) || got == tc.text || !strings.Contains(got, "***") {
28 t.Errorf("%s: %q -> %q", tc.name, tc.text, got)
29 }
30 if !m.has(tc.text) {
31 t.Errorf("%s: has() is false", tc.name)
32 }
33 }
34 // base64 at the three offsets: the secret's own text is found in every form
35 for shift := 0; shift < 3; shift++ {
36 prefix := strings.Repeat("p", shift)
37 enc := base64.StdEncoding.EncodeToString([]byte(prefix + testToken))
38 if got := m.mask("[" + enc + "]"); strings.Contains(got, enc[4:len(enc)-4]) {
39 t.Errorf("shift %d: %q", shift, got)
40 }
41 }
42 for _, tc := range []string{"", "nothing here", "ghs_other"} {
43 if m.has(tc) {
44 t.Errorf("%q must not be masked", tc)
45 }
46 }
47 if !newMasker().has("") == false || newMasker().mask("a") != "a" {
48 t.Error("an empty masker changes text")
49 }
50 // special characters: the JSON and URL forms
51 s := newMasker(`a"b/c d`)
52 for _, text := range []string{`a"b/c d`, `a\"b/c d`, `a%22b%2Fc+d`, `a%22b%2Fc%20d`} {
53 if got := s.mask(text); strings.Contains(got, "b") {
54 t.Errorf("%q -> %q", text, got)
55 }
56 }
57}
58
59// Bytes after the secret change the end of its base64 (`echo "$T" | base64`
60// appends a newline): the part the secret alone determines is still masked, at
61// the three offsets and whatever the length of the secret.
62func TestMaskerBase64FollowedByOtherBytes(t *testing.T) {
63 for _, secret := range []string{testToken, testToken + "4", testToken + "45", "ghs_0123456789abcdef"} {
64 m := newMasker(secret)
65 for shift := 0; shift < 3; shift++ {
66 prefix := strings.Repeat("p", shift)
67 for _, suffix := range []string{"\n", "!", "xy", " and more text"} {
68 enc := base64.StdEncoding.EncodeToString([]byte(prefix + secret + suffix))
69 text := "out: " + enc + " :end"
70 got := m.mask(text)
71 if !m.has(text) || got == text || !strings.Contains(got, "***") {
72 t.Errorf("secret %q, prefix %q, suffix %q: %q -> %q", secret, prefix, suffix, text, got)
73 continue
74 }
75 // the groups the secret alone determines are gone
76 if strings.Contains(got, enc[4:12]) {
77 t.Errorf("secret %q, prefix %q, suffix %q: %q still has the secret", secret, prefix, suffix, got)
78 }
79 }
80 }
81 }
82 // the exact case of the review
83 m := newMasker(testToken)
84 if got := m.mask("Z2hzX3Rlc3R0b2tlbjEyMwo="); got == "Z2hzX3Rlc3R0b2tlbjEyMwo=" {
85 t.Errorf("echo $T | base64 is not masked: %q", got)
86 }
87 // a short secret does not mask the base64 of a few common bytes
88 short := newMasker("abcd")
89 if got := short.mask("YWJj"); got != "YWJj" {
90 t.Errorf("a short tail is masked: %q", got)
91 }
92}
93
94// The name of a step that holds the token is masked before it reaches the
95// server (StepStarted and StepCompleted).
96func TestStepNameIsMasked(t *testing.T) {
97 needTools(t, "bash")
98 yaml := `on: push
99jobs:
100 main:
101 runs-on: ubuntu-24.04
102 steps:
103 - name: leak ${{ github.token }}
104 run: echo hi
105 - name: leak2 ${{ secrets.GITHUB_TOKEN }} ${{ env.X }}
106 env:
107 X: x
108 run: echo hi
109`
110 rec, res := runLocal(t, yaml, "main", func(c *Config) { c.ContextsJSON = tokenContexts() })
111 if res.Conclusion != ConclusionSuccess {
112 t.Fatalf("%+v", res)
113 }
114 for _, s := range rec.steps {
115 if strings.Contains(s.Name, testToken) {
116 t.Errorf("step name %q holds the token", s.Name)
117 }
118 }
119 rec.step(t, "leak ***")
120 rec.step(t, "leak2 *** x")
121}
122
123// A job output that holds the token is dropped with GitHub's warning; the
124// other outputs stay.
125func TestJobOutputWithTokenIsDropped(t *testing.T) {
126 needTools(t, "bash")
127 yaml := `on: push
128jobs:
129 main:
130 runs-on: ubuntu-24.04
131 outputs:
132 leak: ${{ steps.s.outputs.v }}
133 fine: ${{ steps.s.outputs.w }}
134 steps:
135 - id: s
136 env:
137 T: ${{ github.token }}
138 run: |
139 echo "v=prefix-$T-suffix" >> "$GITHUB_OUTPUT"
140 echo "w=ok" >> "$GITHUB_OUTPUT"
141`
142 rec, res := runLocal(t, yaml, "main", func(c *Config) { c.ContextsJSON = tokenContexts() })
143 if res.Conclusion != ConclusionSuccess {
144 t.Fatalf("%+v", res)
145 }
146 if _, has := res.Outputs["leak"]; has || res.Outputs["fine"] != "ok" {
147 t.Errorf("outputs %v", res.Outputs)
148 }
149 for _, v := range res.Outputs {
150 if strings.Contains(v, testToken) {
151 t.Errorf("the token is in an output: %q", v)
152 }
153 }
154 if log := logOf(rec.step(t, CompleteJobName)); !strings.Contains(log, "##[warning]Skip output 'leak' since it may contain secret.") {
155 t.Errorf("warning missing:\n%s", log)
156 }
157}
158
159// Everything the executor reports goes through the masker: the names of the
160// steps, the log, and the error of the job (no real run puts a token there, so
161// the reporter is exercised directly).
162func TestMaskReporter(t *testing.T) {
163 rec := newRecorder()
164 j := &job{}
165 j.masker.Store(newMasker(testToken))
166 r := &maskReporter{j: j, next: rec}
167 r.StepStarted(1, "name "+testToken, time.Now())
168 r.Log(1, time.Now(), "line "+testToken)
169 r.StepCompleted(1, "name "+testToken, ConclusionSuccess, time.Now())
170 r.JobCompleted(Result{Conclusion: ConclusionFailure, Error: "boom " + testToken})
171 if s := rec.steps[0]; s.Name != "name ***" || s.Log[0] != "line ***" {
172 t.Errorf("%+v", s)
173 }
174 if rec.result.Error != "boom ***" {
175 t.Errorf("error %q", rec.result.Error)
176 }
177}
178
179// The token is masked before a message is cut at 4096 bytes: a token split by
180// the cut would otherwise leave its start in the line.
181func TestMaskBeforeTruncation(t *testing.T) {
182 needTools(t, "bash")
183 pad := strings.Repeat("a", maxIssueMessageLength-5)
184 yaml := `on: push
185jobs:
186 main:
187 runs-on: ubuntu-24.04
188 steps:
189 - run: |
190 echo "::error::` + pad + `${{ github.token }}"
191`
192 rec, _ := runLocal(t, yaml, "main", func(c *Config) { c.ContextsJSON = tokenContexts() })
193 for _, s := range rec.steps {
194 for _, l := range s.Log {
195 if strings.Contains(l, "ghs_") {
196 t.Errorf("a piece of the token leaked: %q", l[len(l)-20:])
197 }
198 }
199 }
200}