What a script injection in GitHub Actions is
The class has nothing to do with Snowflake, and it predates AI in the toolchain. It comes from one habit: putting text that a stranger controls into a place where the runner expects code.
The two halves of the bug
run: block, where the value is pasted into the script before the shell parses itenv:, so the shell reads a variable name instead of attacker textText becomes a command inside the run block
GitHub Actions expands the expression into the workflow file before the shell ever sees the line. By the time the shell parses it, the attacker's characters are already part of the script. Quoting the value inside the run: block does not help, because the substitution has already happened.
GitHub's documentation puts the rule in terms of flow: an untrusted value should never reach a place where it could be read as code. Escaping is the instinct. Moving the value out of that position is the fix.
"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
Who is allowed to reach the run block
A workflow that fires on issues: opened has no membership requirement at all. Anyone with a GitHub account can open an issue on a public repository, so the trigger sets the audience for everything the job can do.
That gap decides whether a finding is theoretical or live. The same unsafe line is a minor issue under a trigger only maintainers can fire. Under issues: opened on a public repo, the audience is the whole internet.
"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
How the Snowflake workflow gave up a Jira token
The repository was snowflakedb/snowflake-connector-net, and the workflow filed a Jira ticket whenever an issue was opened. Earlier versions of it were safe.
Before and after the change
env: variable, JSON payload built with jq"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
The safe pattern that PR #1218 deleted
The env-variable-plus-jq construction exists for exactly one reason: it keeps untrusted text out of the shell. PR #1218 swapped it for direct interpolation. The unsafe version replaced a control that was already working.
That is the part that should unsettle you more than the exploit does. Nobody forgot anything here. A control got taken out, and nothing in the pipeline read its removal as a regression.
"It removed the repository's existing safe pattern, which passed the issue title through an env: variable and built the JSON payload with jq. Instead it used the direct ${{ github.event.issue.title }} interpolation shown above." — Wiz blog
Was the bot check ever a gate?
No. The workflow compared a pull request field against a bot account name, and on issue events that field is null, so the comparison passed for everyone. A condition that reads like an access control can evaluate to true for every account on GitHub.
A reviewer skimming the file sees a check and moves on. This is the failure mode to look for: a condition that reads as protective, attached to an event it was never written for.
"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
Where Copilot actually sits in this story
The headline version has an AI writing the bug and another AI finding it. What the sources actually support is narrower, and the difference matters if you plan to repeat the story.
What is established and what is not
The confirmed failure is the review, not the authorship
An automated security review looked at this change and returned all-clear. Wiz revised its own post on 17 August to draw that line. The revision is the more useful claim anyway: a review that passes a critical injection is a repeatable failure, whoever typed the line.
Treating the authorship question as settled would go past the evidence. Wiz states plainly that it is unclear whether the change was AI-assisted, and no source establishes otherwise.
"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." — Wiz blog
Why generated changes drop safe patterns
A model predicts code from probabilistic patterns, and Wiz notes those patterns can reintroduce insecure shell constructions. The safe pattern is the more verbose form, so it is the one more likely to get smoothed away.
The removal then survives review because the reasoning is missing. Wiz makes the point directly: automated assistants lack the historical context for why a particular pattern was chosen, so a construction built to stop shell injection reads as a roundabout way to build a string.
"Automated AI assistants often lack historical context regarding why specific code patterns were chosen. In this incident, an automated PR removed a safe env: + jq parsing pattern that had been explicitly implemented to prevent shell injection." — Wiz blog
Closing this in your own workflows
The first two changes cover most of the exposure, and neither needs new tooling.
What to change, in order
run: blocks, and move each one to an env: variableif: condition against the events that actually trigger the job, not the event you had in mindGive generated PRs no shorter path than human ones
Scrutiny should attach to the change, whoever or whatever produced it. A generated PR that removes a control is the same regression as a human one, and a pipeline that trusts the author label will keep waving them through.
Guardrails cover what review misses. The specific move here — swapping a structured parser for direct string building — is mechanically detectable, and a linter does not need to understand the code to fire on it.
"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." — Wiz blog
Assume discovery in days
Five days passed between the merge and a working exploit. The finder was an autonomous agent, and Wiz frames the lesson as a shift in tempo: discovery now happens in hours, which is what patch cycles and credential lifetimes have to be measured against.
Copy Snowflake's response, not its workflow: the fix shipped the day it was reported, and the credential was rotated 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
Model 2: the model Anthropic has not released covers what happens when AI systems get audited instead of attacked. For the provenance side of the same trust question, see Claude Text Watermark: What a Hit Actually Proves.
Reading a vendor advisory alongside the commit it describes is easier once both are plain text.



