Incidents

Nx s1ngularity: malware that used the coding agents already installed

Nx · August 2025

Updated

In August 2025 attackers published malicious versions of the Nx build system to npm. The postinstall script did something the category had not seen before: rather than hunting for credentials itself, it invoked the Claude Code, Gemini CLI and Amazon Q CLI already installed on the developer's machine, with their permission prompts disabled by flag, and asked them to enumerate the filesystem for secrets. What they found was pushed to a public repository in the victim's own GitHub account. Researchers counted roughly 2,300 distinct secrets across more than a thousand accounts, and a follow-on wave used the stolen tokens to make over 6,700 private repositories public.

What happened

The entry point was not the agents. Attackers exploited an injection flaw in an Nx GitHub Actions workflow on 24 August 2025, took the npm publish token, and pushed eight malicious nx versions along with compromised @nx/devkit, @nx/js, @nx/workspace, @nx/node, @nx/eslint and @nx/enterprise-cloud releases. They were live for roughly four hours on 26 August. The postinstall payload scanned for the usual credential-bearing files - SSH keys, .env, .npmrc, wallet keystores - and then invoked whichever agent CLI it found with the flag that turns approvals off, prompting it to walk the home directory and write what it found to a file. Semgrep's analysis notes that Claude declined the instruction in their testing; the design did not depend on any one agent complying, which is what having three of them was for. The results were base64-encoded and pushed with the gh CLI to a newly created public repository in the victim's own account, and the script appended a shutdown command to the user's shell profile. Wiz put the first phase at over 1,700 users and roughly 2,300 secrets, including more than a thousand valid GitHub tokens, around 90 per cent of them still live; a second wave on 28 and 29 August used those tokens to flip 6,700-plus private repositories public across some 480 accounts.

The failure mode

Credentials were files on a disk that anything running as you could read, and the agent binaries already installed were available as both the search tool and the exfiltration path.

What Forkbench changes about it

  1. 1Keep the value out of the filesystem. A secret in Vault lives in the Keychain on your Mac rather than in a file, so a script walking $HOME for .env and .npmrc finds names it cannot use.
  2. 2Give a secret an established destination and it is not released to the command at all: the command gets an unguessable stand-in minted for that one terminal and that one secret, and a local proxy substitutes the real value into the outgoing request on its way to the host that key belongs to.
  3. 3Grant per Thread, so a compromised install in one job reaches the credentials of that job rather than of every job on the machine.
  4. 4Lock a Thread to the folders the work is about. Every shell in it starts under a kernel-enforced profile that is inherited by dependency install hooks, which is exactly the kind of process this payload ran in.
  5. 5Treat the flags as the tell. The options that skip permissions, trust all tools or run without interaction are what a permission prompt looks like when something other than you is answering it.

What Forkbench would not have stopped: Forkbench would not have stopped the package installing or the script running, and it does not police your filesystem. A postinstall runs as you, so anything your shell can read it can read: keys in ~/.aws, ~/.ssh or a project .env were exposed here and would be again. Vault covers what Vault holds. And a secret with no established destination cannot be substituted, so it is injected into the command's environment instead, where a program inside that process can still read what it was handed. This is containment and evidence, not isolation.

Frequently asked

Related: Getting the value out of the file in the first place, What to rotate, and in what order

Sources