Radware found 83 GitHub issues, pull requests and comments carrying commands hidden in HTML comments, aimed at the CI of 61 public repositories, 77 of them posted in a 43-minute window on 2026-09-01. The workflow mistake they relied on has one recognizable shape: text written by an outsider, placed straight into a shell script with
${{ }}. We wrote a short check for that shape. Below is what it flags, how to read each line it prints, and what it cannot see.
What happened
The Register reported the campaign on 2026-10-29 in Attackers turn GitHub issues into supply chain campaign against 61 public repos in less than an hour, based on research by Radware. Radware was "investigating prompt injection in GitHub" when they came across it.
The method, in The Register's words: "First, the attacker publishes an issue, pull request, or comment containing malicious commands hidden in HTML comments." Hiding them in a comment "ensures the content is not visible in the rendered page", so a maintainer reading the issue in a browser sees nothing unusual. The rest depends on the repository: "a GitHub event – like a pull request or issue – can automatically trigger a workflow if the repository is configured to respond to that event", and "The workflow automatically is triggered within a second".
The numbers: "Researchers identified at least 83 attacks against 61 public GitHub repositories", and "Most (77) of the malicious items were published during a 43-minute time span on September 1". The named targets include agno-agi/agno, ToolJet/ToolJet, google/langextract, QwenLM/qwen-code, mlflow/mlflow and discordjs/discord.js.
The impact is stated carefully. Radware "found two cases containing evidence that the malicious command ran inside the automation environment", and "can't definitively say whether the attacker successfully obtained repository write permissions". Most of the repositories "were closed or fixed".
The root cause gets one line in the article, and it is the line this post is about: "The workflow incorporates attacker-controlled content directly into shell source code".
Why one ${{ }} is enough
GitHub's secure use reference describes the same problem under the name script injection, and its fix is short. "For inline scripts, the preferred approach to handling untrusted input is to set the value of the expression to an intermediate environment variable." Its example takes a pull request title, puts it in env: as TITLE, and reads "$TITLE" in the script. When someone submits a title written to break out of the script, the log shows the attempt treated as text, and the reference explains why: the value "is stored in memory and used as a variable, and doesn't interact with the script generation process."
That last phrase is the whole issue. An expression written inside run: is filled in while the script is being generated, before the shell reads a single character. Whatever the issue author typed becomes part of the program. Moving the same value into env: turns it back into data.
The second half of the shape is which values count as outside text. GitHub's script injections page names them by the end of the field: "These contexts typically end with body, default_branch, email, head_ref, label, message, name, page_name,ref, and title." The same page warns that "branch names and email addresses" are less obvious sources, since they "can be quite flexible in terms of their permitted content". The events that hand those fields to a workflow most directly are the issue, comment, discussion and pull request events, plus workflow_run, which the secure use reference warns about alongside pull_request_target: "These workflows are privileged" and "may have repository write access and access to referenced secrets."
This matters more when an agent writes the YAML. A model asked to post a comment that echoes the issue title can produce a perfectly working step with the title inline in run:, and nothing about it fails in testing. The mistake only shows when somebody else writes the title.
The check we wrote
We wanted something that answers two questions per file: which run: steps contain ${{ at all, and does this workflow listen to an event that carries outside text. Thirty-odd lines of Python with PyYAML cover it:
import glob import yaml # Triggers whose payload can carry text written by someone outside the repository OUTSIDE_TEXT_TRIGGERS = { "issues", "issue_comment", "discussion", "discussion_comment", "pull_request", "pull_request_target", "pull_request_review", "pull_request_review_comment", "workflow_run", } def steps_of(doc): for job in (doc.get("jobs") or {}).values(): yield from job.get("steps") or [] yield from (doc.get("runs") or {}).get("steps") or [] paths = sorted( glob.glob(".github/workflows/*.y*ml") + glob.glob(".github/actions/*/action.y*ml") ) run_steps = 0 for path in paths: with open(path) as f: doc = yaml.safe_load(f) or {} # PyYAML reads the bare key `on` as the boolean True (YAML 1.1) on = doc.get("on", doc.get(True)) or {} triggers = {on} if isinstance(on, str) else set(on) outside = sorted(triggers & OUTSIDE_TEXT_TRIGGERS) for step in steps_of(doc): if "run" not in step: continue run_steps += 1 if "${{" in step["run"]: where = f"outside-text triggers: {outside}" if outside else "no outside-text trigger" print(f"{path} | {step.get('name', '(unnamed step)')} | {where}") print(f"{len(paths)} files, {run_steps} run steps") Two details took a second try. The first is the comment in the middle: PyYAML follows YAML 1.1, where a bare on is a boolean, so yaml.safe_load("on:\n push:\n") returns {True: {'push': None}}. A check that looks up doc["on"] finds nothing and reports every workflow as having no triggers, which is the most comfortable wrong answer possible. The second is that composite actions (action.yml under .github/actions/) have no jobs; their steps sit under runs.steps, so steps_of reads both places.
The script flags every inline expression rather than only the dangerous ones. Deciding whether ${{ steps.x.outputs.y }} can carry outside text needs a person to follow where that output came from, and a list of every candidate is short enough to read.
Reading what it prints
Run it from the root of a repository. Each printed line is a candidate, not a finding, and the question for each one is where the value inside ${{ }} comes from.
- A field whose name ends in one of the words on GitHub's list, such as the title or body of an issue, a comment or a pull request, a commit message or a branch name. In a workflow with one of the outside-text triggers, this is the shape Radware described. Move the value into
env:and quote the variable, as in GitHub's example. Our reading is that the same applies under other triggers too: apushevent carries commit messages and branch names, and those are on the list whatever event delivers them. - The output of an earlier step. Follow that step back. An output computed from an exit code or from a file the job generated itself is data the job made. An output that copies an issue body is the same outside text with one more hop, and needs the same
env:treatment. - A value the repository controls, such as a fixed input, a matrix entry written in the file or the exit code of a command in the job. These do not carry anyone else's text. Moving them into
env:anyway costs nothing and keeps the next reader from having to work out which case they are.
The trigger column is a hint, not a verdict, and it changes over time. A repository whose workflows all run on timers prints nothing alarming, and stays that way only until someone adds a workflow that answers new issues. Running the script on every change under .github/, in CI or a pre-commit hook, catches that day instead of the next audit.
What the check does not see
A clean run of this script is a narrow statement. It does not look at with: inputs. GitHub's reference recommends passing untrusted values to an action as an argument instead of writing them into a script, because the value "is not used to generate a shell script". Our reading is that this protection depends on the action: an action whose input is itself a script to evaluate puts the text back into a program, and the check would not flag it.
It also cannot see workflows in other repositories that call yours, or decide whether a pull_request_target workflow checks out the contributor's code. For that trigger the reference is direct: workflows that use it "must not explicitly check out untrusted code, including from pull request forks".
The defenses, in the order we would apply them
Radware's recommendations, as The Register quotes them, are to apply "least-privilege access to CI/CD workflows", to separate "as much as possible workflows triggered by external events", to "treat external content as untrusted", and to "limit the secrets and environment variables that are available to the workflow". GitHub's reference adds the concrete form of each:
- Move every
${{ github.event.* }}value that a person outside the repository can write out ofrun:and intoenv:, then quote the variable in the script. - Restrict the permissions of the
GITHUB_TOKENin workflows that respond to issues or pull requests, so a step that misbehaves holds as little as possible. - Avoid
pull_request_targetandworkflow_rununless the workflow needs the privileged context, and never check out untrusted code in them. - Keep secrets out of the jobs that read outside text. A job that labels issues does not need a deploy key.
Radware's last line is the one to keep in mind if you are tempted to rely on spotting the hidden comments: "The only way is to make sure that the workflow code is resilient, and not vulnerable to such attacks."
Does any workflow of yours respond to issues, comments or pull requests, and how do you keep the text out of its scripts? If you have a check that also covers with: inputs, or a case where an inline expression turned out to be reachable, please describe it in the comments below.


