Go · 10741 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package expr
4
5 import (
6 "fmt"
7 "strings"
8 )
9
10 // Value is the result of evaluating an Expr. It's intentionally
11 // stringly-typed (no Go interface{} polymorphism) because the types
12 // we care about — strings + booleans — are both representable as a
13 // Go string with a Kind tag, and stringly types make the taint flag
14 // unambiguous to track.
15 //
16 // Tainted=true means the value transitively depends on an
17 // 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.
20 type Value struct {
21 Kind Kind
22 S string
23 B bool
24 Tainted bool
25 }
26
27 // Kind classifies a Value.
28 type Kind int
29
30 const (
31 KindString Kind = iota
32 KindBool
33 KindNull
34 )
35
36 // String renders the Value in the canonical form GHA's evaluator uses:
37 // strings raw, booleans "true"/"false", null as "". Used by both the
38 // admin parse subcommand (S41a) and the runner's command rendering
39 // (S41d).
40 func (v Value) String() string {
41 switch v.Kind {
42 case KindBool:
43 if v.B {
44 return "true"
45 }
46 return "false"
47 case KindNull:
48 return ""
49 }
50 return v.S
51 }
52
53 // Truthy implements GHA's truthiness rules used by `if:` expressions
54 // and the boolean operators: false, "", null are falsy; anything else
55 // is truthy. Numeric truthiness (0 falsy) is irrelevant in v1 since
56 // we don't evaluate arithmetic.
57 func (v Value) Truthy() bool {
58 switch v.Kind {
59 case KindBool:
60 return v.B
61 case KindString:
62 return v.S != ""
63 }
64 return false
65 }
66
67 // Context is the read-only state Eval consults for Ref lookups. The
68 // caller (S41b trigger pipeline, S41d runner) populates it from the
69 // triggering domain_event, the workflow's resolved env / secrets /
70 // vars, and the standard `shithub.*` slots.
71 //
72 // Untrusted is the set of namespace prefixes whose values come from
73 // user-controlled sources (the event payload). Refs landing inside
74 // any of these get Tainted=true. The default is just "shithub.event"
75 // — we may extend in v2 if other namespaces grow user-controlled
76 // fields.
77 type Context struct {
78 Secrets map[string]string
79 Vars map[string]string
80 Env map[string]string
81 Shithub ShithubContext
82 Untrusted map[string]struct{} // namespace prefixes
83 JobStatus JobStatus // for success()/failure()/always()/cancelled()
84 }
85
86 // ShithubContext is the typed `shithub.*` slot. Event is a free-form
87 // map (the trigger payload, JSON-decoded); the named scalars are
88 // pre-resolved.
89 type ShithubContext struct {
90 Event map[string]any
91 RunID string
92 SHA string
93 Ref string
94 Actor string
95 }
96
97 // JobStatus is the rolling status the success()/failure()/always()/
98 // cancelled() functions consult. Filled by the runner before eval.
99 type JobStatus struct {
100 Failed bool
101 Cancelled bool
102 }
103
104 // DefaultUntrusted returns the standard taint-source allowlist:
105 // shithub.event.* is user-controlled, everything else is trusted.
106 func DefaultUntrusted() map[string]struct{} {
107 return map[string]struct{}{
108 "shithub.event": {},
109 }
110 }
111
112 // Eval reduces e against ctx. Errors are precise references back to
113 // the AST node ("unknown function 'fromJSON'", "secret 'X' not bound",
114 // etc.). Eval never panics on well-formed input.
115 func Eval(e Expr, ctx *Context) (Value, error) {
116 switch n := e.(type) {
117 case LitString:
118 return Value{Kind: KindString, S: n.V}, nil
119 case LitBool:
120 return Value{Kind: KindBool, B: n.V}, nil
121 case LitNull:
122 return Value{Kind: KindNull}, nil
123 case Ref:
124 return evalRef(n, ctx)
125 case Call:
126 return evalCall(n, ctx)
127 case Unary:
128 x, err := Eval(n.X, ctx)
129 if err != nil {
130 return Value{}, err
131 }
132 return Value{Kind: KindBool, B: !x.Truthy(), Tainted: x.Tainted}, nil
133 case Binary:
134 return evalBinary(n, ctx)
135 }
136 return Value{}, fmt.Errorf("expr: unknown AST node type")
137 }
138
139 // evalRef walks a dotted path. The first segment selects the
140 // namespace; subsequent segments index into it. Anything outside the
141 // allowlist is an error so we don't silently let workflows reach for
142 // e.g. `runner.os` (which we don't define).
143 func evalRef(r Ref, ctx *Context) (Value, error) {
144 if len(r.Path) == 0 {
145 return Value{}, fmt.Errorf("expr: empty reference")
146 }
147 root := r.Path[0]
148 tainted := isUntrusted(r.Path, ctx.Untrusted)
149 switch root {
150 case "secrets":
151 if len(r.Path) != 2 {
152 return Value{}, fmt.Errorf("expr: secrets.<name> requires exactly one member")
153 }
154 v, ok := ctx.Secrets[r.Path[1]]
155 if !ok {
156 return Value{}, fmt.Errorf("expr: secret %q not bound", r.Path[1])
157 }
158 // Secrets are NEVER tainted — they're operator-controlled.
159 // The runner's log scrubber (S41e) replaces their values in
160 // log output, but the shell-injection guard cares about
161 // untrusted-source taint, not secret-vs-not.
162 return Value{Kind: KindString, S: v}, nil
163 case "vars":
164 if len(r.Path) != 2 {
165 return Value{}, fmt.Errorf("expr: vars.<name> requires exactly one member")
166 }
167 v, ok := ctx.Vars[r.Path[1]]
168 if !ok {
169 // Missing var resolves to empty string (matches GHA).
170 return Value{Kind: KindString, S: ""}, nil
171 }
172 return Value{Kind: KindString, S: v}, nil
173 case "env":
174 if len(r.Path) != 2 {
175 return Value{}, fmt.Errorf("expr: env.<name> requires exactly one member")
176 }
177 v, ok := ctx.Env[r.Path[1]]
178 if !ok {
179 return Value{Kind: KindString, S: ""}, nil
180 }
181 return Value{Kind: KindString, S: v}, nil
182 case "shithub":
183 return evalShithub(r.Path[1:], ctx, tainted)
184 }
185 return Value{}, fmt.Errorf("expr: unknown namespace %q (allowed: secrets, vars, env, shithub)", root)
186 }
187
188 func evalShithub(rest []string, ctx *Context, tainted bool) (Value, error) {
189 if len(rest) == 0 {
190 return Value{}, fmt.Errorf("expr: shithub.<...> requires a member")
191 }
192 switch rest[0] {
193 case "run_id":
194 return Value{Kind: KindString, S: ctx.Shithub.RunID}, nil
195 case "sha":
196 return Value{Kind: KindString, S: ctx.Shithub.SHA}, nil
197 case "ref":
198 return Value{Kind: KindString, S: ctx.Shithub.Ref}, nil
199 case "actor":
200 return Value{Kind: KindString, S: ctx.Shithub.Actor}, nil
201 case "event":
202 return evalEventPath(rest[1:], ctx.Shithub.Event, tainted)
203 }
204 return Value{}, fmt.Errorf("expr: unknown shithub field %q (allowed: run_id, sha, ref, actor, event)", rest[0])
205 }
206
207 // evalEventPath walks into a JSON-decoded map. Missing keys resolve
208 // to null (GHA convention). Every value here is tainted because the
209 // payload is user-controlled.
210 func evalEventPath(path []string, event map[string]any, tainted bool) (Value, error) {
211 var cur any = event
212 for _, key := range path {
213 m, ok := cur.(map[string]any)
214 if !ok {
215 return Value{Kind: KindNull, Tainted: tainted}, nil
216 }
217 cur = m[key]
218 }
219 if cur == nil {
220 return Value{Kind: KindNull, Tainted: tainted}, nil
221 }
222 switch v := cur.(type) {
223 case string:
224 return Value{Kind: KindString, S: v, Tainted: tainted}, nil
225 case bool:
226 return Value{Kind: KindBool, B: v, Tainted: tainted}, nil
227 case float64:
228 return Value{Kind: KindString, S: fmt.Sprintf("%v", v), Tainted: tainted}, nil
229 }
230 // Maps + slices stringify in JSON-like form for if-comparisons.
231 return Value{Kind: KindString, S: fmt.Sprintf("%v", cur), Tainted: tainted}, nil
232 }
233
234 // isUntrusted checks if r.Path traces into any prefix in the untrusted
235 // set. Prefixes are dot-joined like "shithub.event"; a path matches
236 // when it equals or extends the prefix.
237 func isUntrusted(path []string, untrusted map[string]struct{}) bool {
238 if len(untrusted) == 0 {
239 return false
240 }
241 for i := range path {
242 joined := strings.Join(path[:i+1], ".")
243 if _, ok := untrusted[joined]; ok {
244 return true
245 }
246 }
247 return false
248 }
249
250 // evalCall dispatches the seven allowlisted functions. Any other call
251 // is an error — this is the closed door the campaign §"Risks" warns
252 // about ("expression evaluator is a footgun if permissive"). Adding
253 // a function requires a security note in the commit message.
254 func evalCall(c Call, ctx *Context) (Value, error) {
255 switch c.Name {
256 case "contains":
257 return strFnArity2(c, ctx, "contains", strings.Contains)
258 case "startsWith":
259 return strFnArity2(c, ctx, "startsWith", strings.HasPrefix)
260 case "endsWith":
261 return strFnArity2(c, ctx, "endsWith", strings.HasSuffix)
262 case "success":
263 if len(c.Args) != 0 {
264 return Value{}, fmt.Errorf("expr: success() takes no arguments")
265 }
266 return Value{Kind: KindBool, B: !ctx.JobStatus.Failed && !ctx.JobStatus.Cancelled}, nil
267 case "failure":
268 if len(c.Args) != 0 {
269 return Value{}, fmt.Errorf("expr: failure() takes no arguments")
270 }
271 return Value{Kind: KindBool, B: ctx.JobStatus.Failed && !ctx.JobStatus.Cancelled}, nil
272 case "always":
273 if len(c.Args) != 0 {
274 return Value{}, fmt.Errorf("expr: always() takes no arguments")
275 }
276 return Value{Kind: KindBool, B: true}, nil
277 case "cancelled":
278 if len(c.Args) != 0 {
279 return Value{}, fmt.Errorf("expr: cancelled() takes no arguments")
280 }
281 return Value{Kind: KindBool, B: ctx.JobStatus.Cancelled}, nil
282 }
283 return Value{}, fmt.Errorf("expr: unknown function %q (allowed: contains, startsWith, endsWith, success, failure, always, cancelled)", c.Name)
284 }
285
286 func strFnArity2(c Call, ctx *Context, name string, fn func(string, string) bool) (Value, error) {
287 if len(c.Args) != 2 {
288 return Value{}, fmt.Errorf("expr: %s() takes 2 arguments, got %d", name, len(c.Args))
289 }
290 a, err := Eval(c.Args[0], ctx)
291 if err != nil {
292 return Value{}, err
293 }
294 b, err := Eval(c.Args[1], ctx)
295 if err != nil {
296 return Value{}, err
297 }
298 tainted := a.Tainted || b.Tainted
299 return Value{Kind: KindBool, B: fn(a.String(), b.String()), Tainted: tainted}, nil
300 }
301
302 func evalBinary(n Binary, ctx *Context) (Value, error) {
303 l, err := Eval(n.L, ctx)
304 if err != nil {
305 return Value{}, err
306 }
307 switch n.Op {
308 case "&&":
309 if !l.Truthy() {
310 return l, nil // short-circuit — preserves taint
311 }
312 r, err := Eval(n.R, ctx)
313 if err != nil {
314 return Value{}, err
315 }
316 return Value{Kind: r.Kind, S: r.S, B: r.B, Tainted: l.Tainted || r.Tainted}, nil
317 case "||":
318 if l.Truthy() {
319 return l, nil
320 }
321 r, err := Eval(n.R, ctx)
322 if err != nil {
323 return Value{}, err
324 }
325 return Value{Kind: r.Kind, S: r.S, B: r.B, Tainted: l.Tainted || r.Tainted}, nil
326 }
327 r, err := Eval(n.R, ctx)
328 if err != nil {
329 return Value{}, err
330 }
331 tainted := l.Tainted || r.Tainted
332 switch n.Op {
333 case "==":
334 return Value{Kind: KindBool, B: valuesEqual(l, r), Tainted: tainted}, nil
335 case "!=":
336 return Value{Kind: KindBool, B: !valuesEqual(l, r), Tainted: tainted}, nil
337 }
338 return Value{}, fmt.Errorf("expr: unknown binary operator %q", n.Op)
339 }
340
341 func valuesEqual(a, b Value) bool {
342 if a.Kind == KindNull || b.Kind == KindNull {
343 return a.Kind == b.Kind
344 }
345 if a.Kind == KindBool && b.Kind == KindBool {
346 return a.B == b.B
347 }
348 return a.String() == b.String()
349 }
350