1// SPDX-License-Identifier: MIT
2
3package executor
4
5import (
6 "strings"
7 "testing"
8)
9
10const (
11 idTokenTestURL = "http://ermite.example:8088/-/oidc/token?api-version=2.0"
12 idTokenTestToken = "ermiteoidc_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
13)
14
15func withIDToken(c *Config) {
16 c.IDTokenURL, c.IDTokenRequestToken = idTokenTestURL, idTokenTestToken
17}
18
19const idTokenEcho = `echo "oidc url=[${ACTIONS_ID_TOKEN_REQUEST_URL-unset}] token=[${ACTIONS_ID_TOKEN_REQUEST_TOKEN-unset}]"`
20
21const idTokenAction = "name: e\ndescription: d\nruns:\n using: node20\n main: main.sh\n"
22
23var idTokenWant = "oidc url=[" + idTokenTestURL + "] token=[***]"
24
25const idTokenUnset = "oidc url=[unset] token=[unset]"
26
27// ScriptHandler.cs:315-319: a run step gets ACTIONS_ID_TOKEN_REQUEST_URL and
28// ACTIONS_ID_TOKEN_REQUEST_TOKEN in its process, the token masked. The env of
29// the step (its header, the env context) does not show them.
30func TestRunStepGetsIDTokenEnv(t *testing.T) {
31 rec, res := actionRun(t, files{},
32 " - name: r\n env:\n FOO: bar\n run: |\n "+idTokenEcho+"\n echo \"ctx=[${{ env.ACTIONS_ID_TOKEN_REQUEST_URL }}]\"\n", withIDToken)
33 if res.Conclusion != ConclusionSuccess {
34 t.Fatalf("%+v", res)
35 }
36 got := logOf(rec.step(t, "r"))
37 if !strings.Contains(got, idTokenWant) || !strings.Contains(got, "ctx=[]") {
38 t.Errorf("want %q and an empty env context in\n%s", idTokenWant, got)
39 }
40 if strings.Contains(got, idTokenTestToken) || strings.Contains(got, " ACTIONS_ID_TOKEN_REQUEST_URL:") {
41 t.Errorf("the token or the variables are in the log:\n%s", got)
42 }
43}
44
45// NodeScriptActionHandler.cs:66-70: a node action gets them too, without a
46// cache service, and they are not in the env block of its header.
47func TestNodeActionGetsIDTokenEnv(t *testing.T) {
48 rec, res := actionRun(t, files{".github/actions/e/action.yml": idTokenAction, ".github/actions/e/main.sh": idTokenEcho + "\n"},
49 " - uses: ./.github/actions/e\n", withIDToken)
50 if res.Conclusion != ConclusionSuccess {
51 t.Fatalf("%+v", res)
52 }
53 got := logOf(rec.step(t, "Run ./.github/actions/e"))
54 if !strings.Contains(got, idTokenWant) {
55 t.Errorf("want %q in\n%s", idTokenWant, got)
56 }
57 if strings.Contains(got, idTokenTestToken) || strings.Contains(got, " ACTIONS_ID_TOKEN_REQUEST_URL:") {
58 t.Errorf("the token or the variables are in the log:\n%s", got)
59 }
60}
61
62// A composite action sets nothing itself; its steps get the variables from
63// their own handlers, as on GitHub.
64func TestCompositeStepsGetIDTokenEnv(t *testing.T) {
65 composite := "name: c\ndescription: d\nruns:\n using: composite\n steps:\n - shell: bash\n run: '" + idTokenEcho + "'\n"
66 rec, res := actionRun(t, files{".github/actions/c/action.yml": composite}, " - uses: ./.github/actions/c\n", withIDToken)
67 if res.Conclusion != ConclusionSuccess {
68 t.Fatalf("%+v", res)
69 }
70 if got := logOf(rec.step(t, "Run ./.github/actions/c")); !strings.Contains(got, idTokenWant) || strings.Contains(got, idTokenTestToken) {
71 t.Errorf("want %q in\n%s", idTokenWant, got)
72 }
73}
74
75// ContainerActionHandler.cs:232-236: a docker action gets them by name on the
76// "docker run" command line, never by value.
77func TestDockerActionGetsIDTokenEnv(t *testing.T) {
78 d, lf := fakeDocker(t, 0)
79 rec, res := actionRun(t, files{}, " - uses: docker://alpine:3.20\n", func(c *Config) {
80 c.Actions.DockerPath = d
81 withIDToken(c)
82 })
83 if res.Conclusion != ConclusionSuccess {
84 t.Fatalf("%+v %v", res, rec.names())
85 }
86 cs := calls(t, lf)
87 if len(cs) != 2 {
88 t.Fatalf("calls %v", cs)
89 }
90 args := "\n" + strings.Join(cs[1], "\n") + "\n"
91 for _, n := range []string{"ACTIONS_ID_TOKEN_REQUEST_URL", "ACTIONS_ID_TOKEN_REQUEST_TOKEN"} {
92 if !strings.Contains(args, "\n-e\n"+n+"\n") {
93 t.Errorf("docker run lacks -e %s:\n%s", n, args)
94 }
95 }
96 if strings.Contains(args, idTokenTestToken) {
97 t.Errorf("the token value is on the command line:\n%s", args)
98 }
99}
100
101// A run step in the job container gets them through "docker exec -e NAME".
102func TestRunStepInContainerGetsIDTokenEnv(t *testing.T) {
103 f := newFakeCtr(t)
104 rec, res := ctrRun(t, f, " container: ubuntu:24.04\n", " - name: s\n run: echo hi\n", withIDToken)
105 if res.Conclusion != ConclusionSuccess {
106 t.Fatalf("%+v %v", res, rec.names())
107 }
108 ex := f.execCalls(t)
109 if len(ex) != 1 {
110 t.Fatalf("exec calls %v", ex)
111 }
112 c := "\n" + strings.Join(ex[0], "\n") + "\n"
113 for _, n := range []string{"ACTIONS_ID_TOKEN_REQUEST_URL", "ACTIONS_ID_TOKEN_REQUEST_TOKEN"} {
114 if !strings.Contains(c, "\n-e\n"+n+"\n") {
115 t.Errorf("exec lacks -e %s:\n%s", n, c)
116 }
117 }
118 if strings.Contains(c, idTokenTestToken) {
119 t.Errorf("the token value is on the command line:\n%s", c)
120 }
121}
122
123// Nothing is set unless the job has both the URL and the token.
124func TestIDTokenEnvNeedsBoth(t *testing.T) {
125 for name, tweak := range map[string]func(*Config){
126 "none": nil,
127 "url only": func(c *Config) { c.IDTokenURL = idTokenTestURL },
128 "token only": func(c *Config) { c.IDTokenRequestToken = idTokenTestToken },
129 } {
130 t.Run(name, func(t *testing.T) {
131 rec, res := actionRun(t, files{".github/actions/e/action.yml": idTokenAction, ".github/actions/e/main.sh": idTokenEcho + "\n"},
132 " - name: r\n run: '"+idTokenEcho+"'\n - uses: ./.github/actions/e\n", tweak)
133 if res.Conclusion != ConclusionSuccess {
134 t.Fatalf("%+v", res)
135 }
136 for _, s := range []string{"r", "Run ./.github/actions/e"} {
137 if got := logOf(rec.step(t, s)); !strings.Contains(got, idTokenUnset) {
138 t.Errorf("%s: want %q in\n%s", s, idTokenUnset, got)
139 }
140 }
141 })
142 }
143}
144
145// The token is in the masker before the first step: a step that prints it,
146// even by another name, shows ***.
147func TestIDTokenIsMasked(t *testing.T) {
148 rec, res := actionRun(t, files{}, " - name: r\n run: echo \"leak "+idTokenTestToken+"\"\n", withIDToken)
149 if res.Conclusion != ConclusionSuccess {
150 t.Fatalf("%+v", res)
151 }
152 for _, s := range rec.steps {
153 if got := logOf(s); strings.Contains(got, idTokenTestToken) {
154 t.Errorf("the token is in the log:\n%s", got)
155 }
156 }
157 if got := logOf(rec.step(t, "r")); !strings.Contains(got, "leak ***") {
158 t.Errorf("want the token masked in\n%s", got)
159 }
160}