DEV Community

Copilot Autofix Introduced a Critical CI/CD Bug at Snowflake. Here's How to Harden GitHub Actions

On June 23, an autonomous AI security tool walked into Snowflake's internal Jira. It did not brute-force a login or find a leaked password. The door was left open five days earlier by a commit "co-authored by Copilot Autofix powered by AI," and the only key anyone needed was the ability to open a GitHub issue with a carefully written title. Wiz Research published the full write-up on August 17 (Red Agent Exploits Snowflake Vuln Created by Copilot Autofix). It is the cleanest example I have seen of the new failure mode nobody onboarded for: AI coding assistants quietly deleting the security patterns that kept a pipeline safe, and autonomous agents exploiting the result in the same week. I deploy Spring Boot applications through GitHub Actions every week, and I run my own agent infrastructure. When I read this report, I went through every workflow file I touch and changed four things. This article is that story: what exactly broke at Snowflake, why the AI-generated change was the root cause, and the exact hardening steps you can copy into your own pipelines tonight. What actually happened The target was snowflakedb/snowflake-connector-net , a public repository. Its jira_issue.yml workflow ran on issues: opened , meaning any GitHub user could trigger it just by filing an issue on the public repo. - June 18, 2026. A commit ( 4a1b8ce , PR #1218) co-authored by Copilot Autofix rewrote part of the workflow. The AI removed the repository's existing safe pattern, which passed the issue title through anenv: variable and built the JSON payload withjq . It replaced that with direct string expansion of the untrusted title inside a shell script. - June 23, 2026. Wiz's Red Agent, an autonomous AI security research agent working through Snowflake's HackerOne program, discovered the script injection, exploited it, and pulled credentials out of the runner. Wiz disclosed the same day. - June 23, 2026. Snowflake patched the workflow in hours (commit 1dc7766 , PR #1402), restored the safe pattern, and rotated the affected credential. - July 25, 2026. Public disclosure per Snowflake's disclosure policy. The exfiltrated token authenticated as q*@snowflake.net and granted read access across Snowflake's engineering, security compliance, and bug bounty tracking projects on Atlassian. A proof-of-concept that cost nothing to run turned into broad read access inside a major company's internal tooling. The one-line change that broke everything Here is the pattern the repository had before the AI touched it. The title is passed as an environment variable, and jq builds the JSON payload: - env: ISSUE_TITLE: ${{ github.event.issue.title }} run: | jq -n --arg title "$ISSUE_TITLE" ... Here is what Copilot Autofix replaced it with: - run: | TITLE=$(echo '${{ github.event.issue.title }}' | sed 's/"/\"/g' | sed "s/'/\'/g") That looks like the AI tried to escape the input. That is the trap. GitHub expands ${{ github.event.issue.title }} before the shell ever sees the script, so the sed escaping runs too late. A single quote in the title lands inside the shell source itself, breaks out of echo '...' , and the rest of the line executes as a command. The escaping was performed on already-expanded text, which means it can never work. GitHub's own documentation (contexts) says exactly what the safe pattern relies on: workflow expressions are evaluated before the command runs, so anything from an event payload that touches a run: block must go through an env: variable, never inline interpolation. The security gate that was never closed The workflow had an if: condition that looked protective: if: (github.event_name == 'issues' && github.event.pull_request.user.login != 'whitesource-for-github-com[bot]') On issues events, github.event.pull_request is always null . So the condition reduces to null != 'whitesource-for-github-com[bot]' , which is always true. Every single GitHub user passed the gate, including the attacker. This is the second lesson: guard conditions must be checked against the actual event schema for the trigger that fires them. A condition that silently evaluates to true because a field does not exist is worse than no condition at all, because it reads as protection. Why this is an AI-era problem, not a one-off mistake It is easy to read this as "Copilot generated a bug." That misses the point. Human developers make the same mistake, but the AI version has three properties that make it structurally worse: - The AI completed a security regression, not a bug. The removed pattern was not incidental. Passing untrusted input through env: and parsing withjq was an explicit anti-injection design. The AI did not know the history of why that pattern existed, so it treated a security control as if it were style cleanup. - AI suggestions get merged faster. An Autofix arrives pre-approved by the tooling that generated it. The review burden is inverted: a human must actively catch a subtle injection bug in a change that looks like routine refactoring. - The attacker is also an AI. Red Agent hit a bash syntax error on its first exfiltration attempt, analyzed the error, adjusted its payload to close the shell block correctly, and retried successfully. The discovery window is no longer measured in attacker skill; it is measured in tooling capability. Wiz's own key takeaway is blunt: "Automated AI assistants often lack historical context regarding why specific code patterns were chosen." The fix is not to stop using Copilot. The fix is to assume AI-authored changes can delete security controls and build gates that catch it. What I changed in my own GitHub Actions I have Copilot in my editor and Autofix-style suggestions in my PR flow, but I have not run Wiz's Red Agent myself. The hardening below is what I applied to my own Spring Boot pipelines after reading this report, plus the checks I verified against GitHub's documentation. Your mileage will vary with your stack, but the rules are provider-agnostic. Rule 1: Never interpolate event payloads into run: blocks. Search your workflows for ${{ github.event.* }} inside run: . Move every occurrence into an env: block. This is the single highest-value change, because it kills the entire class of injection in one pass: - name: Build payload env: ISSUE_TITLE: ${{ github.event.issue.title }} ISSUE_AUTHOR: ${{ github.event.issue.user.login }} run: | jq -n --arg title "$ISSUE_TITLE" --arg author "$ISSUE_AUTHOR" \ '{title: $title, author: $author}' > payload.json The shell only ever sees environment variables, and jq --arg guarantees the JSON structure stays intact no matter what the input contains. Rule 2: Audit every if: gate against the trigger's event schema. For each workflow, write down which event fires it and check every field referenced in the gate against that event's schema. If a field can be null , the condition is not doing what it looks like it does. GitHub has a public list of webhook events and payloads. For issues , there is no pull_request object, full stop. Rule 3: Treat AI-authored PRs as untrusted code. In repositories where I now merge agent-generated changes, I require a fresh human approval on every touched workflow file, and I run static analysis on the CI files themselves before merge: - actionlint lints workflow syntax and catches bogus expressions. It flags github.event.pull_request used on issue triggers in many cases, and it is one command to run. - zizmor scans workflows specifically for injection and security anti-patterns, including template injection into run: . It is designed for exactly this failure class. - CodeQL on your application code catches sanitizer regressions in the languages it supports, including Java. A minimal merged check looks like this: - name: Lint workflows run: | actionlint -color zizmor . If either finds a problem, the branch does not merge. This is the practical version of "AI PRs must undergo the same static analysis and security scrutiny as human code." Rule 4: Give the runner only what it needs, for as long as it needs it. Snowflake's compromised token was a long-lived credential with broad read access inside Atlassian. Two mitigations shrink the blast radius of any future compromise: - Use OpenID Connect (OIDC) instead of stored secrets where your cloud provider supports it. GitHub's security hardening guide walks through the setup. The runner gets a short-lived token minted per job, so there is nothing to exfiltrate. - Scope every stored secret to the minimum permission and the minimum resource set. A Jira integration token that can read every project is a design flaw even before an attacker finds it. Rule 5: Make the guardrails explicit in code review, not in vibes. The cheapest control is a checklist in your PR template for any change touching .github/workflows/ : - Does this workflow interpolate any github.event.* value intorun: ? If yes, fix it. - Is every if: gate checked against the actual event schema? - Does the change add, remove, or weaken an existing security pattern? The Snowflake bug was a deletion dressed as a refactor. - What credential can this workflow reach, and is that the minimum scope? The checklist to save Here is the version I keep next to my own repo: - Move all event payloads out of run: blocks and intoenv: variables, parsed withjq or equivalent. - Verify every if: condition against the real event schema for the trigger. - Run actionlint andzizmor on every workflow change in CI. - Require explicit human approval on AI-authored changes to .github/workflows/ . - Prefer OIDC over long-lived secrets; scope stored secrets to the minimum. - Watch for deletions in AI diffs: when a parse-with- jq pattern becomes string interpolation, stop and ask why. None of these tools existed to catch the Snowflake bug after the fact. The guardrail that works is the one that runs before merge, on every change, AI-authored or not. Why this matters more than the headline The headline is "AI found a vulnerability." The story underneath is that AI intr

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.