fix(executor): mask the token in step names, the job error and job outputs
The masker was applied to log lines only. Every report now goes through it: the names of the steps (StepStarted and StepCompleted) and Result.Error, so nothing that reaches ermite holds the token. A job output whose value holds a secret is dropped with GitHub's warning (JobExtension.cs:819-824), "Skip output '<name>' since it may contain secret.". The message of an issue is masked before it is cut at 4096 bytes, so a token split by the cut cannot leave its start in the line.
- commit
- f355cc5a96d6d3dd5db1a8166ddeea5a9e858539
- author
- brice · · 2026-10-13 04:07 UTC
- parent
- 5a16c3d
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +222 -10
M internal/executor/contexts.go +3 -3
| @@ -490,9 +490,9 @@ func (s *State) secrets() *expr.Dictionary { | |||
| 490 | 490 | return d | |
| 491 | 491 | } | |
| 492 | 492 | ||
| 493 | - | // Secrets returns the values that must never appear in the job log: the | |
| 494 | - | // token and the base64 of "x-access-token:<token>" the runner also masks | |
| 495 | - | // (ExecutionContext.cs:1026-1028). | |
| 493 | + | // Secrets returns the values that must never leave the job: the token and the | |
| 494 | + | // base64 of "x-access-token:<token>" (ExecutionContext.cs:1026-1028); newMasker | |
| 495 | + | // adds their other encodings. | |
| 496 | 496 | func (s *State) Secrets() []string { | |
| 497 | 497 | t := s.GitHub("token") | |
| 498 | 498 | if t == "" { | |
| @@ -490,9 +490,9 @@ func (s *State) secrets() *expr.Dictionary { | |||||
| 490 | return d | 490 | return d | ||
| 491 | } | 491 | } | ||
| 492 | 492 | ||||
| 493 | - | // Secrets returns the values that must never appear in the job log: the | 493 | + | // Secrets returns the values that must never leave the job: the token and the |
| 494 | - | // token and the base64 of "x-access-token:<token>" the runner also masks | 494 | + | // base64 of "x-access-token:<token>" (ExecutionContext.cs:1026-1028); newMasker |
| 495 | - | // (ExecutionContext.cs:1026-1028). | 495 | + | // adds their other encodings. |
| 496 | func (s *State) Secrets() []string { | 496 | func (s *State) Secrets() []string { | ||
| 497 | t := s.GitHub("token") | 497 | t := s.GitHub("token") | ||
| 498 | if t == "" { | 498 | if t == "" { | ||
M internal/executor/job.go +16 -7
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | "path/filepath" | |
| 11 | 11 | "strings" | |
| 12 | 12 | "sync" | |
| 13 | + | "sync/atomic" | |
| 13 | 14 | "syscall" | |
| 14 | 15 | "time" | |
| 15 | 16 | ||
| @@ -144,10 +145,13 @@ type job struct { | |||
| 144 | 145 | baseEnv []string | |
| 145 | 146 | lookupPath string | |
| 146 | 147 | before map[string]bool | |
| 147 | - | // masks are the secret values replaced by *** in every log line. | |
| 148 | - | masks []string | |
| 148 | + | // masker hides the secrets in everything reported; it is set once the | |
| 149 | + | // contexts are built, before any step runs. | |
| 150 | + | masker atomic.Pointer[masker] | |
| 149 | 151 | } | |
| 150 | 152 | ||
| 153 | + | func (j *job) mask(s string) string { return j.masker.Load().mask(s) } | |
| 154 | + | ||
| 151 | 155 | func (j *job) now() time.Time { | |
| 152 | 156 | if j.cfg.Now != nil { | |
| 153 | 157 | return j.cfg.Now() | |
| @@ -162,6 +166,7 @@ func Run(cancel *Canceller, cfg Config) Result { | |||
| 162 | 166 | cancel = NewCanceller() | |
| 163 | 167 | } | |
| 164 | 168 | j := &job{cfg: cfg, cancel: cancel, trackID: "github_" + uuid.NewString()} | |
| 169 | + | j.cfg.Reporter = &maskReporter{j: j, next: cfg.Reporter} | |
| 165 | 170 | defer func() { | |
| 166 | 171 | if j.fc != nil { | |
| 167 | 172 | _ = j.fc.Close() | |
| @@ -171,7 +176,8 @@ func Run(cancel *Canceller, cfg Config) Result { | |||
| 171 | 176 | } | |
| 172 | 177 | }() | |
| 173 | 178 | res := j.run() | |
| 174 | - | cfg.Reporter.JobCompleted(res) | |
| 179 | + | res.Error = j.mask(res.Error) | |
| 180 | + | j.cfg.Reporter.JobCompleted(res) | |
| 175 | 181 | return res | |
| 176 | 182 | } | |
| 177 | 183 | ||
| @@ -243,9 +249,6 @@ func (j *job) timeoutMinutes() int { | |||
| 243 | 249 | func (j *job) write(num int, line string) { | |
| 244 | 250 | j.mu.Lock() | |
| 245 | 251 | defer j.mu.Unlock() | |
| 246 | - | for _, m := range j.masks { | |
| 247 | - | line = strings.ReplaceAll(line, m, "***") | |
| 248 | - | } | |
| 249 | 252 | j.cfg.Reporter.Log(num, j.now(), line) | |
| 250 | 253 | } | |
| 251 | 254 | ||
| @@ -258,6 +261,7 @@ func (j *job) out(num int, text string) { | |||
| 258 | 261 | ||
| 259 | 262 | // tagged writes tag+message; only the first line carries the tag. | |
| 260 | 263 | func (j *job) tagged(num int, tag, msg string) { | |
| 264 | + | msg = j.mask(msg) // before the cut: a secret split by it would leak its start | |
| 261 | 265 | if len(msg) > maxIssueMessageLength && tag != "" { | |
| 262 | 266 | msg = msg[:maxIssueMessageLength] | |
| 263 | 267 | } | |
| @@ -371,7 +375,7 @@ func (j *job) setUp() ([]*workflow.Step, error) { | |||
| 371 | 375 | state.WithLayout(layout) | |
| 372 | 376 | } | |
| 373 | 377 | j.state = state | |
| 374 | - | j.masks = state.Secrets() | |
| 378 | + | j.masker.Store(newMasker(state.Secrets()...)) | |
| 375 | 379 | ||
| 376 | 380 | // Features that arrive in later phases fail the job with a clear message. | |
| 377 | 381 | switch { | |
| @@ -543,6 +547,11 @@ func (j *job) completeJob(num int) map[string]string { | |||
| 543 | 547 | j.debug(num, fmt.Sprintf("Skip output '%s' since it's empty", p.Key)) | |
| 544 | 548 | continue | |
| 545 | 549 | } | |
| 550 | + | // An output that holds a secret is dropped (JobExtension.cs:819-824). | |
| 551 | + | if j.masker.Load().has(p.Value) { | |
| 552 | + | j.tagged(num, "##[warning]", fmt.Sprintf("Skip output '%s' since it may contain secret.", p.Key)) | |
| 553 | + | continue | |
| 554 | + | } | |
| 546 | 555 | // ermite refuses outputs above 1 MiB in total (runner.proto, | |
| 547 | 556 | // CompleteJobRequest.outputs), and they would not fit a frame of the | |
| 548 | 557 | // stream either: the job fails without outputs. | |
| @@ -10,6 +10,7 @@ import ( | |||||
| 10 | "path/filepath" | 10 | "path/filepath" | ||
| 11 | "strings" | 11 | "strings" | ||
| 12 | "sync" | 12 | "sync" | ||
| 13 | + | "sync/atomic" | |||
| 13 | "syscall" | 14 | "syscall" | ||
| 14 | "time" | 15 | "time" | ||
| 15 | 16 | ||||
| @@ -144,10 +145,13 @@ type job struct { | |||||
| 144 | baseEnv []string | 145 | baseEnv []string | ||
| 145 | lookupPath string | 146 | lookupPath string | ||
| 146 | before map[string]bool | 147 | before map[string]bool | ||
| 147 | - | // masks are the secret values replaced by *** in every log line. | 148 | + | // masker hides the secrets in everything reported; it is set once the |
| 148 | - | masks []string | 149 | + | // contexts are built, before any step runs. |
| 150 | + | masker atomic.Pointer[masker] | |||
| 149 | } | 151 | } | ||
| 150 | 152 | ||||
| 153 | + | func (j *job) mask(s string) string { return j.masker.Load().mask(s) } | |||
| 154 | + | ||||
| 151 | func (j *job) now() time.Time { | 155 | func (j *job) now() time.Time { | ||
| 152 | if j.cfg.Now != nil { | 156 | if j.cfg.Now != nil { | ||
| 153 | return j.cfg.Now() | 157 | return j.cfg.Now() | ||
| @@ -162,6 +166,7 @@ func Run(cancel *Canceller, cfg Config) Result { | |||||
| 162 | cancel = NewCanceller() | 166 | cancel = NewCanceller() | ||
| 163 | } | 167 | } | ||
| 164 | j := &job{cfg: cfg, cancel: cancel, trackID: "github_" + uuid.NewString()} | 168 | j := &job{cfg: cfg, cancel: cancel, trackID: "github_" + uuid.NewString()} | ||
| 169 | + | j.cfg.Reporter = &maskReporter{j: j, next: cfg.Reporter} | |||
| 165 | defer func() { | 170 | defer func() { | ||
| 166 | if j.fc != nil { | 171 | if j.fc != nil { | ||
| 167 | _ = j.fc.Close() | 172 | _ = j.fc.Close() | ||
| @@ -171,7 +176,8 @@ func Run(cancel *Canceller, cfg Config) Result { | |||||
| 171 | } | 176 | } | ||
| 172 | }() | 177 | }() | ||
| 173 | res := j.run() | 178 | res := j.run() | ||
| 174 | - | cfg.Reporter.JobCompleted(res) | 179 | + | res.Error = j.mask(res.Error) |
| 180 | + | j.cfg.Reporter.JobCompleted(res) | |||
| 175 | return res | 181 | return res | ||
| 176 | } | 182 | } | ||
| 177 | 183 | ||||
| @@ -243,9 +249,6 @@ func (j *job) timeoutMinutes() int { | |||||
| 243 | func (j *job) write(num int, line string) { | 249 | func (j *job) write(num int, line string) { | ||
| 244 | j.mu.Lock() | 250 | j.mu.Lock() | ||
| 245 | defer j.mu.Unlock() | 251 | defer j.mu.Unlock() | ||
| 246 | - | for _, m := range j.masks { | |||
| 247 | - | line = strings.ReplaceAll(line, m, "***") | |||
| 248 | - | } | |||
| 249 | j.cfg.Reporter.Log(num, j.now(), line) | 252 | j.cfg.Reporter.Log(num, j.now(), line) | ||
| 250 | } | 253 | } | ||
| 251 | 254 | ||||
| @@ -258,6 +261,7 @@ func (j *job) out(num int, text string) { | |||||
| 258 | 261 | ||||
| 259 | // tagged writes tag+message; only the first line carries the tag. | 262 | // tagged writes tag+message; only the first line carries the tag. | ||
| 260 | func (j *job) tagged(num int, tag, msg string) { | 263 | func (j *job) tagged(num int, tag, msg string) { | ||
| 264 | + | msg = j.mask(msg) // before the cut: a secret split by it would leak its start | |||
| 261 | if len(msg) > maxIssueMessageLength && tag != "" { | 265 | if len(msg) > maxIssueMessageLength && tag != "" { | ||
| 262 | msg = msg[:maxIssueMessageLength] | 266 | msg = msg[:maxIssueMessageLength] | ||
| 263 | } | 267 | } | ||
| @@ -371,7 +375,7 @@ func (j *job) setUp() ([]*workflow.Step, error) { | |||||
| 371 | state.WithLayout(layout) | 375 | state.WithLayout(layout) | ||
| 372 | } | 376 | } | ||
| 373 | j.state = state | 377 | j.state = state | ||
| 374 | - | j.masks = state.Secrets() | 378 | + | j.masker.Store(newMasker(state.Secrets()...)) |
| 375 | 379 | ||||
| 376 | // Features that arrive in later phases fail the job with a clear message. | 380 | // Features that arrive in later phases fail the job with a clear message. | ||
| 377 | switch { | 381 | switch { | ||
| @@ -543,6 +547,11 @@ func (j *job) completeJob(num int) map[string]string { | |||||
| 543 | j.debug(num, fmt.Sprintf("Skip output '%s' since it's empty", p.Key)) | 547 | j.debug(num, fmt.Sprintf("Skip output '%s' since it's empty", p.Key)) | ||
| 544 | continue | 548 | continue | ||
| 545 | } | 549 | } | ||
| 550 | + | // An output that holds a secret is dropped (JobExtension.cs:819-824). | |||
| 551 | + | if j.masker.Load().has(p.Value) { | |||
| 552 | + | j.tagged(num, "##[warning]", fmt.Sprintf("Skip output '%s' since it may contain secret.", p.Key)) | |||
| 553 | + | continue | |||
| 554 | + | } | |||
| 546 | // ermite refuses outputs above 1 MiB in total (runner.proto, | 555 | // ermite refuses outputs above 1 MiB in total (runner.proto, | ||
| 547 | // CompleteJobRequest.outputs), and they would not fit a frame of the | 556 | // CompleteJobRequest.outputs), and they would not fit a frame of the | ||
| 548 | // stream either: the job fails without outputs. | 557 | // stream either: the job fails without outputs. | ||
A internal/executor/masker.go +80 -0
| @@ -0,0 +1,80 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "slices" | |
| 7 | + | "strings" | |
| 8 | + | "time" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // masker replaces secret values by *** in text that leaves the executor | |
| 12 | + | // (HostContext.SecretMasker). A value is masked as is and in the forms the | |
| 13 | + | // official runner registers for it (HostContext.cs:103-113, ValueEncoders). | |
| 14 | + | // Longer forms are replaced first. | |
| 15 | + | type masker struct{ values []string } | |
| 16 | + | ||
| 17 | + | const maskedValue = "***" | |
| 18 | + | ||
| 19 | + | func newMasker(secrets ...string) *masker { | |
| 20 | + | seen := map[string]bool{} | |
| 21 | + | var vals []string | |
| 22 | + | add := func(v string) { | |
| 23 | + | if v != "" && !seen[v] { | |
| 24 | + | seen[v] = true | |
| 25 | + | vals = append(vals, v) | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | for _, s := range secrets { | |
| 29 | + | add(s) | |
| 30 | + | for _, e := range secretEncodings(s) { | |
| 31 | + | add(e) | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | slices.SortStableFunc(vals, func(a, b string) int { return len(b) - len(a) }) | |
| 35 | + | return &masker{values: vals} | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // secretEncodings are the other forms of v that the runner masks. | |
| 39 | + | func secretEncodings(string) []string { return nil } | |
| 40 | + | ||
| 41 | + | // mask returns s with every secret replaced. | |
| 42 | + | func (m *masker) mask(s string) string { | |
| 43 | + | if m == nil { | |
| 44 | + | return s | |
| 45 | + | } | |
| 46 | + | for _, v := range m.values { | |
| 47 | + | if strings.Contains(s, v) { | |
| 48 | + | s = strings.ReplaceAll(s, v, maskedValue) | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | return s | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // has reports whether s contains a secret. | |
| 55 | + | func (m *masker) has(s string) bool { return m != nil && m.mask(s) != s } | |
| 56 | + | ||
| 57 | + | // maskReporter masks everything the executor reports: the log lines, the names | |
| 58 | + | // of the steps and the error of the job. The outputs are checked where they are | |
| 59 | + | // evaluated (a job output with a secret is dropped). | |
| 60 | + | type maskReporter struct { | |
| 61 | + | j *job | |
| 62 | + | next Reporter | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func (r *maskReporter) StepStarted(n int, name string, at time.Time) { | |
| 66 | + | r.next.StepStarted(n, r.j.mask(name), at) | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func (r *maskReporter) Log(n int, at time.Time, line string) { | |
| 70 | + | r.next.Log(n, at, r.j.mask(line)) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func (r *maskReporter) StepCompleted(n int, name string, c Conclusion, at time.Time) { | |
| 74 | + | r.next.StepCompleted(n, r.j.mask(name), c, at) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func (r *maskReporter) JobCompleted(res Result) { | |
| 78 | + | res.Error = r.j.mask(res.Error) | |
| 79 | + | r.next.JobCompleted(res) | |
| 80 | + | } | |
| @@ -0,0 +1,80 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package executor | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "slices" | |||
| 7 | + | "strings" | |||
| 8 | + | "time" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // masker replaces secret values by *** in text that leaves the executor | |||
| 12 | + | // (HostContext.SecretMasker). A value is masked as is and in the forms the | |||
| 13 | + | // official runner registers for it (HostContext.cs:103-113, ValueEncoders). | |||
| 14 | + | // Longer forms are replaced first. | |||
| 15 | + | type masker struct{ values []string } | |||
| 16 | + | ||||
| 17 | + | const maskedValue = "***" | |||
| 18 | + | ||||
| 19 | + | func newMasker(secrets ...string) *masker { | |||
| 20 | + | seen := map[string]bool{} | |||
| 21 | + | var vals []string | |||
| 22 | + | add := func(v string) { | |||
| 23 | + | if v != "" && !seen[v] { | |||
| 24 | + | seen[v] = true | |||
| 25 | + | vals = append(vals, v) | |||
| 26 | + | } | |||
| 27 | + | } | |||
| 28 | + | for _, s := range secrets { | |||
| 29 | + | add(s) | |||
| 30 | + | for _, e := range secretEncodings(s) { | |||
| 31 | + | add(e) | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | slices.SortStableFunc(vals, func(a, b string) int { return len(b) - len(a) }) | |||
| 35 | + | return &masker{values: vals} | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // secretEncodings are the other forms of v that the runner masks. | |||
| 39 | + | func secretEncodings(string) []string { return nil } | |||
| 40 | + | ||||
| 41 | + | // mask returns s with every secret replaced. | |||
| 42 | + | func (m *masker) mask(s string) string { | |||
| 43 | + | if m == nil { | |||
| 44 | + | return s | |||
| 45 | + | } | |||
| 46 | + | for _, v := range m.values { | |||
| 47 | + | if strings.Contains(s, v) { | |||
| 48 | + | s = strings.ReplaceAll(s, v, maskedValue) | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | return s | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | // has reports whether s contains a secret. | |||
| 55 | + | func (m *masker) has(s string) bool { return m != nil && m.mask(s) != s } | |||
| 56 | + | ||||
| 57 | + | // maskReporter masks everything the executor reports: the log lines, the names | |||
| 58 | + | // of the steps and the error of the job. The outputs are checked where they are | |||
| 59 | + | // evaluated (a job output with a secret is dropped). | |||
| 60 | + | type maskReporter struct { | |||
| 61 | + | j *job | |||
| 62 | + | next Reporter | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func (r *maskReporter) StepStarted(n int, name string, at time.Time) { | |||
| 66 | + | r.next.StepStarted(n, r.j.mask(name), at) | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | func (r *maskReporter) Log(n int, at time.Time, line string) { | |||
| 70 | + | r.next.Log(n, at, r.j.mask(line)) | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func (r *maskReporter) StepCompleted(n int, name string, c Conclusion, at time.Time) { | |||
| 74 | + | r.next.StepCompleted(n, r.j.mask(name), c, at) | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func (r *maskReporter) JobCompleted(res Result) { | |||
| 78 | + | res.Error = r.j.mask(res.Error) | |||
| 79 | + | r.next.JobCompleted(res) | |||
| 80 | + | } | |||
A internal/executor/masker_test.go +123 -0
| @@ -0,0 +1,123 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | const testToken = "ghs_testtoken123" | |
| 12 | + | ||
| 13 | + | func tokenContexts() string { | |
| 14 | + | return strings.Replace(testGitHubContext("main"), `"github":{`, `"github":{"token":"`+testToken+`",`, 1) | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | // The name of a step that holds the token is masked before it reaches the | |
| 18 | + | // server (StepStarted and StepCompleted). | |
| 19 | + | func TestStepNameIsMasked(t *testing.T) { | |
| 20 | + | needTools(t, "bash") | |
| 21 | + | yaml := `on: push | |
| 22 | + | jobs: | |
| 23 | + | main: | |
| 24 | + | runs-on: ubuntu-24.04 | |
| 25 | + | steps: | |
| 26 | + | - name: leak ${{ github.token }} | |
| 27 | + | run: echo hi | |
| 28 | + | - name: leak2 ${{ secrets.GITHUB_TOKEN }} ${{ env.X }} | |
| 29 | + | env: | |
| 30 | + | X: x | |
| 31 | + | run: echo hi | |
| 32 | + | ` | |
| 33 | + | rec, res := runLocal(t, yaml, "main", func(c *Config) { c.ContextsJSON = tokenContexts() }) | |
| 34 | + | if res.Conclusion != ConclusionSuccess { | |
| 35 | + | t.Fatalf("%+v", res) | |
| 36 | + | } | |
| 37 | + | for _, s := range rec.steps { | |
| 38 | + | if strings.Contains(s.Name, testToken) { | |
| 39 | + | t.Errorf("step name %q holds the token", s.Name) | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | rec.step(t, "leak ***") | |
| 43 | + | rec.step(t, "leak2 *** x") | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // A job output that holds the token is dropped with GitHub's warning; the | |
| 47 | + | // other outputs stay. | |
| 48 | + | func TestJobOutputWithTokenIsDropped(t *testing.T) { | |
| 49 | + | needTools(t, "bash") | |
| 50 | + | yaml := `on: push | |
| 51 | + | jobs: | |
| 52 | + | main: | |
| 53 | + | runs-on: ubuntu-24.04 | |
| 54 | + | outputs: | |
| 55 | + | leak: ${{ steps.s.outputs.v }} | |
| 56 | + | fine: ${{ steps.s.outputs.w }} | |
| 57 | + | steps: | |
| 58 | + | - id: s | |
| 59 | + | env: | |
| 60 | + | T: ${{ github.token }} | |
| 61 | + | run: | | |
| 62 | + | echo "v=prefix-$T-suffix" >> "$GITHUB_OUTPUT" | |
| 63 | + | echo "w=ok" >> "$GITHUB_OUTPUT" | |
| 64 | + | ` | |
| 65 | + | rec, res := runLocal(t, yaml, "main", func(c *Config) { c.ContextsJSON = tokenContexts() }) | |
| 66 | + | if res.Conclusion != ConclusionSuccess { | |
| 67 | + | t.Fatalf("%+v", res) | |
| 68 | + | } | |
| 69 | + | if _, has := res.Outputs["leak"]; has || res.Outputs["fine"] != "ok" { | |
| 70 | + | t.Errorf("outputs %v", res.Outputs) | |
| 71 | + | } | |
| 72 | + | for _, v := range res.Outputs { | |
| 73 | + | if strings.Contains(v, testToken) { | |
| 74 | + | t.Errorf("the token is in an output: %q", v) | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | if log := logOf(rec.step(t, CompleteJobName)); !strings.Contains(log, "##[warning]Skip output 'leak' since it may contain secret.") { | |
| 78 | + | t.Errorf("warning missing:\n%s", log) | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // Everything the executor reports goes through the masker: the names of the | |
| 83 | + | // steps, the log, and the error of the job (no real run puts a token there, so | |
| 84 | + | // the reporter is exercised directly). | |
| 85 | + | func TestMaskReporter(t *testing.T) { | |
| 86 | + | rec := newRecorder() | |
| 87 | + | j := &job{} | |
| 88 | + | j.masker.Store(newMasker(testToken)) | |
| 89 | + | r := &maskReporter{j: j, next: rec} | |
| 90 | + | r.StepStarted(1, "name "+testToken, time.Now()) | |
| 91 | + | r.Log(1, time.Now(), "line "+testToken) | |
| 92 | + | r.StepCompleted(1, "name "+testToken, ConclusionSuccess, time.Now()) | |
| 93 | + | r.JobCompleted(Result{Conclusion: ConclusionFailure, Error: "boom " + testToken}) | |
| 94 | + | if s := rec.steps[0]; s.Name != "name ***" || s.Log[0] != "line ***" { | |
| 95 | + | t.Errorf("%+v", s) | |
| 96 | + | } | |
| 97 | + | if rec.result.Error != "boom ***" { | |
| 98 | + | t.Errorf("error %q", rec.result.Error) | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | // The token is masked before a message is cut at 4096 bytes: a token split by | |
| 103 | + | // the cut would otherwise leave its start in the line. | |
| 104 | + | func TestMaskBeforeTruncation(t *testing.T) { | |
| 105 | + | needTools(t, "bash") | |
| 106 | + | pad := strings.Repeat("a", maxIssueMessageLength-5) | |
| 107 | + | yaml := `on: push | |
| 108 | + | jobs: | |
| 109 | + | main: | |
| 110 | + | runs-on: ubuntu-24.04 | |
| 111 | + | steps: | |
| 112 | + | - run: | | |
| 113 | + | echo "::error::` + pad + `${{ github.token }}" | |
| 114 | + | ` | |
| 115 | + | rec, _ := runLocal(t, yaml, "main", func(c *Config) { c.ContextsJSON = tokenContexts() }) | |
| 116 | + | for _, s := range rec.steps { | |
| 117 | + | for _, l := range s.Log { | |
| 118 | + | if strings.Contains(l, "ghs_") { | |
| 119 | + | t.Errorf("a piece of the token leaked: %q", l[len(l)-20:]) | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | } | |
| @@ -0,0 +1,123 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package executor | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | const testToken = "ghs_testtoken123" | |||
| 12 | + | ||||
| 13 | + | func tokenContexts() string { | |||
| 14 | + | return strings.Replace(testGitHubContext("main"), `"github":{`, `"github":{"token":"`+testToken+`",`, 1) | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | // The name of a step that holds the token is masked before it reaches the | |||
| 18 | + | // server (StepStarted and StepCompleted). | |||
| 19 | + | func TestStepNameIsMasked(t *testing.T) { | |||
| 20 | + | needTools(t, "bash") | |||
| 21 | + | yaml := `on: push | |||
| 22 | + | jobs: | |||
| 23 | + | main: | |||
| 24 | + | runs-on: ubuntu-24.04 | |||
| 25 | + | steps: | |||
| 26 | + | - name: leak ${{ github.token }} | |||
| 27 | + | run: echo hi | |||
| 28 | + | - name: leak2 ${{ secrets.GITHUB_TOKEN }} ${{ env.X }} | |||
| 29 | + | env: | |||
| 30 | + | X: x | |||
| 31 | + | run: echo hi | |||
| 32 | + | ` | |||
| 33 | + | rec, res := runLocal(t, yaml, "main", func(c *Config) { c.ContextsJSON = tokenContexts() }) | |||
| 34 | + | if res.Conclusion != ConclusionSuccess { | |||
| 35 | + | t.Fatalf("%+v", res) | |||
| 36 | + | } | |||
| 37 | + | for _, s := range rec.steps { | |||
| 38 | + | if strings.Contains(s.Name, testToken) { | |||
| 39 | + | t.Errorf("step name %q holds the token", s.Name) | |||
| 40 | + | } | |||
| 41 | + | } | |||
| 42 | + | rec.step(t, "leak ***") | |||
| 43 | + | rec.step(t, "leak2 *** x") | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // A job output that holds the token is dropped with GitHub's warning; the | |||
| 47 | + | // other outputs stay. | |||
| 48 | + | func TestJobOutputWithTokenIsDropped(t *testing.T) { | |||
| 49 | + | needTools(t, "bash") | |||
| 50 | + | yaml := `on: push | |||
| 51 | + | jobs: | |||
| 52 | + | main: | |||
| 53 | + | runs-on: ubuntu-24.04 | |||
| 54 | + | outputs: | |||
| 55 | + | leak: ${{ steps.s.outputs.v }} | |||
| 56 | + | fine: ${{ steps.s.outputs.w }} | |||
| 57 | + | steps: | |||
| 58 | + | - id: s | |||
| 59 | + | env: | |||
| 60 | + | T: ${{ github.token }} | |||
| 61 | + | run: | | |||
| 62 | + | echo "v=prefix-$T-suffix" >> "$GITHUB_OUTPUT" | |||
| 63 | + | echo "w=ok" >> "$GITHUB_OUTPUT" | |||
| 64 | + | ` | |||
| 65 | + | rec, res := runLocal(t, yaml, "main", func(c *Config) { c.ContextsJSON = tokenContexts() }) | |||
| 66 | + | if res.Conclusion != ConclusionSuccess { | |||
| 67 | + | t.Fatalf("%+v", res) | |||
| 68 | + | } | |||
| 69 | + | if _, has := res.Outputs["leak"]; has || res.Outputs["fine"] != "ok" { | |||
| 70 | + | t.Errorf("outputs %v", res.Outputs) | |||
| 71 | + | } | |||
| 72 | + | for _, v := range res.Outputs { | |||
| 73 | + | if strings.Contains(v, testToken) { | |||
| 74 | + | t.Errorf("the token is in an output: %q", v) | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | if log := logOf(rec.step(t, CompleteJobName)); !strings.Contains(log, "##[warning]Skip output 'leak' since it may contain secret.") { | |||
| 78 | + | t.Errorf("warning missing:\n%s", log) | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // Everything the executor reports goes through the masker: the names of the | |||
| 83 | + | // steps, the log, and the error of the job (no real run puts a token there, so | |||
| 84 | + | // the reporter is exercised directly). | |||
| 85 | + | func TestMaskReporter(t *testing.T) { | |||
| 86 | + | rec := newRecorder() | |||
| 87 | + | j := &job{} | |||
| 88 | + | j.masker.Store(newMasker(testToken)) | |||
| 89 | + | r := &maskReporter{j: j, next: rec} | |||
| 90 | + | r.StepStarted(1, "name "+testToken, time.Now()) | |||
| 91 | + | r.Log(1, time.Now(), "line "+testToken) | |||
| 92 | + | r.StepCompleted(1, "name "+testToken, ConclusionSuccess, time.Now()) | |||
| 93 | + | r.JobCompleted(Result{Conclusion: ConclusionFailure, Error: "boom " + testToken}) | |||
| 94 | + | if s := rec.steps[0]; s.Name != "name ***" || s.Log[0] != "line ***" { | |||
| 95 | + | t.Errorf("%+v", s) | |||
| 96 | + | } | |||
| 97 | + | if rec.result.Error != "boom ***" { | |||
| 98 | + | t.Errorf("error %q", rec.result.Error) | |||
| 99 | + | } | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | // The token is masked before a message is cut at 4096 bytes: a token split by | |||
| 103 | + | // the cut would otherwise leave its start in the line. | |||
| 104 | + | func TestMaskBeforeTruncation(t *testing.T) { | |||
| 105 | + | needTools(t, "bash") | |||
| 106 | + | pad := strings.Repeat("a", maxIssueMessageLength-5) | |||
| 107 | + | yaml := `on: push | |||
| 108 | + | jobs: | |||
| 109 | + | main: | |||
| 110 | + | runs-on: ubuntu-24.04 | |||
| 111 | + | steps: | |||
| 112 | + | - run: | | |||
| 113 | + | echo "::error::` + pad + `${{ github.token }}" | |||
| 114 | + | ` | |||
| 115 | + | rec, _ := runLocal(t, yaml, "main", func(c *Config) { c.ContextsJSON = tokenContexts() }) | |||
| 116 | + | for _, s := range rec.steps { | |||
| 117 | + | for _, l := range s.Log { | |||
| 118 | + | if strings.Contains(l, "ghs_") { | |||
| 119 | + | t.Errorf("a piece of the token leaked: %q", l[len(l)-20:]) | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 122 | + | } | |||
| 123 | + | } | |||