GitHub Actions Script Injection: What the Snowflake Case Actually Shows
Untrusted issue text became shell code in Snowflake's GitHub Actions workflow. The injection path was conventional even though an autonomous agent found and adapted it.
A public GitHub issue title became executable shell code inside a Snowflake repository’s automation. According to security company Wiz, its autonomous Red Agent found the path, used it to obtain an internal Jira credential, and reported it to Snowflake on June 23, 2026. Public repository history confirms that the workflow was fixed that day; Wiz says Snowflake also rotated the credential.
The practical lesson is not that one AI defeated another. It is that a CI workflow crossed three trust boundaries without treating them as boundaries: public input entered a generated script, that script ran on a privileged worker, and the job held a credential for another service.
This is a GitHub Actions script injection: attacker-controlled data is inserted into code before the runner’s shell starts. Quoting the expression in YAML does not solve it. The reliable fix is to keep untrusted values out of generated code, then limit what a successful runner compromise could reach.
How the GitHub Actions script injection happened
Snowflake connector PR #1218 changed a workflow that copied public GitHub issues into the company’s Jira system. The pull request merged on June 18, 2026. Its jira_issue.yml file handled the title in this shape:
run: |
TITLE=$(echo '${{ github.event.issue.title }}' | sed ...)
This snippet is deliberately abbreviated. No exploit payload is needed to understand the failure.
GitHub processes ${{ ... }} expressions while preparing a job. Its script-injection documentation explains that the resulting value is substituted into the temporary script before the shell executes it. If a public issue title contains shell syntax that ends the quoted string, the later sed command never gets a chance to make the value safe. The shell is already parsing attacker-controlled code.
The trigger widened the exposure. The workflow ran for newly opened issues, so an ordinary GitHub user could provide the title. An if expression looked as though it excluded a bot, but Wiz reports that the referenced pull-request field was null for an issue event, leaving the job open to every issue author.
Public input
Any GitHub user could supply an issue title.
Control: Constrain the trigger.
Template expansion
GitHub placed that title into the generated script.
Control: Use an environment variable.
Runner execution
The shell interpreted the resulting text as code.
Control: Lint workflows and quote data.
Privileged boundary
The job exposed credentials for an internal Jira service.
Control: Reduce token and network reach.
The public repository confirms the vulnerable change and the repair. The private portion of the incident is less independently visible. Wiz’s disclosure says its agent adjusted a failed proof-of-concept, obtained a Jira token belonging to a QA account, and validated read access across internal engineering, compliance, and bug-bounty projects. Wiz also says Snowflake’s audit logs found no third-party access during the five-day exposure window and that Wiz deleted the data it accessed. Those impact and forensic conclusions come from Wiz and Snowflake; I found no public independent incident report while researching this article.
The safer pattern keeps data out of shell syntax
Snowflake’s June 23 remediation restored an environment-variable boundary and used jq to construct JSON:
env:
ISSUE_TITLE: ${{ github.event.issue.title }}
run: |
PAYLOAD=$(jq -n --arg title "$ISSUE_TITLE" '{summary: $title}')
Here, GitHub writes the title into an environment variable rather than into the generated shell program. The shell script contains the fixed text "$ISSUE_TITLE", and normal shell quoting passes the value as one argument. Then jq --arg performs the JSON encoding instead of a hand-built chain of replacements.
That distinction is easy to miss: an environment variable is a data boundary, not a magic sanitizer. A later command can still misuse it. Quote shell expansions, pass values as structured arguments, and avoid evaluating constructed strings.
GitHub’s secure-use reference recommends an intermediate environment variable when a dedicated action cannot replace an inline script. A JavaScript or compiled action is often stronger because the untrusted value arrives as an argument and never participates in shell-source generation.
A workflow linter catches the minimal pattern
For this article, I reproduced the data flow in two disposable workflow files and ran Zizmor 1.29.0 offline. Its template-injection audit reported the direct ${{ github.event.issue.title }} expression inside run: as a high-confidence code-injection finding. The environment-variable version produced no reported findings with the same command.
That is useful, but it is not a universal guarantee. A linter can recognize known source-to-sink patterns; it cannot prove that every dynamic script, third-party action, token scope, or network path is safe. Treat workflow-specific static analysis as one required review signal, not the final verdict.
A practical CI check can start with:
zizmor .github/workflows/
Run it on the final merged workflow representation, keep the tool version controlled, and review suppressions. Pair it with normal YAML validation, dependency pinning, secret scanning, and tests that exercise event-specific context. A pull-request workflow and an issue workflow do not receive identical payloads.
The AI story is narrower than the headline suggests
The public commit history does not establish that AI wrote the vulnerable change. PR #1218 contains a separate commit co-authored by “Copilot Autofix powered by AI,” but the vulnerable jira_issue.yml revision appears in another commit authored by the human contributor. Wiz corrected its article on August 17 to say that Copilot checked the merged change and returned an all-clear, while the vulnerable change’s authorship is unclear.
Wiz also sells the Red Agent that found the issue. Its account is valuable primary reporting from the research team, but claims about the agent’s autonomy, private Jira access, and exact forensic scope should remain attributed until they are independently reproduced or Snowflake publishes its own report.
The defensible conclusion is more useful than “bad AI versus good AI”:
- AI-assisted code receives the same security review as human code.
- An automated all-clear is evidence from one tool and configuration, not proof of safety.
- Agentic testing can explore a failure chain quickly, but its result still needs artifact-level verification and responsible disclosure.
- Human reviewers need a workflow threat model: who controls each event field, where it becomes executable, and what the runner can access.
Our guide to reading AI evaluations makes the same measurement point in another context: a green score only answers the question the evaluation actually tested. The August 18 Daily Digest has the short incident chronology.
What to watch next
The unresolved question is not whether AI can find workflow bugs; this case says it can, at least in one vendor-reported test. The harder question is whether teams add event-aware workflow analysis and reduce runner privilege before the next public input reaches a shell.
For this specific incident, useful follow-up evidence would include a Snowflake-authored postmortem, the exact GitHub security checks and rulesets applied to the final PR revision, and an independently reproducible account of Red Agent’s decision process. Until then, the public workflow history supports a strong engineering conclusion but only a qualified AI one.