detached 305254e

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +94 -1
M internal/executor/contexts.go +31 -1
@@ -3,6 +3,7 @@
33 package executor
44
55 import (
6+ "encoding/base64"
67 "fmt"
78 "path/filepath"
89 "runtime"
@@ -241,6 +242,8 @@ func NewState(contextsJSON, matrixJSON, jobKey string, r RunnerInfo, repoFallbac
241242 s := &State{other: map[string]expr.Value{}, env: newOrdered(), steps: map[string]*stepCtx{}, status: "success"}
242243
243244 // 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.
244247 gh := expr.NewDictionary()
245248 gh.Add("token", expr.String(""))
246249 if v, ok := root.Get("github"); ok {
@@ -250,6 +253,9 @@ func NewState(contextsJSON, matrixJSON, jobKey string, r RunnerInfo, repoFallbac
250253 }
251254 for _, p := range d.Pairs() {
252255 if strings.EqualFold(p.Key, "token") {
256+ if t, isStr := p.Value.(expr.String); isStr {
257+ gh = setKey(gh, "token", t)
258+ }
253259 continue
254260 }
255261 gh.Add(p.Key, p.Value)
@@ -463,7 +469,7 @@ func (s *State) Values(env *ordered) map[string]expr.Value {
463469 "job": s.job,
464470 "env": env.Dict(),
465471 "steps": s.stepsValue(),
466- "secrets": expr.NewDictionary(),
472+ "secrets": s.secrets(),
467473 }
468474 for k, v := range s.other {
469475 m[k] = v
@@ -471,6 +477,30 @@ func (s *State) Values(env *ordered) map[string]expr.Value {
471477 return m
472478 }
473479
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+
474504 // ServerContexts returns the contexts that the server has when it evaluates
475505 // the name of a job: github as received (without the keys the runner adds),
476506 // needs, strategy, matrix, vars and inputs.
@@ -3,6 +3,7 @@
3 package executor3 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 continue259 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] = v475 m[k] = v
@@ -471,6 +477,30 @@ func (s *State) Values(env *ordered) map[string]expr.Value {
471 return m477 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 evaluates504 // 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 {
144144 baseEnv []string
145145 lookupPath string
146146 before map[string]bool
147+ // masks are the secret values replaced by *** in every log line.
148+ masks []string
147149 }
148150
149151 func (j *job) now() time.Time {
@@ -241,6 +243,9 @@ func (j *job) timeoutMinutes() int {
241243 func (j *job) write(num int, line string) {
242244 j.mu.Lock()
243245 defer j.mu.Unlock()
246+ for _, m := range j.masks {
247+ line = strings.ReplaceAll(line, m, "***")
248+ }
244249 j.cfg.Reporter.Log(num, j.now(), line)
245250 }
246251
@@ -372,6 +377,7 @@ func (j *job) setUp() ([]*workflow.Step, error) {
372377 state.WithLayout(layout)
373378 }
374379 j.state = state
380+ j.masks = state.Secrets()
375381
376382 // Features that arrive in later phases fail the job with a clear message.
377383 switch {
@@ -144,6 +144,8 @@ type job struct {
144 baseEnv []string144 baseEnv []string
145 lookupPath string145 lookupPath string
146 before map[string]bool146 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 = state379 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+}

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