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