injection-hoist
Auto-defuse script injection by hoisting untrusted ${{ }} out of run: blocks.
Untrusted ${{ }} interpolation inside a run: body is a classic
script-injection
sink: an attacker-controlled value (a PR title, a branch name) is spliced into
your shell command and evaluated. injection-hoist defuses it automatically.
It is on by default. Untrusted interpolation is lifted into a step env:
var and the body is rewritten to reference the shell variable instead, so the
value can never be parsed as shell.
steps:
- name: Greet PR author
run: echo Hello ${{ github.event.pull_request.title }}steps:
- name: Greet PR author
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: echo Hello "$PR_TITLE"steps:
- name: Greet PR author
run: echo Hello ${{ github.event.pull_request.title }}steps:
- name: Greet PR author
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: echo Hello "$PR_TITLE"The expansion now happens in the env block (never word-split or re-evaluated),
and the shell only ever sees a quoted variable reference.
What is untrusted
Hoisted: github.event.* (PR title/body/author and friends), github.head_ref,
github.ref_name, and github.ref. head_ref/ref_name hoist
unconditionally.
Left inline (trusted): secrets.*, github.sha, github.run_id,
github.actor, matrix.*, runner.*, vars.*, env.*, inputs.*,
steps.*, needs.*, and the structural/id leaves of an event payload
(...id, ...number, ...sha, ...).
Only run: bodies are rewritten. if:, name:, with:, env:,
concurrency:, and uses: are never touched.
Bracket notation
Context paths are canonicalized before taint classification, so bracket-quoted and numeric access is treated identically to dotted access:
run: echo ${{ github['event']['issue']['title'] }} # → hoisted like github.event.issue.titleA dynamic index that can't be resolved statically (for example
github['event'][github.event.action]) is left inline but raises a loud
injection-hoist-unanalyzable-path warning — it is never silently passed
through. Rewrite it to dotted access or hoist the value manually.
Modes
Set injection-hoist: at the step, job, or root level (closest scope wins;
default fix):
| Mode | Behavior |
|---|---|
fix | Hoist + rewrite (default). |
warn | Emit env + a diagnostic, leave the body unchanged. |
error | Fail the build on any untrusted interpolation. |
off | Do nothing. |
injection-hoist: error # global default
jobs:
build:
injection-hoist: warn # per-job override
runs-on: ubuntu-latest
steps:
- run: ./build.sh ${{ github.head_ref }}
injection-hoist: fix # per-step overrideThe global default can also be set in actio.config or
passed as a transpile option.
Opt-outs and overrides
Bare step keys, stripped from the generated workflow:
unsafe: true— skip this step entirely (you accept the risk).trust: [path...]— leave these paths inline even though they look untrusted.force: [path...]— hoist these paths even though they are trusted.
steps:
- run: echo ${{ github.event.pull_request.title }}
unsafe: true # opt this step out
- run: ./pin.sh ${{ github.sha }}
force: [github.sha] # hoist a trusted value anywayShells
Quoting is shell-aware (generated refs are always double-quoted):
bash/sh→"$VAR"(bare$VARwhen already inside"...").pwsh/powershell→"$env:VAR".python→ warn-only: theenv:entry is emitted but the body is left unchanged (there is no safe in-place rewrite), so you reados.environ["VAR"]yourself.
Heredocs
An unquoted heredoc (<<EOF) still expands interpolation, so it is hoisted
normally. A quoted heredoc (<<'EOF') suppresses expansion and cannot be safely
rewritten, so an untrusted value inside one is an error — quote the value or
restructure the step.