v0.3.0 8f98fd3

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	"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}

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