Cloud Security Office Hours Banner

Nx / s1ngularity 2025

Step-by-step kill chain mapped to MITRE ATT&CK Cloud, sourced from official post-mortems and primary technical analyses.

August 2025 Critical GitHub Actions / npm

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.

2,349 credentials harvested from 1,079 developer systems
~4 hours of malicious versions live on npm
First supply-chain attack to weaponize installed AI CLIs
Phase 2: 5,500+ private repos flipped public across 400+ accounts
๐Ÿ“„ Nx - s1ngularity postmortem: what happened, how we responded โ†— ๐Ÿ“„ Wiz - s1ngularity: supply chain attack leaks secrets on GitHub โ†—
โšก Initial Access - Shell Commands in a Pull Request Title
01
A workflow triggered by pull_request_target echoed the PR title without sanitizing it, so a title containing shell metacharacters executed with repository permissions
T1059.004 - Command and Scripting Interpreter: Unix Shell T1190 - Exploit Public-Facing Application

pull_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.

Trigger: pull_request_target, which runs with base-repo secrets against fork-controlled input
Injection 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 workflow
Barrier to entry: A GitHub account and the ability to type a title
pull_request_targetPR Title InjectionDefault Token PermsT1059.004
๐Ÿ”‘ Credential Access - Token, Branch, Publish, Token Again
02
The injected commands extracted a read/write GitHub token, pushed a branch carrying a replaced CI script, then dispatched the publish workflow against that branch to capture NPM_TOKEN
T1552.007 - Unsecured Credentials: Container API

The 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.

Step A: Capture the job's GITHUB_TOKEN, read/write by repository default
Step B: Push a branch containing a replaced CI script
Step C: workflow_dispatch the publish workflow against that branch via the API
Step D: The publish job runs attacker code while holding NPM_TOKEN
Notable: No maintainer was phished and no endpoint was compromised
GITHUB_TOKENworkflow_dispatchNPM_TOKENT1552.007
๐Ÿงน Defense Evasion - Branches and Workflow Runs Deleted
03
The attacker removed the branch and the workflow run history to erase the evidence of how the token was taken
T1070 - Indicator Removal

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.

Deleted: The attacker-pushed branch and the associated workflow run records
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
Log DeletionBranch CleanupT1070
๐Ÿ“ฆ Supply Chain - Eight Versions Across the Nx Family
04
Trojanized releases of nx and several @nx/* packages carried a telemetry.js postinstall script and were live for roughly four hours
T1195.002 - Compromise Software Supply Chain

Nx 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: 20.9.0, 20.10.0, 20.11.0, 20.12.0, 21.5.0, 21.6.0, 21.7.0, 21.8.0
@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 sufficient
Window: Roughly four hours before removal
npmpostinstallMulti-Version PublishT1195.002
๐Ÿค– Credential Access - The Developer's Own AI Agents, Turned Around
05
The payload located installed AI command-line tools and invoked them with permission-bypass flags to enumerate secrets across the filesystem
T1552.001 - Unsecured Credentials: Credentials In Files T1059 - Command and Scripting Interpreter

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.

Targeted tools: Claude, Gemini and Q command-line agents
Flags used: --dangerously-skip-permissions, --yolo, --trust-all-tools
Conventional 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
AI CLI AbusePermission Bypass FlagsNovel TradecraftT1552.001
๐Ÿ“ค Exfiltration - Into a Public Repo in the Victim's Own Account
06
Harvested secrets were committed via the GitHub CLI to a newly created public repository in the victim's own account, named s1ngularity-repository
T1567.001 - Exfiltration to Code Repository

There 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.

Destination: A new public repo in the victim's own account, created with the GitHub CLI
Names: s1ngularity-repository, -0, -1
Why 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
Public Repo ExfilGitHub CLIWorld-Readable SecretsT1567.001
๐Ÿ’ฅ Impact - Phase Two Turned the Stolen Tokens on Private Code
07
Two days later the stolen GitHub tokens were used to flip private repositories to public across hundreds of accounts, and shell startup files were sabotaged
T1485 - Data Destruction T1530 - Data from Cloud Storage

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.

Phase 2 window: ~16:00 UTC August 28 to ~14:00 UTC August 29, 2025
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 ~/.zshrc
Decisive factor: Whether tokens were rotated on day one, not whether the package was downgraded
Private Repos ExposedSecond WaveShell RC SabotageT1485

๐Ÿ›ก How to Defend Against This Chain

Never interpolate pull request data into a shell step, and treat 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.
Publish through OIDC trusted publishing rather than a stored token, and require a human for releases. Nx's own remediation is the right template: npm Trusted Publishers with OIDC so there is no long-lived 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.
Treat installed AI agents as a credential-access surface with their own threat model. This payload succeeded because the agent was present, trusted, and drivable from the command line with its safety flags disabled. Inventory which agents are installed on developer machines, restrict or alert on invocations carrying --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.
Disable install scripts by default in CI and on developer machines. The entire payload ran from a postinstall hook, which means 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.
Rotate credentials on day one, and treat "we installed a bad package" as credential compromise. The two-phase structure punished slow responders specifically. When a dependency compromise is announced, the immediate action is not downgrading the package - it is rotating every credential that was reachable from the machines and pipelines that installed it: GitHub tokens, npm tokens, cloud keys, SSH keys. Then audit your GitHub account for repositories you did not create and check shell startup files for appended content.
Watch for exfiltration to legitimate destinations, since egress filtering will not catch it. Secrets left in a public GitHub repository created with the victim's own token, over a connection to a host every developer machine already talks to. Detect it at the API layer instead: alert on repository creation events, on visibility changes from private to public, and on unusual bursts of GitHub API activity from a user account. Secret scanning with push protection helps on the receiving end, and pays for itself the first time it fires.

Related defense topics