Stealing a Jira token from Snowflake — one with read access to its engineering, security compliance and bug bounty projects — required no credentials at all. It required opening an issue on one of its public repositories and typing the exploit into the title.
Wiz published it on 17 August and the story has been summarised everywhere with the same headline: an AI broke Snowflake's code. It is a good headline and it is approximately true, but the diff tells a duller and considerably worse story. Up front: we do not sell GitHub, or Copilot, or any tool that appears in this post. We do run our own pipeline — and we have already written about its technical debt — so read it with that in mind.
A workflow that opens tickets
The repository is snowflakedb/snowflake-connector-net, Snowflake's public .NET connector. Inside it there is a file called .github/workflows/jira_issue.yml that does something most of us have built at some point: when somebody opens a GitHub issue, it automatically creates the matching ticket in the internal Jira. To do that the workflow needs a Jira token, and the token lives where it always lives, in the repository secrets.
On 18 June 2026 pull request 1218 was merged, titled "SNOW-2069227 : Update jira workflows". It had been open since 7 August 2025: ten months and eleven days. It touched two files, and hold on to that detail, because it is the knot in the whole thing. Before the change, the issue title travelled like this (we are trimming the block, which is longer):
env:
ISSUE_TITLE: ${{ github.event.issue.title }}
run: |
TITLE=$(echo "$ISSUE_TITLE" | sed 's/"/\\"/g' | sed "s/'/\\\'/g")
PAYLOAD=$(jq -n --arg issuetitle "$TITLE" '{ ... }')
And after the change, like this:
run: TITLE=$(echo '${{ github.event.issue.title }}' | sed 's/"/\\"/g' | sed "s/'/\\\'/g")
It is worth noticing that both sed calls were already there before. They are not the new thing and they were never the defence. What protected the workflow was the other part: the title came in as data in an environment variable and reached jq as an argument. After the change, the title is pasted into the script before the script exists, and those same two sed calls are worth nothing: they run after GitHub has substituted the expression, so by the time they act, the quote that breaks out of echo '…' is already part of the script. Escaping something that is already code does not turn it back into text.
The pattern that was removed is nobody's personal quirk: it is literally the one GitHub's own guidance on securely using Actions recommends. "For inline scripts, the preferred approach to handling untrusted input is to set the value of the expression to an intermediate environment variable", and the example it gives is a title arriving from outside, github.event.pull_request.title. The change replaced the vendor's recommendation with the practice that same page warns against.
The headline says AI. The history says four steps
This is the point to turn the volume down and open the pull request, which is public. It has four commits. One is titled, with no further decoration, "copilot suggestion" and carries the line Co-authored-by: Copilot Autofix powered by AI. And that commit touches a single file, which is not the one that broke: it is jira_close.yml, the other one. What it does there is this:
- name: Close Jira Issue
+ env:
+ ISSUE_KEY: ${{ steps.extract.outputs.jira }}
+ JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }}
+ JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }}
+ JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}
run: |
- ISSUE_KEY="${{ steps.extract.outputs.jira }}"
- JIRA_API_URL="${{ secrets.JIRA_BASE_URL }}/rest/api/2/issue/..."
+ JIRA_API_URL="${JIRA_BASE_URL}/rest/api/2/issue/..."
In other words: the commit attributed to the machine pulls the secrets out of the run: block and moves them into env:. Exactly the pattern GitHub recommends, exactly the one that vanished from the file next to it. In the same pull request, in the opposite direction.
Which turns the headline inside out. Who wrote the vulnerable line is not on record — Wiz published an update the same day saying Copilot "was a co-author that checked the merged PR and code change, and identified it as all-clear without noticing the critical vulnerabilities", and that "it's unclear whether the code-change was AI-assisted" — and we are not going to settle it from the outside. The only thing on record with a commit hash attached is that, in this pull request, what the assistant did was the right thing.
And assigning blame hides the interesting part. Between that line and production there were three gates: the person who opened the pull request, a different person who merged it ten months later, and GitHub Advanced Security's static analysis, which — Wiz reports — pulled the vulnerable file out of the final pull request and did not flag the injection. Three gates, all open, none broken. Nobody did anything odd: somebody reviewed a formatting change and it looked to them like a formatting change.
Wiz's conclusion we sign in full: "Security intent can be lost when safer code patterns are not explicitly enforced." There is the verb that matters — enforced. Those four lines of env: were a security decision that depended on whoever touched the file recognising it as one. Ten months later, nobody did.
The "if" that looked like a filter
There is a second detail in that workflow that gave us more to think about than the injection itself, because it has nothing to do with AI and we have seen it in our own pipelines and in clients'. The job was guarded by a condition which, once you strip out the branch that is beside the point, reads:
if: github.event_name == 'issues' &&
github.event.pull_request.user.login != 'whitesource-for-github-com[bot]'
It reads like a reasonable filter: do not fire this for that particular bot. But the first half already requires the event to be issues, and on an issues event the pull_request object does not exist. The condition was checking the author of a pull request that was not there. GitHub's documentation explains half of what happens next: when the types do not match, it casts them to a number to compare, and its table says null becomes 0 while a string that is not a legal JSON number becomes NaN. The other half — that a 0 and a NaN never end up equal — the docs do not write down: they only cover the relational-comparison case and point at the definition of NaN. The observed result, on the other hand, leaves no room for doubt, and Wiz puts it in one line: the condition reduces to comparing null against a string, and that is always true.
In other words: a line that reads like a restriction in review and behaves like a permanent yes at runtime. Our reading, which is not in the report: that has every appearance of a condition written for one event and copied into a workflow that fires on a different one. It is the most human mistake in the whole case, and the only one you can go looking for in your own repositories today with a grep.
What they took was not code
The payload Wiz used fitted in an issue title and did one thing: an outbound request to a domain of theirs carrying the step's variables encoded in base64. Back came the Jira token, the account email and the instance URL. The first version of the payload did not even work — they used a hash character, which swallowed the closing parenthesis and left the shell waiting for an ending that never arrived — so the agent adjusted the payload and tried again.
Here is the part that actually matters to us, and it is neither a GitHub nor an AI problem. The token was not "a token for opening tickets". It belonged to a QA account and granted read access across engineering, security compliance and bug bounty projects. Nobody deliberately gave it that reach: they gave it the token that existed. A pipeline step inherits the permissions of whatever secret you put next to it, and that permission is almost never sized by what the step does — it is sized by what the team does. GitHub's documentation warns about the same thing in different words: the runner has access to all the secrets configured in the repository.
Five days, and Snowflake did not set the clock
The timeline is short. On 18 June the change goes in. On 23 June it is found, exploited and reported; Snowflake fixes it the same day. On 24 June the token is rotated. A five-day window, and the audit logs later confirmed nobody else came through it: every anomalous query came from the testing addresses.
Five days is an excellent response. What gives pause is who set the clock. It was not Snowflake reviewing its own code, nor a researcher poking at the repository on a Sunday: it was an automated Wiz agent — "Red Agent", they call it — scanning Snowflake's GitHub organisation as part of Snowflake's own HackerOne bug bounty programme. In July we wrote about an attacker who used an AI agent for the boring part of an intrusion, and we said the AI had broken nothing new — it had just done the dirty work faster and without getting tired. This is the same sentence from the other side of the board. And it is worth not overstating it: there was a bounty programme here, which is to say somebody paying to be looked at. What changes is not that hunters exist, but the price of reading a whole repository line by line, which has stopped being an afternoon's work and become a formality.
What we check (and what we are not going to do)
None of what follows is original or difficult; it is the short list this case boils down to once you run it against your own repositories.
- No
${{ }}expression inside arun:block. Into an environment variable, quoted, always, even when the data looks harmless. It is a syntax rule rather than a judgement call, which is exactly why it can be automated and checked at a glance. - A workflow triggered by
issues,issue_commentorpull_request_targetis internet-facing code. Anyone with a free account decides when it runs and with what input. Review it on those terms, not as an internal script. - Size the secret to the step, not to the team. If the step creates a ticket in one project, the credential creates tickets in that project. It is the most tedious part and the only one that limits the damage when everything else fails.
- An
if:that mentions a context the event does not populate is not a filter. Check it against the real event payload rather than by reading it. Half an hour ofgrepacross your workflows looking for conditions that talk aboutpull_requestunderissuestriggers (and the reverse) usually turns something up. - A machine-suggested diff gets the same review as a human one. No more, no less. That is precisely Wiz's conclusion, and GitHub's documentation about its own autofix says as much unprompted: suggestions may "fail to remediate the underlying vulnerability or introduce new vulnerabilities" and require explicit review and acceptance before being applied.
And what we are not going to do, even though it is the comfortable conclusion here: ban the autofix, or the assistants, or machine-suggested changes. That would be taking the wrong lesson. A few weeks ago, writing about agent projects getting cancelled, we said the problem is rarely the model and almost always binary governance: either it gets everything or it gets nothing. The same applies here. An assistant proposing a change to a documentation file and one proposing a change to the file that holds the production token cannot go through the same gate, and today, in most places, they go through the same one.
Recommended is not the same as enforced
Back to Wiz's verb, because that is where the only actionable part lives. Enforcing is not the same as recommending, and in that repository the safe pattern was merely recommended: it existed, it worked, and there was nothing whatsoever stopping anyone from removing it. No test failed. No reviewer had any reason to know. The page of GitHub documentation that explains it sat intact where it always sits, with nobody having a reason to open it.
There are two ways to enforce it, and both are cheap. One is a four-word comment above the line — "this prevents shell injection" — because what you write there is no longer read only by the next colleague. The other, more reliable, is to have the pipeline itself refuse: there are linters built specifically for GitHub Actions workflows that spot an expression inside a run: block and fail the check. A change like that never gets argued about in review, because it never reaches review.
Sources (verified on 18 August 2026): the finding, the payload used and the failure of the first attempt with the hash character, the [email protected] account with read access across engineering, security compliance and bug bounty projects, the timeline (change merged on 18 June 2026; found, exploited and reported through HackerOne on 23 June; fixed by Snowflake the same day; token rotated on the 24th), the five-day window, the audit-log confirmation that only the testing addresses came through, the fact that GitHub Advanced Security's scan extracted the vulnerable workflow from the final pull request and did not flag the injection, that it was Wiz's "Red Agent" scanning Snowflake's GitHub organisation under Snowflake's HackerOne programme, that the autofix's documented contribution was a separate fix to jira_close.yml in the same pull request, the reduction of the condition to comparing null against a string, the conclusion "security intent can be lost when safer code patterns are not explicitly enforced", and the update posted later the same day ("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"), from Wiz's analysis published on 17 August 2026. Verified directly against the public repository rather than against the coverage: the title of pull request 1218, its opening on 7 August 2025 and merge on 18 June 2026, the two files touched, its four commits, the fact that two different accounts opened and merged it, the contents of jira_issue.yml before and after the change (including both sed calls, which were already there, and the jq --arg), the full if: condition — which is on the job and has a second branch for issue_comment that we have left out for clarity — the diff of commit 6d0e2fa ("copilot suggestion"), which moves the secrets out of the run: block into env: in jira_close.yml and is the only file it touches, and the Co-authored-by: Copilot Autofix powered by AI line on commit 4a1b8ce. The quote "for inline scripts, the preferred approach to handling untrusted input is to set the value of the expression to an intermediate environment variable", the example using github.event.pull_request.title, and the warning that the runner has access to all repository secrets, from GitHub's guidance on using Actions securely; the number-casting table (null to 0, a string that is not a legal JSON number to NaN) and the fact that the page only documents the relational-comparison case, from the expressions reference; and the statements that autofix suggestions may fail to remediate the vulnerability or introduce new ones and require explicit review and acceptance, from GitHub's responsible use note for Copilot Autofix. Snowflake's statement, from SC Media's coverage. Ours: the arithmetic of the ten months and eleven days the pull request stayed open, the observation that the sed calls were already there and why they stopped helping, the reading that the condition looks written for a different event, the checklist, the position on not banning assistants, and the distinction between recommending and enforcing. As of 18 August 2026 we attribute the vulnerable line to no individual and no tool: the exact split between model and author is not publicly established, and the one contribution by the assistant that is on record in this pull request runs the other way.
What permissions does your automation hold?
In the automation and AI projects we build, the first decision is not which model to use: it is what each step can touch and with which credential. And in data and applications, reviewing the pipeline that deploys is as much part of the job as reviewing the application it deploys.
Talk to everyWAN