feat(ci): OIDC ID token request variables (#34)
- commit
- 9517f293ae373d69fe9b1d70d9b3d6d84a54dd93
- author
- brice · · 2026-10-16 08:48 UTC
- committer
- bnema · · 2026-10-16 08:48 UTC
- parent
- 0e950d6
- signature
- SSH Unverified
SHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivAThe key is unknown
13 files · +374 -10
M internal/execstream/exec.proto +11 -0
| @@ -109,6 +109,17 @@ message JobSpec { | |||
| 109 | 109 | // secrets are not available. Never set for servers without the feature, for | |
| 110 | 110 | // which an absent Job.trusted only means "not sent". | |
| 111 | 111 | bool secrets_withheld = 19; | |
| 112 | + | // id_token_url and id_token_request_token are Job.id_token_url and | |
| 113 | + | // Job.id_token_request_token: the OIDC token endpoint of the server, its | |
| 114 | + | // origin rewritten as for github.server_url, and the bearer for it. The | |
| 115 | + | // executor gives them as ACTIONS_ID_TOKEN_REQUEST_URL and | |
| 116 | + | // ACTIONS_ID_TOKEN_REQUEST_TOKEN to run steps, node actions and docker | |
| 117 | + | // actions (ScriptHandler.cs:315-319, NodeScriptActionHandler.cs:66-70, | |
| 118 | + | // ContainerActionHandler.cs:232-236), and masks the token before anything | |
| 119 | + | // runs. Both are set or neither. (21 and 22 are taken by the artifacts | |
| 120 | + | // branch.) | |
| 121 | + | string id_token_url = 23; | |
| 122 | + | string id_token_request_token = 24; | |
| 112 | 123 | // token_permissions are the effective permissions of the job's GITHUB_TOKEN | |
| 113 | 124 | // as the server sent them (Job.permissions, feature ci.secrets): "Set up job" | |
| 114 | 125 | // prints them instead of working them out from the workflow. Unset when the | |
| @@ -109,6 +109,17 @@ message JobSpec { | |||||
| 109 | // secrets are not available. Never set for servers without the feature, for | 109 | // secrets are not available. Never set for servers without the feature, for | ||
| 110 | // which an absent Job.trusted only means "not sent". | 110 | // which an absent Job.trusted only means "not sent". | ||
| 111 | bool secrets_withheld = 19; | 111 | bool secrets_withheld = 19; | ||
| 112 | + | // id_token_url and id_token_request_token are Job.id_token_url and | |||
| 113 | + | // Job.id_token_request_token: the OIDC token endpoint of the server, its | |||
| 114 | + | // origin rewritten as for github.server_url, and the bearer for it. The | |||
| 115 | + | // executor gives them as ACTIONS_ID_TOKEN_REQUEST_URL and | |||
| 116 | + | // ACTIONS_ID_TOKEN_REQUEST_TOKEN to run steps, node actions and docker | |||
| 117 | + | // actions (ScriptHandler.cs:315-319, NodeScriptActionHandler.cs:66-70, | |||
| 118 | + | // ContainerActionHandler.cs:232-236), and masks the token before anything | |||
| 119 | + | // runs. Both are set or neither. (21 and 22 are taken by the artifacts | |||
| 120 | + | // branch.) | |||
| 121 | + | string id_token_url = 23; | |||
| 122 | + | string id_token_request_token = 24; | |||
| 112 | // token_permissions are the effective permissions of the job's GITHUB_TOKEN | 123 | // token_permissions are the effective permissions of the job's GITHUB_TOKEN | ||
| 113 | // as the server sent them (Job.permissions, feature ci.secrets): "Set up job" | 124 | // as the server sent them (Job.permissions, feature ci.secrets): "Set up job" | ||
| 114 | // prints them instead of working them out from the workflow. Unset when the | 125 | // prints them instead of working them out from the workflow. Unset when the | ||
M internal/execstream/execpb/exec.pb.go +30 -2 generated file: collapsed
M internal/executor/actions_run.go +3 -0
| @@ -206,6 +206,9 @@ func (j *job) runAction(s *stepRun, cancel <-chan struct{}) stepResult { | |||
| 206 | 206 | return fail(err) | |
| 207 | 207 | } | |
| 208 | 208 | j.printDetails(s, in, env) | |
| 209 | + | if def.kind == kindNode || def.kind == kindDocker { | |
| 210 | + | j.addIDTokenEnv(env) | |
| 211 | + | } | |
| 209 | 212 | switch def.kind { | |
| 210 | 213 | case kindNode: | |
| 211 | 214 | return j.runNode(s, def, in, env, cancel) | |
| @@ -206,6 +206,9 @@ func (j *job) runAction(s *stepRun, cancel <-chan struct{}) stepResult { | |||||
| 206 | return fail(err) | 206 | return fail(err) | ||
| 207 | } | 207 | } | ||
| 208 | j.printDetails(s, in, env) | 208 | j.printDetails(s, in, env) | ||
| 209 | + | if def.kind == kindNode || def.kind == kindDocker { | |||
| 210 | + | j.addIDTokenEnv(env) | |||
| 211 | + | } | |||
| 209 | switch def.kind { | 212 | switch def.kind { | ||
| 210 | case kindNode: | 213 | case kindNode: | ||
| 211 | return j.runNode(s, def, in, env, cancel) | 214 | return j.runNode(s, def, in, env, cancel) | ||
M internal/executor/containers_exec.go +1 -1
| @@ -101,7 +101,7 @@ func (j *job) containerScript(c *container, plan ShellPlan, in scriptIn, s *step | |||
| 101 | 101 | if len(sc.Argv) > 1 { | |
| 102 | 102 | rest = sc.Argv[1:] | |
| 103 | 103 | } | |
| 104 | - | henv := j.handlerEnv(s.env) | |
| 104 | + | henv := j.handlerEnv(s.actEnv) | |
| 105 | 105 | if err := checkEnvNames(henv.Keys()); err != nil { | |
| 106 | 106 | return nil, err | |
| 107 | 107 | } | |
| @@ -101,7 +101,7 @@ func (j *job) containerScript(c *container, plan ShellPlan, in scriptIn, s *step | |||||
| 101 | if len(sc.Argv) > 1 { | 101 | if len(sc.Argv) > 1 { | ||
| 102 | rest = sc.Argv[1:] | 102 | rest = sc.Argv[1:] | ||
| 103 | } | 103 | } | ||
| 104 | - | henv := j.handlerEnv(s.env) | 104 | + | henv := j.handlerEnv(s.actEnv) |
| 105 | if err := checkEnvNames(henv.Keys()); err != nil { | 105 | if err := checkEnvNames(henv.Keys()); err != nil { | ||
| 106 | return nil, err | 106 | return nil, err | ||
| 107 | } | 107 | } | ||
M internal/executor/exec_step.go +2 -0
| @@ -38,6 +38,8 @@ func (j *job) execute(s *stepRun, stepCancel <-chan struct{}) stepResult { | |||
| 38 | 38 | if err != nil { | |
| 39 | 39 | return fail(err) // the group stays open, like the original | |
| 40 | 40 | } | |
| 41 | + | // What the handler adds goes to the process only, after the header. | |
| 42 | + | s.actEnv = j.scriptEnv(s) | |
| 41 | 43 | if c := j.stepContainer(); c != nil { | |
| 42 | 44 | argv, err := j.containerScript(c, plan, in, s) | |
| 43 | 45 | if err != nil { | |
| @@ -38,6 +38,8 @@ func (j *job) execute(s *stepRun, stepCancel <-chan struct{}) stepResult { | |||||
| 38 | if err != nil { | 38 | if err != nil { | ||
| 39 | return fail(err) // the group stays open, like the original | 39 | return fail(err) // the group stays open, like the original | ||
| 40 | } | 40 | } | ||
| 41 | + | // What the handler adds goes to the process only, after the header. | |||
| 42 | + | s.actEnv = j.scriptEnv(s) | |||
| 41 | if c := j.stepContainer(); c != nil { | 43 | if c := j.stepContainer(); c != nil { | ||
| 42 | argv, err := j.containerScript(c, plan, in, s) | 44 | argv, err := j.containerScript(c, plan, in, s) | ||
| 43 | if err != nil { | 45 | if err != nil { | ||
A internal/executor/idtoken.go +27 -0
| @@ -0,0 +1,27 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | // addIDTokenEnv is the OIDC part of the handlers (ScriptHandler.cs:315-319, | |
| 6 | + | // NodeScriptActionHandler.cs:66-70, ContainerActionHandler.cs:232-236): | |
| 7 | + | // ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN, which | |
| 8 | + | // @actions/core's getIDToken reads, for run steps, node actions and docker | |
| 9 | + | // actions. A composite action sets nothing itself: its steps get them from | |
| 10 | + | // their own handlers. Nothing is set unless the job has both (the server gives | |
| 11 | + | // them only to a trusted job with "id-token: write"). | |
| 12 | + | func (j *job) addIDTokenEnv(env *ordered) { | |
| 13 | + | if j.cfg.IDTokenURL == "" || j.cfg.IDTokenRequestToken == "" { | |
| 14 | + | return | |
| 15 | + | } | |
| 16 | + | env.Set("ACTIONS_ID_TOKEN_REQUEST_URL", j.cfg.IDTokenURL) | |
| 17 | + | env.Set("ACTIONS_ID_TOKEN_REQUEST_TOKEN", j.cfg.IDTokenRequestToken) | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // scriptEnv returns the environment of the process of a run step: the env of | |
| 21 | + | // the step, plus the variables its handler adds (addIDTokenEnv). The env of | |
| 22 | + | // the step itself, which the env context and the header show, is left alone. | |
| 23 | + | func (j *job) scriptEnv(s *stepRun) *ordered { | |
| 24 | + | env := s.env.Clone() | |
| 25 | + | j.addIDTokenEnv(env) | |
| 26 | + | return env | |
| 27 | + | } | |
| @@ -0,0 +1,27 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package executor | |||
| 4 | + | ||||
| 5 | + | // addIDTokenEnv is the OIDC part of the handlers (ScriptHandler.cs:315-319, | |||
| 6 | + | // NodeScriptActionHandler.cs:66-70, ContainerActionHandler.cs:232-236): | |||
| 7 | + | // ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN, which | |||
| 8 | + | // @actions/core's getIDToken reads, for run steps, node actions and docker | |||
| 9 | + | // actions. A composite action sets nothing itself: its steps get them from | |||
| 10 | + | // their own handlers. Nothing is set unless the job has both (the server gives | |||
| 11 | + | // them only to a trusted job with "id-token: write"). | |||
| 12 | + | func (j *job) addIDTokenEnv(env *ordered) { | |||
| 13 | + | if j.cfg.IDTokenURL == "" || j.cfg.IDTokenRequestToken == "" { | |||
| 14 | + | return | |||
| 15 | + | } | |||
| 16 | + | env.Set("ACTIONS_ID_TOKEN_REQUEST_URL", j.cfg.IDTokenURL) | |||
| 17 | + | env.Set("ACTIONS_ID_TOKEN_REQUEST_TOKEN", j.cfg.IDTokenRequestToken) | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // scriptEnv returns the environment of the process of a run step: the env of | |||
| 21 | + | // the step, plus the variables its handler adds (addIDTokenEnv). The env of | |||
| 22 | + | // the step itself, which the env context and the header show, is left alone. | |||
| 23 | + | func (j *job) scriptEnv(s *stepRun) *ordered { | |||
| 24 | + | env := s.env.Clone() | |||
| 25 | + | j.addIDTokenEnv(env) | |||
| 26 | + | return env | |||
| 27 | + | } | |||
A internal/executor/idtoken_test.go +160 -0
| @@ -0,0 +1,160 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package executor | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | const ( | |
| 11 | + | idTokenTestURL = "http://ermite.example:8088/-/oidc/token?api-version=2.0" | |
| 12 | + | idTokenTestToken = "ermiteoidc_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func withIDToken(c *Config) { | |
| 16 | + | c.IDTokenURL, c.IDTokenRequestToken = idTokenTestURL, idTokenTestToken | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | const idTokenEcho = `echo "oidc url=[${ACTIONS_ID_TOKEN_REQUEST_URL-unset}] token=[${ACTIONS_ID_TOKEN_REQUEST_TOKEN-unset}]"` | |
| 20 | + | ||
| 21 | + | const idTokenAction = "name: e\ndescription: d\nruns:\n using: node20\n main: main.sh\n" | |
| 22 | + | ||
| 23 | + | var idTokenWant = "oidc url=[" + idTokenTestURL + "] token=[***]" | |
| 24 | + | ||
| 25 | + | const 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. | |
| 30 | + | func 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. | |
| 47 | + | func 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. | |
| 64 | + | func 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. | |
| 77 | + | func 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". | |
| 102 | + | func 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. | |
| 124 | + | func 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 ***. | |
| 147 | + | func 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 | + | } | |
| @@ -0,0 +1,160 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package executor | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | const ( | |||
| 11 | + | idTokenTestURL = "http://ermite.example:8088/-/oidc/token?api-version=2.0" | |||
| 12 | + | idTokenTestToken = "ermiteoidc_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func withIDToken(c *Config) { | |||
| 16 | + | c.IDTokenURL, c.IDTokenRequestToken = idTokenTestURL, idTokenTestToken | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | const idTokenEcho = `echo "oidc url=[${ACTIONS_ID_TOKEN_REQUEST_URL-unset}] token=[${ACTIONS_ID_TOKEN_REQUEST_TOKEN-unset}]"` | |||
| 20 | + | ||||
| 21 | + | const idTokenAction = "name: e\ndescription: d\nruns:\n using: node20\n main: main.sh\n" | |||
| 22 | + | ||||
| 23 | + | var idTokenWant = "oidc url=[" + idTokenTestURL + "] token=[***]" | |||
| 24 | + | ||||
| 25 | + | const 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. | |||
| 30 | + | func 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. | |||
| 47 | + | func 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. | |||
| 64 | + | func 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. | |||
| 77 | + | func 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". | |||
| 102 | + | func 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. | |||
| 124 | + | func 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 ***. | |||
| 147 | + | func 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 | + | } | |||
M internal/executor/job.go +7 -0
| @@ -107,6 +107,12 @@ type Config struct { | |||
| 107 | 107 | // job" prints as they are. Nil means the server sent none: they are then | |
| 108 | 108 | // worked out from the workflow. An empty, non-nil map is "none at all". | |
| 109 | 109 | TokenPermissions map[string]string | |
| 110 | + | // IDTokenURL and IDTokenRequestToken are Job.id_token_url (its origin | |
| 111 | + | // already rewritten for the sandbox) and Job.id_token_request_token: | |
| 112 | + | // ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN of the run | |
| 113 | + | // steps, node actions and docker actions (see addIDTokenEnv). Nothing is set | |
| 114 | + | // unless both are. The token is masked before anything is reported. | |
| 115 | + | IDTokenURL, IDTokenRequestToken string | |
| 110 | 116 | // Debug turns ##[debug] lines on (ACTIONS_STEP_DEBUG). | |
| 111 | 117 | Debug bool | |
| 112 | 118 | // Actions configures the actions of the job (resolution, downloads, Node.js, | |
| @@ -258,6 +264,7 @@ func Run(cancel *Canceller, cfg Config) Result { | |||
| 258 | 264 | // values), so a one-character secret is masked too. | |
| 259 | 265 | j.masker.Store(newMasker()) | |
| 260 | 266 | registerSecrets(j.masker.Load(), cfg.Secrets) | |
| 267 | + | j.masker.Load().add(cfg.IDTokenRequestToken) | |
| 261 | 268 | j.cfg.Reporter = &maskReporter{j: j, next: cfg.Reporter} | |
| 262 | 269 | defer func() { | |
| 263 | 270 | if j.fc != nil { | |
| @@ -107,6 +107,12 @@ type Config struct { | |||||
| 107 | // job" prints as they are. Nil means the server sent none: they are then | 107 | // job" prints as they are. Nil means the server sent none: they are then | ||
| 108 | // worked out from the workflow. An empty, non-nil map is "none at all". | 108 | // worked out from the workflow. An empty, non-nil map is "none at all". | ||
| 109 | TokenPermissions map[string]string | 109 | TokenPermissions map[string]string | ||
| 110 | + | // IDTokenURL and IDTokenRequestToken are Job.id_token_url (its origin | |||
| 111 | + | // already rewritten for the sandbox) and Job.id_token_request_token: | |||
| 112 | + | // ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN of the run | |||
| 113 | + | // steps, node actions and docker actions (see addIDTokenEnv). Nothing is set | |||
| 114 | + | // unless both are. The token is masked before anything is reported. | |||
| 115 | + | IDTokenURL, IDTokenRequestToken string | |||
| 110 | // Debug turns ##[debug] lines on (ACTIONS_STEP_DEBUG). | 116 | // Debug turns ##[debug] lines on (ACTIONS_STEP_DEBUG). | ||
| 111 | Debug bool | 117 | Debug bool | ||
| 112 | // Actions configures the actions of the job (resolution, downloads, Node.js, | 118 | // Actions configures the actions of the job (resolution, downloads, Node.js, | ||
| @@ -258,6 +264,7 @@ func Run(cancel *Canceller, cfg Config) Result { | |||||
| 258 | // values), so a one-character secret is masked too. | 264 | // values), so a one-character secret is masked too. | ||
| 259 | j.masker.Store(newMasker()) | 265 | j.masker.Store(newMasker()) | ||
| 260 | registerSecrets(j.masker.Load(), cfg.Secrets) | 266 | registerSecrets(j.masker.Load(), cfg.Secrets) | ||
| 267 | + | j.masker.Load().add(cfg.IDTokenRequestToken) | |||
| 261 | j.cfg.Reporter = &maskReporter{j: j, next: cfg.Reporter} | 268 | j.cfg.Reporter = &maskReporter{j: j, next: cfg.Reporter} | ||
| 262 | defer func() { | 269 | defer func() { | ||
| 263 | if j.fc != nil { | 270 | if j.fc != nil { | ||
M internal/guest/jobexec.go +1 -0
| @@ -265,6 +265,7 @@ func runJob(ctx context.Context, conn *execstream.Conn, cfg ExecConfig, spec *ex | |||
| 265 | 265 | Secrets: vals.secrets, Vars: vals.vars, SecretsWithheld: spec.GetSecretsWithheld(), TokenPermissions: tokenPermissions(spec), | |
| 266 | 266 | SendAnnotations: spec.GetSendAnnotations(), SendSummaries: spec.GetSendSummaries(), | |
| 267 | 267 | TermAfter: cfg.TermAfter, KillAfter: cfg.KillAfter, WaitDelay: cfg.WaitDelay, | |
| 268 | + | IDTokenURL: spec.GetIdTokenUrl(), IDTokenRequestToken: spec.GetIdTokenRequestToken(), | |
| 268 | 269 | }) | |
| 269 | 270 | if result.Conclusion == executor.ConclusionSuccess { | |
| 270 | 271 | return 0 | |
| @@ -265,6 +265,7 @@ func runJob(ctx context.Context, conn *execstream.Conn, cfg ExecConfig, spec *ex | |||||
| 265 | Secrets: vals.secrets, Vars: vals.vars, SecretsWithheld: spec.GetSecretsWithheld(), TokenPermissions: tokenPermissions(spec), | 265 | Secrets: vals.secrets, Vars: vals.vars, SecretsWithheld: spec.GetSecretsWithheld(), TokenPermissions: tokenPermissions(spec), | ||
| 266 | SendAnnotations: spec.GetSendAnnotations(), SendSummaries: spec.GetSendSummaries(), | 266 | SendAnnotations: spec.GetSendAnnotations(), SendSummaries: spec.GetSendSummaries(), | ||
| 267 | TermAfter: cfg.TermAfter, KillAfter: cfg.KillAfter, WaitDelay: cfg.WaitDelay, | 267 | TermAfter: cfg.TermAfter, KillAfter: cfg.KillAfter, WaitDelay: cfg.WaitDelay, | ||
| 268 | + | IDTokenURL: spec.GetIdTokenUrl(), IDTokenRequestToken: spec.GetIdTokenRequestToken(), | |||
| 268 | }) | 269 | }) | ||
| 269 | if result.Conclusion == executor.ConclusionSuccess { | 270 | if result.Conclusion == executor.ConclusionSuccess { | ||
| 270 | return 0 | 271 | return 0 | ||
M internal/guest/jobexec_test.go +23 -0
| @@ -414,3 +414,26 @@ func TestTokenPermissionsNilVersusEmpty(t *testing.T) { | |||
| 414 | 414 | } | |
| 415 | 415 | } | |
| 416 | 416 | } | |
| 417 | + | ||
| 418 | + | // JobSpec.id_token_url and id_token_request_token reach the run steps as | |
| 419 | + | // ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN, the token | |
| 420 | + | // masked in every log line. | |
| 421 | + | func TestExecJobGetsIDTokenRequest(t *testing.T) { | |
| 422 | + | requireBash(t) | |
| 423 | + | const url, token = "http://ermite.example:8088/-/oidc/token?api-version=2.0", "ermiteoidc_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" | |
| 424 | + | yaml := "on: push\njobs:\n j:\n runs-on: x\n steps:\n - run: echo \"oidc [$ACTIONS_ID_TOKEN_REQUEST_URL] [$ACTIONS_ID_TOKEN_REQUEST_TOKEN]\"\n" | |
| 425 | + | _, logs, res, _ := runJobSessionSpec(t, yaml, "", nil, func(s *execpb.JobSpec) { s.IdTokenUrl, s.IdTokenRequestToken = url, token }) | |
| 426 | + | if res == nil || res.Conclusion != execpb.StepConclusion_STEP_CONCLUSION_SUCCESS { | |
| 427 | + | t.Fatalf("%+v", res) | |
| 428 | + | } | |
| 429 | + | found := false | |
| 430 | + | for _, l := range logs { | |
| 431 | + | if strings.Contains(l.Text, token) { | |
| 432 | + | t.Errorf("the token is in the log: %q", l.Text) | |
| 433 | + | } | |
| 434 | + | found = found || l.Text == "oidc ["+url+"] [***]" | |
| 435 | + | } | |
| 436 | + | if !found { | |
| 437 | + | t.Error("the step did not print the variables") | |
| 438 | + | } | |
| 439 | + | } | |
| @@ -414,3 +414,26 @@ func TestTokenPermissionsNilVersusEmpty(t *testing.T) { | |||||
| 414 | } | 414 | } | ||
| 415 | } | 415 | } | ||
| 416 | } | 416 | } | ||
| 417 | + | ||||
| 418 | + | // JobSpec.id_token_url and id_token_request_token reach the run steps as | |||
| 419 | + | // ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN, the token | |||
| 420 | + | // masked in every log line. | |||
| 421 | + | func TestExecJobGetsIDTokenRequest(t *testing.T) { | |||
| 422 | + | requireBash(t) | |||
| 423 | + | const url, token = "http://ermite.example:8088/-/oidc/token?api-version=2.0", "ermiteoidc_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" | |||
| 424 | + | yaml := "on: push\njobs:\n j:\n runs-on: x\n steps:\n - run: echo \"oidc [$ACTIONS_ID_TOKEN_REQUEST_URL] [$ACTIONS_ID_TOKEN_REQUEST_TOKEN]\"\n" | |||
| 425 | + | _, logs, res, _ := runJobSessionSpec(t, yaml, "", nil, func(s *execpb.JobSpec) { s.IdTokenUrl, s.IdTokenRequestToken = url, token }) | |||
| 426 | + | if res == nil || res.Conclusion != execpb.StepConclusion_STEP_CONCLUSION_SUCCESS { | |||
| 427 | + | t.Fatalf("%+v", res) | |||
| 428 | + | } | |||
| 429 | + | found := false | |||
| 430 | + | for _, l := range logs { | |||
| 431 | + | if strings.Contains(l.Text, token) { | |||
| 432 | + | t.Errorf("the token is in the log: %q", l.Text) | |||
| 433 | + | } | |||
| 434 | + | found = found || l.Text == "oidc ["+url+"] [***]" | |||
| 435 | + | } | |||
| 436 | + | if !found { | |||
| 437 | + | t.Error("the step did not print the variables") | |||
| 438 | + | } | |||
| 439 | + | } | |||
M internal/host/guestforge.go +21 -7
| @@ -81,16 +81,14 @@ func RewriteServerURLs(contexts, target string) (string, error) { | |||
| 81 | 81 | continue | |
| 82 | 82 | } | |
| 83 | 83 | s, ok := str(m.val) | |
| 84 | - | if !ok || originOf(s) != public { | |
| 84 | + | if !ok { | |
| 85 | 85 | continue | |
| 86 | 86 | } | |
| 87 | - | rest := s[strings.Index(s, "://")+3:] | |
| 88 | - | if k := strings.IndexAny(rest, "/?#"); k >= 0 { | |
| 89 | - | rest = rest[k:] | |
| 90 | - | } else { | |
| 91 | - | rest = "" | |
| 87 | + | u, ok := rewriteOrigin(s, public, target) | |
| 88 | + | if !ok { | |
| 89 | + | continue | |
| 92 | 90 | } | |
| 93 | - | nv, err := marshalNoEscape(target + rest) | |
| 91 | + | nv, err := marshalNoEscape(u) | |
| 94 | 92 | if err != nil { | |
| 95 | 93 | return "", err | |
| 96 | 94 | } | |
| @@ -110,6 +108,22 @@ func RewriteServerURLs(contexts, target string) (string, error) { | |||
| 110 | 108 | return string(out), nil | |
| 111 | 109 | } | |
| 112 | 110 | ||
| 111 | + | // rewriteOrigin returns raw with its origin replaced by target and true when | |
| 112 | + | // the origin of raw is public (as originOf spells it); the path, the query and | |
| 113 | + | // the fragment are kept. Anything else is returned as it is, with false. | |
| 114 | + | func rewriteOrigin(raw, public, target string) (string, bool) { | |
| 115 | + | if public == "" || target == "" || originOf(raw) != public { | |
| 116 | + | return raw, false | |
| 117 | + | } | |
| 118 | + | rest := raw[strings.Index(raw, "://")+3:] | |
| 119 | + | if k := strings.IndexAny(rest, "/?#"); k >= 0 { | |
| 120 | + | rest = rest[k:] | |
| 121 | + | } else { | |
| 122 | + | rest = "" | |
| 123 | + | } | |
| 124 | + | return target + rest, true | |
| 125 | + | } | |
| 126 | + | ||
| 113 | 127 | // originOf returns "scheme://host[:port]" of an http(s) URL, lower-cased and | |
| 114 | 128 | // without the default port, an IPv6 host in brackets, or "" for anything else | |
| 115 | 129 | // (including URLs with credentials, which are never rewritten). It is | |
| @@ -81,16 +81,14 @@ func RewriteServerURLs(contexts, target string) (string, error) { | |||||
| 81 | continue | 81 | continue | ||
| 82 | } | 82 | } | ||
| 83 | s, ok := str(m.val) | 83 | s, ok := str(m.val) | ||
| 84 | - | if !ok || originOf(s) != public { | 84 | + | if !ok { |
| 85 | continue | 85 | continue | ||
| 86 | } | 86 | } | ||
| 87 | - | rest := s[strings.Index(s, "://")+3:] | 87 | + | u, ok := rewriteOrigin(s, public, target) |
| 88 | - | if k := strings.IndexAny(rest, "/?#"); k >= 0 { | 88 | + | if !ok { |
| 89 | - | rest = rest[k:] | 89 | + | continue |
| 90 | - | } else { | |||
| 91 | - | rest = "" | |||
| 92 | } | 90 | } | ||
| 93 | - | nv, err := marshalNoEscape(target + rest) | 91 | + | nv, err := marshalNoEscape(u) |
| 94 | if err != nil { | 92 | if err != nil { | ||
| 95 | return "", err | 93 | return "", err | ||
| 96 | } | 94 | } | ||
| @@ -110,6 +108,22 @@ func RewriteServerURLs(contexts, target string) (string, error) { | |||||
| 110 | return string(out), nil | 108 | return string(out), nil | ||
| 111 | } | 109 | } | ||
| 112 | 110 | ||||
| 111 | + | // rewriteOrigin returns raw with its origin replaced by target and true when | |||
| 112 | + | // the origin of raw is public (as originOf spells it); the path, the query and | |||
| 113 | + | // the fragment are kept. Anything else is returned as it is, with false. | |||
| 114 | + | func rewriteOrigin(raw, public, target string) (string, bool) { | |||
| 115 | + | if public == "" || target == "" || originOf(raw) != public { | |||
| 116 | + | return raw, false | |||
| 117 | + | } | |||
| 118 | + | rest := raw[strings.Index(raw, "://")+3:] | |||
| 119 | + | if k := strings.IndexAny(rest, "/?#"); k >= 0 { | |||
| 120 | + | rest = rest[k:] | |||
| 121 | + | } else { | |||
| 122 | + | rest = "" | |||
| 123 | + | } | |||
| 124 | + | return target + rest, true | |||
| 125 | + | } | |||
| 126 | + | ||||
| 113 | // originOf returns "scheme://host[:port]" of an http(s) URL, lower-cased and | 127 | // originOf returns "scheme://host[:port]" of an http(s) URL, lower-cased and | ||
| 114 | // without the default port, an IPv6 host in brackets, or "" for anything else | 128 | // without the default port, an IPv6 host in brackets, or "" for anything else | ||
| 115 | // (including URLs with credentials, which are never rewritten). It is | 129 | // (including URLs with credentials, which are never rewritten). It is | ||
A internal/host/idtoken_test.go +71 -0
| @@ -0,0 +1,71 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package host | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | ermite "bnema.dev/ermite-api/gen/ermite" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite-runner/internal/execstream" | |
| 11 | + | "bnema.dev/ermite-runner/internal/execstream/execpb" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | const ( | |
| 15 | + | oidcPublicURL = "https://ermite.bnema.dev/-/oidc/token?api-version=2.0" | |
| 16 | + | oidcToken = "ermiteoidc_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // The executor gets Job.id_token_url, its origin rewritten to the guest forge | |
| 20 | + | // like github.server_url (path and query kept), and the token, only when the | |
| 21 | + | // server sent both. | |
| 22 | + | func TestIDTokenRequestReachesTheExecutor(t *testing.T) { | |
| 23 | + | for _, tc := range []struct { | |
| 24 | + | name, url, token, guest string | |
| 25 | + | wantURL, wantToken string | |
| 26 | + | }{ | |
| 27 | + | {"guest forge", oidcPublicURL, oidcToken, "http://ermite.bnema.dev:8088", "http://ermite.bnema.dev:8088/-/oidc/token?api-version=2.0", oidcToken}, | |
| 28 | + | {"no guest forge", oidcPublicURL, oidcToken, "", oidcPublicURL, oidcToken}, | |
| 29 | + | {"other origin", "https://other.example/-/oidc/token?api-version=2.0", oidcToken, "http://ermite.bnema.dev:8088", "https://other.example/-/oidc/token?api-version=2.0", oidcToken}, | |
| 30 | + | {"url only", oidcPublicURL, "", "http://ermite.bnema.dev:8088", "", ""}, | |
| 31 | + | {"token only", "", oidcToken, "http://ermite.bnema.dev:8088", "", ""}, | |
| 32 | + | {"none", "", "", "", "", ""}, | |
| 33 | + | } { | |
| 34 | + | t.Run(tc.name, func(t *testing.T) { | |
| 35 | + | h := newHarness(t, simpleScript("x")) | |
| 36 | + | job := testJob("j1") | |
| 37 | + | job.Contexts = publicContexts | |
| 38 | + | job.IdTokenUrl, job.IdTokenRequestToken = tc.url, tc.token | |
| 39 | + | h.srv.jobs = []*ermite.Job{job} | |
| 40 | + | h.runner.Cfg.GuestForgeOrigin = tc.guest | |
| 41 | + | var spec *execpb.JobSpec | |
| 42 | + | inner := h.driver.script | |
| 43 | + | h.driver.script = func(t *testing.T, c *execstream.Conn, run *execpb.Run, cn <-chan *execpb.Cancel) { | |
| 44 | + | spec = run.Job | |
| 45 | + | inner(t, c, run, cn) | |
| 46 | + | } | |
| 47 | + | h.run(t) | |
| 48 | + | eventually(t, "CompleteJob", func() bool { return h.srv.completeCount() == 1 }) | |
| 49 | + | if spec == nil { | |
| 50 | + | t.Fatal("no Run") | |
| 51 | + | } | |
| 52 | + | if spec.GetIdTokenUrl() != tc.wantURL || spec.GetIdTokenRequestToken() != tc.wantToken { | |
| 53 | + | t.Errorf("got %q %q, want %q %q", spec.GetIdTokenUrl(), spec.GetIdTokenRequestToken(), tc.wantURL, tc.wantToken) | |
| 54 | + | } | |
| 55 | + | }) | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func TestRewriteOrigin(t *testing.T) { | |
| 60 | + | for _, tc := range []struct{ in, want string }{ | |
| 61 | + | {"https://ermite.bnema.dev/-/oidc/token?api-version=2.0", "http://g:8088/-/oidc/token?api-version=2.0"}, | |
| 62 | + | {"https://ermite.bnema.dev:443?a=b", "http://g:8088?a=b"}, | |
| 63 | + | {"https://ermite.bnema.dev", "http://g:8088"}, | |
| 64 | + | {"https://other.example/x", "https://other.example/x"}, | |
| 65 | + | {"https://u@ermite.bnema.dev/x", "https://u@ermite.bnema.dev/x"}, | |
| 66 | + | } { | |
| 67 | + | if got, _ := rewriteOrigin(tc.in, "https://ermite.bnema.dev", "http://g:8088"); got != tc.want { | |
| 68 | + | t.Errorf("rewriteOrigin(%q) = %q, want %q", tc.in, got, tc.want) | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | } | |
| @@ -0,0 +1,71 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package host | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | ermite "bnema.dev/ermite-api/gen/ermite" | |||
| 9 | + | ||||
| 10 | + | "bnema.dev/ermite-runner/internal/execstream" | |||
| 11 | + | "bnema.dev/ermite-runner/internal/execstream/execpb" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | const ( | |||
| 15 | + | oidcPublicURL = "https://ermite.bnema.dev/-/oidc/token?api-version=2.0" | |||
| 16 | + | oidcToken = "ermiteoidc_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | // The executor gets Job.id_token_url, its origin rewritten to the guest forge | |||
| 20 | + | // like github.server_url (path and query kept), and the token, only when the | |||
| 21 | + | // server sent both. | |||
| 22 | + | func TestIDTokenRequestReachesTheExecutor(t *testing.T) { | |||
| 23 | + | for _, tc := range []struct { | |||
| 24 | + | name, url, token, guest string | |||
| 25 | + | wantURL, wantToken string | |||
| 26 | + | }{ | |||
| 27 | + | {"guest forge", oidcPublicURL, oidcToken, "http://ermite.bnema.dev:8088", "http://ermite.bnema.dev:8088/-/oidc/token?api-version=2.0", oidcToken}, | |||
| 28 | + | {"no guest forge", oidcPublicURL, oidcToken, "", oidcPublicURL, oidcToken}, | |||
| 29 | + | {"other origin", "https://other.example/-/oidc/token?api-version=2.0", oidcToken, "http://ermite.bnema.dev:8088", "https://other.example/-/oidc/token?api-version=2.0", oidcToken}, | |||
| 30 | + | {"url only", oidcPublicURL, "", "http://ermite.bnema.dev:8088", "", ""}, | |||
| 31 | + | {"token only", "", oidcToken, "http://ermite.bnema.dev:8088", "", ""}, | |||
| 32 | + | {"none", "", "", "", "", ""}, | |||
| 33 | + | } { | |||
| 34 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 35 | + | h := newHarness(t, simpleScript("x")) | |||
| 36 | + | job := testJob("j1") | |||
| 37 | + | job.Contexts = publicContexts | |||
| 38 | + | job.IdTokenUrl, job.IdTokenRequestToken = tc.url, tc.token | |||
| 39 | + | h.srv.jobs = []*ermite.Job{job} | |||
| 40 | + | h.runner.Cfg.GuestForgeOrigin = tc.guest | |||
| 41 | + | var spec *execpb.JobSpec | |||
| 42 | + | inner := h.driver.script | |||
| 43 | + | h.driver.script = func(t *testing.T, c *execstream.Conn, run *execpb.Run, cn <-chan *execpb.Cancel) { | |||
| 44 | + | spec = run.Job | |||
| 45 | + | inner(t, c, run, cn) | |||
| 46 | + | } | |||
| 47 | + | h.run(t) | |||
| 48 | + | eventually(t, "CompleteJob", func() bool { return h.srv.completeCount() == 1 }) | |||
| 49 | + | if spec == nil { | |||
| 50 | + | t.Fatal("no Run") | |||
| 51 | + | } | |||
| 52 | + | if spec.GetIdTokenUrl() != tc.wantURL || spec.GetIdTokenRequestToken() != tc.wantToken { | |||
| 53 | + | t.Errorf("got %q %q, want %q %q", spec.GetIdTokenUrl(), spec.GetIdTokenRequestToken(), tc.wantURL, tc.wantToken) | |||
| 54 | + | } | |||
| 55 | + | }) | |||
| 56 | + | } | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func TestRewriteOrigin(t *testing.T) { | |||
| 60 | + | for _, tc := range []struct{ in, want string }{ | |||
| 61 | + | {"https://ermite.bnema.dev/-/oidc/token?api-version=2.0", "http://g:8088/-/oidc/token?api-version=2.0"}, | |||
| 62 | + | {"https://ermite.bnema.dev:443?a=b", "http://g:8088?a=b"}, | |||
| 63 | + | {"https://ermite.bnema.dev", "http://g:8088"}, | |||
| 64 | + | {"https://other.example/x", "https://other.example/x"}, | |||
| 65 | + | {"https://u@ermite.bnema.dev/x", "https://u@ermite.bnema.dev/x"}, | |||
| 66 | + | } { | |||
| 67 | + | if got, _ := rewriteOrigin(tc.in, "https://ermite.bnema.dev", "http://g:8088"); got != tc.want { | |||
| 68 | + | t.Errorf("rewriteOrigin(%q) = %q, want %q", tc.in, got, tc.want) | |||
| 69 | + | } | |||
| 70 | + | } | |||
| 71 | + | } | |||
M internal/host/job.go +17 -0
| @@ -700,6 +700,7 @@ func (j *jobRun) drive(conn sandbox.Conn, rt *Runner) (*execpb.Exit, error) { | |||
| 700 | 700 | // only when the server has ci.secrets. | |
| 701 | 701 | SecretsWithheld: j.features.secrets && !j.job.Trusted, | |
| 702 | 702 | }} | |
| 703 | + | run.Job.IdTokenUrl, run.Job.IdTokenRequestToken = j.idTokenRequest() | |
| 703 | 704 | if p := j.job.Permissions; p != nil && j.features.secrets { | |
| 704 | 705 | run.Job.TokenPermissions = &execpb.TokenPermissions{Levels: p.GetLevels()} | |
| 705 | 706 | } | |
| @@ -715,6 +716,22 @@ func (j *jobRun) drive(conn sandbox.Conn, rt *Runner) (*execpb.Exit, error) { | |||
| 715 | 716 | return exit, err | |
| 716 | 717 | } | |
| 717 | 718 | ||
| 719 | + | // idTokenRequest returns Job.id_token_url, its origin rewritten to the guest | |
| 720 | + | // forge as for github.server_url (RewriteServerURLs), and | |
| 721 | + | // Job.id_token_request_token: the ACTIONS_ID_TOKEN_REQUEST_URL and | |
| 722 | + | // ACTIONS_ID_TOKEN_REQUEST_TOKEN of the steps. Both are empty unless the | |
| 723 | + | // server sent both. The token is never logged. | |
| 724 | + | func (j *jobRun) idTokenRequest() (url, token string) { | |
| 725 | + | url, token = j.job.GetIdTokenUrl(), j.job.GetIdTokenRequestToken() | |
| 726 | + | if url == "" || token == "" { | |
| 727 | + | return "", "" | |
| 728 | + | } | |
| 729 | + | if j.guestForge != "" { | |
| 730 | + | url, _ = rewriteOrigin(url, j.publicOrigin, j.guestForge) | |
| 731 | + | } | |
| 732 | + | return url, token | |
| 733 | + | } | |
| 734 | + | ||
| 718 | 735 | // outcomeOf decides how the job ended from what the executor reported and what | |
| 719 | 736 | // the host asked of it. | |
| 720 | 737 | func (j *jobRun) outcomeOf(ctx context.Context, exit *execpb.Exit, driveErr error) outcome { | |
| @@ -700,6 +700,7 @@ func (j *jobRun) drive(conn sandbox.Conn, rt *Runner) (*execpb.Exit, error) { | |||||
| 700 | // only when the server has ci.secrets. | 700 | // only when the server has ci.secrets. | ||
| 701 | SecretsWithheld: j.features.secrets && !j.job.Trusted, | 701 | SecretsWithheld: j.features.secrets && !j.job.Trusted, | ||
| 702 | }} | 702 | }} | ||
| 703 | + | run.Job.IdTokenUrl, run.Job.IdTokenRequestToken = j.idTokenRequest() | |||
| 703 | if p := j.job.Permissions; p != nil && j.features.secrets { | 704 | if p := j.job.Permissions; p != nil && j.features.secrets { | ||
| 704 | run.Job.TokenPermissions = &execpb.TokenPermissions{Levels: p.GetLevels()} | 705 | run.Job.TokenPermissions = &execpb.TokenPermissions{Levels: p.GetLevels()} | ||
| 705 | } | 706 | } | ||
| @@ -715,6 +716,22 @@ func (j *jobRun) drive(conn sandbox.Conn, rt *Runner) (*execpb.Exit, error) { | |||||
| 715 | return exit, err | 716 | return exit, err | ||
| 716 | } | 717 | } | ||
| 717 | 718 | ||||
| 719 | + | // idTokenRequest returns Job.id_token_url, its origin rewritten to the guest | |||
| 720 | + | // forge as for github.server_url (RewriteServerURLs), and | |||
| 721 | + | // Job.id_token_request_token: the ACTIONS_ID_TOKEN_REQUEST_URL and | |||
| 722 | + | // ACTIONS_ID_TOKEN_REQUEST_TOKEN of the steps. Both are empty unless the | |||
| 723 | + | // server sent both. The token is never logged. | |||
| 724 | + | func (j *jobRun) idTokenRequest() (url, token string) { | |||
| 725 | + | url, token = j.job.GetIdTokenUrl(), j.job.GetIdTokenRequestToken() | |||
| 726 | + | if url == "" || token == "" { | |||
| 727 | + | return "", "" | |||
| 728 | + | } | |||
| 729 | + | if j.guestForge != "" { | |||
| 730 | + | url, _ = rewriteOrigin(url, j.publicOrigin, j.guestForge) | |||
| 731 | + | } | |||
| 732 | + | return url, token | |||
| 733 | + | } | |||
| 734 | + | ||||
| 718 | // outcomeOf decides how the job ended from what the executor reported and what | 735 | // outcomeOf decides how the job ended from what the executor reported and what | ||
| 719 | // the host asked of it. | 736 | // the host asked of it. | ||
| 720 | func (j *jobRun) outcomeOf(ctx context.Context, exit *execpb.Exit, driveErr error) outcome { | 737 | func (j *jobRun) outcomeOf(ctx context.Context, exit *execpb.Exit, driveErr error) outcome { | ||