detached b90459e

feat(executor): mask like the official runner and add masks at run time

commit
b90459e2f268b35bae5d64c9f8a0bbd3cc0c901a
author
brice · · 2026-10-13 15:06 UTC
parent
d727ef4
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +342 -40
M internal/executor/masker.go +234 -39
@@ -4,26 +4,36 @@ package executor
44
55 import (
66 "encoding/base64"
7- "encoding/json"
8- "net/url"
9- "slices"
7+ "sort"
108 "strings"
9+ "sync"
1110 "time"
11+ "unicode/utf8"
1212 )
1313
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.
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.
2026 //
2127 // Bytes that follow the secret change the last, incomplete group of its base64
2228 // (`echo "$T" | base64` appends a newline), so each base64 form is also
2329 // 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.
26-type masker struct{ values []string }
30+// determines. actions/runner only registers the full forms; the truncated ones
31+// are an addition of ermite.
32+type masker struct {
33+ mu sync.RWMutex
34+ values map[string]struct{}
35+ list []string
36+}
2737
2838 const maskedValue = "***"
2939
@@ -33,57 +43,242 @@ const maskedValue = "***"
3343 const minBase64Tail = 8
3444
3545 func 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)
46+ m := &masker{values: map[string]struct{}{}}
47+ for _, s := range secrets {
48+ m.add(s)
49+ }
50+ return m
51+}
52+
53+// add is SecretMasker.AddValue: the value and its encodings. An empty value is
54+// ignored.
55+func (m *masker) add(value string) {
56+ if m == nil || value == "" {
57+ return
58+ }
59+ m.mu.RLock()
60+ _, known := m.values[value]
61+ m.mu.RUnlock()
62+ if known {
63+ return
64+ }
65+ forms := append([]string{value}, secretEncodings(value)...)
66+ m.mu.Lock()
67+ defer m.mu.Unlock()
68+ for _, f := range forms {
69+ if f == "" {
70+ continue
71+ }
72+ if _, ok := m.values[f]; !ok {
73+ m.values[f] = struct{}{}
74+ m.list = append(m.list, f)
4275 }
4376 }
44- for _, s := range secrets {
45- add(s)
46- for _, e := range secretEncodings(s) {
47- add(e)
77+}
78+
79+// utf16Len is the length of s in .NET string units.
80+func utf16Len(s string) int {
81+ n := 0
82+ for _, r := range s {
83+ if r >= 0x10000 {
84+ n += 2
85+ } else {
86+ n++
4887 }
4988 }
50- slices.SortStableFunc(vals, func(a, b string) int { return len(b) - len(a) })
51- return &masker{values: vals}
89+ return n
5290 }
5391
54-// secretEncodings are the other forms of v that the runner masks.
92+// secretEncodings are the other forms of v that the runner masks, in the order
93+// of HostContext.cs:103-113.
5594 func secretEncodings(v string) []string {
5695 var out []string
5796 b := []byte(v)
97+ // Base64StringEscape, ...Shift1, ...Shift2 (ValueEncoders.cs:17-52, 150-163).
5898 for shift := range 3 {
59- if len(b) <= shift && shift != 0 {
60- continue
99+ sub := b
100+ if len(b) > shift {
101+ sub = b[shift:]
61102 }
62- sub := b[shift:]
63103 out = append(out, base64.StdEncoding.EncodeToString(sub))
64- // Whole groups only: 3 bytes give 4 characters that no later byte changes.
104+ // ermite: whole groups only, 3 bytes give 4 characters that no later
105+ // byte changes.
65106 if n := len(sub) / 3 * 4; n >= minBase64Tail {
66107 out = append(out, base64.StdEncoding.EncodeToString(sub[:len(sub)/3*3]))
67108 }
68109 }
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))
110+ out = append(out,
111+ strings.ReplaceAll(v, `"`, `\"`), // CommandLineArgumentEscape
112+ strings.ReplaceAll(v, "'", "''"), // ExpressionStringEscape
113+ jsonStringEscape(v),
114+ uriDataEscape(v),
115+ xmlDataEscape(v),
116+ trimDoubleQuotes(v),
117+ powerShellPreAmpersandEscape(v),
118+ powerShellPostAmpersandEscape(v),
119+ )
73120 return out
74121 }
75122
76-// mask returns s with every secret replaced.
123+// jsonStringEscape is ValueEncoders.JsonStringEscape: JsonConvert.ToString
124+// without the quotes. Newtonsoft escapes the quote, the backslash, the control
125+// characters below U+0020 and U+0085, U+2028, U+2029; it leaves the rest of
126+// Unicode, "/" and "<>&'" alone.
127+func jsonStringEscape(s string) string {
128+ var sb strings.Builder
129+ const hex = "0123456789abcdef"
130+ for _, r := range s {
131+ switch {
132+ case r == '"':
133+ sb.WriteString(`\"`)
134+ case r == '\\':
135+ sb.WriteString(`\\`)
136+ case r == '\t':
137+ sb.WriteString(`\t`)
138+ case r == '\n':
139+ sb.WriteString(`\n`)
140+ case r == '\r':
141+ sb.WriteString(`\r`)
142+ case r == '\b':
143+ sb.WriteString(`\b`)
144+ case r == '\f':
145+ sb.WriteString(`\f`)
146+ case r < 0x20 || r == 0x85 || r == 0x2028 || r == 0x2029:
147+ sb.WriteString(`\u`)
148+ for shift := 12; shift >= 0; shift -= 4 {
149+ sb.WriteByte(hex[(r>>shift)&15])
150+ }
151+ default:
152+ sb.WriteRune(r)
153+ }
154+ }
155+ return sb.String()
156+}
157+
158+// uriDataEscape is ValueEncoders.UriDataEscape: Uri.EscapeDataString, which
159+// leaves only the unreserved characters of RFC 3986 and writes the others as
160+// the percent-encoded UTF-8 bytes in upper case. The runner cuts values of
161+// more than 65519 characters into segments that do not split a surrogate
162+// pair; the escape of a character does not depend on its neighbours, so the
163+// result is the same.
164+func uriDataEscape(s string) string {
165+ const hex = "0123456789ABCDEF"
166+ var sb strings.Builder
167+ for i := 0; i < len(s); i++ {
168+ c := s[i]
169+ if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-' || c == '_' || c == '.' || c == '~' {
170+ sb.WriteByte(c)
171+ continue
172+ }
173+ sb.WriteByte('%')
174+ sb.WriteByte(hex[c>>4])
175+ sb.WriteByte(hex[c&15])
176+ }
177+ return sb.String()
178+}
179+
180+// xmlDataEscape is SecurityElement.Escape.
181+func xmlDataEscape(s string) string {
182+ return strings.NewReplacer("<", "&lt;", ">", "&gt;", `"`, "&quot;", "'", "&apos;", "&", "&amp;").Replace(s)
183+}
184+
185+// trimDoubleQuotes is ValueEncoders.TrimDoubleQuotes: the value without its
186+// quotes when it has more than 8 characters and starts and ends with one.
187+func trimDoubleQuotes(s string) string {
188+ if utf16Len(s) > 8 && strings.HasPrefix(s, `"`) && strings.HasSuffix(s, `"`) {
189+ return s[1 : len(s)-1]
190+ }
191+ return ""
192+}
193+
194+// powerShellPreAmpersandEscape is ValueEncoders.PowerShellPreAmpersandEscape:
195+// the part up to the last "&" (up to "&+" when there is one), when it has at
196+// least 6 characters.
197+func powerShellPreAmpersandEscape(s string) string {
198+ if !strings.Contains(s, "&") {
199+ return ""
200+ }
201+ var section string
202+ if i := strings.Index(s, "&+"); i >= 0 {
203+ section = s[:i+2]
204+ } else {
205+ section = s[:strings.LastIndex(s, "&")+1]
206+ }
207+ if utf16Len(section) >= 6 {
208+ return section
209+ }
210+ return ""
211+}
212+
213+// powerShellPostAmpersandEscape is ValueEncoders.PowerShellPostAmpersandEscape:
214+// the part after the last "&" (after "&+" and the letter that got coloured),
215+// when it has at least 6 characters.
216+func powerShellPostAmpersandEscape(s string) string {
217+ if !strings.Contains(s, "&") {
218+ return ""
219+ }
220+ var section string
221+ if i := strings.Index(s, "&+"); i >= 0 {
222+ // Substring(i + 2 + 1): skip the letter that got coloured.
223+ rest := s[i+2:]
224+ if _, size := utf8.DecodeRuneInString(rest); size > 0 && len(rest) > size {
225+ section = rest[size:]
226+ }
227+ } else {
228+ section = s[strings.LastIndex(s, "&")+1:]
229+ }
230+ if utf16Len(section) >= 6 {
231+ return section
232+ }
233+ return ""
234+}
235+
236+type span struct{ start, end int }
237+
238+// mask returns s with every secret replaced (SecretMasker.MaskSecrets).
77239 func (m *masker) mask(s string) string {
78- if m == nil {
240+ if m == nil || s == "" {
241+ return s
242+ }
243+ var spans []span
244+ m.mu.RLock()
245+ for _, v := range m.list {
246+ if len(v) > len(s) || !strings.Contains(s, v) {
247+ continue
248+ }
249+ for from := 0; from <= len(s)-len(v); {
250+ i := strings.Index(s[from:], v)
251+ if i < 0 {
252+ break
253+ }
254+ spans = append(spans, span{from + i, from + i + len(v)})
255+ from += i + 1
256+ }
257+ }
258+ m.mu.RUnlock()
259+ if len(spans) == 0 {
79260 return s
80261 }
81- for _, v := range m.values {
82- if strings.Contains(s, v) {
83- s = strings.ReplaceAll(s, v, maskedValue)
262+ sort.SliceStable(spans, func(a, b int) bool { return spans[a].start < spans[b].start })
263+ var sb strings.Builder
264+ pos := 0
265+ cur := spans[0]
266+ flush := func(sp span) {
267+ sb.WriteString(s[pos:sp.start])
268+ sb.WriteString(maskedValue)
269+ pos = sp.end
270+ }
271+ for _, sp := range spans[1:] {
272+ if sp.start <= cur.end {
273+ cur.end = max(cur.end, sp.end)
274+ continue
84275 }
276+ flush(cur)
277+ cur = sp
85278 }
86- return s
279+ flush(cur)
280+ sb.WriteString(s[pos:])
281+ return sb.String()
87282 }
88283
89284 // has reports whether s contains a secret.
@@ -4,26 +4,36 @@ package executor
4 4
5 import (5 import (
6 "encoding/base64"6 "encoding/base64"
7- "encoding/json"7+ "sort"
8- "net/url"
9- "slices"
10 "strings"8 "strings"
9+ "sync"
11 "time"10 "time"
11+ "unicode/utf8"
12 )12 )
13 13
14-// masker replaces secret values by *** in text that leaves the executor14+// masker replaces secret values by *** in text that leaves the executor. It is
15-// (HostContext.SecretMasker). A value is masked as is and in the forms the15+// a port of GitHub.DistributedTask.Logging.SecretMasker (Sdk/DTLogging/Logging/
16-// official runner registers for it (HostContext.cs:103-113, ValueEncoders): its16+// SecretMasker.cs) with the value encoders HostContext registers
17-// base64 at the three byte offsets a prefix of one or two bytes can give17+// (Runner.Common/HostContext.cs:103-113, Sdk/DTLogging/Logging/ValueEncoders.cs).
18-// ("user:" + secret is base64-encoded as a whole), and its JSON and URI18+// A value is masked as is and in the forms the encoders give: base64 and its
19-// escapes. Longer forms are replaced first.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.
20 //26 //
21 // Bytes that follow the secret change the last, incomplete group of its base6427 // 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 also28 // (`echo "$T" | base64` appends a newline), so each base64 form is also
23 // registered without that group: only the characters the secret alone29 // registered without that group: only the characters the secret alone
24-// determines. actions/runner (ValueEncoders.Base64StringEscapeShift) only30+// determines. actions/runner only registers the full forms; the truncated ones
25-// registers the full forms; the truncated ones are an addition.31+// are an addition of ermite.
26-type masker struct{ values []string }32+type masker struct {
33+ mu sync.RWMutex
34+ values map[string]struct{}
35+ list []string
36+}
27 37
28 const maskedValue = "***"38 const maskedValue = "***"
29 39
@@ -33,57 +43,242 @@ const maskedValue = "***"
33 const minBase64Tail = 843 const minBase64Tail = 8
34 44
35 func newMasker(secrets ...string) *masker {45 func newMasker(secrets ...string) *masker {
36- seen := map[string]bool{}46+ m := &masker{values: map[string]struct{}{}}
37- var vals []string47+ for _, s := range secrets {
38- add := func(v string) {48+ m.add(s)
39- if v != "" && !seen[v] {49+ }
40- seen[v] = true50+ return m
41- vals = append(vals, v)51+}
52+
53+// add is SecretMasker.AddValue: the value and its encodings. An empty value is
54+// ignored.
55+func (m *masker) add(value string) {
56+ if m == nil || value == "" {
57+ return
58+ }
59+ m.mu.RLock()
60+ _, known := m.values[value]
61+ m.mu.RUnlock()
62+ if known {
63+ return
64+ }
65+ forms := append([]string{value}, secretEncodings(value)...)
66+ m.mu.Lock()
67+ defer m.mu.Unlock()
68+ for _, f := range forms {
69+ if f == "" {
70+ continue
71+ }
72+ if _, ok := m.values[f]; !ok {
73+ m.values[f] = struct{}{}
74+ m.list = append(m.list, f)
42 }75 }
43 }76 }
44- for _, s := range secrets {77+}
45- add(s)78+
46- for _, e := range secretEncodings(s) {79+// utf16Len is the length of s in .NET string units.
47- add(e)80+func utf16Len(s string) int {
81+ n := 0
82+ for _, r := range s {
83+ if r >= 0x10000 {
84+ n += 2
85+ } else {
86+ n++
48 }87 }
49 }88 }
50- slices.SortStableFunc(vals, func(a, b string) int { return len(b) - len(a) })89+ return n
51- return &masker{values: vals}
52 }90 }
53 91
54-// secretEncodings are the other forms of v that the runner masks.92+// secretEncodings are the other forms of v that the runner masks, in the order
93+// of HostContext.cs:103-113.
55 func secretEncodings(v string) []string {94 func secretEncodings(v string) []string {
56 var out []string95 var out []string
57 b := []byte(v)96 b := []byte(v)
97+ // Base64StringEscape, ...Shift1, ...Shift2 (ValueEncoders.cs:17-52, 150-163).
58 for shift := range 3 {98 for shift := range 3 {
59- if len(b) <= shift && shift != 0 {99+ sub := b
60- continue100+ if len(b) > shift {
101+ sub = b[shift:]
61 }102 }
62- sub := b[shift:]
63 out = append(out, base64.StdEncoding.EncodeToString(sub))103 out = append(out, base64.StdEncoding.EncodeToString(sub))
64- // Whole groups only: 3 bytes give 4 characters that no later byte changes.104+ // ermite: whole groups only, 3 bytes give 4 characters that no later
105+ // byte changes.
65 if n := len(sub) / 3 * 4; n >= minBase64Tail {106 if n := len(sub) / 3 * 4; n >= minBase64Tail {
66 out = append(out, base64.StdEncoding.EncodeToString(sub[:len(sub)/3*3]))107 out = append(out, base64.StdEncoding.EncodeToString(sub[:len(sub)/3*3]))
67 }108 }
68 }109 }
69- if j, err := json.Marshal(v); err == nil && len(j) > 2 {110+ out = append(out,
70- out = append(out, string(j[1:len(j)-1]))111+ strings.ReplaceAll(v, `"`, `\"`), // CommandLineArgumentEscape
71- }112+ strings.ReplaceAll(v, "'", "''"), // ExpressionStringEscape
72- out = append(out, url.QueryEscape(v), url.PathEscape(v))113+ jsonStringEscape(v),
114+ uriDataEscape(v),
115+ xmlDataEscape(v),
116+ trimDoubleQuotes(v),
117+ powerShellPreAmpersandEscape(v),
118+ powerShellPostAmpersandEscape(v),
119+ )
73 return out120 return out
74 }121 }
75 122
76-// mask returns s with every secret replaced.123+// jsonStringEscape is ValueEncoders.JsonStringEscape: JsonConvert.ToString
124+// without the quotes. Newtonsoft escapes the quote, the backslash, the control
125+// characters below U+0020 and U+0085, U+2028, U+2029; it leaves the rest of
126+// Unicode, "/" and "<>&'" alone.
127+func jsonStringEscape(s string) string {
128+ var sb strings.Builder
129+ const hex = "0123456789abcdef"
130+ for _, r := range s {
131+ switch {
132+ case r == '"':
133+ sb.WriteString(`\"`)
134+ case r == '\\':
135+ sb.WriteString(`\\`)
136+ case r == '\t':
137+ sb.WriteString(`\t`)
138+ case r == '\n':
139+ sb.WriteString(`\n`)
140+ case r == '\r':
141+ sb.WriteString(`\r`)
142+ case r == '\b':
143+ sb.WriteString(`\b`)
144+ case r == '\f':
145+ sb.WriteString(`\f`)
146+ case r < 0x20 || r == 0x85 || r == 0x2028 || r == 0x2029:
147+ sb.WriteString(`\u`)
148+ for shift := 12; shift >= 0; shift -= 4 {
149+ sb.WriteByte(hex[(r>>shift)&15])
150+ }
151+ default:
152+ sb.WriteRune(r)
153+ }
154+ }
155+ return sb.String()
156+}
157+
158+// uriDataEscape is ValueEncoders.UriDataEscape: Uri.EscapeDataString, which
159+// leaves only the unreserved characters of RFC 3986 and writes the others as
160+// the percent-encoded UTF-8 bytes in upper case. The runner cuts values of
161+// more than 65519 characters into segments that do not split a surrogate
162+// pair; the escape of a character does not depend on its neighbours, so the
163+// result is the same.
164+func uriDataEscape(s string) string {
165+ const hex = "0123456789ABCDEF"
166+ var sb strings.Builder
167+ for i := 0; i < len(s); i++ {
168+ c := s[i]
169+ if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-' || c == '_' || c == '.' || c == '~' {
170+ sb.WriteByte(c)
171+ continue
172+ }
173+ sb.WriteByte('%')
174+ sb.WriteByte(hex[c>>4])
175+ sb.WriteByte(hex[c&15])
176+ }
177+ return sb.String()
178+}
179+
180+// xmlDataEscape is SecurityElement.Escape.
181+func xmlDataEscape(s string) string {
182+ return strings.NewReplacer("<", "&lt;", ">", "&gt;", `"`, "&quot;", "'", "&apos;", "&", "&amp;").Replace(s)
183+}
184+
185+// trimDoubleQuotes is ValueEncoders.TrimDoubleQuotes: the value without its
186+// quotes when it has more than 8 characters and starts and ends with one.
187+func trimDoubleQuotes(s string) string {
188+ if utf16Len(s) > 8 && strings.HasPrefix(s, `"`) && strings.HasSuffix(s, `"`) {
189+ return s[1 : len(s)-1]
190+ }
191+ return ""
192+}
193+
194+// powerShellPreAmpersandEscape is ValueEncoders.PowerShellPreAmpersandEscape:
195+// the part up to the last "&" (up to "&+" when there is one), when it has at
196+// least 6 characters.
197+func powerShellPreAmpersandEscape(s string) string {
198+ if !strings.Contains(s, "&") {
199+ return ""
200+ }
201+ var section string
202+ if i := strings.Index(s, "&+"); i >= 0 {
203+ section = s[:i+2]
204+ } else {
205+ section = s[:strings.LastIndex(s, "&")+1]
206+ }
207+ if utf16Len(section) >= 6 {
208+ return section
209+ }
210+ return ""
211+}
212+
213+// powerShellPostAmpersandEscape is ValueEncoders.PowerShellPostAmpersandEscape:
214+// the part after the last "&" (after "&+" and the letter that got coloured),
215+// when it has at least 6 characters.
216+func powerShellPostAmpersandEscape(s string) string {
217+ if !strings.Contains(s, "&") {
218+ return ""
219+ }
220+ var section string
221+ if i := strings.Index(s, "&+"); i >= 0 {
222+ // Substring(i + 2 + 1): skip the letter that got coloured.
223+ rest := s[i+2:]
224+ if _, size := utf8.DecodeRuneInString(rest); size > 0 && len(rest) > size {
225+ section = rest[size:]
226+ }
227+ } else {
228+ section = s[strings.LastIndex(s, "&")+1:]
229+ }
230+ if utf16Len(section) >= 6 {
231+ return section
232+ }
233+ return ""
234+}
235+
236+type span struct{ start, end int }
237+
238+// mask returns s with every secret replaced (SecretMasker.MaskSecrets).
77 func (m *masker) mask(s string) string {239 func (m *masker) mask(s string) string {
78- if m == nil {240+ if m == nil || s == "" {
241+ return s
242+ }
243+ var spans []span
244+ m.mu.RLock()
245+ for _, v := range m.list {
246+ if len(v) > len(s) || !strings.Contains(s, v) {
247+ continue
248+ }
249+ for from := 0; from <= len(s)-len(v); {
250+ i := strings.Index(s[from:], v)
251+ if i < 0 {
252+ break
253+ }
254+ spans = append(spans, span{from + i, from + i + len(v)})
255+ from += i + 1
256+ }
257+ }
258+ m.mu.RUnlock()
259+ if len(spans) == 0 {
79 return s260 return s
80 }261 }
81- for _, v := range m.values {262+ sort.SliceStable(spans, func(a, b int) bool { return spans[a].start < spans[b].start })
82- if strings.Contains(s, v) {263+ var sb strings.Builder
83- s = strings.ReplaceAll(s, v, maskedValue)264+ pos := 0
265+ cur := spans[0]
266+ flush := func(sp span) {
267+ sb.WriteString(s[pos:sp.start])
268+ sb.WriteString(maskedValue)
269+ pos = sp.end
270+ }
271+ for _, sp := range spans[1:] {
272+ if sp.start <= cur.end {
273+ cur.end = max(cur.end, sp.end)
274+ continue
84 }275 }
276+ flush(cur)
277+ cur = sp
85 }278 }
86- return s279+ flush(cur)
280+ sb.WriteString(s[pos:])
281+ return sb.String()
87 }282 }
88 283
89 // has reports whether s contains a secret.284 // has reports whether s contains a secret.
A internal/executor/masker_parity_test.go +107 -0
@@ -0,0 +1,107 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "encoding/base64"
7+ "strings"
8+ "testing"
9+)
10+
11+// Cases ported from actions/runner src/Test/L0/HostContextL0.cs
12+// (DefaultSecretMaskers, SecretSectionMasking), MIT, (c) 2019 GitHub; see
13+// THIRD_PARTY.md.
14+func TestDefaultSecretMaskers(t *testing.T) {
15+ m := newMasker()
16+ for _, v := range []string{"Password123!", "Pass\"word\"123!", "Pass word 123!", "Pass<word>123!", "Pass'word'123!", "\"Password123!!\"", "\"short\""} {
17+ m.add(v)
18+ }
19+ b64 := func(s string) string { return base64.StdEncoding.EncodeToString([]byte(s)) }
20+ for _, tc := range []struct{ in, want string }{
21+ {"123Password123!123", "123***123"},
22+ {"password123", "password123"},
23+ {`123Pass\"word\"123!123`, "123***123"},
24+ {"123Pass%20word%20123%21123", "123***123"},
25+ {"123Pass&lt;word&gt;123!123", "123***123"},
26+ {"123Pass''word''123!123", "123***123"},
27+ {b64(":Password123!"), "OlBh***"},
28+ {b64("a:Password123!"), "YTpQ***"},
29+ {b64("ab:Password123!"), "YWI6***"},
30+ {b64("abc:Password123!"), "YWJjOlBh***"},
31+ {b64("abcd:Password123!"), "YWJjZDpQ***"},
32+ {b64("abcde:Password123!"), "YWJjZGU6***"},
33+ {"123Password123!!123", "123***123"},
34+ {"123short123", "123short123"},
35+ {"123\"short\"123", "123***123"},
36+ } {
37+ if got := m.mask(tc.in); got != tc.want {
38+ t.Errorf("mask(%q) = %q, want %q", tc.in, got, tc.want)
39+ }
40+ }
41+}
42+
43+func TestSecretSectionMasking(t *testing.T) {
44+ for _, tc := range []struct{ secret, raw, want string }{
45+ {"secret&secret&secret", "secret&secret&\x1b[96msecret\x1b[0m", "***\x1b[96m***\x1b[0m"},
46+ {"secret&secret+secret", "secret&\x1b[96msecret+secret\x1b[0m", "***\x1b[96m***\x1b[0m"},
47+ {"secret+secret&secret", "secret+secret&\x1b[96msecret\x1b[0m", "***\x1b[96m***\x1b[0m"},
48+ {"secret&secret&+secretsecret", "secret&secret&+\x1b[96ms\x1b[0mecretsecret", "***\x1b[96ms\x1b[0m***"},
49+ {"secret&+secret&secret", "secret&+\x1b[96ms\x1b[0mecret&secret", "***\x1b[96ms\x1b[0m***"},
50+ {"secret&+secret&+secret", "secret&+\x1b[96ms\x1b[0mecret&+secret", "***\x1b[96ms\x1b[0m***"},
51+ {"secret&+secret&secret&+secret", "secret&+\x1b[96ms\x1b[0mecret&secret&+secret", "***\x1b[96ms\x1b[0m***"},
52+ {"secret&secret&+", "secret&secret&+\x1b[96m\x1b[0m", "***\x1b[96m\x1b[0m"},
53+ } {
54+ m := newMasker(tc.secret)
55+ if got := m.mask(tc.raw); got != tc.want {
56+ t.Errorf("secret %q: mask(%q) = %q, want %q", tc.secret, tc.raw, got, tc.want)
57+ }
58+ }
59+}
60+
61+// Encoders of ValueEncoders.cs, one by one.
62+func TestValueEncoders(t *testing.T) {
63+ for _, tc := range []struct{ name, got, want string }{
64+ {"cmdline", strings.ReplaceAll(`a"b`, `"`, `\"`), `a\"b`},
65+ {"json", jsonStringEscape("a\"b\\c\n\t\x01/é\u2028"), `a\"b\\c\n\t\u0001/é\u2028`},
66+ {"uri", uriDataEscape("a b/c~d-é"), "a%20b%2Fc~d-%C3%A9"},
67+ {"xml", xmlDataEscape(`<a href="x">'&'</a>`), "&lt;a href=&quot;x&quot;&gt;&apos;&amp;&apos;&lt;/a&gt;"},
68+ {"trim 9 chars", trimDoubleQuotes(`"12345678"`), "12345678"},
69+ {"trim 8 chars", trimDoubleQuotes(`"123456"`), ""},
70+ {"trim no quote", trimDoubleQuotes(`12345678901`), ""},
71+ {"pre", powerShellPreAmpersandEscape("secret&secret&secret"), "secret&secret&"},
72+ {"pre plus", powerShellPreAmpersandEscape("secret&+secret&secret"), "secret&+"},
73+ {"pre short", powerShellPreAmpersandEscape("ab&c"), ""},
74+ {"pre no ampersand", powerShellPreAmpersandEscape("secretsecret"), ""},
75+ {"post", powerShellPostAmpersandEscape("secret&secret&secret"), "secret"},
76+ {"post plus", powerShellPostAmpersandEscape("secret&+Xsecret&secret"), "secret&secret"},
77+ {"post plus, nothing after", powerShellPostAmpersandEscape("secret&+X"), ""},
78+ {"post short", powerShellPostAmpersandEscape("secret&abc"), ""},
79+ } {
80+ if tc.got != tc.want {
81+ t.Errorf("%s: %q, want %q", tc.name, tc.got, tc.want)
82+ }
83+ }
84+}
85+
86+// Adding a value after the first masks makes later text masked, empty values
87+// are ignored, overlapping matches merge into one ***.
88+func TestMaskerAddAndMerge(t *testing.T) {
89+ m := newMasker()
90+ m.add("")
91+ if m.mask("abc") != "abc" {
92+ t.Fatal("empty value masks")
93+ }
94+ m.add("abcd")
95+ m.add("cdef")
96+ if got := m.mask("xabcdefy abcd"); got != "x***y ***" {
97+ t.Errorf("%q", got)
98+ }
99+ if got := m.mask(""); got != "" {
100+ t.Error(got)
101+ }
102+ // the same value again changes nothing
103+ m.add("abcd")
104+ if got := m.mask("abcd"); got != "***" {
105+ t.Error(got)
106+ }
107+}
@@ -0,0 +1,107 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "encoding/base64"
7+ "strings"
8+ "testing"
9+)
10+
11+// Cases ported from actions/runner src/Test/L0/HostContextL0.cs
12+// (DefaultSecretMaskers, SecretSectionMasking), MIT, (c) 2019 GitHub; see
13+// THIRD_PARTY.md.
14+func TestDefaultSecretMaskers(t *testing.T) {
15+ m := newMasker()
16+ for _, v := range []string{"Password123!", "Pass\"word\"123!", "Pass word 123!", "Pass<word>123!", "Pass'word'123!", "\"Password123!!\"", "\"short\""} {
17+ m.add(v)
18+ }
19+ b64 := func(s string) string { return base64.StdEncoding.EncodeToString([]byte(s)) }
20+ for _, tc := range []struct{ in, want string }{
21+ {"123Password123!123", "123***123"},
22+ {"password123", "password123"},
23+ {`123Pass\"word\"123!123`, "123***123"},
24+ {"123Pass%20word%20123%21123", "123***123"},
25+ {"123Pass&lt;word&gt;123!123", "123***123"},
26+ {"123Pass''word''123!123", "123***123"},
27+ {b64(":Password123!"), "OlBh***"},
28+ {b64("a:Password123!"), "YTpQ***"},
29+ {b64("ab:Password123!"), "YWI6***"},
30+ {b64("abc:Password123!"), "YWJjOlBh***"},
31+ {b64("abcd:Password123!"), "YWJjZDpQ***"},
32+ {b64("abcde:Password123!"), "YWJjZGU6***"},
33+ {"123Password123!!123", "123***123"},
34+ {"123short123", "123short123"},
35+ {"123\"short\"123", "123***123"},
36+ } {
37+ if got := m.mask(tc.in); got != tc.want {
38+ t.Errorf("mask(%q) = %q, want %q", tc.in, got, tc.want)
39+ }
40+ }
41+}
42+
43+func TestSecretSectionMasking(t *testing.T) {
44+ for _, tc := range []struct{ secret, raw, want string }{
45+ {"secret&secret&secret", "secret&secret&\x1b[96msecret\x1b[0m", "***\x1b[96m***\x1b[0m"},
46+ {"secret&secret+secret", "secret&\x1b[96msecret+secret\x1b[0m", "***\x1b[96m***\x1b[0m"},
47+ {"secret+secret&secret", "secret+secret&\x1b[96msecret\x1b[0m", "***\x1b[96m***\x1b[0m"},
48+ {"secret&secret&+secretsecret", "secret&secret&+\x1b[96ms\x1b[0mecretsecret", "***\x1b[96ms\x1b[0m***"},
49+ {"secret&+secret&secret", "secret&+\x1b[96ms\x1b[0mecret&secret", "***\x1b[96ms\x1b[0m***"},
50+ {"secret&+secret&+secret", "secret&+\x1b[96ms\x1b[0mecret&+secret", "***\x1b[96ms\x1b[0m***"},
51+ {"secret&+secret&secret&+secret", "secret&+\x1b[96ms\x1b[0mecret&secret&+secret", "***\x1b[96ms\x1b[0m***"},
52+ {"secret&secret&+", "secret&secret&+\x1b[96m\x1b[0m", "***\x1b[96m\x1b[0m"},
53+ } {
54+ m := newMasker(tc.secret)
55+ if got := m.mask(tc.raw); got != tc.want {
56+ t.Errorf("secret %q: mask(%q) = %q, want %q", tc.secret, tc.raw, got, tc.want)
57+ }
58+ }
59+}
60+
61+// Encoders of ValueEncoders.cs, one by one.
62+func TestValueEncoders(t *testing.T) {
63+ for _, tc := range []struct{ name, got, want string }{
64+ {"cmdline", strings.ReplaceAll(`a"b`, `"`, `\"`), `a\"b`},
65+ {"json", jsonStringEscape("a\"b\\c\n\t\x01/é\u2028"), `a\"b\\c\n\t\u0001/é\u2028`},
66+ {"uri", uriDataEscape("a b/c~d-é"), "a%20b%2Fc~d-%C3%A9"},
67+ {"xml", xmlDataEscape(`<a href="x">'&'</a>`), "&lt;a href=&quot;x&quot;&gt;&apos;&amp;&apos;&lt;/a&gt;"},
68+ {"trim 9 chars", trimDoubleQuotes(`"12345678"`), "12345678"},
69+ {"trim 8 chars", trimDoubleQuotes(`"123456"`), ""},
70+ {"trim no quote", trimDoubleQuotes(`12345678901`), ""},
71+ {"pre", powerShellPreAmpersandEscape("secret&secret&secret"), "secret&secret&"},
72+ {"pre plus", powerShellPreAmpersandEscape("secret&+secret&secret"), "secret&+"},
73+ {"pre short", powerShellPreAmpersandEscape("ab&c"), ""},
74+ {"pre no ampersand", powerShellPreAmpersandEscape("secretsecret"), ""},
75+ {"post", powerShellPostAmpersandEscape("secret&secret&secret"), "secret"},
76+ {"post plus", powerShellPostAmpersandEscape("secret&+Xsecret&secret"), "secret&secret"},
77+ {"post plus, nothing after", powerShellPostAmpersandEscape("secret&+X"), ""},
78+ {"post short", powerShellPostAmpersandEscape("secret&abc"), ""},
79+ } {
80+ if tc.got != tc.want {
81+ t.Errorf("%s: %q, want %q", tc.name, tc.got, tc.want)
82+ }
83+ }
84+}
85+
86+// Adding a value after the first masks makes later text masked, empty values
87+// are ignored, overlapping matches merge into one ***.
88+func TestMaskerAddAndMerge(t *testing.T) {
89+ m := newMasker()
90+ m.add("")
91+ if m.mask("abc") != "abc" {
92+ t.Fatal("empty value masks")
93+ }
94+ m.add("abcd")
95+ m.add("cdef")
96+ if got := m.mask("xabcdefy abcd"); got != "x***y ***" {
97+ t.Errorf("%q", got)
98+ }
99+ if got := m.mask(""); got != "" {
100+ t.Error(got)
101+ }
102+ // the same value again changes nothing
103+ m.add("abcd")
104+ if got := m.mask("abcd"); got != "***" {
105+ t.Error(got)
106+ }
107+}
M internal/executor/masker_test.go +1 -1
@@ -49,7 +49,7 @@ func TestMaskerForms(t *testing.T) {
4949 }
5050 // special characters: the JSON and URL forms
5151 s := newMasker(`a"b/c d`)
52- for _, text := range []string{`a"b/c d`, `a\"b/c d`, `a%22b%2Fc+d`, `a%22b%2Fc%20d`} {
52+ for _, text := range []string{`a"b/c d`, `a\"b/c d`, `a%22b%2Fc%20d`} {
5353 if got := s.mask(text); strings.Contains(got, "b") {
5454 t.Errorf("%q -> %q", text, got)
5555 }
@@ -49,7 +49,7 @@ func TestMaskerForms(t *testing.T) {
49 }49 }
50 // special characters: the JSON and URL forms50 // special characters: the JSON and URL forms
51 s := newMasker(`a"b/c d`)51 s := newMasker(`a"b/c d`)
52- for _, text := range []string{`a"b/c d`, `a\"b/c d`, `a%22b%2Fc+d`, `a%22b%2Fc%20d`} {52+ for _, text := range []string{`a"b/c d`, `a\"b/c d`, `a%22b%2Fc%20d`} {
53 if got := s.mask(text); strings.Contains(got, "b") {53 if got := s.mask(text); strings.Contains(got, "b") {
54 t.Errorf("%q -> %q", text, got)54 t.Errorf("%q -> %q", text, got)
55 }55 }

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