Nx / s1ngularity - PR Title Injection in pull_request_target โ Stolen npm Token โ AI CLIs Weaponized to Hunt Secrets โ Credentials Published to Public Repos
On August 26, 2025 an attacker opened a pull request against the Nx build platform whose title contained shell commands. A workflow using pull_request_target echoed that title without sanitizing it, so the commands ran with repository permissions, and from there the attacker extracted a read/write GitHub token, planted a malicious CI script on a branch, triggered the publish workflow against it, and walked away with the NPM_TOKEN. Trojanized versions of nx and several @nx/* packages were live for roughly four hours. The payload is what makes this chain worth studying: alongside the usual sweep for wallets, tokens and SSH keys, it looked for locally installed AI command-line tools - Claude, Gemini and Q - and invoked them with --dangerously-skip-permissions, --yolo and --trust-all-tools to enumerate secrets on the developer's filesystem. This is the first known supply-chain attack to weaponize AI coding agents already installed on victim machines. Harvested credentials were then committed to a new public repository in the victim's own GitHub account named s1ngularity-repository.
pull_request_target echoed the PR title without sanitizing it, so a title containing shell metacharacters executed with repository permissionspull_request_target is the trigger that exists specifically so a workflow can run with the base repository's secrets and permissions when the pull request comes from a fork. That is its purpose, and that is the trap: the code and metadata are attacker-controlled while the credentials are yours. Anything interpolated from the pull request into a shell step is an injection point, and a PR title is about as attacker-controlled as data gets - anyone with a GitHub account can set one, no fork approval required. Nx's postmortem names three weaknesses that had to line up: the unsanitized title in a pull_request_target workflow, a repository still on the deprecated read/write default for the Actions token rather than read-only, and a workflow_dispatch trigger that let the publish pipeline be invoked programmatically through the API.
pull_request_target, which runs with base-repo secrets against fork-controlled inputInjection point: The pull request title, interpolated unsanitized into a shell step
Second weakness: Repository Actions token still on the read/write default rather than read-only
Third weakness:
workflow_dispatch allowed programmatic triggering of the publish workflowBarrier to entry: A GitHub account and the ability to type a title
NPM_TOKENThe sequence is worth reading slowly, because it is a small privilege escalation carried out entirely with intended features. The injected shell first captured the GitHub token available to the running job - read/write, because the repository had never moved off that default. That token allowed pushing a branch, and on that branch the attacker replaced a script the release pipeline would execute. Then, using workflow_dispatch through the API, they triggered the publish workflow against their branch, so the publish job ran the attacker's script while holding the real NPM_TOKEN. At no point was a maintainer phished or a laptop compromised. Each hop used a documented GitHub capability exactly as designed.
GITHUB_TOKEN, read/write by repository defaultStep B: Push a branch containing a replaced CI script
Step C:
workflow_dispatch the publish workflow against that branch via the APIStep D: The publish job runs attacker code while holding
NPM_TOKENNotable: No maintainer was phished and no endpoint was compromised
Having spent the token, the attacker deleted the branch they had pushed and removed the workflow runs that recorded the dispatch. It is a small detail with a large operational consequence: the most direct evidence of the intrusion lived in GitHub's own UI, and the same over-permissive token that enabled the theft also permitted the cleanup. Anyone investigating from the repository alone would find trojanized packages with no visible explanation for how they were published. This is a standing argument for shipping Actions audit logs off-platform into a SIEM, where an attacker holding a repository token cannot reach them.
Enabled by: The same read/write token used for the theft
Investigative effect: Malicious releases with no on-platform trace of the publish path
Countermeasure: Ship Actions and audit logs off-platform, beyond reach of a repo token
nx and several @nx/* packages carried a telemetry.js postinstall script and were live for roughly four hoursNx is a build platform with millions of weekly downloads, so a four-hour window is not a small window. The malicious releases spanned nx itself at 20.9.0, 20.10.0, 20.11.0, 20.12.0, 21.5.0, 21.6.0, 21.7.0 and 21.8.0, plus @nx/devkit, @nx/js, @nx/workspace and @nx/node at 21.5.0 and 20.9.0, @nx/eslint at 21.5.0, and @nx/key and @nx/enterprise-cloud at 3.2.0. Publishing across many version lines at once is deliberate: it maximizes the chance of matching whatever range a given consumer's manifest allows, and it makes "are we affected" a question about resolved lockfiles rather than a single version string. The payload ran as a postinstall script, so simply installing the dependency was enough - nothing had to import or call Nx.
@nx/devkit, @nx/js, @nx/workspace, @nx/node: 21.5.0 and 20.9.0
@nx/eslint: 21.5.0 ยท @nx/key, @nx/enterprise-cloud: 3.2.0
Delivery:
telemetry.js as a postinstall script - installing was sufficientWindow: Roughly four hours before removal
This is the step that made s1ngularity a landmark rather than another npm incident. Beyond scanning for the usual artifacts - cryptocurrency wallets, GitHub and npm tokens, SSH keys, environment files - the payload checked whether the developer had Claude, Gemini or Q command-line tools installed, and if so drove them with flags that disable their safety interlocks: --dangerously-skip-permissions, --yolo, --trust-all-tools. The reasoning is uncomfortable and sound. An AI coding agent is already trusted on that machine, already has filesystem access, and is far better than a regex at working out which of ten thousand files contains something that looks like a credential. The malware did not need to be clever, because it could ask something clever that was already installed and already authorized.
Flags used:
--dangerously-skip-permissions, --yolo, --trust-all-toolsConventional targets too: Crypto wallets, GitHub and npm tokens, SSH keys, environment files
Why it works: The agent is already trusted, already has filesystem access, and reasons better than a regex
Significance: First known supply-chain attack to weaponize installed AI agents on victim machines
s1ngularity-repositoryThere was no attacker-controlled server to block, because the exfiltration destination was GitHub, using the victim's own credentials, over a connection every developer machine makes constantly. Egress filtering does not help when the destination is the same host your build already talks to. The choice also multiplied the damage: the repositories were public, so every harvested secret was world-readable for as long as it stood, exposed not only to the original attacker but to anyone scraping GitHub for credentials. Wiz counted over a thousand valid GitHub tokens, dozens of cloud credentials and npm tokens, and roughly 20,000 files; The Hacker News reported 2,349 distinct secrets from 1,079 systems. GitHub disabled the repositories around 09:00 UTC on August 27, an exposure window of roughly eight hours.
Names:
s1ngularity-repository, -0, -1Why egress filtering fails: The destination is github.com, using the victim's own token
Counts: 2,349 secrets from 1,079 systems; 1,000+ valid GitHub tokens; ~20,000 files
Exposure window: Roughly eight hours, until GitHub disabled the repos at 09:00 UTC on August 27
Between roughly 16:00 UTC on August 28 and 14:00 UTC on August 29, the attacker used the harvested tokens for a second, automated wave: taking private repositories belonging to the victims and making them public, renamed to s1ngularity-repository- plus five random letters. Wiz counted more than 400 affected users and organizations and over 5,500 private repositories exposed. The payload had also appended shutdown commands to shell startup files such as ~/.bashrc and ~/.zshrc, so some developers found their machines powering off on the next terminal launch. The lesson in the two-phase structure is about response tempo: organizations that rotated GitHub tokens promptly on the 27th were not exposed on the 28th, and those that treated it as a package-version problem rather than a credential-compromise problem were.
Action: Private repositories made public, renamed
s1ngularity-repository-<5 letters>Scale: 400+ users and organizations, 5,500+ private repositories exposed
Also: Shutdown commands appended to
~/.bashrc and ~/.zshrcDecisive factor: Whether tokens were rotated on day one, not whether the package was downgraded
๐ก How to Defend Against This Chain
pull_request_target as privileged by definition. Titles, branch names, body text and author fields are all attacker-controlled. Pass them through the env: block rather than inlining ${{ }} into run:, and reserve pull_request_target for workflows that genuinely need base-repo secrets, keeping them free of any step that executes fork-controlled content. Then set the repository's default Actions token to read-only and grant write scopes per job, which alone would have stopped this at step two.NPM_TOKEN to steal, mandatory manual 2FA approval on every release, and external-contributor pipeline runs disabled pending approval. A static publish token sitting in a repository is a single object whose theft is equivalent to owning the package.--dangerously-skip-permissions, --yolo or --trust-all-tools, and keep agent configuration and credential files out of directories that arbitrary postinstall scripts can read. Assume any capability you install for convenience is a capability an attacker inherits.npm install --ignore-scripts, or the equivalent in your package manager's configuration, would have neutralized it without knowing anything about this attack. Allow-list the small number of packages that genuinely need install-time scripts and deny the rest.