Go · 15795 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package expr_test
4
5 import (
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10
11 "github.com/tenseleyFlow/shithub/internal/actions/expr"
12 "github.com/tenseleyFlow/shithub/internal/actions/workflow"
13 )
14
15 // evalString is the test helper that lex + parse + eval in one shot.
16 func evalString(t *testing.T, src string, ctx *expr.Context) (expr.Value, error) {
17 t.Helper()
18 toks, err := expr.Lex(src)
19 if err != nil {
20 return expr.Value{}, err
21 }
22 ast, err := expr.Parse(toks)
23 if err != nil {
24 return expr.Value{}, err
25 }
26 return expr.Eval(ast, ctx)
27 }
28
29 func defaultContext() *expr.Context {
30 return &expr.Context{
31 Secrets: map[string]string{"MY_SECRET": "shh"},
32 Vars: map[string]string{"REGION": "us-east-1"},
33 Env: map[string]string{"GREETING": "hello"},
34 Shithub: expr.ShithubContext{
35 RunID: "42",
36 SHA: "deadbeef",
37 Ref: "refs/heads/trunk",
38 Actor: "alice",
39 Event: map[string]any{
40 "pull_request": map[string]any{
41 "title": "feat: add foo",
42 "head": map[string]any{
43 "ref": "feat/foo",
44 },
45 },
46 "head_commit": map[string]any{
47 "message": "WIP: testing",
48 },
49 },
50 },
51 Untrusted: expr.DefaultUntrusted(),
52 }
53 }
54
55 func TestEval_LiteralAndRefs(t *testing.T) {
56 t.Parallel()
57 ctx := defaultContext()
58 cases := []struct {
59 src string
60 want string
61 }{
62 {`'hello'`, "hello"},
63 {`secrets.MY_SECRET`, "shh"},
64 {`vars.REGION`, "us-east-1"},
65 {`env.GREETING`, "hello"},
66 {`shithub.run_id`, "42"},
67 {`shithub.sha`, "deadbeef"},
68 {`shithub.actor`, "alice"},
69 }
70 for _, tc := range cases {
71 t.Run(tc.src, func(t *testing.T) {
72 t.Parallel()
73 v, err := evalString(t, tc.src, ctx)
74 if err != nil {
75 t.Fatalf("eval: %v", err)
76 }
77 if v.S != tc.want {
78 t.Errorf("got %q, want %q", v.S, tc.want)
79 }
80 })
81 }
82 }
83
84 func TestEval_TaintFromEvent(t *testing.T) {
85 t.Parallel()
86 ctx := defaultContext()
87 v, err := evalString(t, `shithub.event.pull_request.title`, ctx)
88 if err != nil {
89 t.Fatalf("eval: %v", err)
90 }
91 if v.S != "feat: add foo" {
92 t.Errorf("got %q", v.S)
93 }
94 if !v.Tainted {
95 t.Fatal("expected Tainted=true on shithub.event.* reference (load-bearing for S41d injection prevention)")
96 }
97 }
98
99 func TestEval_TaintNotFromTrustedSources(t *testing.T) {
100 t.Parallel()
101 ctx := defaultContext()
102 for _, src := range []string{
103 `secrets.MY_SECRET`,
104 `vars.REGION`,
105 `env.GREETING`,
106 `shithub.run_id`,
107 `shithub.sha`,
108 `shithub.actor`,
109 } {
110 t.Run(src, func(t *testing.T) {
111 t.Parallel()
112 v, err := evalString(t, src, ctx)
113 if err != nil {
114 t.Fatalf("eval: %v", err)
115 }
116 if v.Tainted {
117 t.Fatalf("expected Tainted=false on %s; got Tainted=true (would falsely trip S41d guard)", src)
118 }
119 })
120 }
121 }
122
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
152 func TestEval_TaintPropagatesThroughBinary(t *testing.T) {
153 t.Parallel()
154 ctx := defaultContext()
155 // 'WIP' compared to a tainted value → result tainted.
156 v, err := evalString(t, `shithub.event.pull_request.title == 'WIP'`, ctx)
157 if err != nil {
158 t.Fatalf("eval: %v", err)
159 }
160 if !v.Tainted {
161 t.Fatal("equality with a tainted operand must be tainted")
162 }
163 }
164
165 func TestEval_TaintPropagatesThroughFunction(t *testing.T) {
166 t.Parallel()
167 ctx := defaultContext()
168 v, err := evalString(t, `contains(shithub.event.head_commit.message, 'WIP')`, ctx)
169 if err != nil {
170 t.Fatalf("eval: %v", err)
171 }
172 if !v.B {
173 t.Errorf("expected contains() to return true; got false")
174 }
175 if !v.Tainted {
176 t.Fatal("contains() with a tainted operand must be tainted")
177 }
178 }
179
180 func TestEval_AllowedFunctions(t *testing.T) {
181 t.Parallel()
182 ctx := defaultContext()
183 cases := []struct {
184 src string
185 want bool
186 }{
187 {`contains('hello world', 'world')`, true},
188 {`contains('hello', 'WORLD')`, false},
189 {`startsWith('refs/heads/release/v1', 'refs/heads/release/')`, true},
190 {`endsWith('foo.tar.gz', '.gz')`, true},
191 {`success()`, true}, // JobStatus zero-value: not failed, not cancelled
192 {`failure()`, false},
193 {`always()`, true},
194 {`cancelled()`, false},
195 {`!success()`, false},
196 {`true && false`, false},
197 {`true || false`, true},
198 {`'a' == 'a'`, true},
199 {`'a' != 'b'`, true},
200 }
201 for _, tc := range cases {
202 t.Run(tc.src, func(t *testing.T) {
203 t.Parallel()
204 v, err := evalString(t, tc.src, ctx)
205 if err != nil {
206 t.Fatalf("eval: %v", err)
207 }
208 if v.B != tc.want {
209 t.Errorf("got %v, want %v", v.B, tc.want)
210 }
211 })
212 }
213 }
214
215 func TestEval_DisallowedFunctionFails(t *testing.T) {
216 t.Parallel()
217 ctx := defaultContext()
218 cases := []string{
219 `fromJSON('{}')`,
220 `hashFiles('**/*.go')`,
221 `toJSON('foo')`,
222 `format('hello {0}', 'world')`,
223 }
224 for _, src := range cases {
225 t.Run(src, func(t *testing.T) {
226 t.Parallel()
227 _, err := evalString(t, src, ctx)
228 if err == nil {
229 t.Fatal("expected eval error for disallowed function")
230 }
231 if !strings.Contains(err.Error(), "unknown function") {
232 t.Errorf("expected 'unknown function' error, got: %v", err)
233 }
234 })
235 }
236 }
237
238 func TestEval_DisallowedNamespaceFails(t *testing.T) {
239 t.Parallel()
240 ctx := defaultContext()
241 // Each of these would let workflows reach into a namespace we
242 // don't want to support in v1. Some fail at lex (when the
243 // identifier shape isn't lex-valid Go-ish, e.g. `go-version`),
244 // some at eval. Both are correct rejections — neither lets
245 // the workflow author smuggle a value through.
246 cases := []string{
247 `runner.os`,
248 `steps.foo.outputs.bar`,
249 `needs.lint.result`,
250 `matrix.versions`,
251 `inputs.foo`,
252 }
253 for _, src := range cases {
254 t.Run(src, func(t *testing.T) {
255 t.Parallel()
256 _, err := evalString(t, src, ctx)
257 if err == nil {
258 t.Fatal("expected eval error for disallowed namespace")
259 }
260 if !strings.Contains(err.Error(), "unknown namespace") {
261 t.Errorf("expected 'unknown namespace' error, got: %v", err)
262 }
263 })
264 }
265 }
266
267 func TestEval_MissingSecretIsError(t *testing.T) {
268 t.Parallel()
269 ctx := defaultContext()
270 _, err := evalString(t, `secrets.NOT_BOUND`, ctx)
271 if err == nil {
272 t.Fatal("expected error for unbound secret")
273 }
274 if !strings.Contains(err.Error(), "not bound") {
275 t.Errorf("expected 'not bound', got: %v", err)
276 }
277 }
278
279 func TestEval_MissingVarIsEmpty(t *testing.T) {
280 t.Parallel()
281 // vars (and env) match GHA semantics: missing → empty string, not error.
282 ctx := defaultContext()
283 v, err := evalString(t, `vars.MISSING`, ctx)
284 if err != nil {
285 t.Fatalf("eval: %v", err)
286 }
287 if v.S != "" {
288 t.Errorf("got %q", v.S)
289 }
290 }
291
292 func TestEval_MissingEventPathReturnsNull(t *testing.T) {
293 t.Parallel()
294 ctx := defaultContext()
295 v, err := evalString(t, `shithub.event.deeply.nested.missing`, ctx)
296 if err != nil {
297 t.Fatalf("eval: %v", err)
298 }
299 if v.Kind != expr.KindNull {
300 t.Errorf("got Kind=%v, want KindNull", v.Kind)
301 }
302 if !v.Tainted {
303 t.Errorf("missing-key result from event.* must still be tainted (it derives from the event payload)")
304 }
305 }
306
307 func TestEval_StringEscapeQuote(t *testing.T) {
308 t.Parallel()
309 ctx := defaultContext()
310 v, err := evalString(t, `'it''s ok'`, ctx)
311 if err != nil {
312 t.Fatalf("eval: %v", err)
313 }
314 if v.S != "it's ok" {
315 t.Errorf("got %q", v.S)
316 }
317 }
318
319 // TestEval_GithubAliasResolves exercises the documented `${{ github.* }}`
320 // → `${{ shithub.* }}` rebrand alias. Workflow authors copy-pasting GHA
321 // workflows expect the github namespace to keep working; the evaluator
322 // rewrites at the namespace boundary and routes through shithub semantics.
323 // Audit S41a-H1 found this was dead code; this test pins it live.
324 func TestEval_GithubAliasResolves(t *testing.T) {
325 t.Parallel()
326 ctx := defaultContext()
327 cases := []struct {
328 src string
329 want string
330 }{
331 {`github.run_id`, "42"},
332 {`github.sha`, "deadbeef"},
333 {`github.actor`, "alice"},
334 {`github.ref`, "refs/heads/trunk"},
335 {`github.event.pull_request.title`, "feat: add foo"},
336 {`github.event.head_commit.message`, "WIP: testing"},
337 }
338 for _, tc := range cases {
339 t.Run(tc.src, func(t *testing.T) {
340 t.Parallel()
341 v, err := evalString(t, tc.src, ctx)
342 if err != nil {
343 t.Fatalf("eval: %v", err)
344 }
345 if v.S != tc.want {
346 t.Errorf("got %q, want %q", v.S, tc.want)
347 }
348 })
349 }
350 }
351
352 // TestEval_GithubAliasIsTainted asserts the rebrand alias preserves the
353 // load-bearing taint flag: github.event.* must taint exactly like
354 // shithub.event.*. If the alias rewrite happens after isUntrusted runs,
355 // taint quietly disappears and S41d's injection guard misses untrusted
356 // PR-title input. Pin it.
357 func TestEval_GithubAliasIsTainted(t *testing.T) {
358 t.Parallel()
359 ctx := defaultContext()
360 v, err := evalString(t, `github.event.pull_request.title`, ctx)
361 if err != nil {
362 t.Fatalf("eval: %v", err)
363 }
364 if !v.Tainted {
365 t.Fatal("github.event.* must be tainted (load-bearing for S41d injection prevention)")
366 }
367 }
368
369 // TestEval_GithubAliasNonEventNotTainted is the inverse pin: github.run_id
370 // (and friends) ride the same alias path but must NOT be tainted because
371 // they don't derive from the user-controlled event payload.
372 func TestEval_GithubAliasNonEventNotTainted(t *testing.T) {
373 t.Parallel()
374 ctx := defaultContext()
375 for _, src := range []string{`github.run_id`, `github.sha`, `github.actor`, `github.ref`} {
376 t.Run(src, func(t *testing.T) {
377 t.Parallel()
378 v, err := evalString(t, src, ctx)
379 if err != nil {
380 t.Fatalf("eval: %v", err)
381 }
382 if v.Tainted {
383 t.Fatalf("expected Tainted=false on %s; got Tainted=true (would falsely trip S41d guard)", src)
384 }
385 })
386 }
387 }
388
389 func TestEval_EnvTaintPropagates(t *testing.T) {
390 t.Parallel()
391 ctx := defaultContext()
392 ctx.Env["TITLE"] = "hello"
393 ctx.EnvTaint = map[string]bool{"TITLE": true}
394 v, err := evalString(t, `env.TITLE`, ctx)
395 if err != nil {
396 t.Fatalf("Eval: %v", err)
397 }
398 if !v.Tainted {
399 t.Fatal("env values resolved from tainted expressions must remain tainted")
400 }
401 }
402
403 // TestEval_GithubUnknownFieldErrors confirms the alias is *narrow*: only
404 // the shithub.{run_id,sha,ref,actor,event} subset routes through. github
405 // fields we don't expose (event_name, repository, run_number, etc.) get
406 // the canonical shithub error message — slightly confusing for a github-
407 // flavored author but actionable.
408 func TestEval_GithubUnknownFieldErrors(t *testing.T) {
409 t.Parallel()
410 ctx := defaultContext()
411 for _, src := range []string{`github.event_name`, `github.repository`, `github.run_number`} {
412 t.Run(src, func(t *testing.T) {
413 t.Parallel()
414 _, err := evalString(t, src, ctx)
415 if err == nil {
416 t.Fatalf("expected eval error for unsupported github.* field %s", src)
417 }
418 if !strings.Contains(err.Error(), "unknown shithub field") {
419 t.Errorf("expected 'unknown shithub field' (canonical), got: %v", err)
420 }
421 })
422 }
423 }
424
425 // TestEval_GithubAliasFixtureEndToEnd lifts the actual github-alias.yml
426 // fixture, parses the workflow, extracts the `${{ … }}` body from the
427 // step's run command, and evaluates it through the alias path. This is
428 // the end-to-end pin that would have caught S41a-H1: the fixture
429 // existed but no test exercised it through eval.
430 func TestEval_GithubAliasFixtureEndToEnd(t *testing.T) {
431 t.Parallel()
432 src, err := os.ReadFile(filepath.Join("../../../tests/fixtures/workflows", "github-alias.yml"))
433 if err != nil {
434 t.Fatalf("read fixture: %v", err)
435 }
436 w, diags, err := workflow.Parse(src)
437 if err != nil {
438 t.Fatalf("parse fixture: %v", err)
439 }
440 if len(diags) != 0 {
441 t.Fatalf("unexpected diagnostics: %v", diags)
442 }
443 // Step 0's run command is: echo "${{ github.event.head_commit.message }}"
444 run := w.Jobs[0].Steps[0].Run
445 body, ok := extractFirstExpression(run)
446 if !ok {
447 t.Fatalf("expected ${{ ... }} expression in run command, got %q", run)
448 }
449 v, err := evalString(t, body, &expr.Context{
450 Shithub: expr.ShithubContext{
451 Event: map[string]any{
452 "head_commit": map[string]any{
453 "message": "WIP: from fixture",
454 },
455 },
456 },
457 Untrusted: expr.DefaultUntrusted(),
458 })
459 if err != nil {
460 t.Fatalf("eval %q: %v", body, err)
461 }
462 if v.S != "WIP: from fixture" {
463 t.Errorf("got %q, want %q", v.S, "WIP: from fixture")
464 }
465 if !v.Tainted {
466 t.Error("github.event.head_commit.message must be tainted (S41d injection guard)")
467 }
468 }
469
470 // extractFirstExpression pulls the body of the first `${{ … }}` block
471 // out of s. Tiny helper used by the fixture round-trip test; the real
472 // runner-side templating in S41d will be more sophisticated.
473 func extractFirstExpression(s string) (string, bool) {
474 start := strings.Index(s, "${{")
475 if start < 0 {
476 return "", false
477 }
478 end := strings.Index(s[start:], "}}")
479 if end < 0 {
480 return "", false
481 }
482 return strings.TrimSpace(s[start+3 : start+end]), true
483 }
484
485 // TestLex_UnicodeIdentifier pins rune-aware identifier lexing. The
486 // pre-M1 byte-level lexer would fail mid-byte on multi-byte UTF-8
487 // characters with a confusing "unexpected character" error pointing
488 // at a continuation byte. After M1, identifiers can contain any
489 // unicode.IsLetter rune. This is a quality fix, not a security one
490 // — the byte-level lexer failed closed; the rune-aware one accepts
491 // more identifiers but the namespace allowlist still rejects them
492 // at eval time.
493 func TestLex_UnicodeIdentifier(t *testing.T) {
494 t.Parallel()
495 cases := []string{
496 "αlpha", // Greek letter
497 "über", // Latin-1 supplement
498 "日本語", // CJK
499 "snake_α", // mixed ASCII + non-ASCII
500 "_underline", // leading underscore
501 }
502 for _, src := range cases {
503 t.Run(src, func(t *testing.T) {
504 t.Parallel()
505 toks, err := expr.Lex(src)
506 if err != nil {
507 t.Fatalf("lex %q: %v", src, err)
508 }
509 if len(toks) != 2 || toks[0].Kind != expr.TokIdent {
510 t.Fatalf("expected single Ident token + EOF, got %+v", toks)
511 }
512 if toks[0].Value != src {
513 t.Errorf("ident value: got %q, want %q", toks[0].Value, src)
514 }
515 })
516 }
517 }
518
519 // TestLex_InvalidUTF8 surfaces a clean error message when src isn't
520 // valid UTF-8 — pre-M1 the lexer would feed RuneError bytes through
521 // the byte-level loop and produce a misleading offset.
522 func TestLex_InvalidUTF8(t *testing.T) {
523 t.Parallel()
524 _, err := expr.Lex(string([]byte{'a', 0x80, 'b'}))
525 if err == nil {
526 t.Fatal("expected error on invalid UTF-8")
527 }
528 if !strings.Contains(err.Error(), "invalid UTF-8") {
529 t.Errorf("expected 'invalid UTF-8' error, got: %v", err)
530 }
531 }
532
533 func TestEval_JobStatusFunctions(t *testing.T) {
534 t.Parallel()
535 cases := []struct {
536 failed, cancelled bool
537 src string
538 want bool
539 }{
540 {false, false, `success()`, true},
541 {true, false, `success()`, false},
542 {true, false, `failure()`, true},
543 {false, true, `cancelled()`, true},
544 {false, true, `failure()`, false}, // cancelled overrides failure
545 {true, true, `failure()`, false},
546 {true, true, `always()`, true},
547 }
548 for _, tc := range cases {
549 t.Run(tc.src, func(t *testing.T) {
550 t.Parallel()
551 ctx := defaultContext()
552 ctx.JobStatus = expr.JobStatus{Failed: tc.failed, Cancelled: tc.cancelled}
553 v, err := evalString(t, tc.src, ctx)
554 if err != nil {
555 t.Fatalf("eval: %v", err)
556 }
557 if v.B != tc.want {
558 t.Errorf("failed=%v cancelled=%v %s = %v, want %v",
559 tc.failed, tc.cancelled, tc.src, v.B, tc.want)
560 }
561 })
562 }
563 }
564