Skip to main content

"A Mini Shai-Hulud Has Appeared": Bun-Based Stealer Hits SAP @cap-js and mbt npm Packages

Written by

April 29, 2026

0 mins read

On April 29, 2026, attackers published malicious versions of four npm packages in the SAP development ecosystem: mbt, @cap-js/db-service, @cap-js/sqlite, and @cap-js/postgres. Each compromised release ships a preinstall hook that downloads the Bun JavaScript runtime from GitHub Releases and uses it to execute an ~11.6 MB obfuscated credential stealer.

The payload tags itself with a hardcoded description, "A Mini Shai-Hulud has Appeared", which is appearing in public GitHub search results in real time as compromised developer machines create dead-drop repositories on their own accounts. The campaign reuses the Shai-Hulud name (the dead-drop repositories are tagged with it) and includes functional npm self-propagation code per StepSecurity's full deobfuscation. As of publication, only the four originally compromised packages have been observed in the wild; the technical breakdown and the compromised npm account that enabled the initial publishes are detailed below.

Snyk has published advisories for all four compromised versions, and the affected releases are flagged by snyk test and shown on each package's Snyk Security Database page.

Affected packages and Snyk advisories

Package

Compromised version

Snyk advisory

Approx. weekly downloads

mbt

1.2.48

~52,000

@cap-js/db-service

2.10.1

~260,000

@cap-js/sqlite

2.2.2

~250,000

@cap-js/postgres

2.2.2

~10,000

Weekly download counts from the npm registry's public downloads API for the week preceding compromise (April 22–28, 2026). All four packages are part of the SAP Cloud Application Programming Model toolchain. mbt is the npm-distributed Cloud MTA Build Tool used to build deployment archives for SAP cloud applications, and the @cap-js/* packages provide database services for CAP applications.

The malicious versions were published in a tight window on April 29, 2026, all UTC, with timestamps verified directly from registry.npmjs.org and the GitHub API:

  • 09:55:25: mbt@1.2.48 published from the cloudmtabot npm account.

  • 10:01:07: first victim dead-drop repository appears on GitHub (gruposbftechrecruiter/siridar-navigator-935, per GitHub API timestamps).

  • 11:25:47: @cap-js/sqlite@2.2.2 published.

  • 12:03 to 12:04: StepSecurity files disclosure issues on cap-js/cds-dbs#1588 and SAP/cloud-mta-build-tool#1224. An independent third disclosure, SAP/open-ux-tools#4616 by longieirl, is filed at 14:02.

  • 12:14:00: @cap-js/postgres@2.2.2 and @cap-js/db-service@2.10.1 published.

  • 13:31: cap-js maintainer chgeo responds: "Thanks for lettings us know. We are about to clean up the packages with highest priority and investigate the root causes."

  • 13:46: SAP publishes clean post-incident versions via the cap-npm GitHub Actions OIDC trusted publisher: @cap-js/db-service@2.11.0, @cap-js/sqlite@2.4.0, @cap-js/postgres@2.3.0 (with @cap-js/hana@2.8.0 as a coordinated bump).

  • 14:02:50: SAP engineer patricebender files cap-js/cds-dbs PR #1592, gating npm publish behind a manual approval environment, with the verbatim root-cause statement quoted above.

  • 14:24: cloud-mta-build-tool collaborator kbarnold responds: "Thank you for the report. We're working on it with high priority."

@cap-js/sqlite@2.2.2 was unpublished from npm shortly after detection. The remaining malicious versions carry npm deprecation strings: mbt@1.2.48 is flagged with "SECURITY: This version contains malicious code. Do not use." while the @cap-js/* malicious versions read "DO NOT USE. This version contains unknown content." Critically, mbt@1.2.48 was still the latest dist-tag for mbt at the time of writing, with no remediated mbt version published yet, meaning a bare npm install mbt still resolves to the malicious tarball. No CVE, GHSA, or OSV record had been assigned for any of the four packages at publication time; the only authoritative public sources are the four vendor blogs cited throughout this post and the three GitHub disclosure issues.

Why this is called "Mini" Shai-Hulud (and what's actually different)

The Shai-Hulud naming convention has appeared in npm supply chain incidents twice before. The original Shai-Hulud campaign in September 2025 hit @ctrl/tinycolor, ngx-bootstrap, ng2-file-upload, and a long tail of dependents (Snyk's zero-day vulnerability report tracked it as it unfolded). The follow-on wave SHA1-Hulud in November 2025 expanded to over 600 distinct packages, including releases from Zapier, PostHog, and Postman. Kaspersky's Securelist team has published independent technical analyses of both prior waves (detection name HEUR:Worm.Script.Shulud.gen) at securelist.com/shai-hulud-worm-infects-500-npm-packages and securelist.com/shai-hulud-2-0.

Shai-Hulud NPM Attack: Remediation with Snyk (short walkthrough on identifying and remediating Shai-Hulud-affected dependencies in your projects using Snyk's CLI and advisor pages).

Both prior campaigns demonstrated worm behavior: the payload would harvest a victim's npm token, then use it to publish itself into every other package the token had write access to. Public attention has tracked accordingly: the Wikipedia article on the Dune sandworm peaked at 2,772 views during the November 2025 SHA1-Hulud disclosure (the highest single-day count in a 16-month dataset, roughly four times the article's average), and the broader "supply chain attack" Wikipedia article reached its highest monthly average on record in April 2026, the month of Mini Shai-Hulud (310 views/day, per Wikimedia REST API).

The April 29 campaign reuses the Shai-Hulud branding (the dead-drop repositories are named with the description string) but the public evidence so far points to a narrower set of behaviors:

  • Credential theft: observed and detailed by multiple researchers.

  • Persistence injection into developer tooling configs: novel and detailed below.

  • Autonomous npm self-publishing: the code is present and functional per StepSecurity's static deobfuscation. The payload harvests npm tokens with regex /npm_[A-Za-z0-9]{36,}/g, validates each one against registry.npmjs.org/-/npm/v1/tokens (filtering for bypass_2fa: true plus org-level write scope), enumerates accessible packages, patches setup.mjs and execution.js into a tarball copy, and publishes via a direct PUT to the npm registry without invoking the npm CLI. As of publication, no fifth package outside the original four has been observed in the wild carrying the malicious payload, but the worm capability is an empirical fact, not an inference.

  • CI-pipeline hijack as the access vector: SAP's own first-party statement in PR #1592 by patricebender on cap-js/cds-dbs (filed 14:02 UTC the same day) reads: "On April 29, 2026, a supply chain attack compromised the repository — an unauthorized actor pushed malicious commits that hijacked the release workflow and triggered unauthorized npm publications. The attacker was able to publish compromised packages because the workflow had publish permissions without any manual approval gate." The fix is to gate npm publish behind an environment review. Per npm registry data, all four malicious versions were published from the cloudmtabot account (the legitimate mbt maintainer); SAP's clean post-incident versions at 13:46 UTC were published via the cap-npm GitHub Actions OIDC trusted publisher, not by cloudmtabot. The cloudmtabot account itself has since been suspended on npm. Maintainer-credential compromise is the recurring entry point for this class of incident, but the SAP statement specifically points to the workflow's missing approval gate as the structural root cause.

The credential theft enables npm self-replication, but the immediately observed activity is exfiltration and a new persistence mechanism. Defensive priorities (lockfile audit, credential rotation, lifecycle script policy) are the same either way.

How the attack works

The compromise pattern is consistent across all four packages: the malicious tarball preserves the legitimate package files unchanged (so the CLI still works after install) and adds two new files, setup.mjs (a 4.5 KB plaintext Bun loader, byte-identical across all four packages) and execution.js (an obfuscated payload at exactly 11,678,349 bytes, with hashes that differ between mbt and the @cap-js/* packages). For mbt only, the malicious package.json also adds three dependencies (axios, tar, unzip-stream) that don't appear in the prior clean release; the three cap-js packages added only the preinstall hook to their existing package.json.

Fetching a small loader at install time and using it to pull in a much larger runtime and payload is a pattern Snyk has seen elsewhere this year, including the axios cross-platform RAT incident where the install hook reached out for a native binary delivered through a separate dependency.

For mbt, the diff between 1.2.47 (clean) and 1.2.48 (malicious) looks like this:

1// 1.2.47: no scripts block
2// 1.2.48:
3{
4  "scripts": {
5    "preinstall": "node setup.mjs"
6  },
7  "dependencies": {
8    "axios": "^1.13.5",
9    "tar": "^7.5.7",
10    "unzip-stream": "^0.3.4"
11  }
12}

preinstall runs before npm reports any output to the user and before any conditional --ignore-scripts policy can intervene if the flag isn't set. The hook executes setup.mjs, which:

  1. Detects platform and architecture (including Alpine/musl detection on Linux).

  2. Checks whether bun is already on PATH via hasCommand("bun"). If present, skips the download step and uses the existing binary. Otherwise downloads Bun 1.3.13 from GitHub Releases (following HTTP redirects without destination validation, per Socket's analysis).

  3. Extracts the Bun binary to a temporary directory if downloaded.

  4. Invokes bun execution.js to run the obfuscated payload.

Once bun execution.js fires, the payload runs through two layers of obfuscation: a string-table rotation in the obfuscator.io style (48,370 entries decoded against a non-standard base64 alphabet and gated by a startup checksum) and a custom cipher StepSecurity dubbed "ctf-scramble-v2" with a PBKDF2-derived master key. Both layers were fully recovered statically. The unwrapped runtime is JavaScript executing under Bun (no embedded WASM or native code; the payload uses Bun-native APIs like Bun.gunzipSync() and Bun.main).

StepSecurity's static deobfuscation describes the following payload behaviors:

  • Harvests local credentials, GitHub and npm tokens, GitHub Actions secrets, cloud secrets across AWS, Azure, GCP, and Kubernetes, password manager CLI tokens (1Password, Bitwarden, LastPass), ~/.claude.json and MCP server configs, environment variables matching KEY/TOKEN/SECRET/PASSWORD patterns, and crypto wallet keys present on the host. The payload also queries the AWS instance metadata service (169.254.169.254) when reachable.

  • On Linux CI runners, it spawns a Python child process that reads /proc/{pid}/mem of the GitHub Actions Runner.Worker process to dump plaintext secrets directly from runner memory.

  • On Windows, invokes PowerShell with -ExecutionPolicy Bypass.

  • Serializes the stolen data as JSON, gzip-compresses it, encrypts it with AES-256-GCM (32-byte key, 12-byte IV), and wraps the AES key using RSA-OAEP-SHA256 against an embedded RSA-4096 public key (stored as gzip+base64 PEM at file offset 9,429,992 in execution.js, decompressed at runtime via Bun.gunzipSync()). This means defenders who discover the dead-drop repository can see that something was stolen, but cannot determine what. Effectively, every credential the process could touch should be treated as exposed.

  • Creates a public repository on the victim's GitHub account, named with random characters and tagged with the description "A Mini Shai-Hulud has Appeared", and commits the encrypted ciphertext as the dead drop.

  • Checks the system locale via JavaScript's Intl API and POSIX environment variables (LC_ALL, LC_MESSAGES, LANGUAGE, LANG). If a Russian (ru) locale is detected, the payload logs "Exiting as russian language detected!" and terminates. CIS-region exemption checks align with documented behavior of cybercriminal operations in those regions.

The persistence mechanism

Whether the campaign is classified as a worm, the persistence mechanism is the most novel element documented in public reporting.

According to StepSecurity, the payload uses three persistence and re-execution mechanisms:

  • Developer-tool config injection. Into every GitHub repository the stolen token can write to, the payload commits a .claude/settings.json file that uses Claude Code's SessionStart hook to re-execute the payload whenever a developer starts a Claude Code session, and a .vscode/tasks.json with "runOn": "folderOpen" that triggers when the project is opened in VS Code. These injected files are committed under the identity claude@users.noreply.github.com with the message "chore: update dependencies", which can resemble routine automation commits on PR review.

  • GitHub Actions workflow injection. When the stolen token has workflow scope, the payload creates a typosquatted branch dependabout/github_actions/format/setup-formatter (note the missing t) and commits .github/workflows/format-check.yml impersonating the Dependabot service account (dependabot[bot]@users.noreply.github.com, commit message "Add formatter workflow"). The injected workflow uses ${{ toJSON(secrets) }} to dump every repository secret into a build artifact named format-results.txt.

  • Daemonization on developer machines. On non-CI hosts, the payload forks itself as a detached background process tagged with the env var __DAEMONIZED=1, then the parent exits cleanly so npm install returns to the user with no visible error. The detached child continues running with no clear process ancestry.

This extends the set of installation-side persistence vectors observed in npm supply chain campaigns to include AI coding agent configuration files, alongside package.json lifecycle hooks, CI workflows, and IDE plugin manifests.

The AI-agent configuration attack surface is documented prior art rather than a first appearance. Microsoft's VSCode #309406 on the tasks.json runOn: folderOpen vector was filed sixteen days earlier and closed as By Design (Workspace Trust is treated as sufficient mitigation, though the trust check does not cover commits made into already-trusted repos). Anthropic's claude-code #49778 on the SessionStart hook was filed twelve days earlier and remains open without an Anthropic response, citing a real-world precedent in the Cozempic supply chain audit. The Trivy AI-agent compromise (CVE-2026-28353) in March 2026 went further, using stolen tokens to publish a weaponized VS Code extension that targeted five different AI coding agents. Snyk has covered this same crossover separately in the Clinejection writeup. Mini Shai-Hulud is the next data point in that pattern, not the first.

Indicators of compromise

This list is condensed from public reporting by StepSecurity, Aikido, Socket, and SafeDep. Refer to those reports for full IOCs and forensic detail. Each malicious version remains queryable as an immutable record on the npm registry at registry.npmjs.org/mbt/1.2.48, registry.npmjs.org/@cap-js/sqlite/2.2.2, registry.npmjs.org/@cap-js/postgres/2.2.2, and registry.npmjs.org/@cap-js/db-service/2.10.1; the publish timestamp and "preinstall": "node setup.mjs" fingerprint are preserved in each record.

1Compromised npm artifacts
2  mbt@1.2.48                       shasum 0af7415d65753f6aede8c9c0f39be478666b9c12
3  @cap-js/db-service@2.10.1        shasum 4b04304f6d51392e3f43856c94ca95800518a694
4  @cap-js/sqlite@2.2.2             shasum 7b6a28e92149637e5d7c7f4a2d3e54acd507c929
5  @cap-js/postgres@2.2.2           shasum e80824a19f48d778a746571bb15279b5679fd61c
6
7Compromised npm publisher account
8  cloudmtabot (used to publish all four malicious versions)
9
10Files added to the compromised tarballs
11  setup.mjs       ~4.5 KB plaintext Bun loader, byte-identical
12                   across all four packages
13  execution.js    11,678,349 bytes obfuscated payload, hash
14                   differs per package
15
16File hashes (SHA-256, per StepSecurity)
17  setup.mjs (all packages)
18    4066781fa830224c8bbcc3aa005a396657f9c8f9016f9a64ad44a9d7f5f45e34
19  execution.js (mbt@1.2.48)
20    80a3d2877813968ef847ae73b5eeeb70b9435254e74d7f07d8cf4057f0a710ac
21  execution.js (@cap-js/sqlite@2.2.2)
22    6f933d00b7d05678eb43c90963a80b8947c4ae6830182f89df31da9f568fea95
23  Embedded /proc/mem dumper
24    29ac906c8bd801dfe1cb39596197df49f80fff2270b3e7fbab52278c24e4f1a7
25
26In-payload indicators (recovered by StepSecurity static analysis)
27  Custom cipher salt              ctf-scramble-v2
28  PBKDF2 input key                5012caa5847ae9261dfa16f91417042f367d6bed149c3b8af7a50b203a093007
29  Derived master key              fd4b0f07b27e8f41bc70b8e2b79d168fb3fe80d7e0b37f43c506136a3418b44d
30  CIS evasion log string          Exiting as russian language detected!
31  Daemonization env var           __DAEMONIZED
32  GitHub PAT regex                /gh[op]_[A-Za-z0-9]{36}/g
33  npm token regex                 /npm_[A-Za-z0-9]{36,}/g
34
35Network and runtime indicators
36  - Outbound request to github.com/oven-sh/bun/releases/download/bun-v1.3.13/{asset}.zip
37    where {asset} is one of:
38      bun-linux-aarch64
39      bun-linux-x64-baseline
40      bun-linux-x64-musl-baseline
41      bun-darwin-aarch64
42      bun-darwin-x64
43      bun-windows-aarch64
44      bun-windows-x64-baseline
45    (skipped if `bun` is already on PATH)
46  - Process chain during install:
47    node setup.mjs -> bun execution.js
48  - Linux CI runners: child Python process reading
49    /proc/{pid}/mem of Runner.Worker
50  - Windows: powershell -ExecutionPolicy Bypass
51  - api.github.com calls:
52    POST /user/repos               (dead-drop repo creation)
53    GET  /search/commits?q=OhNoWhatsGoingOnWithGitHub
54                                   (P2P token dead-drop search)
55  - Direct PUT to registry.npmjs.org during self-propagation
56    (no npm CLI involvement)
57  - Cloud metadata reads:
58    http://169.254.169.254          (AWS/Azure IMDS)
59    http://169.254.170.2            (ECS task metadata)
60    http://[fd00:ec2::254]          (AWS IMDSv2 IPv6)
61
62GitHub-side indicators
63  - New public repositories on developer accounts with the
64    description string "A Mini Shai-Hulud has Appeared"
65  - Repository names follow a Dune-themed pattern, regex:
66    (sardaukar|mentat|fremen|atreides|harkonnen|gesserit|
67     prescient|fedaykin|tleilaxu|siridar|kanly|sayyadina|
68     ghola|powindah|prana|kralizec)-(sandworm|ornithopter|
69     heighliner|stillsuit|lasgun|sietch|melange|thumper|
70     navigator|fedaykin|futar|slig|phibian|laza|cogitor|
71     ghola)-\d{1,3}
72  - Commits authored by claude@users.noreply.github.com
73    with the message "chore: update dependencies"
74  - New or modified .claude/settings.json files containing
75    a SessionStart hook
76  - New or modified .vscode/tasks.json with a task using
77    "runOn": "folderOpen"
78  - P2P token dead-drop commits referencing the string
79    "OhNoWhatsGoingOnWithGitHub" (used by the malware to
80    locate base64-encoded tokens left by other victims)
81  - Typosquatted Dependabot branch:
82    dependabout/github_actions/format/setup-formatter
83  - Injected workflow file .github/workflows/format-check.yml
84    using the expression toJSON(secrets), authored by
85    dependabot[bot]@users.noreply.github.com with commit
86    message "Add formatter workflow", uploading artifact
87    "format-results.txt"

Live GitHub searches for the two distinctive strings:

Each dead-drop repo contains a single README.md plus one or more files at results/results-<unix-ms>-<counter>.json. Direct inspection of one live victim repo by external researchers confirmed the file format:

1{"envelope": "<base64 AES-256-GCM ciphertext>", "key": "<base64 RSA-4096 wrapped session key>"}

Because the wrapping key is the attacker's RSA-4096 public key, the contents are unreadable to defenders even with full access to the victim's GitHub account.

What to do

The blast radius depends on whether your build pipelines or developer machines ran npm install against any of the four affected versions during the exposure window (roughly 10:00 to 14:00 UTC on April 29, 2026, plus any time the deprecated versions remain resolvable).

Check your installs. Search lockfiles for the affected versions, and watch the transitive resolution: @cap-js/sqlite@2.2.2 declares @cap-js/db-service@^2.10.0 as a dependency, so a clean install of @cap-js/sqlite from a reachable version range can pull @cap-js/db-service@2.10.1 (the malicious version) as a transitive without anyone listing it directly.

1# in any project root
2rg -n '"mbt"|"@cap-js/db-service"|"@cap-js/sqlite"|"@cap-js/postgres"' \
3   package-lock.json npm-shrinkwrap.json yarn.lock pnpm-lock.yaml 2>/dev/null

For Snyk customers, snyk test and snyk monitor will surface the malicious versions against the four advisories listed above. The Snyk Security Database pages for mbt, @cap-js/db-service, @cap-js/sqlite, and @cap-js/postgres reflect the security incident as well.

If a compromised version was installed:

  • Treat every credential reachable from that machine or pipeline as exposed. The attacker-controlled RSA encryption means defenders cannot read the dead-drop contents to confirm scope, so rotation has to be conservative. The 134 file-path patterns StepSecurity recovered from the payload include, at minimum: npm publish tokens (highest priority, since these enable worm-style propagation across other packages a developer maintains), GitHub PATs and OAuth grants, AWS/Azure/GCP credentials and any roles assumable from the affected machine, Kubernetes kubeconfigs (~/.kube/config, k3s YAML, in-pod service-account tokens), Docker configs (~/.docker/config.json), Terraform Cloud credentials (~/.terraform.d/credentials.tfrc.json), Helm config, SSH keys (~/.ssh/id*, config, known_hosts, host keys under /etc/ssh/), password manager CLI tokens (1Password's op, Bitwarden's bw, LastPass), .npmrc / .pypirc / .yarnrc, .gitconfig and .git-credentials, shell history files, environment variables and .env* files matching *_TOKEN, *_KEY, *_SECRET, or *_PASSWORD patterns, ~/.claude.json and any MCP server configs (including Kiro MCP configs at ~/.kiro/settings/mcp.json), messaging app session data (Signal, Slack cookies, Discord, Telegram, Element, Pidgin), VPN configs (NordVPN, ProtonVPN, OpenVPN, etc.), FileZilla server lists, Ansible configs, and crypto wallet keys for Bitcoin, Ethereum, Monero, Dash, Zcash, Dogecoin, Litecoin, Electrum, Exodus, Ledger Live, and Atomic. The payload also reads AWS instance metadata (169.254.169.254, 169.254.170.2, fd00:ec2::254) and ECS task metadata, so any IAM roles reachable from those endpoints should be considered exposed.

  • Audit GitHub Actions secret exposure. The /proc/{pid}/mem extraction technique reads Runner.Worker's full readable address space, so any secret used in the workflow up to that point is in scope, even if not directly referenced by the install step. Default GitHub-hosted runners do not block this access; detection requires a runner-side security agent like StepSecurity Harden-Runner.

  • Sweep your GitHub org for the IOCs in the code block above: dead-drop repos matching the Dune-themed naming pattern with the "Mini Shai-Hulud" description, the claude@users.noreply.github.com author signature, the dependabot[bot]@users.noreply.github.com impersonation on a dependabout/... branch, the injected .github/workflows/format-check.yml workflow that exfiltrates toJSON(secrets) to a format-results.txt artifact, the OhNoWhatsGoingOnWithGitHub P2P token-dead-drop commit string, and unexpected .claude/settings.json or .vscode/tasks.json additions.

  • Pin to clean versions. For the three @cap-js packages, prefer SAP's post-incident releases (@cap-js/db-service@2.11.0, @cap-js/sqlite@2.4.0, @cap-js/postgres@2.3.0) as the forward pin and the pre-incident versions (2.10.0, 2.2.1, 2.2.1 respectively) as the minimum floor. For mbt, no remediated version had been published at the time of writing; pin to mbt@1.2.47 until SAP ships a successor.

General hardening:

Start securing your Python apps

Find and fix Python vulnerabilities with Snyk for free.

No credit card required.

Or Sign up with Azure AD Docker ID Bitbucket

By using Snyk, you agree to abide by our policies, including our Terms of Service and Privacy Policy.