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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +342 -40
M internal/executor/masker.go +234 -39
| @@ -4,26 +4,36 @@ package executor | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "encoding/base64" | |
| 7 | - | "encoding/json" | |
| 8 | - | "net/url" | |
| 9 | - | "slices" | |
| 7 | + | "sort" | |
| 10 | 8 | "strings" | |
| 9 | + | "sync" | |
| 11 | 10 | "time" | |
| 11 | + | "unicode/utf8" | |
| 12 | 12 | ) | |
| 13 | 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. | |
| 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. | |
| 20 | 26 | // | |
| 21 | 27 | // Bytes that follow the secret change the last, incomplete group of its base64 | |
| 22 | 28 | // (`echo "$T" | base64` appends a newline), so each base64 form is also | |
| 23 | 29 | // 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 | + | } | |
| 27 | 37 | ||
| 28 | 38 | const maskedValue = "***" | |
| 29 | 39 | ||
| @@ -33,57 +43,242 @@ const maskedValue = "***" | |||
| 33 | 43 | const minBase64Tail = 8 | |
| 34 | 44 | ||
| 35 | 45 | 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) | |
| 42 | 75 | } | |
| 43 | 76 | } | |
| 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++ | |
| 48 | 87 | } | |
| 49 | 88 | } | |
| 50 | - | slices.SortStableFunc(vals, func(a, b string) int { return len(b) - len(a) }) | |
| 51 | - | return &masker{values: vals} | |
| 89 | + | return n | |
| 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 | 94 | func secretEncodings(v string) []string { | |
| 56 | 95 | var out []string | |
| 57 | 96 | b := []byte(v) | |
| 97 | + | // Base64StringEscape, ...Shift1, ...Shift2 (ValueEncoders.cs:17-52, 150-163). | |
| 58 | 98 | 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:] | |
| 61 | 102 | } | |
| 62 | - | sub := b[shift:] | |
| 63 | 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 | 106 | if n := len(sub) / 3 * 4; n >= minBase64Tail { | |
| 66 | 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 { | |
| 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 | + | ) | |
| 73 | 120 | 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("<", "<", ">", ">", `"`, """, "'", "'", "&", "&").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 | 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 | 260 | return s | |
| 80 | 261 | } | |
| 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 | |
| 84 | 275 | } | |
| 276 | + | flush(cur) | |
| 277 | + | cur = sp | |
| 85 | 278 | } | |
| 86 | - | return s | |
| 279 | + | flush(cur) | |
| 280 | + | sb.WriteString(s[pos:]) | |
| 281 | + | return sb.String() | |
| 87 | 282 | } | |
| 88 | 283 | ||
| 89 | 284 | // 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 executor | 14 | + | // 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 the | 15 | + | // a port of GitHub.DistributedTask.Logging.SecretMasker (Sdk/DTLogging/Logging/ |
| 16 | - | // official runner registers for it (HostContext.cs:103-113, ValueEncoders): its | 16 | + | // SecretMasker.cs) with the value encoders HostContext registers |
| 17 | - | // base64 at the three byte offsets a prefix of one or two bytes can give | 17 | + | // (Runner.Common/HostContext.cs:103-113, Sdk/DTLogging/Logging/ValueEncoders.cs). |
| 18 | - | // ("user:" + secret is base64-encoded as a whole), and its JSON and URI | 18 | + | // 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 base64 | 27 | // 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 | 28 | // (`echo "$T" | base64` appends a newline), so each base64 form is also | ||
| 23 | // registered without that group: only the characters the secret alone | 29 | // registered without that group: only the characters the secret alone | ||
| 24 | - | // determines. actions/runner (ValueEncoders.Base64StringEscapeShift) only | 30 | + | // 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 = 8 | 43 | 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 []string | 47 | + | for _, s := range secrets { |
| 38 | - | add := func(v string) { | 48 | + | m.add(s) |
| 39 | - | if v != "" && !seen[v] { | 49 | + | } |
| 40 | - | seen[v] = true | 50 | + | 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 []string | 95 | 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 | - | continue | 100 | + | 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 out | 120 | 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("<", "<", ">", ">", `"`, """, "'", "'", "&", "&").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 s | 260 | 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 s | 279 | + | 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<word>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>`), "<a href="x">'&'</a>"}, | |
| 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<word>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>`), "<a href="x">'&'</a>"}, | |||
| 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) { | |||
| 49 | 49 | } | |
| 50 | 50 | // special characters: the JSON and URL forms | |
| 51 | 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 | 53 | if got := s.mask(text); strings.Contains(got, "b") { | |
| 54 | 54 | t.Errorf("%q -> %q", text, got) | |
| 55 | 55 | } | |
| @@ -49,7 +49,7 @@ func TestMaskerForms(t *testing.T) { | |||||
| 49 | } | 49 | } | ||
| 50 | // special characters: the JSON and URL forms | 50 | // 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 | } | ||