v0.2.0 43088fd

masker.go RawHistoryFolder

1// SPDX-License-Identifier: MIT
2
3package executor
4
5import (
6	"encoding/base64"
7	"sort"
8	"strings"
9	"sync"
10	"time"
11	"unicode/utf8"
12)
13
14// masker replaces secret values by *** in text that leaves the executor. It is
15// a port of GitHub.DistributedTask.Logging.SecretMasker (Sdk/DTLogging/Logging/
16// SecretMasker.cs) with the value encoders HostContext registers
17// (Runner.Common/HostContext.cs:103-113, Sdk/DTLogging/Logging/ValueEncoders.cs).
18// A value is masked as is and in the forms the encoders give: base64 and its
19// two shifts, the command line, expression, JSON, URI, XML escapes, the value
20// without its double quotes and the sections around "&" of PowerShell.
21//
22// Values can be added while the job runs (::add-mask::). Masking finds every
23// occurrence of every value (overlaps included), merges the ranges that
24// overlap or touch and replaces each merged range by one ***, like
25// SecretMasker.MaskSecrets.
26//
27// Bytes that follow the secret change the last, incomplete group of its base64
28// (`echo "$T" | base64` appends a newline), so each base64 form is also
29// registered without that group: only the characters the secret alone
30// determines. actions/runner only registers the full forms; the truncated ones
31// are an addition of ermite.
32type masker struct {
33	mu sync.RWMutex
34	// originals are the values passed to add (SecretMasker.m_originalValueSecrets);
35	// values are the forms to look for: the originals and their encodings. A
36	// value that is the encoding of an earlier one is still a new original: its
37	// own encodings must be registered (AddValue tests the originals only).
38	originals map[string]struct{}
39	values    map[string]struct{}
40	list      []string
41}
42
43const maskedValue = "***"
44
45// minBase64Tail is the shortest base64 form, in characters, registered once its
46// incomplete last group is removed: a shorter one (3 bytes of the secret)
47// would mask ordinary text.
48const minBase64Tail = 8
49
50func newMasker(secrets ...string) *masker {
51	m := &masker{originals: map[string]struct{}{}, values: map[string]struct{}{}}
52	for _, s := range secrets {
53		m.add(s)
54	}
55	return m
56}
57
58// add is SecretMasker.AddValue: the value and its encodings. An empty value is
59// ignored.
60func (m *masker) add(value string) {
61	if m == nil || value == "" {
62		return
63	}
64	m.mu.RLock()
65	_, known := m.originals[value]
66	m.mu.RUnlock()
67	if known {
68		return
69	}
70	forms := append([]string{value}, secretEncodings(value)...)
71	m.mu.Lock()
72	defer m.mu.Unlock()
73	m.originals[value] = struct{}{}
74	for _, f := range forms {
75		if f == "" {
76			continue
77		}
78		if _, ok := m.values[f]; !ok {
79			m.values[f] = struct{}{}
80			m.list = append(m.list, f)
81		}
82	}
83}
84
85// utf16Len is the length of s in .NET string units.
86func utf16Len(s string) int {
87	n := 0
88	for _, r := range s {
89		if r >= 0x10000 {
90			n += 2
91		} else {
92			n++
93		}
94	}
95	return n
96}
97
98// secretEncodings are the other forms of v that the runner masks, in the order
99// of HostContext.cs:103-113.
100func secretEncodings(v string) []string {
101	var out []string
102	b := []byte(v)
103	// Base64StringEscape, ...Shift1, ...Shift2 (ValueEncoders.cs:17-52, 150-163).
104	for shift := range 3 {
105		sub := b
106		if len(b) > shift {
107			sub = b[shift:]
108		}
109		out = append(out, base64.StdEncoding.EncodeToString(sub))
110		// ermite: whole groups only, 3 bytes give 4 characters that no later
111		// byte changes.
112		if n := len(sub) / 3 * 4; n >= minBase64Tail {
113			out = append(out, base64.StdEncoding.EncodeToString(sub[:len(sub)/3*3]))
114		}
115	}
116	out = append(out,
117		strings.ReplaceAll(v, `"`, `\"`), // CommandLineArgumentEscape
118		strings.ReplaceAll(v, "'", "''"), // ExpressionStringEscape
119		jsonStringEscape(v),
120		uriDataEscape(v),
121		xmlDataEscape(v),
122		trimDoubleQuotes(v),
123		powerShellPreAmpersandEscape(v),
124		powerShellPostAmpersandEscape(v),
125	)
126	return out
127}
128
129// jsonStringEscape is ValueEncoders.JsonStringEscape: JsonConvert.ToString
130// without the quotes. Newtonsoft escapes the quote, the backslash, the control
131// characters below U+0020 and U+0085, U+2028, U+2029; it leaves the rest of
132// Unicode, "/" and "<>&'" alone.
133func jsonStringEscape(s string) string {
134	var sb strings.Builder
135	const hex = "0123456789abcdef"
136	for _, r := range s {
137		switch {
138		case r == '"':
139			sb.WriteString(`\"`)
140		case r == '\\':
141			sb.WriteString(`\\`)
142		case r == '\t':
143			sb.WriteString(`\t`)
144		case r == '\n':
145			sb.WriteString(`\n`)
146		case r == '\r':
147			sb.WriteString(`\r`)
148		case r == '\b':
149			sb.WriteString(`\b`)
150		case r == '\f':
151			sb.WriteString(`\f`)
152		case r < 0x20 || r == 0x85 || r == 0x2028 || r == 0x2029:
153			sb.WriteString(`\u`)
154			for shift := 12; shift >= 0; shift -= 4 {
155				sb.WriteByte(hex[(r>>shift)&15])
156			}
157		default:
158			sb.WriteRune(r)
159		}
160	}
161	return sb.String()
162}
163
164// uriDataEscape is ValueEncoders.UriDataEscape: Uri.EscapeDataString, which
165// leaves only the unreserved characters of RFC 3986 and writes the others as
166// the percent-encoded UTF-8 bytes in upper case. The runner cuts values of
167// more than 65519 characters into segments that do not split a surrogate
168// pair; the escape of a character does not depend on its neighbours, so the
169// result is the same.
170func uriDataEscape(s string) string {
171	const hex = "0123456789ABCDEF"
172	var sb strings.Builder
173	for i := 0; i < len(s); i++ {
174		c := s[i]
175		if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-' || c == '_' || c == '.' || c == '~' {
176			sb.WriteByte(c)
177			continue
178		}
179		sb.WriteByte('%')
180		sb.WriteByte(hex[c>>4])
181		sb.WriteByte(hex[c&15])
182	}
183	return sb.String()
184}
185
186// xmlDataEscape is SecurityElement.Escape.
187func xmlDataEscape(s string) string {
188	return strings.NewReplacer("<", "&lt;", ">", "&gt;", `"`, "&quot;", "'", "&apos;", "&", "&amp;").Replace(s)
189}
190
191// trimDoubleQuotes is ValueEncoders.TrimDoubleQuotes: the value without its
192// quotes when it has more than 8 characters and starts and ends with one.
193func trimDoubleQuotes(s string) string {
194	if utf16Len(s) > 8 && strings.HasPrefix(s, `"`) && strings.HasSuffix(s, `"`) {
195		return s[1 : len(s)-1]
196	}
197	return ""
198}
199
200// powerShellPreAmpersandEscape is ValueEncoders.PowerShellPreAmpersandEscape:
201// the part up to the last "&" (up to "&+" when there is one), when it has at
202// least 6 characters.
203func powerShellPreAmpersandEscape(s string) string {
204	if !strings.Contains(s, "&") {
205		return ""
206	}
207	var section string
208	if i := strings.Index(s, "&+"); i >= 0 {
209		section = s[:i+2]
210	} else {
211		section = s[:strings.LastIndex(s, "&")+1]
212	}
213	if utf16Len(section) >= 6 {
214		return section
215	}
216	return ""
217}
218
219// powerShellPostAmpersandEscape is ValueEncoders.PowerShellPostAmpersandEscape:
220// the part after the last "&" (after "&+" and the letter that got coloured),
221// when it has at least 6 characters.
222func powerShellPostAmpersandEscape(s string) string {
223	if !strings.Contains(s, "&") {
224		return ""
225	}
226	var section string
227	if i := strings.Index(s, "&+"); i >= 0 {
228		// Substring(i + 2 + 1): skip the letter that got coloured.
229		rest := s[i+2:]
230		if _, size := utf8.DecodeRuneInString(rest); size > 0 && len(rest) > size {
231			section = rest[size:]
232		}
233	} else {
234		section = s[strings.LastIndex(s, "&")+1:]
235	}
236	if utf16Len(section) >= 6 {
237		return section
238	}
239	return ""
240}
241
242type span struct{ start, end int }
243
244// mask returns s with every secret replaced (SecretMasker.MaskSecrets).
245func (m *masker) mask(s string) string {
246	if m == nil || s == "" {
247		return s
248	}
249	var spans []span
250	m.mu.RLock()
251	for _, v := range m.list {
252		if len(v) > len(s) || !strings.Contains(s, v) {
253			continue
254		}
255		for from := 0; from <= len(s)-len(v); {
256			i := strings.Index(s[from:], v)
257			if i < 0 {
258				break
259			}
260			spans = append(spans, span{from + i, from + i + len(v)})
261			from += i + 1
262		}
263	}
264	m.mu.RUnlock()
265	if len(spans) == 0 {
266		return s
267	}
268	sort.SliceStable(spans, func(a, b int) bool { return spans[a].start < spans[b].start })
269	var sb strings.Builder
270	pos := 0
271	cur := spans[0]
272	flush := func(sp span) {
273		sb.WriteString(s[pos:sp.start])
274		sb.WriteString(maskedValue)
275		pos = sp.end
276	}
277	for _, sp := range spans[1:] {
278		if sp.start <= cur.end {
279			cur.end = max(cur.end, sp.end)
280			continue
281		}
282		flush(cur)
283		cur = sp
284	}
285	flush(cur)
286	sb.WriteString(s[pos:])
287	return sb.String()
288}
289
290// has reports whether s contains a secret.
291func (m *masker) has(s string) bool { return m != nil && m.mask(s) != s }
292
293// maskReporter masks everything the executor reports: the log lines, the names
294// of the steps and the error of the job. The outputs are checked where they are
295// evaluated (a job output with a secret is dropped).
296type maskReporter struct {
297	j    *job
298	next Reporter
299
300	mu    sync.Mutex
301	names map[int]string // the masked name each step started with
302}
303
304// StepStarted masks the name with the secrets known when the step starts, which
305// is when the runner masks it: ActionRunner.EvaluateDisplayName masks the name
306// once, as it evaluates it (ActionRunner.cs:343-347), and nothing masks the
307// name of the timeline record afterwards. A secret that the step itself
308// registers (add-mask, the token of stop-commands) is thus not applied to its
309// own name (recorded: cmd-stop-commands, "pause-logging allowed by step env").
310func (r *maskReporter) StepStarted(n int, name string, at time.Time) {
311	name = r.j.mask(name)
312	r.mu.Lock()
313	if r.names == nil {
314		r.names = map[int]string{}
315	}
316	r.names[n] = name
317	r.mu.Unlock()
318	r.next.StepStarted(n, name, at)
319}
320
321func (r *maskReporter) Log(n int, at time.Time, line string) {
322	r.next.Log(n, at, r.j.mask(line))
323}
324
325func (r *maskReporter) StepCompleted(n int, name string, c Conclusion, at time.Time) {
326	// The results of the step (outcome, annotations, summary) go first.
327	r.j.flushStepResults(r.next, n, c)
328	// The name as reported at the start: the secrets a step registers while it
329	// runs do not apply to its own name (see StepStarted).
330	r.mu.Lock()
331	started, ok := r.names[n]
332	delete(r.names, n)
333	r.mu.Unlock()
334	if ok {
335		name = started
336	} else {
337		name = r.j.mask(name)
338	}
339	r.next.StepCompleted(n, name, c, at)
340}
341
342func (r *maskReporter) JobCompleted(res Result) {
343	res.Error = r.j.mask(res.Error)
344	r.next.JobCompleted(res)
345}

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