critical Threat analysis

Bitwarden CLI npm 2026.4.0 Credential Stealer

Bitwarden confirmed that @bitwarden/cli@2026.4.0 was maliciously distributed through the npm CLI delivery path for a short April 22, 2026 window. JFrog and Socket analysis tied the package to bw_setup.js, bw1.js, Bun bootstrap, audit.checkmarx.cx exfiltration, GitHub fallback channels, and developer/CI credential theft.

#npm#supply-chain#bitwarden#github-actions#credential-theft#ci-cd
On this page 0% read

    Executive Summary

    Bitwarden confirmed a malicious npm release of @bitwarden/cli@2026.4.0 in the CLI npm delivery path on April 22, 2026. Bitwarden’s public statement narrows affected users to npm CLI installs during the vendor-stated window of 5:57 PM to 7:30 PM ET on April 22, 2026, and states that vault data, production data, and production systems were not found to be compromised [Source 1].

    JFrog analyzed the malicious package and found that the package rewired preinstall and the bw binary entrypoint to bw_setup.js, which bootstrapped Bun 1.3.13 and ran bw1.js. The payload targeted developer and CI credentials, exfiltrated to audit.checkmarx.cx/v1/telemetry, resolved the primary domain to 94.154.172.43, and used GitHub commit search/repository creation as fallback transport [Source 2]. Socket independently tracked the same package/version, endpoint, IP, lock file, and GitHub artifact/workflow abuse patterns [Source 3].

    The npm registry metadata still records a 2026.4.0 timestamp even though the removed version is absent from the current versions list. Use 2026-04-22T21:22:59Z to start collection and 2026-04-22T23:30:00Z as the initial end bound; classify exposure by exact package/version plus execution evidence, not by generic Bitwarden usage [Source 4].

    Key Facts

    event_type: "legitimate npm package delivery compromise"
    ecosystem: "npm"
    package:
      name: "@bitwarden/cli"
      malicious_version: "2026.4.0"
      clean_replacement_versions:
        - "2026.4.1"
        - "2026.4.2"
    collection_window_utc:
      start: "2026-04-22T21:22:59Z"
      vendor_affected_start: "2026-04-22T21:57:00Z"
      vendor_affected_end: "2026-04-22T23:30:00Z"
    execution_triggers:
      - "npm preinstall runs bw_setup.js"
      - "bw binary entrypoint points to bw_setup.js"
    payload_files:
      - "bw_setup.js"
      - "bw1.js"
    payload_hashes_sha256:
      bw_setup_js: "18f784b3bc9a0bcdcb1a8d7f51bc5f54323fc40cbd874119354ab609bef6e4cb"
      bw1_js: "8605e365edf11160aad517c7d79a3b26b62290e5072ef97b102a01ddbb343f14"
      tampered_root_metadata: "167ce57ef59a32a6a0ef4137785828077879092d7f83ddbc1755d6e69116e0ad"
    network_iocs:
      - "audit.checkmarx.cx"
      - "94.154.172.43"
      - "https://audit.checkmarx.cx/v1/telemetry"
    github_iocs:
      - "LongLiveTheResistanceAgainstMachines"
      - "beautifulcastle"
      - "Shai-Hulud: The Third Coming"
    runtime_iocs:
      - "github.com/oven-sh/bun/releases/download/bun-v1.3.13"
      - "bun-v1.3.13"
    credentials_at_risk:
      - "GitHub CLI tokens and PATs"
      - "npm tokens"
      - "SSH keys"
      - "AWS credentials"
      - "GCP credentials"
      - "Azure credentials"
      - "GitHub Actions secrets reachable through stolen tokens"
      - "AI and MCP tool configuration files"

    Source Confidence & Evidence Mapping

    • confirmed: Bitwarden publicly confirmed malicious distribution of @bitwarden/cli@2026.4.0 through npm and limited the affected population to npm CLI users in the April 22, 2026 window [Source 1].
    • confirmed: JFrog identified bw_setup.js, bw1.js, the preinstall and bin.bw rewiring, Bun 1.3.13, audit.checkmarx.cx/v1/telemetry, 94.154.172.43, the GitHub fallback markers, and SHA-256 hashes for the loader, payload, and tampered metadata [Source 2].
    • confirmed: Socket reported the same package/version and called out audit.checkmarx.cx, 94.154.172.43, /tmp/tmp.987654321.lock, package update artifacts, Bun execution, and GitHub Actions artifact/workflow abuse patterns [Source 3].
    • unclear: Public sources do not prove which third-party action, token path, or repository state produced the malicious npm package. Treat CI/CD compromise mechanism claims beyond the observed package behavior as unresolved unless new vendor evidence appears.
    • not_observed: Bitwarden reported no evidence of end-user vault data access, production data compromise, or production system compromise [Source 1].

    Impact Determination

    ClassificationCriteriaEvidence to collectHandling decision
    Confirmed compromise@bitwarden/cli@2026.4.0 executed and any payload, network, GitHub fallback, or credential access indicator appears.npm install output, lockfile/package cache, bw_setup.js, bw1.js, Bun 1.3.13, audit.checkmarx.cx, 94.154.172.43, LongLiveTheResistanceAgainstMachines, beautifulcastle, GitHub repo/artifact creation evidence.Isolate the host or runner, preserve package/cache/process/network evidence, revoke credentials present on that environment, and run the downstream audits below.
    Presumed exposed@bitwarden/cli@2026.4.0 was installed or pulled on a developer host, container build, or CI job, but runtime/network telemetry is missing.Package manager cache, package-lock.json, npm registry proxy entries, CI job logs, image layer history, endpoint inventory.Treat credentials reachable from that process as exposed unless negative execution evidence is complete.
    Potentially exposed@bitwarden/cli appears in dependency manifests or install scripts and the resolved version during the April 22 window is unknown.Dependency manifests, historical lockfiles, package proxy records, CI log exports, build image SBOMs.Collect resolver/version evidence until the asset moves to confirmed compromise, presumed exposed, or not exposed.
    Not exposedEvidence shows no @bitwarden/cli@2026.4.0 tarball, install, cache entry, image layer, process, or network selector in scope.Negative repository search, package proxy query, CI job export, endpoint search, and image/cache inventory.Keep the negative evidence with the case record and close this event for the asset.
    UnknownRequired package, CI, endpoint, proxy, or registry telemetry is unavailable for the April 22 collection window.A named telemetry gap with owner, system, and retention status.Keep high-value developer/CI assets in scope and decide credential revocation based on reachable secret inventory.

    Minimum Evidence To Collect

    package_evidence:
      - "@bitwarden/cli@2026.4.0 in package-lock.json, yarn.lock, pnpm-lock.yaml, npm-shrinkwrap.json, npm cache, or package proxy records"
      - "npm registry metadata showing 2026.4.0 pulled by an internal cache or CI job"
    execution_evidence:
      - "bw_setup.js"
      - "bw1.js"
      - "bun-v1.3.13"
      - "/tmp/tmp.987654321.lock"
    network_evidence:
      - "audit.checkmarx.cx"
      - "94.154.172.43"
      - "https://audit.checkmarx.cx/v1/telemetry"
    github_evidence:
      - "LongLiveTheResistanceAgainstMachines"
      - "beautifulcastle"
      - "Shai-Hulud: The Third Coming"
      - "unexpected GitHub Actions workflow, artifact, branch, or repository creation from an exposed token"

    Timeline

    • 2026-04-22T21:22:59Z: npm registry metadata records @bitwarden/cli@2026.4.0 in the package time map [Source 4].
    • 2026-04-22T21:57:00Z: Bitwarden’s affected-window statement starts at 5:57 PM ET [Source 1].
    • 2026-04-22T23:30:00Z: Bitwarden states the malicious npm delivery window ended at 7:30 PM ET [Source 1].
    • 2026-04-23: Bitwarden published the public notice and directed affected npm CLI users to uninstall @bitwarden/cli, clear npm cache, disable install scripts during cleanup, and install 2026.4.1 [Source 1].
    • 2026-04-23: JFrog published artifact-level analysis of bw_setup.js, bw1.js, the primary exfiltration URL, fallback GitHub paths, hashes, and targeted local paths [Source 2].
    • 2026-04-23: Socket published independent analysis of the same package/version and overlapping IOCs [Source 3].

    What Happened

    The malicious npm package kept Bitwarden CLI branding but changed the package execution path. JFrog observed a preinstall script of node bw_setup.js and a bin.bw value pointing to bw_setup.js, so both installation and direct CLI invocation could reach the malicious loader [Source 2].

    bw_setup.js checked for Bun, downloaded bun-v1.3.13 from github.com/oven-sh/bun when needed, and used Bun to execute bw1.js. bw1.js then collected local developer and CI credential material, encrypted the collected result set, and sent it to https://audit.checkmarx.cx/v1/telemetry with GitHub-based fallback paths if direct HTTPS exfiltration failed [Source 2].

    The GitHub abuse path matters for responders because the payload did not stop at local file theft. JFrog reports token validation against https://api.github.com/user, commit search for LongLiveTheResistanceAgainstMachines, fallback discovery using beautifulcastle, repository creation under a victim account, and GitHub Actions secret extraction through workflow execution and artifact retrieval [Source 2]. Socket also calls out workflow file creation and artifacts such as format-results.txt [Source 3].

    Technical Analysis

    Package Manipulation

    package_identity:
      registry: "npm"
      package: "@bitwarden/cli"
      malicious_version: "2026.4.0"
      modified_manifest_fields:
        scripts.preinstall: "node bw_setup.js"
        bin.bw: "bw_setup.js"
      mismatched_embedded_cli_version: "2026.3.0"

    Execution And Collection

    The execution chain is npm install or bw invocation to bw_setup.js, then Bun 1.3.13, then bw1.js. JFrog decoded credential targeting for gh auth token, GitHub and npm token patterns, environment variables, SSH paths, .git-credentials, .npmrc, .env, shell histories, AWS credentials, GCP credential DB files, and AI/MCP configuration paths [Source 2].

    Exfiltration

    primary_exfiltration:
      domain: "audit.checkmarx.cx"
      ip: "94.154.172.43"
      url: "https://audit.checkmarx.cx/v1/telemetry"
      encoding: "gzip plus RSA-OAEP-wrapped AES-256-GCM envelope"
    fallback_github_paths:
      - "https://api.github.com/search/commits?q=LongLiveTheResistanceAgainstMachines&sort=author-date&order=desc&per_page=50"
      - "https://api.github.com/search/commits?q=beautifulcastle%20&sort=author-date&order=desc"

    Affected Assets and Blast Radius

    affected_assets:
      ecosystems:
        - "npm"
      packages:
        - "@bitwarden/cli@2026.4.0"
      developer_hosts:
        - "hosts that installed or ran @bitwarden/cli@2026.4.0"
      ci_cd_systems:
        - "runners that installed or ran @bitwarden/cli@2026.4.0"
      containers:
        - "images built while resolving @bitwarden/cli@2026.4.0"
      source_control:
        - "GitHub accounts and repositories reachable from stolen tokens"
      package_registries:
        - "npm accounts reachable from stolen npm tokens"
    not_currently_known_to_affect:
      - "Bitwarden web vault usage without npm CLI install"
      - "Bitwarden browser extension"
      - "Bitwarden server production systems per vendor statement"

    Indicators of Compromise

    package_versions:
      - "@bitwarden/cli@2026.4.0"
    files:
      - "bw_setup.js"
      - "bw1.js"
      - "/tmp/tmp.987654321.lock"
      - "package-updated.tgz"
    hashes_sha256:
      - "18f784b3bc9a0bcdcb1a8d7f51bc5f54323fc40cbd874119354ab609bef6e4cb"
      - "8605e365edf11160aad517c7d79a3b26b62290e5072ef97b102a01ddbb343f14"
      - "167ce57ef59a32a6a0ef4137785828077879092d7f83ddbc1755d6e69116e0ad"
    domains:
      - "audit.checkmarx.cx"
    ips:
      - "94.154.172.43"
    urls:
      - "https://audit.checkmarx.cx/v1/telemetry"
      - "https://api.github.com/search/commits?q=LongLiveTheResistanceAgainstMachines&sort=author-date&order=desc&per_page=50"
      - "https://api.github.com/search/commits?q=beautifulcastle%20&sort=author-date&order=desc"
      - "https://github.com/oven-sh/bun/releases/download/bun-v1.3.13"
    strings:
      - "LongLiveTheResistanceAgainstMachines"
      - "beautifulcastle"
      - "Shai-Hulud: The Third Coming"
      - "gh auth token"
    targeted_paths:
      - "~/.ssh/id_"
      - "~/.ssh/id*"
      - "~/.ssh/known_hosts"
      - "~/.ssh/keys"
      - ".git/config"
      - ".git-credentials"
      - "~/.npmrc"
      - ".npmrc"
      - ".env"
      - "~/.bash_history"
      - "~/.zsh_history"
      - "~/.aws/credentials"
      - "~/.config/gcloud/credentials.db"
      - "~/.claude.json"
      - ".claude.json"
      - "~/.claude/mcp.json"
      - "~/.kiro/settings/mcp.json"
      - ".kiro/settings/mcp.json"

    Detection and Hunting

    Script: local source, cache, image-export, and telemetry selector sweep

    #!/usr/bin/env python3
    import json
    import os
    import subprocess
    import sys
    from pathlib import Path
    
    ROOT = Path(sys.argv[1] if len(sys.argv) > 1 else ".").resolve()
    LOG_ROOT = Path(os.environ.get("LOG_ROOT", "")).resolve() if os.environ.get("LOG_ROOT") else None
    OUT = Path(os.environ.get("OUT", "hp-bitwarden-cli-2026-4-0-scope")).resolve()
    
    COLLECTION_START = "2026-04-22T21:22:59Z"
    VENDOR_AFFECTED_START = "2026-04-22T21:57:00Z"
    VENDOR_AFFECTED_END = "2026-04-22T23:30:00Z"
    
    SELECTORS = [
        "@bitwarden/cli",
        "@bitwarden/cli@2026.4.0",
        "2026.4.0",
        "bw_setup.js",
        "bw1.js",
        "/tmp/tmp.987654321.lock",
        "package-updated.tgz",
        "audit.checkmarx.cx",
        "94.154.172.43",
        "https://audit.checkmarx.cx/v1/telemetry",
        "LongLiveTheResistanceAgainstMachines",
        "beautifulcastle",
        "Shai-Hulud: The Third Coming",
        "github.com/oven-sh/bun/releases/download/bun-v1.3.13",
        "18f784b3bc9a0bcdcb1a8d7f51bc5f54323fc40cbd874119354ab609bef6e4cb",
        "8605e365edf11160aad517c7d79a3b26b62290e5072ef97b102a01ddbb343f14",
        "167ce57ef59a32a6a0ef4137785828077879092d7f83ddbc1755d6e69116e0ad",
    ]
    
    LOCKFILES = {
        "package-lock.json",
        "npm-shrinkwrap.json",
        "pnpm-lock.yaml",
        "yarn.lock",
        "package.json",
        "sbom.json",
        "cyclonedx.json",
    }
    
    # Positive signal: a file, lockfile, npm cache, container/SBOM export, DNS/proxy log, or CI log contains @bitwarden/cli@2026.4.0 plus any payload/network/GitHub selector above.
    # Escalation: any selector match on a developer host, CI runner, container image, or package cache between COLLECTION_START and VENDOR_AFFECTED_END moves the asset to presumed exposed at minimum.
    
    OUT.mkdir(parents=True, exist_ok=True)
    (OUT / "matches").mkdir(exist_ok=True)
    (OUT / "registry").mkdir(exist_ok=True)
    
    selectors_file = OUT / "bitwarden-cli-selectors.txt"
    selectors_file.write_text("\n".join(SELECTORS) + "\n")
    
    def scan_tree(base: Path, label: str) -> list[dict]:
        findings = []
        if not base.exists():
            return findings
        skip = {".git", "node_modules", ".venv", "venv", "dist", "build"}
        for root, dirs, files in os.walk(base):
            dirs[:] = [d for d in dirs if d not in skip]
            for name in files:
                p = Path(root) / name
                if p.stat().st_size > 50_000_000:
                    continue
                try:
                    data = p.read_text(errors="ignore")
                except Exception:
                    continue
                hits = [s for s in SELECTORS if s in data]
                if hits:
                    findings.append({"scope": label, "path": str(p), "hits": sorted(set(hits))})
        return findings
    
    findings = scan_tree(ROOT, "root")
    if LOG_ROOT:
        findings.extend(scan_tree(LOG_ROOT, "log_root"))
    
    for item in findings:
        print(f"[MATCH] {item['scope']} {item['path']} :: {', '.join(item['hits'])}")
    
    (OUT / "matches" / "selector-matches.jsonl").write_text(
        "".join(json.dumps(x, sort_keys=True) + "\n" for x in findings)
    )
    
    lockfile_hits = [f for f in findings if Path(f["path"]).name in LOCKFILES]
    (OUT / "matches" / "lockfile-and-manifest-hits.jsonl").write_text(
        "".join(json.dumps(x, sort_keys=True) + "\n" for x in lockfile_hits)
    )
    
    npm_cache = subprocess.run(
        ["npm", "cache", "ls", "@bitwarden/cli"],
        text=True,
        capture_output=True,
    )
    (OUT / "npm-cache-ls-bitwarden-cli.txt").write_text(npm_cache.stdout + npm_cache.stderr)
    
    npm_view = subprocess.run(
        ["npm", "view", "@bitwarden/cli", "name", "version", "time", "versions", "dist-tags", "--json"],
        text=True,
        capture_output=True,
    )
    (OUT / "registry" / "npm-view-bitwarden-cli.json").write_text(npm_view.stdout + npm_view.stderr)
    
    if findings:
        print(f"[!] {len(findings)} selector-bearing files written under {OUT}")
    else:
        print(f"[+] No selector-bearing files found under {ROOT}")

    Downstream Abuse Audits

    Script: GitHub, npm, and cloud follow-on activity collector

    #!/usr/bin/env python3
    import json
    import os
    import subprocess
    from pathlib import Path
    
    OUT = Path(os.environ.get("OUT", "hp-bitwarden-cli-2026-4-0-downstream")).resolve()
    ORG = os.environ.get("ORG", "")
    AWS_REGIONS = [r for r in os.environ.get("AWS_REGIONS", "us-east-1").split(",") if r]
    
    SINCE = "2026-04-22T21:22:59Z"
    UNTIL = "2026-04-23T06:00:00Z"
    SELECTORS = [
        "@bitwarden/cli",
        "2026.4.0",
        "bw_setup.js",
        "bw1.js",
        "audit.checkmarx.cx",
        "94.154.172.43",
        "LongLiveTheResistanceAgainstMachines",
        "beautifulcastle",
        "Shai-Hulud: The Third Coming",
        "format-results.txt",
    ]
    
    # Positive signal: post-exposure GitHub workflow, artifact, branch, release, npm publish, cloud IAM, or secret-manager activity overlaps SINCE/UNTIL and contains a selector or is performed by an exposed identity.
    # Remediation trigger: unexpected write/deploy/IAM/secret/package activity after @bitwarden/cli@2026.4.0 exposure requires token revocation, package publish suspension, and cloud session invalidation for that identity.
    
    OUT.mkdir(parents=True, exist_ok=True)
    (OUT / "github").mkdir(exist_ok=True)
    (OUT / "cloud").mkdir(exist_ok=True)
    (OUT / "npm").mkdir(exist_ok=True)
    selectors_file = OUT / "selectors.txt"
    selectors_file.write_text("\n".join(SELECTORS) + "\n")
    
    def run(cmd: list[str], outfile: Path) -> None:
        res = subprocess.run(cmd, text=True, capture_output=True)
        outfile.write_text(res.stdout + res.stderr)
    
    run(["npm", "whoami"], OUT / "npm" / "whoami.txt")
    run(["npm", "token", "list", "--json"], OUT / "npm" / "token-list.json")
    
    if ORG:
        repos_res = subprocess.run(
            ["gh", "repo", "list", ORG, "--limit", "1000", "--json", "nameWithOwner"],
            text=True,
            capture_output=True,
        )
        (OUT / "github" / "repos.json").write_text(repos_res.stdout + repos_res.stderr)
        repos = []
        if repos_res.returncode == 0:
            repos = [r["nameWithOwner"] for r in json.loads(repos_res.stdout)]
        for repo in repos:
            safe = repo.replace("/", "__")
            run(["gh", "api", f"/repos/{repo}/actions/runs", "-f", "per_page=100", "-f", f"created=>={SINCE}", "--paginate"], OUT / "github" / f"{safe}-runs.json")
            run(["gh", "api", f"/repos/{repo}/actions/secrets", "-f", "per_page=100", "--paginate"], OUT / "github" / f"{safe}-actions-secrets.json")
            run(["gh", "api", f"/repos/{repo}/branches", "-f", "per_page=100", "--paginate"], OUT / "github" / f"{safe}-branches.json")
            runs_path = OUT / "github" / f"{safe}-runs.json"
            try:
                runs = json.loads(runs_path.read_text()).get("workflow_runs", [])
            except Exception:
                runs = []
            for run_obj in runs:
                created = run_obj.get("created_at", "")
                if not created or created > UNTIL:
                    continue
                run_id = str(run_obj.get("id", ""))
                if not run_id:
                    continue
                run(["gh", "run", "view", run_id, "--repo", repo, "--json", "databaseId,workflowName,event,createdAt,headSha,jobs"], OUT / "github" / f"{safe}-{run_id}.json")
                run(["gh", "run", "view", run_id, "--repo", repo, "--log"], OUT / "github" / f"{safe}-{run_id}.log")
    
    for region in AWS_REGIONS:
        for event_name in [
            "AssumeRoleWithWebIdentity",
            "GetSecretValue",
            "CreateAccessKey",
            "PutRolePolicy",
            "UpdateAssumeRolePolicy",
        ]:
            run(
                [
                    "aws", "cloudtrail", "lookup-events",
                    "--region", region,
                    "--start-time", SINCE,
                    "--end-time", UNTIL,
                    "--lookup-attributes", f"AttributeKey=EventName,AttributeValue={event_name}",
                    "--output", "json",
                ],
                OUT / "cloud" / f"aws-{region}-{event_name}.json",
            )
    
    subprocess.run(["rg", "-n", "--hidden", "--fixed-strings", "-f", str(selectors_file), str(OUT)])
    print(f"[+] Downstream artifacts written under {OUT}")

    Sources

    1. Bitwarden Community Forums: Bitwarden Statement on Checkmarx Supply Chain Incident - Role: DIRECT_SOURCE - Impact: Vendor scope, affected window, non-impact statements, cleanup package version.
    2. JFrog Security Research: TeamPCP Campaign Spreads to npm via a Hijacked Bitwarden CLI - Role: PRIMARY_RESEARCH - Impact: Package manifest rewiring, loader/payload files, hashes, exfiltration, GitHub fallback selectors, credential targets.
    3. Socket: Bitwarden CLI Compromised in Ongoing Checkmarx Supply Chain Campaign - Role: PRIMARY_RESEARCH - Impact: Independent IOC set and GitHub Actions workflow/artifact abuse context.
    4. npm registry metadata for @bitwarden/cli - Role: REGISTRY_METADATA - Impact: Current versions list and time metadata for removed 2026.4.0.