tenseleyflow/shithub / 841bff9

Browse files

actions/runner: keep sensitive values out of argv

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
841bff957b8f966a82afce8bb83c3af0580f7b1d
Parents
1a11410
Tree
136eb49

8 changed files

StatusFile+-
M internal/actions/expr/eval.go 28 25
M internal/actions/expr/eval_test.go 29 0
M internal/runner/config/config.go 4 1
M internal/runner/config/config_test.go 6 0
M internal/runner/engine/docker.go 16 5
M internal/runner/engine/docker_test.go 104 6
M internal/runner/exec/render.go 41 17
M internal/runner/exec/render_test.go 75 0
internal/actions/expr/eval.gomodified
@@ -15,13 +15,15 @@ import (
1515
 //
1616
 // Tainted=true means the value transitively depends on an
1717
 // untrusted-source reference (anything in the shithub.event.*
18
-// namespace). The runner's exec layer (S41d) refuses to interpolate
19
-// Tainted values into shell strings.
18
+// namespace). Sensitive=true means the value transitively contains a
19
+// secret. The runner's exec layer (S41d/S41e) never interpolates either
20
+// class directly into shell strings.
2021
 type Value struct {
21
-	Kind    Kind
22
-	S       string
23
-	B       bool
24
-	Tainted bool
22
+	Kind      Kind
23
+	S         string
24
+	B         bool
25
+	Tainted   bool
26
+	Sensitive bool
2527
 }
2628
 
2729
 // Kind classifies a Value.
@@ -75,13 +77,14 @@ func (v Value) Truthy() bool {
7577
 // — we may extend in v2 if other namespaces grow user-controlled
7678
 // fields.
7779
 type Context struct {
78
-	Secrets   map[string]string
79
-	Vars      map[string]string
80
-	Env       map[string]string
81
-	EnvTaint  map[string]bool
82
-	Shithub   ShithubContext
83
-	Untrusted map[string]struct{} // namespace prefixes
84
-	JobStatus JobStatus           // for success()/failure()/always()/cancelled()
80
+	Secrets      map[string]string
81
+	Vars         map[string]string
82
+	Env          map[string]string
83
+	EnvTaint     map[string]bool
84
+	EnvSensitive map[string]bool
85
+	Shithub      ShithubContext
86
+	Untrusted    map[string]struct{} // namespace prefixes
87
+	JobStatus    JobStatus           // for success()/failure()/always()/cancelled()
8588
 }
8689
 
8790
 // ShithubContext is the typed `shithub.*` slot. Event is a free-form
@@ -130,7 +133,7 @@ func Eval(e Expr, ctx *Context) (Value, error) {
130133
 		if err != nil {
131134
 			return Value{}, err
132135
 		}
133
-		return Value{Kind: KindBool, B: !x.Truthy(), Tainted: x.Tainted}, nil
136
+		return Value{Kind: KindBool, B: !x.Truthy(), Tainted: x.Tainted, Sensitive: x.Sensitive}, nil
134137
 	case Binary:
135138
 		return evalBinary(n, ctx)
136139
 	}
@@ -172,11 +175,11 @@ func evalRef(r Ref, ctx *Context) (Value, error) {
172175
 		if !ok {
173176
 			return Value{}, fmt.Errorf("expr: secret %q not bound", path[1])
174177
 		}
175
-		// Secrets are NEVER tainted — they're operator-controlled.
176
-		// The runner's log scrubber (S41e) replaces their values in
177
-		// log output, but the shell-injection guard cares about
178
-		// untrusted-source taint, not secret-vs-not.
179
-		return Value{Kind: KindString, S: v}, nil
178
+		// Secrets are never tainted because they're operator-controlled,
179
+		// but they are sensitive. The runner render layer binds them
180
+		// through env references rather than embedding plaintext in
181
+		// `bash -c` argv.
182
+		return Value{Kind: KindString, S: v, Sensitive: true}, nil
180183
 	case "vars":
181184
 		if len(path) != 2 {
182185
 			return Value{}, fmt.Errorf("expr: vars.<name> requires exactly one member")
@@ -195,7 +198,7 @@ func evalRef(r Ref, ctx *Context) (Value, error) {
195198
 		if !ok {
196199
 			return Value{Kind: KindString, S: ""}, nil
197200
 		}
198
-		return Value{Kind: KindString, S: v, Tainted: ctx.EnvTaint[path[1]]}, nil
201
+		return Value{Kind: KindString, S: v, Tainted: ctx.EnvTaint[path[1]], Sensitive: ctx.EnvSensitive[path[1]]}, nil
199202
 	case "shithub":
200203
 		return evalShithub(path[1:], ctx, tainted)
201204
 	}
@@ -313,7 +316,7 @@ func strFnArity2(c Call, ctx *Context, name string, fn func(string, string) bool
313316
 		return Value{}, err
314317
 	}
315318
 	tainted := a.Tainted || b.Tainted
316
-	return Value{Kind: KindBool, B: fn(a.String(), b.String()), Tainted: tainted}, nil
319
+	return Value{Kind: KindBool, B: fn(a.String(), b.String()), Tainted: tainted, Sensitive: a.Sensitive || b.Sensitive}, nil
317320
 }
318321
 
319322
 func evalBinary(n Binary, ctx *Context) (Value, error) {
@@ -330,7 +333,7 @@ func evalBinary(n Binary, ctx *Context) (Value, error) {
330333
 		if err != nil {
331334
 			return Value{}, err
332335
 		}
333
-		return Value{Kind: r.Kind, S: r.S, B: r.B, Tainted: l.Tainted || r.Tainted}, nil
336
+		return Value{Kind: r.Kind, S: r.S, B: r.B, Tainted: l.Tainted || r.Tainted, Sensitive: l.Sensitive || r.Sensitive}, nil
334337
 	case "||":
335338
 		if l.Truthy() {
336339
 			return l, nil
@@ -339,7 +342,7 @@ func evalBinary(n Binary, ctx *Context) (Value, error) {
339342
 		if err != nil {
340343
 			return Value{}, err
341344
 		}
342
-		return Value{Kind: r.Kind, S: r.S, B: r.B, Tainted: l.Tainted || r.Tainted}, nil
345
+		return Value{Kind: r.Kind, S: r.S, B: r.B, Tainted: l.Tainted || r.Tainted, Sensitive: l.Sensitive || r.Sensitive}, nil
343346
 	}
344347
 	r, err := Eval(n.R, ctx)
345348
 	if err != nil {
@@ -348,9 +351,9 @@ func evalBinary(n Binary, ctx *Context) (Value, error) {
348351
 	tainted := l.Tainted || r.Tainted
349352
 	switch n.Op {
350353
 	case "==":
351
-		return Value{Kind: KindBool, B: valuesEqual(l, r), Tainted: tainted}, nil
354
+		return Value{Kind: KindBool, B: valuesEqual(l, r), Tainted: tainted, Sensitive: l.Sensitive || r.Sensitive}, nil
352355
 	case "!=":
353
-		return Value{Kind: KindBool, B: !valuesEqual(l, r), Tainted: tainted}, nil
356
+		return Value{Kind: KindBool, B: !valuesEqual(l, r), Tainted: tainted, Sensitive: l.Sensitive || r.Sensitive}, nil
354357
 	}
355358
 	return Value{}, fmt.Errorf("expr: unknown binary operator %q", n.Op)
356359
 }
internal/actions/expr/eval_test.gomodified
@@ -120,6 +120,35 @@ func TestEval_TaintNotFromTrustedSources(t *testing.T) {
120120
 	}
121121
 }
122122
 
123
+func TestEval_SecretsAreSensitiveNotTainted(t *testing.T) {
124
+	t.Parallel()
125
+	ctx := defaultContext()
126
+	v, err := evalString(t, `secrets.MY_SECRET`, ctx)
127
+	if err != nil {
128
+		t.Fatalf("eval: %v", err)
129
+	}
130
+	if v.Tainted {
131
+		t.Fatal("secrets must not be tainted; they are operator-controlled")
132
+	}
133
+	if !v.Sensitive {
134
+		t.Fatal("secrets must be sensitive so runner argv never contains plaintext secret values")
135
+	}
136
+}
137
+
138
+func TestEval_EnvSensitivityPropagates(t *testing.T) {
139
+	t.Parallel()
140
+	ctx := defaultContext()
141
+	ctx.Env["TOKEN"] = "hunter2"
142
+	ctx.EnvSensitive = map[string]bool{"TOKEN": true}
143
+	v, err := evalString(t, `env.TOKEN`, ctx)
144
+	if err != nil {
145
+		t.Fatalf("Eval: %v", err)
146
+	}
147
+	if !v.Sensitive {
148
+		t.Fatal("env values resolved from secrets must remain sensitive")
149
+	}
150
+}
151
+
123152
 func TestEval_TaintPropagatesThroughBinary(t *testing.T) {
124153
 	t.Parallel()
125154
 	ctx := defaultContext()
internal/runner/config/config.gomodified
@@ -29,6 +29,8 @@ const (
2929
 	DefaultPath            = "/etc/shithubd-runner/config.toml"
3030
 	EnvPrefix              = "SHITHUB_RUNNER_"
3131
 	defaultImage           = "ghcr.io/shithub/runner-nix:1.0"
32
+	defaultNetwork         = "shithub-actions"
33
+	defaultDNSServer       = "172.30.0.1"
3234
 	defaultSeccompProfile  = "/etc/shithubd-runner/seccomp.json"
3335
 	defaultContainerUser   = "65534:65534"
3436
 	defaultContainerPIDMax = 512
@@ -108,12 +110,13 @@ func Defaults() Config {
108110
 		Engine: EngineConfig{
109111
 			Kind:           "docker",
110112
 			DefaultImage:   defaultImage,
111
-			Network:        "bridge",
113
+			Network:        defaultNetwork,
112114
 			Memory:         "2g",
113115
 			CPUs:           "2",
114116
 			SeccompProfile: defaultSeccompProfile,
115117
 			User:           defaultContainerUser,
116118
 			PidsLimit:      defaultContainerPIDMax,
119
+			DNSServers:     []string{defaultDNSServer},
117120
 		},
118121
 		Log: LogConfig{
119122
 			Level:  "info",
internal/runner/config/config_test.gomodified
@@ -25,9 +25,15 @@ func TestLoad_DefaultsWithToken(t *testing.T) {
2525
 	if cfg.Engine.Kind != "docker" {
2626
 		t.Fatalf("Engine.Kind: %q", cfg.Engine.Kind)
2727
 	}
28
+	if cfg.Engine.Network != "shithub-actions" {
29
+		t.Fatalf("Engine.Network: %q", cfg.Engine.Network)
30
+	}
2831
 	if cfg.Engine.SeccompProfile != "/etc/shithubd-runner/seccomp.json" {
2932
 		t.Fatalf("Engine.SeccompProfile: %q", cfg.Engine.SeccompProfile)
3033
 	}
34
+	if want := []string{"172.30.0.1"}; !reflect.DeepEqual(cfg.Engine.DNSServers, want) {
35
+		t.Fatalf("DNSServers: got %#v want %#v", cfg.Engine.DNSServers, want)
36
+	}
3137
 	if cfg.Engine.User != "65534:65534" {
3238
 		t.Fatalf("Engine.User: %q", cfg.Engine.User)
3339
 	}
internal/runner/engine/docker.gomodified
@@ -43,13 +43,16 @@ const (
4343
 )
4444
 
4545
 type CommandRunner interface {
46
-	Run(ctx context.Context, name string, args []string, stdout, stderr io.Writer) error
46
+	Run(ctx context.Context, name string, args []string, env []string, stdout, stderr io.Writer) error
4747
 }
4848
 
4949
 type ExecRunner struct{}
5050
 
51
-func (ExecRunner) Run(ctx context.Context, name string, args []string, stdout, stderr io.Writer) error {
51
+func (ExecRunner) Run(ctx context.Context, name string, args []string, env []string, stdout, stderr io.Writer) error {
5252
 	cmd := exec.CommandContext(ctx, name, args...)
53
+	if len(env) > 0 {
54
+		cmd.Env = append(os.Environ(), env...)
55
+	}
5356
 	cmd.Stdout = stdout
5457
 	cmd.Stderr = stderr
5558
 	return cmd.Run()
@@ -72,6 +75,7 @@ type DockerConfig struct {
7275
 	Stderr           io.Writer
7376
 	Runner           CommandRunner
7477
 	MaskValues       []string
78
+	AllowRoot        bool
7579
 	Logger           *slog.Logger
7680
 }
7781
 
@@ -191,7 +195,7 @@ func (d *Docker) executeStep(ctx context.Context, job Job, step Step) error {
191195
 	writer := d.newStepLogWriter(ctx, job.ID, step.ID, job.MaskValues)
192196
 	out := io.MultiWriter(d.cfg.Stdout, writer)
193197
 	errOut := io.MultiWriter(d.cfg.Stderr, writer)
194
-	if err := d.cfg.Runner.Run(ctx, d.cfg.Binary, invocation.args, out, errOut); err != nil {
198
+	if err := d.cfg.Runner.Run(ctx, d.cfg.Binary, invocation.args, invocation.env, out, errOut); err != nil {
195199
 		d.logStep(ctx, "runner step completed", job, step, invocation, conclusionForError(err))
196200
 		if closeErr := writer.Close(); closeErr != nil {
197201
 			return fmt.Errorf("runner engine: step %q failed: %w", stepLabel(step), errors.Join(err, closeErr))
@@ -207,6 +211,7 @@ func (d *Docker) executeStep(ctx context.Context, job Job, step Step) error {
207211
 
208212
 type dockerInvocation struct {
209213
 	args           []string
214
+	env            []string
210215
 	image          string
211216
 	network        string
212217
 	memory         string
@@ -238,7 +243,7 @@ func (d *Docker) dockerInvocation(job Job, step Step) (dockerInvocation, error)
238243
 		return dockerInvocation{}, fmt.Errorf("runner engine: render step %q: %w", stepLabel(step), err)
239244
 	}
240245
 	user := d.cfg.User
241
-	if permissionsRequestRoot(job.Permissions) {
246
+	if d.cfg.AllowRoot && permissionsRequestRoot(job.Permissions) {
242247
 		user = "0:0"
243248
 	}
244249
 	args := []string{
@@ -272,12 +277,15 @@ func (d *Docker) dockerInvocation(job Job, step Step) (dockerInvocation, error)
272277
 	if err != nil {
273278
 		return dockerInvocation{}, err
274279
 	}
280
+	processEnv := make([]string, 0, len(env))
275281
 	for _, key := range sortedKeys(env) {
276
-		args = append(args, "-e", key+"="+env[key])
282
+		args = append(args, "--env", key)
283
+		processEnv = append(processEnv, key+"="+env[key])
277284
 	}
278285
 	args = append(args, image, "bash", "-c", rendered.Run)
279286
 	return dockerInvocation{
280287
 		args:           args,
288
+		env:            processEnv,
281289
 		image:          image,
282290
 		network:        d.cfg.Network,
283291
 		memory:         d.cfg.Memory,
@@ -614,6 +622,9 @@ func validateEnv(env map[string]string) (map[string]string, error) {
614622
 		if !envNameRE.MatchString(k) {
615623
 			return nil, fmt.Errorf("runner engine: invalid env name %q", k)
616624
 		}
625
+		if strings.ContainsRune(v, '\x00') {
626
+			return nil, fmt.Errorf("runner engine: invalid env value for %q", k)
627
+		}
617628
 		out[k] = v
618629
 	}
619630
 	return out, nil
internal/runner/engine/docker_test.gomodified
@@ -15,18 +15,20 @@ import (
1515
 type recordingRunner struct {
1616
 	name string
1717
 	args []string
18
+	env  []string
1819
 	err  error
1920
 }
2021
 
21
-func (r *recordingRunner) Run(_ context.Context, name string, args []string, _, _ io.Writer) error {
22
+func (r *recordingRunner) Run(_ context.Context, name string, args []string, env []string, _, _ io.Writer) error {
2223
 	r.name = name
2324
 	r.args = append([]string{}, args...)
25
+	r.env = append([]string{}, env...)
2426
 	return r.err
2527
 }
2628
 
2729
 type loggingRunner struct{}
2830
 
29
-func (loggingRunner) Run(_ context.Context, _ string, _ []string, stdout, stderr io.Writer) error {
31
+func (loggingRunner) Run(_ context.Context, _ string, _ []string, _ []string, stdout, stderr io.Writer) error {
3032
 	_, _ = stdout.Write([]byte("hello "))
3133
 	_, _ = stderr.Write([]byte("world\n"))
3234
 	return nil
@@ -34,7 +36,7 @@ func (loggingRunner) Run(_ context.Context, _ string, _ []string, stdout, stderr
3436
 
3537
 type secretLoggingRunner struct{}
3638
 
37
-func (secretLoggingRunner) Run(_ context.Context, _ string, _ []string, stdout, _ io.Writer) error {
39
+func (secretLoggingRunner) Run(_ context.Context, _ string, _ []string, _ []string, stdout, _ io.Writer) error {
3840
 	_, _ = stdout.Write([]byte("hun"))
3941
 	_, _ = stdout.Write([]byte("ter2\n"))
4042
 	return nil
@@ -80,7 +82,7 @@ func TestDockerExecute_BuildsResourceCappedRunCommand(t *testing.T) {
8082
 		"--user", "65534:65534",
8183
 		"--workdir=/workspace/subdir",
8284
 		"--mount", rec.args[23],
83
-		"-e", "A=job", "-e", "B=step",
85
+		"--env", "A", "--env", "B",
8486
 		"runner-image", "bash", "-c", "echo hi",
8587
 	}
8688
 	if rec.name != "podman" {
@@ -92,6 +94,9 @@ func TestDockerExecute_BuildsResourceCappedRunCommand(t *testing.T) {
9294
 	if !strings.HasPrefix(rec.args[23], "type=bind,src=") || !strings.HasSuffix(rec.args[23], ",dst=/workspace,rw") {
9395
 		t.Fatalf("workspace mount arg: %q", rec.args[23])
9496
 	}
97
+	if wantEnv := []string{"A=job", "B=step"}; !reflect.DeepEqual(rec.env, wantEnv) {
98
+		t.Fatalf("env:\ngot  %#v\nwant %#v", rec.env, wantEnv)
99
+	}
95100
 }
96101
 
97102
 func TestDockerExecute_RendersTaintedExpressionsThroughInputEnv(t *testing.T) {
@@ -121,9 +126,51 @@ func TestDockerExecute_RendersTaintedExpressionsThroughInputEnv(t *testing.T) {
121126
 	if got := rec.args[len(rec.args)-1]; got != `echo "${SHITHUB_INPUT_0}"` {
122127
 		t.Fatalf("rendered command: %q", got)
123128
 	}
124
-	if !containsArg(rec.args, "SHITHUB_INPUT_0="+malicious) {
129
+	if !containsFlagValue(rec.args, "--env", "SHITHUB_INPUT_0") {
125130
 		t.Fatalf("input binding missing from args: %#v", rec.args)
126131
 	}
132
+	if containsSubstring(rec.args, malicious) {
133
+		t.Fatalf("tainted input leaked into argv: %#v", rec.args)
134
+	}
135
+	if !containsEnv(rec.env, "SHITHUB_INPUT_0="+malicious) {
136
+		t.Fatalf("input binding missing from process env: %#v", rec.env)
137
+	}
138
+}
139
+
140
+func TestDockerExecute_RendersSecretsThroughEnvWithoutArgvLeak(t *testing.T) {
141
+	t.Parallel()
142
+	rec := &recordingRunner{}
143
+	d := NewDocker(DockerConfig{
144
+		DefaultImage: "runner-image",
145
+		Network:      "bridge",
146
+		Memory:       "2g",
147
+		CPUs:         "2",
148
+		Runner:       rec,
149
+	})
150
+	const secret = "hunter2"
151
+	if _, err := d.Execute(t.Context(), Job{
152
+		ID:           1,
153
+		RunID:        2,
154
+		Secrets:      map[string]string{"TOKEN": secret},
155
+		WorkspaceDir: t.TempDir(),
156
+		Steps: []Step{{
157
+			Run: `printf '%s\n' "${{ secrets.TOKEN }}"`,
158
+		}},
159
+	}); err != nil {
160
+		t.Fatalf("Execute: %v", err)
161
+	}
162
+	if got := rec.args[len(rec.args)-1]; got != `printf '%s\n' "${SHITHUB_INPUT_0}"` {
163
+		t.Fatalf("rendered command: %q", got)
164
+	}
165
+	if !containsFlagValue(rec.args, "--env", "SHITHUB_INPUT_0") {
166
+		t.Fatalf("secret binding missing from args: %#v", rec.args)
167
+	}
168
+	if containsSubstring(rec.args, secret) {
169
+		t.Fatalf("secret leaked into argv: %#v", rec.args)
170
+	}
171
+	if !containsEnv(rec.env, "SHITHUB_INPUT_0="+secret) {
172
+		t.Fatalf("secret binding missing from process env: %#v", rec.env)
173
+	}
127174
 }
128175
 
129176
 func TestDockerExecute_RootRequiresExplicitPermission(t *testing.T) {
@@ -135,7 +182,7 @@ func TestDockerExecute_RootRequiresExplicitPermission(t *testing.T) {
135182
 	}{
136183
 		{name: "default", permissions: `{}`, wantUser: "65534:65534"},
137184
 		{name: "write-all-does-not-root", permissions: `{"mode":"write-all"}`, wantUser: "65534:65534"},
138
-		{name: "explicit-root", permissions: `{"per":{"shithub-runner-root":"write"}}`, wantUser: "0:0"},
185
+		{name: "explicit-root-disabled-by-default", permissions: `{"per":{"shithub-runner-root":"write"}}`, wantUser: "65534:65534"},
139186
 	} {
140187
 		t.Run(tc.name, func(t *testing.T) {
141188
 			t.Parallel()
@@ -162,6 +209,30 @@ func TestDockerExecute_RootRequiresExplicitPermission(t *testing.T) {
162209
 	}
163210
 }
164211
 
212
+func TestDockerExecute_AllowRootEnablesExplicitRootPermission(t *testing.T) {
213
+	t.Parallel()
214
+	rec := &recordingRunner{}
215
+	d := NewDocker(DockerConfig{
216
+		DefaultImage: "runner-image",
217
+		Network:      "bridge",
218
+		Memory:       "2g",
219
+		CPUs:         "2",
220
+		AllowRoot:    true,
221
+		Runner:       rec,
222
+	})
223
+	if _, err := d.Execute(t.Context(), Job{
224
+		ID:           1,
225
+		Permissions:  []byte(`{"per":{"shithub-runner-root":"write"}}`),
226
+		WorkspaceDir: t.TempDir(),
227
+		Steps:        []Step{{Run: "id -u"}},
228
+	}); err != nil {
229
+		t.Fatalf("Execute: %v", err)
230
+	}
231
+	if got := argAfter(rec.args, "--user"); got != "0:0" {
232
+		t.Fatalf("--user: got %q want %q in %#v", got, "0:0", rec.args)
233
+	}
234
+}
235
+
165236
 func TestDockerExecute_AddsConfiguredDNSServers(t *testing.T) {
166237
 	t.Parallel()
167238
 	rec := &recordingRunner{}
@@ -365,6 +436,33 @@ func containsArg(args []string, want string) bool {
365436
 	return false
366437
 }
367438
 
439
+func containsFlagValue(args []string, flag, value string) bool {
440
+	for i, arg := range args {
441
+		if arg == flag && i+1 < len(args) && args[i+1] == value {
442
+			return true
443
+		}
444
+	}
445
+	return false
446
+}
447
+
448
+func containsSubstring(args []string, substr string) bool {
449
+	for _, arg := range args {
450
+		if strings.Contains(arg, substr) {
451
+			return true
452
+		}
453
+	}
454
+	return false
455
+}
456
+
457
+func containsEnv(env []string, want string) bool {
458
+	for _, item := range env {
459
+		if item == want {
460
+			return true
461
+		}
462
+	}
463
+	return false
464
+}
465
+
368466
 func argAfter(args []string, flag string) string {
369467
 	for i, arg := range args {
370468
 		if arg == flag && i+1 < len(args) {
internal/runner/exec/render.gomodified
@@ -45,14 +45,16 @@ func (b *Bindings) Env() map[string]string {
4545
 }
4646
 
4747
 type ResolvedText struct {
48
-	Text    string
49
-	Tainted bool
48
+	Text      string
49
+	Tainted   bool
50
+	Sensitive bool
5051
 }
5152
 
5253
 type RenderedStep struct {
53
-	Run      string
54
-	Env      map[string]string
55
-	EnvTaint map[string]bool
54
+	Run          string
55
+	Env          map[string]string
56
+	EnvTaint     map[string]bool
57
+	EnvSensitive map[string]bool
5658
 }
5759
 
5860
 type StepInput struct {
@@ -66,13 +68,13 @@ func RenderStep(in StepInput) (RenderedStep, error) {
6668
 	ctx := cloneContext(&in.Context)
6769
 	bindings := NewBindings("")
6870
 
69
-	env, taint, err := resolveEnv(in.JobEnv, &ctx)
71
+	env, taint, sensitive, err := resolveEnv(in.JobEnv, &ctx)
7072
 	if err != nil {
7173
 		return RenderedStep{}, fmt.Errorf("render job env: %w", err)
7274
 	}
73
-	mergeContextEnv(&ctx, env, taint)
75
+	mergeContextEnv(&ctx, env, taint, sensitive)
7476
 
75
-	stepEnv, stepTaint, err := resolveEnv(in.StepEnv, &ctx)
77
+	stepEnv, stepTaint, stepSensitive, err := resolveEnv(in.StepEnv, &ctx)
7678
 	if err != nil {
7779
 		return RenderedStep{}, fmt.Errorf("render step env: %w", err)
7880
 	}
@@ -83,8 +85,13 @@ func RenderStep(in StepInput) (RenderedStep, error) {
8385
 		} else {
8486
 			delete(taint, k)
8587
 		}
88
+		if stepSensitive[k] {
89
+			sensitive[k] = true
90
+		} else {
91
+			delete(sensitive, k)
92
+		}
8693
 	}
87
-	mergeContextEnv(&ctx, stepEnv, stepTaint)
94
+	mergeContextEnv(&ctx, stepEnv, stepTaint, stepSensitive)
8895
 
8996
 	run, err := RenderShell(in.Run, &ctx, bindings)
9097
 	if err != nil {
@@ -93,7 +100,7 @@ func RenderStep(in StepInput) (RenderedStep, error) {
93100
 	for k, v := range bindings.Env() {
94101
 		env[k] = v
95102
 	}
96
-	return RenderedStep{Run: run, Env: env, EnvTaint: taint}, nil
103
+	return RenderedStep{Run: run, Env: env, EnvTaint: taint, EnvSensitive: sensitive}, nil
97104
 }
98105
 
99106
 func RenderShell(raw string, ctx *expr.Context, bindings *Bindings) (string, error) {
@@ -111,7 +118,7 @@ func RenderShell(raw string, ctx *expr.Context, bindings *Bindings) (string, err
111118
 		if err != nil {
112119
 			return err
113120
 		}
114
-		if v.Tainted {
121
+		if v.Tainted || v.Sensitive {
115122
 			out.WriteString("${")
116123
 			out.WriteString(bindings.Add(v.String()))
117124
 			out.WriteString("}")
@@ -128,6 +135,7 @@ func RenderShell(raw string, ctx *expr.Context, bindings *Bindings) (string, err
128135
 func ResolveText(raw string, ctx *expr.Context) (ResolvedText, error) {
129136
 	var out strings.Builder
130137
 	tainted := false
138
+	sensitive := false
131139
 	if err := walkExpressions(raw, func(literal, body string) error {
132140
 		if body == "" {
133141
 			out.WriteString(literal)
@@ -141,31 +149,38 @@ func ResolveText(raw string, ctx *expr.Context) (ResolvedText, error) {
141149
 		if v.Tainted {
142150
 			tainted = true
143151
 		}
152
+		if v.Sensitive {
153
+			sensitive = true
154
+		}
144155
 		out.WriteString(v.String())
145156
 		return nil
146157
 	}); err != nil {
147158
 		return ResolvedText{}, err
148159
 	}
149
-	return ResolvedText{Text: out.String(), Tainted: tainted}, nil
160
+	return ResolvedText{Text: out.String(), Tainted: tainted, Sensitive: sensitive}, nil
150161
 }
151162
 
152
-func resolveEnv(raw map[string]string, ctx *expr.Context) (map[string]string, map[string]bool, error) {
163
+func resolveEnv(raw map[string]string, ctx *expr.Context) (map[string]string, map[string]bool, map[string]bool, error) {
153164
 	out := make(map[string]string, len(raw))
154165
 	taint := make(map[string]bool, len(raw))
166
+	sensitive := make(map[string]bool, len(raw))
155167
 	for _, key := range sortedKeys(raw) {
156168
 		if strings.HasPrefix(key, defaultBindingPrefix) {
157
-			return nil, nil, fmt.Errorf("%s uses reserved runner input prefix %s", key, defaultBindingPrefix)
169
+			return nil, nil, nil, fmt.Errorf("%s uses reserved runner input prefix %s", key, defaultBindingPrefix)
158170
 		}
159171
 		v, err := ResolveText(raw[key], ctx)
160172
 		if err != nil {
161
-			return nil, nil, fmt.Errorf("%s: %w", key, err)
173
+			return nil, nil, nil, fmt.Errorf("%s: %w", key, err)
162174
 		}
163175
 		out[key] = v.Text
164176
 		if v.Tainted {
165177
 			taint[key] = true
166178
 		}
179
+		if v.Sensitive {
180
+			sensitive[key] = true
181
+		}
167182
 	}
168
-	return out, taint, nil
183
+	return out, taint, sensitive, nil
169184
 }
170185
 
171186
 func eval(body string, ctx *expr.Context) (expr.Value, error) {
@@ -211,18 +226,22 @@ func cloneContext(ctx *expr.Context) expr.Context {
211226
 	out.Vars = cloneStringMap(ctx.Vars)
212227
 	out.Env = cloneStringMap(ctx.Env)
213228
 	out.EnvTaint = cloneBoolMap(ctx.EnvTaint)
229
+	out.EnvSensitive = cloneBoolMap(ctx.EnvSensitive)
214230
 	out.Untrusted = cloneSet(ctx.Untrusted)
215231
 	out.Shithub.Event = cloneAnyMap(ctx.Shithub.Event)
216232
 	return out
217233
 }
218234
 
219
-func mergeContextEnv(ctx *expr.Context, env map[string]string, taint map[string]bool) {
235
+func mergeContextEnv(ctx *expr.Context, env map[string]string, taint map[string]bool, sensitive map[string]bool) {
220236
 	if ctx.Env == nil {
221237
 		ctx.Env = map[string]string{}
222238
 	}
223239
 	if ctx.EnvTaint == nil {
224240
 		ctx.EnvTaint = map[string]bool{}
225241
 	}
242
+	if ctx.EnvSensitive == nil {
243
+		ctx.EnvSensitive = map[string]bool{}
244
+	}
226245
 	for k, v := range env {
227246
 		ctx.Env[k] = v
228247
 		if taint[k] {
@@ -230,6 +249,11 @@ func mergeContextEnv(ctx *expr.Context, env map[string]string, taint map[string]
230249
 		} else {
231250
 			delete(ctx.EnvTaint, k)
232251
 		}
252
+		if sensitive[k] {
253
+			ctx.EnvSensitive[k] = true
254
+		} else {
255
+			delete(ctx.EnvSensitive, k)
256
+		}
233257
 	}
234258
 }
235259
 
internal/runner/exec/render_test.gomodified
@@ -34,6 +34,27 @@ func TestRenderShell_TaintedExpressionUsesEnvBinding(t *testing.T) {
3434
 	}
3535
 }
3636
 
37
+func TestRenderShell_SensitiveSecretUsesEnvBinding(t *testing.T) {
38
+	t.Parallel()
39
+	ctx := expr.Context{
40
+		Secrets: map[string]string{
41
+			"TOKEN": "hunter2",
42
+		},
43
+		Untrusted: expr.DefaultUntrusted(),
44
+	}
45
+	bindings := NewBindings("")
46
+	got, err := RenderShell(`echo "${{ secrets.TOKEN }}"`, &ctx, bindings)
47
+	if err != nil {
48
+		t.Fatalf("RenderShell: %v", err)
49
+	}
50
+	if got != `echo "${SHITHUB_INPUT_0}"` {
51
+		t.Fatalf("command:\ngot  %q\nwant %q", got, `echo "${SHITHUB_INPUT_0}"`)
52
+	}
53
+	if bindings.Env()["SHITHUB_INPUT_0"] != "hunter2" {
54
+		t.Fatalf("bindings: %#v", bindings.Env())
55
+	}
56
+}
57
+
3758
 func TestRenderStep_EnvTaintPropagatesToRunExpressions(t *testing.T) {
3859
 	t.Parallel()
3960
 	ctx := expr.Context{
@@ -63,6 +84,33 @@ func TestRenderStep_EnvTaintPropagatesToRunExpressions(t *testing.T) {
6384
 	}
6485
 }
6586
 
87
+func TestRenderStep_EnvSensitivityPropagatesToRunExpressions(t *testing.T) {
88
+	t.Parallel()
89
+	ctx := expr.Context{
90
+		Secrets:   map[string]string{"TOKEN": "hunter2"},
91
+		Untrusted: expr.DefaultUntrusted(),
92
+	}
93
+	got, err := RenderStep(StepInput{
94
+		Context: ctx,
95
+		JobEnv: map[string]string{
96
+			"TOKEN": "${{ secrets.TOKEN }}",
97
+		},
98
+		Run: "echo ${{ env.TOKEN }}",
99
+	})
100
+	if err != nil {
101
+		t.Fatalf("RenderStep: %v", err)
102
+	}
103
+	if got.Env["TOKEN"] != "hunter2" || !got.EnvSensitive["TOKEN"] {
104
+		t.Fatalf("env/sensitive: env=%#v sensitive=%#v", got.Env, got.EnvSensitive)
105
+	}
106
+	if got.Run != "echo ${SHITHUB_INPUT_0}" {
107
+		t.Fatalf("run: %q", got.Run)
108
+	}
109
+	if got.Env["SHITHUB_INPUT_0"] != "hunter2" {
110
+		t.Fatalf("input binding: %#v", got.Env)
111
+	}
112
+}
113
+
66114
 func TestRenderStep_ResolvesTrustedExpressionsInline(t *testing.T) {
67115
 	t.Parallel()
68116
 	got, err := RenderStep(StepInput{
@@ -112,6 +160,33 @@ func TestRenderStep_StepEnvOverrideClearsJobEnvTaint(t *testing.T) {
112160
 	}
113161
 }
114162
 
163
+func TestRenderStep_StepEnvOverrideClearsJobEnvSensitivity(t *testing.T) {
164
+	t.Parallel()
165
+	ctx := expr.Context{
166
+		Secrets:   map[string]string{"TOKEN": "hunter2"},
167
+		Untrusted: expr.DefaultUntrusted(),
168
+	}
169
+	got, err := RenderStep(StepInput{
170
+		Context: ctx,
171
+		JobEnv: map[string]string{
172
+			"TOKEN": "${{ secrets.TOKEN }}",
173
+		},
174
+		StepEnv: map[string]string{
175
+			"TOKEN": "trusted",
176
+		},
177
+		Run: "echo ${{ env.TOKEN }}",
178
+	})
179
+	if err != nil {
180
+		t.Fatalf("RenderStep: %v", err)
181
+	}
182
+	if got.EnvSensitive["TOKEN"] {
183
+		t.Fatalf("step override should clear sensitivity: %#v", got.EnvSensitive)
184
+	}
185
+	if got.Run != "echo trusted" {
186
+		t.Fatalf("run: %q", got.Run)
187
+	}
188
+}
189
+
115190
 func TestRenderStep_RejectsReservedInputEnv(t *testing.T) {
116191
 	t.Parallel()
117192
 	_, err := RenderStep(StepInput{