feat(executor): expose the job token as secrets.GITHUB_TOKEN, masked in the log
GitHub's secrets context holds GITHUB_TOKEN, equal to github.token (recorded: ctx-inputs-vars). The runner takes the token from the github context the server sends and masks it, and the base64 of x-access-token:<token>, in every log line. ermite does not send github.token yet (the job token must not reach the sandbox, so it cannot be reused): the server has to add a token of its own to the contexts.
- commit
- 305254ef9a928c87e585faa838e8d357f9cb7b5b
- author
- brice · · 2026-10-13 03:28 UTC
- parent
- 79df483
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +94 -1
M internal/executor/contexts.go +31 -1
| @@ -3,6 +3,7 @@ | |||
| 3 | 3 | package executor | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "encoding/base64" | |
| 6 | 7 | "fmt" | |
| 7 | 8 | "path/filepath" | |
| 8 | 9 | "runtime" | |
| @@ -241,6 +242,8 @@ func NewState(contextsJSON, matrixJSON, jobKey string, r RunnerInfo, repoFallbac | |||
| 241 | 242 | s := &State{other: map[string]expr.Value{}, env: newOrdered(), steps: map[string]*stepCtx{}, status: "success"} | |
| 242 | 243 | ||
| 243 | 244 | // github: token first, then what the server gave (ExecutionContext.cs:1023-1037). | |
| 245 | + | // The token is the one the server sent in github.token (GitHub's | |
| 246 | + | // system.github.token variable), empty when it sent none. | |
| 244 | 247 | gh := expr.NewDictionary() | |
| 245 | 248 | gh.Add("token", expr.String("")) | |
| 246 | 249 | if v, ok := root.Get("github"); ok { | |
| @@ -250,6 +253,9 @@ func NewState(contextsJSON, matrixJSON, jobKey string, r RunnerInfo, repoFallbac | |||
| 250 | 253 | } | |
| 251 | 254 | for _, p := range d.Pairs() { | |
| 252 | 255 | if strings.EqualFold(p.Key, "token") { | |
| 256 | + | if t, isStr := p.Value.(expr.String); isStr { | |
| 257 | + | gh = setKey(gh, "token", t) | |
| 258 | + | } | |
| 253 | 259 | continue | |
| 254 | 260 | } | |
| 255 | 261 | gh.Add(p.Key, p.Value) | |
| @@ -463,7 +469,7 @@ func (s *State) Values(env *ordered) map[string]expr.Value { | |||
| 463 | 469 | "job": s.job, | |
| 464 | 470 | "env": env.Dict(), | |
| 465 | 471 | "steps": s.stepsValue(), | |
| 466 | - | "secrets": expr.NewDictionary(), | |
| 472 | + | "secrets": s.secrets(), | |
| 467 | 473 | } | |
| 468 | 474 | for k, v := range s.other { | |
| 469 | 475 | m[k] = v | |
| @@ -471,6 +477,30 @@ func (s *State) Values(env *ordered) map[string]expr.Value { | |||
| 471 | 477 | return m | |
| 472 | 478 | } | |
| 473 | 479 | ||
| 480 | + | // secrets is the secrets context. The job's token is in it as GITHUB_TOKEN | |
| 481 | + | // (Variables.ToSecretsContext keeps every secret variable but the access token; | |
| 482 | + | // GitHub's job message carries the token as a secret named GITHUB_TOKEN, recorded: | |
| 483 | + | // ctx-inputs-vars, secrets.GITHUB_TOKEN == github.token). Without a token the | |
| 484 | + | // context is empty. | |
| 485 | + | func (s *State) secrets() *expr.Dictionary { | |
| 486 | + | d := expr.NewDictionary() | |
| 487 | + | if t := s.GitHub("token"); t != "" { | |
| 488 | + | d.Add("GITHUB_TOKEN", expr.String(t)) | |
| 489 | + | } | |
| 490 | + | return d | |
| 491 | + | } | |
| 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). | |
| 496 | + | func (s *State) Secrets() []string { | |
| 497 | + | t := s.GitHub("token") | |
| 498 | + | if t == "" { | |
| 499 | + | return nil | |
| 500 | + | } | |
| 501 | + | return []string{t, base64.StdEncoding.EncodeToString([]byte("x-access-token:" + t))} | |
| 502 | + | } | |
| 503 | + | ||
| 474 | 504 | // ServerContexts returns the contexts that the server has when it evaluates | |
| 475 | 505 | // the name of a job: github as received (without the keys the runner adds), | |
| 476 | 506 | // needs, strategy, matrix, vars and inputs. | |
| @@ -3,6 +3,7 @@ | |||||
| 3 | package executor | 3 | package executor | ||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | + | "encoding/base64" | |||
| 6 | "fmt" | 7 | "fmt" | ||
| 7 | "path/filepath" | 8 | "path/filepath" | ||
| 8 | "runtime" | 9 | "runtime" | ||
| @@ -241,6 +242,8 @@ func NewState(contextsJSON, matrixJSON, jobKey string, r RunnerInfo, repoFallbac | |||||
| 241 | s := &State{other: map[string]expr.Value{}, env: newOrdered(), steps: map[string]*stepCtx{}, status: "success"} | 242 | s := &State{other: map[string]expr.Value{}, env: newOrdered(), steps: map[string]*stepCtx{}, status: "success"} | ||
| 242 | 243 | ||||
| 243 | // github: token first, then what the server gave (ExecutionContext.cs:1023-1037). | 244 | // github: token first, then what the server gave (ExecutionContext.cs:1023-1037). | ||
| 245 | + | // The token is the one the server sent in github.token (GitHub's | |||
| 246 | + | // system.github.token variable), empty when it sent none. | |||
| 244 | gh := expr.NewDictionary() | 247 | gh := expr.NewDictionary() | ||
| 245 | gh.Add("token", expr.String("")) | 248 | gh.Add("token", expr.String("")) | ||
| 246 | if v, ok := root.Get("github"); ok { | 249 | if v, ok := root.Get("github"); ok { | ||
| @@ -250,6 +253,9 @@ func NewState(contextsJSON, matrixJSON, jobKey string, r RunnerInfo, repoFallbac | |||||
| 250 | } | 253 | } | ||
| 251 | for _, p := range d.Pairs() { | 254 | for _, p := range d.Pairs() { | ||
| 252 | if strings.EqualFold(p.Key, "token") { | 255 | if strings.EqualFold(p.Key, "token") { | ||
| 256 | + | if t, isStr := p.Value.(expr.String); isStr { | |||
| 257 | + | gh = setKey(gh, "token", t) | |||
| 258 | + | } | |||
| 253 | continue | 259 | continue | ||
| 254 | } | 260 | } | ||
| 255 | gh.Add(p.Key, p.Value) | 261 | gh.Add(p.Key, p.Value) | ||
| @@ -463,7 +469,7 @@ func (s *State) Values(env *ordered) map[string]expr.Value { | |||||
| 463 | "job": s.job, | 469 | "job": s.job, | ||
| 464 | "env": env.Dict(), | 470 | "env": env.Dict(), | ||
| 465 | "steps": s.stepsValue(), | 471 | "steps": s.stepsValue(), | ||
| 466 | - | "secrets": expr.NewDictionary(), | 472 | + | "secrets": s.secrets(), |
| 467 | } | 473 | } | ||
| 468 | for k, v := range s.other { | 474 | for k, v := range s.other { | ||
| 469 | m[k] = v | 475 | m[k] = v | ||
| @@ -471,6 +477,30 @@ func (s *State) Values(env *ordered) map[string]expr.Value { | |||||
| 471 | return m | 477 | return m | ||
| 472 | } | 478 | } | ||
| 473 | 479 | ||||
| 480 | + | // secrets is the secrets context. The job's token is in it as GITHUB_TOKEN | |||
| 481 | + | // (Variables.ToSecretsContext keeps every secret variable but the access token; | |||
| 482 | + | // GitHub's job message carries the token as a secret named GITHUB_TOKEN, recorded: | |||
| 483 | + | // ctx-inputs-vars, secrets.GITHUB_TOKEN == github.token). Without a token the | |||
| 484 | + | // context is empty. | |||
| 485 | + | func (s *State) secrets() *expr.Dictionary { | |||
| 486 | + | d := expr.NewDictionary() | |||
| 487 | + | if t := s.GitHub("token"); t != "" { | |||
| 488 | + | d.Add("GITHUB_TOKEN", expr.String(t)) | |||
| 489 | + | } | |||
| 490 | + | return d | |||
| 491 | + | } | |||
| 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). | |||
| 496 | + | func (s *State) Secrets() []string { | |||
| 497 | + | t := s.GitHub("token") | |||
| 498 | + | if t == "" { | |||
| 499 | + | return nil | |||
| 500 | + | } | |||
| 501 | + | return []string{t, base64.StdEncoding.EncodeToString([]byte("x-access-token:" + t))} | |||
| 502 | + | } | |||
| 503 | + | ||||
| 474 | // ServerContexts returns the contexts that the server has when it evaluates | 504 | // ServerContexts returns the contexts that the server has when it evaluates | ||
| 475 | // the name of a job: github as received (without the keys the runner adds), | 505 | // the name of a job: github as received (without the keys the runner adds), | ||
| 476 | // needs, strategy, matrix, vars and inputs. | 506 | // needs, strategy, matrix, vars and inputs. | ||
M internal/executor/job.go +6 -0
| @@ -144,6 +144,8 @@ type job struct { | |||
| 144 | 144 | baseEnv []string | |
| 145 | 145 | lookupPath string | |
| 146 | 146 | before map[string]bool | |
| 147 | + | // masks are the secret values replaced by *** in every log line. | |
| 148 | + | masks []string | |
| 147 | 149 | } | |
| 148 | 150 | ||
| 149 | 151 | func (j *job) now() time.Time { | |
| @@ -241,6 +243,9 @@ func (j *job) timeoutMinutes() int { | |||
| 241 | 243 | func (j *job) write(num int, line string) { | |
| 242 | 244 | j.mu.Lock() | |
| 243 | 245 | defer j.mu.Unlock() | |
| 246 | + | for _, m := range j.masks { | |
| 247 | + | line = strings.ReplaceAll(line, m, "***") | |
| 248 | + | } | |
| 244 | 249 | j.cfg.Reporter.Log(num, j.now(), line) | |
| 245 | 250 | } | |
| 246 | 251 | ||
| @@ -372,6 +377,7 @@ func (j *job) setUp() ([]*workflow.Step, error) { | |||
| 372 | 377 | state.WithLayout(layout) | |
| 373 | 378 | } | |
| 374 | 379 | j.state = state | |
| 380 | + | j.masks = state.Secrets() | |
| 375 | 381 | ||
| 376 | 382 | // Features that arrive in later phases fail the job with a clear message. | |
| 377 | 383 | switch { | |
| @@ -144,6 +144,8 @@ type job struct { | |||||
| 144 | baseEnv []string | 144 | baseEnv []string | ||
| 145 | lookupPath string | 145 | lookupPath string | ||
| 146 | before map[string]bool | 146 | before map[string]bool | ||
| 147 | + | // masks are the secret values replaced by *** in every log line. | |||
| 148 | + | masks []string | |||
| 147 | } | 149 | } | ||
| 148 | 150 | ||||
| 149 | func (j *job) now() time.Time { | 151 | func (j *job) now() time.Time { | ||
| @@ -241,6 +243,9 @@ func (j *job) timeoutMinutes() int { | |||||
| 241 | func (j *job) write(num int, line string) { | 243 | func (j *job) write(num int, line string) { | ||
| 242 | j.mu.Lock() | 244 | j.mu.Lock() | ||
| 243 | defer j.mu.Unlock() | 245 | defer j.mu.Unlock() | ||
| 246 | + | for _, m := range j.masks { | |||
| 247 | + | line = strings.ReplaceAll(line, m, "***") | |||
| 248 | + | } | |||
| 244 | j.cfg.Reporter.Log(num, j.now(), line) | 249 | j.cfg.Reporter.Log(num, j.now(), line) | ||
| 245 | } | 250 | } | ||
| 246 | 251 | ||||
| @@ -372,6 +377,7 @@ func (j *job) setUp() ([]*workflow.Step, error) { | |||||
| 372 | state.WithLayout(layout) | 377 | state.WithLayout(layout) | ||
| 373 | } | 378 | } | ||
| 374 | j.state = state | 379 | j.state = state | ||
| 380 | + | j.masks = state.Secrets() | |||
| 375 | 381 | ||||
| 376 | // Features that arrive in later phases fail the job with a clear message. | 382 | // Features that arrive in later phases fail the job with a clear message. | ||
| 377 | switch { | 383 | switch { | ||
A internal/executor/token_test.go +57 -0
| @@ -0,0 +1,57 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "encoding/base64" | |
| 7 | + | "strings" | |
| 8 | + | "testing" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // Recorded on github.com (ctx-inputs-vars): secrets.GITHUB_TOKEN is set and | |
| 12 | + | // equals github.token. The token comes from the github context the server | |
| 13 | + | // sends; it is masked in the log. | |
| 14 | + | func TestSecretsHoldTheGithubToken(t *testing.T) { | |
| 15 | + | needTools(t, "bash") | |
| 16 | + | const tok = "ghs_testtoken123" | |
| 17 | + | yaml := `on: push | |
| 18 | + | jobs: | |
| 19 | + | main: | |
| 20 | + | runs-on: ubuntu-24.04 | |
| 21 | + | steps: | |
| 22 | + | - name: absent values | |
| 23 | + | env: | |
| 24 | + | V_SET: ${{ secrets.GITHUB_TOKEN != '' }} | |
| 25 | + | V_EQ: ${{ secrets.GITHUB_TOKEN == github.token }} | |
| 26 | + | V_NONE: ${{ secrets.NOT_DEFINED }} | |
| 27 | + | V_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| 28 | + | run: | | |
| 29 | + | echo "V_SET=$V_SET V_EQ=$V_EQ V_NONE=$V_NONE" | |
| 30 | + | echo "token=$V_TOKEN b64=` + base64.StdEncoding.EncodeToString([]byte("x-access-token:"+tok)) + `" | |
| 31 | + | ` | |
| 32 | + | rec, res := runLocal(t, yaml, "main", func(c *Config) { | |
| 33 | + | c.ContextsJSON = strings.Replace(testGitHubContext("main"), `"github":{`, `"github":{"token":"`+tok+`",`, 1) | |
| 34 | + | }) | |
| 35 | + | if res.Conclusion != ConclusionSuccess { | |
| 36 | + | t.Fatalf("%+v", res) | |
| 37 | + | } | |
| 38 | + | log := logOf(rec.step(t, "absent values")) | |
| 39 | + | if !strings.Contains(log, "V_SET=true V_EQ=true V_NONE=\n") { | |
| 40 | + | t.Errorf("log:\n%s", log) | |
| 41 | + | } | |
| 42 | + | if strings.Contains(log, tok) || strings.Contains(log, "eC1hY2Nlc3MtdG9rZW46") { | |
| 43 | + | t.Errorf("the token leaked into the log:\n%s", log) | |
| 44 | + | } | |
| 45 | + | if !strings.Contains(log, " V_TOKEN: ***") || !strings.Contains(log, "token=*** b64=***") { | |
| 46 | + | t.Errorf("the token must be masked:\n%s", log) | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // Without a token from the server the secrets context is empty and nothing is | |
| 51 | + | // masked. | |
| 52 | + | func TestSecretsWithoutToken(t *testing.T) { | |
| 53 | + | s := newTestState(t) | |
| 54 | + | if d := s.Values(nil)["secrets"]; d == nil || len(s.Secrets()) != 0 { | |
| 55 | + | t.Fatalf("%v %v", d, s.Secrets()) | |
| 56 | + | } | |
| 57 | + | } | |
| @@ -0,0 +1,57 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package executor | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "encoding/base64" | |||
| 7 | + | "strings" | |||
| 8 | + | "testing" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // Recorded on github.com (ctx-inputs-vars): secrets.GITHUB_TOKEN is set and | |||
| 12 | + | // equals github.token. The token comes from the github context the server | |||
| 13 | + | // sends; it is masked in the log. | |||
| 14 | + | func TestSecretsHoldTheGithubToken(t *testing.T) { | |||
| 15 | + | needTools(t, "bash") | |||
| 16 | + | const tok = "ghs_testtoken123" | |||
| 17 | + | yaml := `on: push | |||
| 18 | + | jobs: | |||
| 19 | + | main: | |||
| 20 | + | runs-on: ubuntu-24.04 | |||
| 21 | + | steps: | |||
| 22 | + | - name: absent values | |||
| 23 | + | env: | |||
| 24 | + | V_SET: ${{ secrets.GITHUB_TOKEN != '' }} | |||
| 25 | + | V_EQ: ${{ secrets.GITHUB_TOKEN == github.token }} | |||
| 26 | + | V_NONE: ${{ secrets.NOT_DEFINED }} | |||
| 27 | + | V_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |||
| 28 | + | run: | | |||
| 29 | + | echo "V_SET=$V_SET V_EQ=$V_EQ V_NONE=$V_NONE" | |||
| 30 | + | echo "token=$V_TOKEN b64=` + base64.StdEncoding.EncodeToString([]byte("x-access-token:"+tok)) + `" | |||
| 31 | + | ` | |||
| 32 | + | rec, res := runLocal(t, yaml, "main", func(c *Config) { | |||
| 33 | + | c.ContextsJSON = strings.Replace(testGitHubContext("main"), `"github":{`, `"github":{"token":"`+tok+`",`, 1) | |||
| 34 | + | }) | |||
| 35 | + | if res.Conclusion != ConclusionSuccess { | |||
| 36 | + | t.Fatalf("%+v", res) | |||
| 37 | + | } | |||
| 38 | + | log := logOf(rec.step(t, "absent values")) | |||
| 39 | + | if !strings.Contains(log, "V_SET=true V_EQ=true V_NONE=\n") { | |||
| 40 | + | t.Errorf("log:\n%s", log) | |||
| 41 | + | } | |||
| 42 | + | if strings.Contains(log, tok) || strings.Contains(log, "eC1hY2Nlc3MtdG9rZW46") { | |||
| 43 | + | t.Errorf("the token leaked into the log:\n%s", log) | |||
| 44 | + | } | |||
| 45 | + | if !strings.Contains(log, " V_TOKEN: ***") || !strings.Contains(log, "token=*** b64=***") { | |||
| 46 | + | t.Errorf("the token must be masked:\n%s", log) | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // Without a token from the server the secrets context is empty and nothing is | |||
| 51 | + | // masked. | |||
| 52 | + | func TestSecretsWithoutToken(t *testing.T) { | |||
| 53 | + | s := newTestState(t) | |||
| 54 | + | if d := s.Values(nil)["secrets"]; d == nil || len(s.Secrets()) != 0 { | |||
| 55 | + | t.Fatalf("%v %v", d, s.Secrets()) | |||
| 56 | + | } | |||
| 57 | + | } | |||