detached 33a243c

secrets_test.go RawHistoryFolder

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}

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