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("<", "<", ">", ">", `"`, """, "'", "'", "&", "&").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}