GitHub Actions script injection at Snowflake

How GitHub Actions script injection becomes shell injection

A script injection happens when text that a stranger controls is pasted into a place where the runner expects code. The runner is the machine GitHub starts to execute a job, and a run block is the run: step that holds the script the runner executes. This class of GitHub Actions vulnerability is described in GitHub's own documentation, and it is not specific to Snowflake or to AI-written code.

Script injection is also called shell injection, because the pasted text ends up being read by the shell. Anything a person outside the repository can set counts as untrusted input.

The two halves of the bug, the result and the safe form

Untrusted input
Issue titles, PR bodies, branch names
Executable context
A run: block, where the value is pasted into the script before the shell parses it
Result
The value is read as commands, with whatever permissions and secrets the job holds
Safe form
Pass the value in through env:, so the shell reads a variable name instead of attacker text

Why an issue title reaches the run block

GitHub Actions replaces an expression with its value before the shell reads the line, so quoting the value inside the run block does not help. GitHub Docs states that the expressions inside ${{ }} are evaluated and substituted before the shell script runs. It follows that the attacker's characters are already part of the script when the shell parses it.

The same page frames the rule as a question of flow: an untrusted value should never reach a place where it could be read as executable code. The rule is about where the value goes, not how it is escaped, so the remedy is to keep it out of any position where it could be read as code.

"Before the shell script is run, the expressions inside ${{ }} are evaluated and then substituted with the resulting values, which can make it vulnerable to shell command injection."(Understanding the risk of script injections)/"When your workflow runs, those strings might be interpreted as code which is then executed on the runner."(Understanding the risk of script injections)/"You should ensure that these values do not flow directly into workflows, actions, API calls, or anywhere else where they could be interpreted as executable code."(Understanding the risk of script injections) — GitHub Docs
View official source →

Who can trigger the workflow

A workflow that fires on issues: opened has no membership requirement, because anyone with a GitHub account can open an issue on a public repository. The trigger therefore decides who can start everything the job does.

Who can fire the trigger decides whether a finding is theoretical or live. The same unsafe line is a smaller risk under a trigger that only maintainers can fire. Under issues: opened, every GitHub user can reach it.

"The workflow triggered on issues: opened - meaning any GitHub user could fire it by opening an issue - and interpolated the attacker-controlled issue title directly into a shell script" — The Code Change, Wiz blog
View official source →

How Snowflake's workflow handed over a Jira token

The jira_issue.yml workflow in snowflakedb/snowflake-connector-net ran for any new issue and put the issue title straight into a shell script, which let Wiz pull out a Jira API token. Wiz found it with Red Agent, its automated security research tool, and the token gave read access to Snowflake's Jira.

Wiz describes the reach as read access across three areas: engineering, security compliance and bug bounty tracking. Wiz's post does not go beyond that and says nothing about customer data.

Before and after PR #1218, the live window and the reach

Original
Issue title passed through an env: variable, JSON payload built with jq
After PR #1218
Issue title expanded directly into the shell script
Live window
Five days before an automated agent found and validated it
Reached
A Jira API token with read access across engineering, security compliance and bug bounty projects
"The exfiltrated token authenticated as qa@snowflake.net to snowflakecomputing.atlassian.net, granting read access across Snowflake's engineering, security compliance, and bug bounty tracking projects." — Exploitation, Wiz blog
View official source →

The env variable and jq pattern that PR #1218 removed

PR #1218 deleted a safe pattern the repository already had and replaced it with direct interpolation of the issue title. In the old version, the title traveled in an env: variable, so the shell read a variable name instead of pasted text, and jq (a command-line tool that builds JSON) assembled the payload.

The control was not missing from the start; it was taken out. Wiz adds that GitHub Advanced Security analyzed the final PR revision, including the vulnerable workflow, and did not flag the injection.

"The merged PR removed the repository’s existing safe env: and jq pattern and replaced it with direct ${{ github.event.issue.title }} interpolation, creating the injection vector." — Wiz blog
View official source →

Why the bot check never worked as a security gate

The if: condition compared a pull request field against a bot account name, and on issue events that field is always null, so every GitHub user passed. The condition looked like an access control, but it read a field that is always null on the issue events that fire this job.

A reviewer who skims the file sees a check and moves on. The failure to look for is a condition that reads as protective but tests a field the triggering event does not carry.

"However, on issues events, github.event.pull_request is always null. So the condition reduces to (null != 'whitesource-for-github-com[bot]'). This is always true, and every GitHub user passes the gate." — Wiz blog
View official source →

Did Copilot Autofix write the vulnerable change?

The evidence does not settle who wrote the change. Wiz describes Copilot as a co-author that reviewed the merged change and returned all-clear, and says it is unclear whether the change was AI-assisted. The press framing is stronger: SC Media describes the code as co-authored by the Autofix feature, and Unite.AI says Autofix opened the shell injection.

Cyber Kendra blames an Autofix bug, and IT Pro covered the confusion that followed. The Wiz update of August 17, 2026 pins down Copilot's role as co-author and checker but leaves authorship of the change open. Wiz also writes that the documented contribution of Autofix in PR #1218 was a separate fix to a different workflow file, jira_close.yml, and not the change to jira_issue.yml.

What is established and what is not

Established
Copilot was a co-author that checked the merged PR and the code change
Established
It returned all-clear without noticing the critical vulnerabilities
Not established
Whether the code change itself was AI-assisted
Not established
That the Autofix feature produced the change

Why an AI code review by Copilot passed a critical bug

The confirmed failure is that Copilot checked the merged change and returned all-clear with a critical injection in it, and that failure matters whoever typed the line. Treating authorship as settled would go past the evidence, since Wiz states plainly that it is unclear whether the change was AI-assisted. A second automated check, the Advanced Security scan, also analyzed the workflow and did not flag it.

Wiz's takeaway is that security intent can be lost when safer code patterns are not explicitly enforced. Without a rule that requires it, a construction built to stop shell injection can look like a roundabout way to assemble a string, which makes the safe form easy to simplify away.

"August 17, 2026, 1957 UTC update: This blog has been updated to clarify that Copilot was a co-author that checked the merged PR and code change, and identified it as all-clear without noticing the critical vulnerabilities. It's unclear whether the code-change was AI-assisted." / "AI coding tools predict code based on probabilistic patterns, which can inadvertently reintroduce deprecated or insecure shell patterns." / "Security intent can be lost when safer code patterns are not explicitly enforced." — Wiz blog
View official source →

How Wiz Red Agent found a flaw that was live for five days

The flaw was live for five days before an automated agent found and validated it. Wiz Red Agent scanned Snowflake's GitHub organization and flagged jira_issue.yml as open to script injection through untrusted input in run blocks. It then exploited the flaw as part of research under Snowflake's HackerOne disclosure program.

Wiz draws a timing lesson from this. Discovery by automated tools now happens in hours, so patch cycles and credential lifetimes have to be measured against that speed. Wiz's disclosure timeline records that Snowflake patched the workflow the day it was reported and rotated the token the day after.

"The vulnerability was live for only five days before an automated agent discovered and validated it. Security operations must adapt to a landscape where automated discovery occurs in hours, requiring rapid patch cycles and short-lived credentials." — Key Takeaways, Wiz blog
View official source →

GitHub Actions workflow security after the Snowflake bug

Start with the expressions inside run blocks and the if: conditions, because those two checks cover the two mistakes in this incident and need no new tooling. The other two checks cover how changes are reviewed and how long a stolen credential stays useful.

What to change, in order

  1. Search every workflow for expressions inside run: blocks, and move each one to an env: variable
  2. Re-read every if: condition against the events that actually trigger the job, not the event you had in mind
  3. Put AI-generated PRs through the same static analysis as human ones, with no fast path
  4. Use short-lived credentials, so a runner compromise reaches less and expires sooner

For the second check, take each condition and ask which fields exist on the event that fires the job. In this case github.event.pull_request is always null on an issue event, which is why the comparison passed for everyone.

Move expressions out of run blocks into env variables

Move every ${{ }} expression out of run: blocks and into an env: variable, which is the pattern PR #1218 removed. Look first at values an outsider can set, such as github.event.issue.title, PR bodies and branch names. Once the value travels in a variable, the shell reads $ISSUE_TITLE as a name, and the attacker's characters stay data instead of becoming part of the script, unless the script later evaluates it as code.

This is the safe env variable and jq --arg pattern that Snowflake fully restored in its same-day patch, according to Wiz. Keep it as a rule that review must not drop, not as a style preference.

"Security intent can be lost when safer code patterns are not explicitly enforced." — Key Takeaways, Wiz blog
View official source →

Hold AI-generated code to the same security scrutiny

An AI-generated PR needs the same static analysis, meaning automated checking of code without running it, and the same security review as human code. Wiz notes that AI coding tools predict code from probabilistic patterns, which can bring back deprecated or insecure shell constructions. A pipeline with a lighter path for AI-generated changes skips the checks that would catch such constructions.

Two automated checks passed this change, so running a check is not the same as catching the pattern. A team can add its own rule that flags any expression inside a run block, which does not depend on who wrote the line.

"AI coding tools predict code based on probabilistic patterns, which can inadvertently reintroduce deprecated or insecure shell patterns. AI-generated PRs must undergo the same static analysis and security scrutiny as human code." — Key Takeaways, Wiz blog
View official source →

Use short-lived credentials for workflows

Short-lived credentials limit how long a token taken from a runner stays useful. In this incident the exposed Jira token had read access across engineering, security compliance and bug bounty projects, and Snowflake rotated it after the report. A token that expires on its own turns that cleanup into routine.

Wiz names short-lived credentials together with rapid patch cycles as the response to discovery that happens in hours. For a workflow that holds a token, that means issuing it per run where the service allows it.

Anthropic Model 2 Tops Mythos 5 but Stays Internal covers what happens when AI systems get audited instead of attacked. For a related question, what a check on a text can prove about where it came from, see Claude Text Watermark: What a Hit Actually Proves.

FAQ

What makes an issue title dangerous in a workflow?
Nothing about the title itself. The danger is where it lands. GitHub's documentation is explicit that untrusted strings dropped into a run block can be read as code rather than as data, and an issue title is attacker-controlled by definition, because anyone with an account can open an issue on a public repository.
Did Copilot write the vulnerable code?
That is not established. Wiz updated its post on August 17 to say Copilot was a co-author that checked the merged PR and marked it all-clear, and that whether the code change itself was AI-assisted is unclear. The confirmed failure is a review that passed a critical bug, not authorship.
How long do I have to patch something like this?
Wiz says automated discovery now happens in hours, so patch cycles have to be measured against that speed. This flaw was live for five days before an automated agent found and validated it.