Go · 11467 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 EnvTaint map[string]bool
82 Shithub ShithubContext
83 Untrusted map[string]struct{} // namespace prefixes
84 JobStatus JobStatus // for success()/failure()/always()/cancelled()
85 }
86
87 // ShithubContext is the typed `shithub.*` slot. Event is a free-form
88 // map (the trigger payload, JSON-decoded); the named scalars are
89 // pre-resolved.
90 type ShithubContext struct {
91 Event map[string]any
92 RunID string
93 SHA string
94 Ref string
95 Actor string
96 }
97
98 // JobStatus is the rolling status the success()/failure()/always()/
99 // cancelled() functions consult. Filled by the runner before eval.
100 type JobStatus struct {
101 Failed bool
102 Cancelled bool
103 }
104
105 // DefaultUntrusted returns the standard taint-source allowlist:
106 // shithub.event.* is user-controlled, everything else is trusted.
107 func DefaultUntrusted() map[string]struct{} {
108 return map[string]struct{}{
109 "shithub.event": {},
110 }
111 }
112
113 // Eval reduces e against ctx. Errors are precise references back to
114 // the AST node ("unknown function 'fromJSON'", "secret 'X' not bound",
115 // etc.). Eval never panics on well-formed input.
116 func Eval(e Expr, ctx *Context) (Value, error) {
117 switch n := e.(type) {
118 case LitString:
119 return Value{Kind: KindString, S: n.V}, nil
120 case LitBool:
121 return Value{Kind: KindBool, B: n.V}, nil
122 case LitNull:
123 return Value{Kind: KindNull}, nil
124 case Ref:
125 return evalRef(n, ctx)
126 case Call:
127 return evalCall(n, ctx)
128 case Unary:
129 x, err := Eval(n.X, ctx)
130 if err != nil {
131 return Value{}, err
132 }
133 return Value{Kind: KindBool, B: !x.Truthy(), Tainted: x.Tainted}, nil
134 case Binary:
135 return evalBinary(n, ctx)
136 }
137 return Value{}, fmt.Errorf("expr: unknown AST node type")
138 }
139
140 // evalRef walks a dotted path. The first segment selects the
141 // namespace; subsequent segments index into it. Anything outside the
142 // allowlist is an error so we don't silently let workflows reach for
143 // e.g. `runner.os` (which we don't define).
144 //
145 // `github.*` is accepted as an alias for `shithub.*` (intentional
146 // rebrand — see the campaign decision and docs/internal/actions-schema.md).
147 // We rewrite path[0] from "github" to "shithub" up-front so taint
148 // computation, dispatch, and error messages all flow through the
149 // canonical name. github.* refs that don't resolve under shithub.*
150 // (e.g. `github.event_name`, which we don't expose in v1) error with
151 // the canonical "unknown shithub field" message — slightly confusing
152 // for github-flavored authors but actionable.
153 func evalRef(r Ref, ctx *Context) (Value, error) {
154 if len(r.Path) == 0 {
155 return Value{}, fmt.Errorf("expr: empty reference")
156 }
157 path := r.Path
158 if path[0] == "github" {
159 aliased := make([]string, len(path))
160 copy(aliased, path)
161 aliased[0] = "shithub"
162 path = aliased
163 }
164 root := path[0]
165 tainted := isUntrusted(path, ctx.Untrusted)
166 switch root {
167 case "secrets":
168 if len(path) != 2 {
169 return Value{}, fmt.Errorf("expr: secrets.<name> requires exactly one member")
170 }
171 v, ok := ctx.Secrets[path[1]]
172 if !ok {
173 return Value{}, fmt.Errorf("expr: secret %q not bound", path[1])
174 }
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
180 case "vars":
181 if len(path) != 2 {
182 return Value{}, fmt.Errorf("expr: vars.<name> requires exactly one member")
183 }
184 v, ok := ctx.Vars[path[1]]
185 if !ok {
186 // Missing var resolves to empty string (matches GHA).
187 return Value{Kind: KindString, S: ""}, nil
188 }
189 return Value{Kind: KindString, S: v}, nil
190 case "env":
191 if len(path) != 2 {
192 return Value{}, fmt.Errorf("expr: env.<name> requires exactly one member")
193 }
194 v, ok := ctx.Env[path[1]]
195 if !ok {
196 return Value{Kind: KindString, S: ""}, nil
197 }
198 return Value{Kind: KindString, S: v, Tainted: ctx.EnvTaint[path[1]]}, nil
199 case "shithub":
200 return evalShithub(path[1:], ctx, tainted)
201 }
202 return Value{}, fmt.Errorf("expr: unknown namespace %q (allowed: secrets, vars, env, shithub)", root)
203 }
204
205 func evalShithub(rest []string, ctx *Context, tainted bool) (Value, error) {
206 if len(rest) == 0 {
207 return Value{}, fmt.Errorf("expr: shithub.<...> requires a member")
208 }
209 switch rest[0] {
210 case "run_id":
211 return Value{Kind: KindString, S: ctx.Shithub.RunID}, nil
212 case "sha":
213 return Value{Kind: KindString, S: ctx.Shithub.SHA}, nil
214 case "ref":
215 return Value{Kind: KindString, S: ctx.Shithub.Ref}, nil
216 case "actor":
217 return Value{Kind: KindString, S: ctx.Shithub.Actor}, nil
218 case "event":
219 return evalEventPath(rest[1:], ctx.Shithub.Event, tainted)
220 }
221 return Value{}, fmt.Errorf("expr: unknown shithub field %q (allowed: run_id, sha, ref, actor, event)", rest[0])
222 }
223
224 // evalEventPath walks into a JSON-decoded map. Missing keys resolve
225 // to null (GHA convention). Every value here is tainted because the
226 // payload is user-controlled.
227 func evalEventPath(path []string, event map[string]any, tainted bool) (Value, error) {
228 var cur any = event
229 for _, key := range path {
230 m, ok := cur.(map[string]any)
231 if !ok {
232 return Value{Kind: KindNull, Tainted: tainted}, nil
233 }
234 cur = m[key]
235 }
236 if cur == nil {
237 return Value{Kind: KindNull, Tainted: tainted}, nil
238 }
239 switch v := cur.(type) {
240 case string:
241 return Value{Kind: KindString, S: v, Tainted: tainted}, nil
242 case bool:
243 return Value{Kind: KindBool, B: v, Tainted: tainted}, nil
244 case float64:
245 return Value{Kind: KindString, S: fmt.Sprintf("%v", v), Tainted: tainted}, nil
246 }
247 // Maps + slices stringify in JSON-like form for if-comparisons.
248 return Value{Kind: KindString, S: fmt.Sprintf("%v", cur), Tainted: tainted}, nil
249 }
250
251 // isUntrusted checks if r.Path traces into any prefix in the untrusted
252 // set. Prefixes are dot-joined like "shithub.event"; a path matches
253 // when it equals or extends the prefix.
254 func isUntrusted(path []string, untrusted map[string]struct{}) bool {
255 if len(untrusted) == 0 {
256 return false
257 }
258 for i := range path {
259 joined := strings.Join(path[:i+1], ".")
260 if _, ok := untrusted[joined]; ok {
261 return true
262 }
263 }
264 return false
265 }
266
267 // evalCall dispatches the seven allowlisted functions. Any other call
268 // is an error — this is the closed door the campaign §"Risks" warns
269 // about ("expression evaluator is a footgun if permissive"). Adding
270 // a function requires a security note in the commit message.
271 func evalCall(c Call, ctx *Context) (Value, error) {
272 switch c.Name {
273 case "contains":
274 return strFnArity2(c, ctx, "contains", strings.Contains)
275 case "startsWith":
276 return strFnArity2(c, ctx, "startsWith", strings.HasPrefix)
277 case "endsWith":
278 return strFnArity2(c, ctx, "endsWith", strings.HasSuffix)
279 case "success":
280 if len(c.Args) != 0 {
281 return Value{}, fmt.Errorf("expr: success() takes no arguments")
282 }
283 return Value{Kind: KindBool, B: !ctx.JobStatus.Failed && !ctx.JobStatus.Cancelled}, nil
284 case "failure":
285 if len(c.Args) != 0 {
286 return Value{}, fmt.Errorf("expr: failure() takes no arguments")
287 }
288 return Value{Kind: KindBool, B: ctx.JobStatus.Failed && !ctx.JobStatus.Cancelled}, nil
289 case "always":
290 if len(c.Args) != 0 {
291 return Value{}, fmt.Errorf("expr: always() takes no arguments")
292 }
293 return Value{Kind: KindBool, B: true}, nil
294 case "cancelled":
295 if len(c.Args) != 0 {
296 return Value{}, fmt.Errorf("expr: cancelled() takes no arguments")
297 }
298 return Value{Kind: KindBool, B: ctx.JobStatus.Cancelled}, nil
299 }
300 return Value{}, fmt.Errorf("expr: unknown function %q (allowed: contains, startsWith, endsWith, success, failure, always, cancelled)", c.Name)
301 }
302
303 func strFnArity2(c Call, ctx *Context, name string, fn func(string, string) bool) (Value, error) {
304 if len(c.Args) != 2 {
305 return Value{}, fmt.Errorf("expr: %s() takes 2 arguments, got %d", name, len(c.Args))
306 }
307 a, err := Eval(c.Args[0], ctx)
308 if err != nil {
309 return Value{}, err
310 }
311 b, err := Eval(c.Args[1], ctx)
312 if err != nil {
313 return Value{}, err
314 }
315 tainted := a.Tainted || b.Tainted
316 return Value{Kind: KindBool, B: fn(a.String(), b.String()), Tainted: tainted}, nil
317 }
318
319 func evalBinary(n Binary, ctx *Context) (Value, error) {
320 l, err := Eval(n.L, ctx)
321 if err != nil {
322 return Value{}, err
323 }
324 switch n.Op {
325 case "&&":
326 if !l.Truthy() {
327 return l, nil // short-circuit — preserves taint
328 }
329 r, err := Eval(n.R, ctx)
330 if err != nil {
331 return Value{}, err
332 }
333 return Value{Kind: r.Kind, S: r.S, B: r.B, Tainted: l.Tainted || r.Tainted}, nil
334 case "||":
335 if l.Truthy() {
336 return l, nil
337 }
338 r, err := Eval(n.R, ctx)
339 if err != nil {
340 return Value{}, err
341 }
342 return Value{Kind: r.Kind, S: r.S, B: r.B, Tainted: l.Tainted || r.Tainted}, nil
343 }
344 r, err := Eval(n.R, ctx)
345 if err != nil {
346 return Value{}, err
347 }
348 tainted := l.Tainted || r.Tainted
349 switch n.Op {
350 case "==":
351 return Value{Kind: KindBool, B: valuesEqual(l, r), Tainted: tainted}, nil
352 case "!=":
353 return Value{Kind: KindBool, B: !valuesEqual(l, r), Tainted: tainted}, nil
354 }
355 return Value{}, fmt.Errorf("expr: unknown binary operator %q", n.Op)
356 }
357
358 func valuesEqual(a, b Value) bool {
359 if a.Kind == KindNull || b.Kind == KindNull {
360 return a.Kind == b.Kind
361 }
362 if a.Kind == KindBool && b.Kind == KindBool {
363 return a.B == b.B
364 }
365 return a.String() == b.String()
366 }
367