RC RANDOM CHAOS

npm was never a trust boundary

Technical analysis of the Shai-Hulud npm supply chain attack hitting 314 packages including echarts-for-react, size-sensor, and timeago.js.

· 7 min read

The npm registry took another mass package compromise. Public reporting names 314 packages caught in a coordinated publish event tied to the Shai-Hulud worm family. The high-traffic names include the @ant-design/icons ecosystem fragments, echarts-for-react, size-sensor, and timeago.js. Weekly download volume across the affected set runs into the tens of millions. The malicious versions were live on the registry before takedown. Any install, CI build, or npm ci against an unpinned manifest during the exposure window pulled the payload.

The bug class is not in the code. It is in the trust model. npm permits a single maintainer credential to publish any version of any package that account owns. Once the credential is captured, package integrity collapses to the security of one inbox, one token, one developer machine. The compromised maintainers in this wave were phished or had their automation tokens lifted from CI logs and public repositories. The publish chain operated exactly as designed. The signal that the new version was malicious did not exist at the registry boundary. MITRE T1195.002, compromise software supply chain.

The payload itself follows the Shai-Hulud pattern observed in the September 2025 wave and the November escalation. A postinstall hook is added to package.json. The hook executes a small bootstrap that fetches or unpacks a second-stage script. The bootstrap is environment-aware. It checks for a CI runner signature, a developer workstation, or a container image. The behaviour forks accordingly. On a developer machine it scrapes ~/.npmrc, ~/.aws/credentials, ~/.docker/config.json, GitHub CLI tokens under ~/.config/gh/, and SSH keys. On a CI runner it walks the environment for NPM_TOKEN, GITHUB_TOKEN, AWS_*, GCP_*, and Azure service principal secrets. Anything that smells like a long-lived credential is collected and exfiltrated.

Exfiltration in this family uses a public sink. Prior waves published the loot to attacker-controlled GitHub repositories created under the victim’s own GitHub identity using the stolen GITHUB_TOKEN. The repo names follow a pattern. The data is base64-encoded JSON. This is the worm propagation step. The same token is then used to identify other npm packages the victim publishes, modify their package.json, and re-publish them with the same postinstall hook. The compromise propagates along the maintainer graph. One captured token becomes N captured packages becomes N captured downstream installer environments.

The exploitation primitive is arbitrary code execution at install time. There is no memory corruption. There is no sandbox escape. The npm lifecycle script runs with the privilege of the user invoking npm install. On a developer laptop that is full user context. On a CI runner that is the build identity, which in most pipelines holds production deploy keys, cloud credentials, and registry push rights. The privilege model of the install command is the exploit primitive. T1059.007 for the JavaScript execution, T1552.001 for credential access from files, T1567 for exfiltration over web service.

The reach question is what the install context can touch. A standard Node CI job in GitHub Actions runs with GITHUB_TOKEN scoped by workflow permissions, plus whatever secrets are injected via env or with blocks. If the workflow uses OIDC federation to AWS or GCP, the runner can mint short-lived cloud credentials. The postinstall script runs before any of the actual build steps. It runs before tests. It runs before the developer’s eyes are on the terminal. By the time npm install returns, the credential set is already on its way out. The exposure window is measured in seconds from invocation.

The packages in this wave matter because of where they sit. echarts-for-react is a React binding for Apache ECharts. It is pulled into dashboards, analytics consoles, and internal tooling across the enterprise stack. size-sensor is a transitive dependency of echarts and several other charting libraries. timeago.js is a small relative-time formatter with broad transitive reach. @ant-design/icons fragments are pulled by every Ant Design consumer. The blast radius is not the direct dependents. It is the transitive tree. A repository that does not list any of these in its top-level package.json will still resolve them through a chart library, a UI kit, or a date-display component. Lockfile inspection is the only way to confirm exposure.

The in-the-wild status is confirmed. Socket, Snyk, and Aikido published indicators while the packages were still live. GitHub Security took down the exfiltration repositories as they appeared, but the pattern reasserts. The actor behind Shai-Hulud has now executed three coordinated waves against npm in under a year. The tooling is automated. The publish-and-propagate cycle does not require human attention once a seed credential is captured. This is worm behaviour against a package registry, and the registry’s defences are still oriented around the single-package, single-incident response model.

Telemetry on the install side is where most defenders find the gap. EDR products see a node process spawning a child process. On Windows that is Sysmon Event ID 1, ParentImage of node.exe, CommandLine containing the postinstall script path. On Linux it is an auditd execve under the npm process tree. What EDR rarely flags is the semantic. node spawning curl, wget, or another node script during npm install is unremarkable on a developer machine and routine in CI. The baseline noise from legitimate postinstall scripts - node-gyp builds, native binary downloads, husky hooks - buries the signal. Network egress to raw.githubusercontent.com, api.github.com, or a newly-created GitHub repository under the victim’s own org does not trip standard outbound rules. The exfiltration channel is the same channel the build pipeline uses for legitimate operations.

What does fire, if the controls exist. Sysmon Event ID 11 for file creation events touching .npmrc, .aws/credentials, or id_rsa from a process not in the expected reader set. EDR file-access telemetry on the credential paths if the agent is configured to monitor them - most are not by default. Network telemetry showing a CI runner making authenticated GitHub API calls to create a new repository, when the workflow definition does not include that action. CloudTrail showing an sts:GetCallerIdentity from an unfamiliar source IP using a runner-minted credential, followed by enumeration calls the workflow never makes. The detection that works is behavioural, not signature-based. The malicious package name is known after the fact. The behaviour is observable in the moment.

The propagation step is the highest-fidelity detection point. The worm uses the stolen GITHUB_TOKEN to push a new commit to the victim’s own npm package repositories, modifying package.json to add the postinstall hook. That is a git push event from a CI context to a repository under the same organisation, outside any planned release workflow. GitHub audit log surfaces this as a repo.push event with a workflow-issued token. Correlation against the workflow’s declared scope catches the anomaly. Most organisations do not run that correlation. The audit log sits in a SIEM bucket that nobody queries until after a disclosure.

The patch boundary is narrow and unsatisfying. npm has unpublished the named versions. Any environment that resolved those versions before takedown is already compromised at the credential layer. Removing the package does not unwind the exfiltration. Rotation of every credential that was reachable from the install context is the only remediation. NPM tokens, GitHub PATs, cloud keys, SSH keys, anything readable by the user account that ran the install. For CI environments, the rotation set extends to every secret the workflow had access to during the window. The package downgrade is cleanup, not containment.

Residual exposure post-patch is the maintainer graph. The same credentials that enabled this wave enable the next. Maintainers who reused tokens across packages, who stored npm credentials in shell history, who committed .npmrc to a repository at any point, remain exploitable. The registry has not shipped mandatory hardware-bound publishing. 2FA on npm is enforced for top packages but the enforcement is TOTP-class, phishable, and bypassed when the attacker captures the session token rather than the second factor. Trusted Publishing via OIDC exists and is not adopted at scale. The conditions that produced this wave are still present.

The operational reading is direct. Lockfile audit against the published IOC list is the immediate action. Credential rotation across any environment that resolved a compromised version is the second. Postinstall execution should be disabled by default in CI - npm config set ignore-scripts true at the runner level, with explicit allow-lists for packages that genuinely need build steps. Outbound egress from build runners should be restricted to the registries and artefact stores the build actually uses. The install command is a code execution primitive. Treat it as one.

See also: NordVPN for tunneled traffic when operating outside controlled networks.


#ad Contains an affiliate link.

Share

Keep Reading

Stay in the loop

New writing delivered when it's ready. No schedule, no spam.