v0.1.0 cbb8ad6

masker.go RawHistoryFolder

1// SPDX-License-Identifier: MIT
2
3package executor
4
5import (
6	"encoding/base64"
7	"encoding/json"
8	"net/url"
9	"slices"
10	"strings"
11	"time"
12)
13
14// masker replaces secret values by *** in text that leaves the executor
15// (HostContext.SecretMasker). A value is masked as is and in the forms the
16// official runner registers for it (HostContext.cs:103-113, ValueEncoders): its
17// base64 at the three byte offsets a prefix of one or two bytes can give
18// ("user:" + secret is base64-encoded as a whole), and its JSON and URI
19// escapes. Longer forms are replaced first.
20//
21// Bytes that follow the secret change the last, incomplete group of its base64
22// (`echo "$T" | base64` appends a newline), so each base64 form is also
23// registered without that group: only the characters the secret alone
24// determines. actions/runner (ValueEncoders.Base64StringEscapeShift) only
25// registers the full forms; the truncated ones are an addition.
26type masker struct{ values []string }
27
28const maskedValue = "***"
29
30// minBase64Tail is the shortest base64 form, in characters, registered once its
31// incomplete last group is removed: a shorter one (3 bytes of the secret)
32// would mask ordinary text.
33const minBase64Tail = 8
34
35func newMasker(secrets ...string) *masker {
36	seen := map[string]bool{}
37	var vals []string
38	add := func(v string) {
39		if v != "" && !seen[v] {
40			seen[v] = true
41			vals = append(vals, v)
42		}
43	}
44	for _, s := range secrets {
45		add(s)
46		for _, e := range secretEncodings(s) {
47			add(e)
48		}
49	}
50	slices.SortStableFunc(vals, func(a, b string) int { return len(b) - len(a) })
51	return &masker{values: vals}
52}
53
54// secretEncodings are the other forms of v that the runner masks.
55func secretEncodings(v string) []string {
56	var out []string
57	b := []byte(v)
58	for shift := range 3 {
59		if len(b) <= shift && shift != 0 {
60			continue
61		}
62		sub := b[shift:]
63		out = append(out, base64.StdEncoding.EncodeToString(sub))
64		// Whole groups only: 3 bytes give 4 characters that no later byte changes.
65		if n := len(sub) / 3 * 4; n >= minBase64Tail {
66			out = append(out, base64.StdEncoding.EncodeToString(sub[:len(sub)/3*3]))
67		}
68	}
69	if j, err := json.Marshal(v); err == nil && len(j) > 2 {
70		out = append(out, string(j[1:len(j)-1]))
71	}
72	out = append(out, url.QueryEscape(v), url.PathEscape(v))
73	return out
74}
75
76// mask returns s with every secret replaced.
77func (m *masker) mask(s string) string {
78	if m == nil {
79		return s
80	}
81	for _, v := range m.values {
82		if strings.Contains(s, v) {
83			s = strings.ReplaceAll(s, v, maskedValue)
84		}
85	}
86	return s
87}
88
89// has reports whether s contains a secret.
90func (m *masker) has(s string) bool { return m != nil && m.mask(s) != s }
91
92// maskReporter masks everything the executor reports: the log lines, the names
93// of the steps and the error of the job. The outputs are checked where they are
94// evaluated (a job output with a secret is dropped).
95type maskReporter struct {
96	j    *job
97	next Reporter
98}
99
100func (r *maskReporter) StepStarted(n int, name string, at time.Time) {
101	r.next.StepStarted(n, r.j.mask(name), at)
102}
103
104func (r *maskReporter) Log(n int, at time.Time, line string) {
105	r.next.Log(n, at, r.j.mask(line))
106}
107
108func (r *maskReporter) StepCompleted(n int, name string, c Conclusion, at time.Time) {
109	r.next.StepCompleted(n, r.j.mask(name), c, at)
110}
111
112func (r *maskReporter) JobCompleted(res Result) {
113	res.Error = r.j.mask(res.Error)
114	r.next.JobCompleted(res)
115}

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