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