v0.5.0 22211fd

masker_test.go RawHistoryFolder

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%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}
201
202// cmd-stop-commands: the token of ::stop-commands:: is a secret from then on
203// (ActionCommandManager.cs:117-119) but the name of the step that registers it
204// is not masked: the runner masks a display name once, when it evaluates it
205// (ActionRunner.cs:343-347). Recorded: "pause-logging allowed by step env". A
206// secret that is known when the step starts is still masked in its name, and a
207// later step's name gets the new secret.
208func TestSecretRegisteredByAStepIsNotAppliedToItsOwnName(t *testing.T) {
209	needTools(t, "bash")
210	yaml := `on: push
211jobs:
212  main:
213    runs-on: ubuntu-24.04
214    steps:
215      - name: pause-logging allowed by step env
216        env:
217          ACTIONS_ALLOW_UNSECURE_STOPCOMMAND_TOKENS: 'true'
218        run: |
219          echo "::stop-commands::pause-logging"
220          echo "::pause-logging::"
221      - name: after pause-logging
222        run: echo hi
223      - name: leak ${{ github.token }}
224        run: echo hi
225`
226	rec, res := runLocal(t, yaml, "main", func(c *Config) { c.ContextsJSON = tokenContexts() })
227	if res.Conclusion != ConclusionSuccess {
228		t.Fatalf("%+v", res)
229	}
230	rec.step(t, "pause-logging allowed by step env")
231	rec.step(t, "after ***")
232	rec.step(t, "leak ***")
233}

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