intercom-client npm Mini Shai-Hulud Compromise
On April 30, 2026, `[email protected]` on npm introduced a first-ever `preinstall` hook that executed a Bun-launched obfuscated credential stealer and exfiltrated secrets through GitHub APIs.
On this page 0% read
Executive Summary
On April 30, 2026, [email protected], the official Node.js SDK for Intercom, was published to npm with a malicious preinstall hook and two undocumented files: setup.mjs and router_runtime.js StepSecurity Upwind. StepSecurity reports that the package had roughly 361,510 weekly downloads at the time and that the malicious version was published through an OIDC-backed GitHub Actions publisher path StepSecurity.
The malicious release preserved the legitimate SDK while adding "preinstall": "node setup.mjs" to package.json StepSecurity Upwind. setup.mjs bootstrapped Bun and executed an approximately 11.7 MB obfuscated JavaScript payload, router_runtime.js, designed to harvest GitHub, npm, and multi-cloud credentials StepSecurity Upwind. Both StepSecurity and Upwind associate the activity with the Mini Shai-Hulud campaign pattern targeting CI/CD and developer credentials StepSecurity Upwind.
Key Facts
threat_type: "npm package compromise, preinstall credential stealer"
ecosystem: "npm, javascript"
registry: "npm"
affected_packages:
- "intercom-client"
malicious_versions:
- "7.0.4"
known_good_versions:
- "7.0.3"
execution_trigger: "npm install lifecycle preinstall hook"
primary_impact: "GitHub, npm, AWS, GCP, Azure, and CI/CD secret theft"
campaign_context: "Mini Shai-Hulud wave"
known_iocs:
- "setup.mjs"
- "router_runtime.js"
- "\"preinstall\": \"node setup.mjs\""
- "api.github.com/user"
- "private repository creation under victim GitHub account"
confidence: "high"
canonical_source: "https://www.stepsecurity.io/blog/shai-hulud-worm-pivots-to-multi-cloud-intercom-client-hijacked"
Source Confidence & Evidence Mapping
- confirmed:
[email protected]introduced apreinstallhook that was absent from prior releases StepSecurity Upwind. - confirmed: The malicious release added
setup.mjsandrouter_runtime.js, which did not exist in prior releases or the upstream GitHub repository StepSecurity Upwind. - confirmed: The package unpacked size increased from roughly 6 MB to 17.8 MB, with the payload file accounting for the bulk of the anomaly StepSecurity.
- confirmed: StepSecurity reports that
7.0.3had SLSA provenance attestations while7.0.4did not, making provenance absence a strong artifact-integrity signal StepSecurity. - likely: The package compromise is part of the same Mini Shai-Hulud wave as nearby SAP npm, Lightning PyPI, and Intercom PHP events, based on payload structure and GitHub-based exfiltration behavior StepSecurity Upwind.
Impact Determination
| Classification | Criteria | Required evidence | Required action | Closure condition |
|---|---|---|---|---|
| Confirmed compromise | [email protected] is present and npm preinstall launches Bun-backed loader files or the reported process, file, or network indicators is observed. | Artifact inventory plus runtime telemetry showing npm preinstall launches Bun-backed loader files or listed C2/process/file indicators. | Isolate affected hosts or runners, preserve artifacts, and rotate reachable credentials from a clean environment. | Affected artifacts are removed, exposed credentials are replaced, and downstream audit modules show no suspicious follow-on use. |
| Presumed exposed | [email protected] was installed, pulled, imported, built, or executed during the exposure window, but telemetry cannot prove exfiltration. | Lockfile, package cache, workflow, image pull, extension inventory, build log, or deployment record tied to the exposure window. | Rebuild from clean artifacts and rotate credentials available to the affected environment. | Credential owners confirm revocation of old material and clean artifacts are deployed. |
| Potentially exposed | The package, workflow, image, extension, or module appears in dependency or deployment records, but npm lifecycle execution is not established. | Manifest, lockfile, build, deployment, or endpoint records plus a named telemetry gap. | Collect the missing execution and telemetry evidence before narrowing scope. | Every hit is dispositioned as confirmed compromise, presumed exposed, or not exposed. |
| Not exposed | No affected version, artifact, mutable reference, or indicator appears in source, lockfiles, build outputs, deployments, package caches, or runtime telemetry. | Repository search, dependency inventory, build/deployment export, package cache query, and runtime telemetry query results. | Preserve the negative search output and keep the prevention controls active. | Search evidence covers developer endpoints, CI runners, production deployments, and package or image caches. |
| Unknown | Required inventory, build, endpoint, network, or audit telemetry is unavailable. | A gap statement naming unavailable systems, owners, and time windows. | Keep the asset in scope and make conservative rotation or rebuild decisions for high-value environments. | The missing evidence is recovered or the risk owner accepts residual uncertainty. |
Minimum Evidence To Collect
minimum_evidence:
- "Dependency, workflow, extension, image, or module inventory covering developer endpoints, CI runners, and production deployments."
- "Positive or negative search results for [email protected]."
- "Execution evidence for npm preinstall launches Bun-backed loader files."
- "Process, file, DNS, proxy, firewall, or package-manager telemetry for listed indicators."
- "Inventory of credentials, tokens, deployment paths, and downstream systems reachable from exposed environments."
Timeline
- 2026-04-30T14:41:00Z
[email protected]is published to npm with the malicious changes StepSecurity. - 2026-04-30T00:00:00Z Upwind detects the malicious version through scanning of newly published npm versions Upwind.
- 2026-04-30T00:00:00Z Intercom status pages report investigation of a malicious
intercom-clientversion and later note relatedintercom-phpcompromise activity IsDown mirror of Intercom status. - 2026-05-01T00:00:00Z Snyk advisory coverage for the related Composer package
intercom/intercom-phpconfirms the same Bun-plus-router_runtime.jsmalicious behavior in the adjacent ecosystem Snyk Vulnerability Database.
What Happened
Attackers published a malicious patch version of the official intercom-client package. The SDK remained functional, but package installation now triggered setup.mjs before normal install completion through npm’s preinstall lifecycle hook StepSecurity Upwind.
The loader acquired or reused Bun and executed router_runtime.js, a single-line obfuscated payload of roughly 11.7 MB StepSecurity Upwind. Exfiltration used the victim’s own GitHub access: the payload authenticated to api.github.com/user, created a private repository, encrypted harvested secrets, and committed them there StepSecurity.
Technical Analysis
Initial Access
StepSecurity reports that [email protected] was published through a GitHub Actions OIDC publisher path, but the artifact lost the SLSA attestations present in 7.0.3 StepSecurity. That combination points to compromise or abuse of the publishing path, not a normal tagged release.
Package or Artifact Manipulation
The malicious diff centered on one lifecycle hook and two new files: "preinstall": "node setup.mjs", setup.mjs, and router_runtime.js StepSecurity Upwind. The package size tripled in a single patch bump, a reliable anomaly for an SDK with no historical install hook StepSecurity.
Payload Behavior
The stealer targets GitHub, npm, and multi-cloud credentials StepSecurity Upwind. StepSecurity reports token regex patterns for GitHub and npm tokens and a GitHub-only C2 path that creates private repositories under victim accounts StepSecurity.
Affected Assets and Blast Radius
affected_assets:
ecosystems:
- "npm"
- "JavaScript"
registries:
- "npmjs.com"
packages:
- "intercom-client"
versions:
- "[email protected]"
repositories:
- "intercom/intercom-node"
ci_cd_systems:
- "GitHub Actions"
- "npm publishing pipeline"
container_images:
[]
developer_tools:
- "Node.js package managers"
- "CI runners"
credentials_at_risk:
- "GitHub tokens"
- "npm tokens"
- "cloud credentials"
- "CI/CD secrets"
- "environment variables"
- "package registry credentials"
downstream_systems_to_audit:
- "source control"
- "package registries"
- "cloud control planes"
- "deployment platforms"
- "Kubernetes or containers"
- "secret managers"
not_currently_known_to_affect:
- "Assets without the affected artifact and without execution evidence."
Indicators of Compromise
package_versions:
- "[email protected]"
files:
- "setup.mjs"
- "router_runtime.js"
hashes:
[]
domains:
[]
urls:
[]
ips:
[]
process_patterns:
- "npm preinstall launches Bun-backed loader files"
network_patterns:
- "egress related to intercom-client 7.0.4"
provenance_signals:
- "artifact, tag, release, package, or marketplace metadata differs from expected source state"
Detection and Hunting
Script: local repository and exported telemetry scope
#!/usr/bin/env python3
import os
import sys
import json
import subprocess
from pathlib import Path
ROOT = sys.argv[1] if len(sys.argv) > 1 else "."
LOG_ROOT = os.environ.get("LOG_ROOT", "")
OUT = Path(os.environ.get("OUT", "hp-intercom-client-npm-shai-hulud-scope"))
SINCE = "2026-04-30T00:00:00Z"
UNTIL = "2026-05-01T00:00:00Z"
PACKAGES = [
"intercom-client",
]
VERSIONS = [
"7.0.4",
"[email protected]",
]
FILES = [
"setup.mjs",
"router_runtime.js",
]
DOMAINS = [
]
URLS = [
]
IPS = [
]
HASHES = [
]
PROCESS_PATTERNS = [
"npm preinstall launches Bun-backed loader files",
]
NETWORK_PATTERNS = [
"egress related to intercom-client 7.0.4",
]
# Positive signal: repository, lockfile, artifact, process, or network telemetry contains one of the exact incident selectors above.
# Escalation: any match tied to a production build, CI run, deployed asset, or secret-bearing host moves the asset to presumed exposed.
OUT.mkdir(parents=True, exist_ok=True)
indicators_file = OUT / "indicators.txt"
# Collect unique indicators
indicators = set()
for group in [PACKAGES, VERSIONS, FILES, DOMAINS, URLS, IPS, HASHES, PROCESS_PATTERNS, NETWORK_PATTERNS]:
for val in group:
if val:
indicators.add(val)
with open(indicators_file, "w") as f:
for ind in sorted(indicators):
f.write(ind + "\n")
print(f"[+] Written unique selectors to {indicators_file}")
# Walk local directory
print(f"[+] Scanning directory: {ROOT} for selectors...")
matches = []
exclude_dirs = {"node_modules", "vendor", "dist", ".git"}
for root, dirs, filenames in os.walk(ROOT):
dirs[:] = [d for d in dirs if d not in exclude_dirs]
for filename in filenames:
filepath = Path(root) / filename
try:
content = filepath.read_text(errors="ignore")
for ind in indicators:
if ind in content:
matches.append(f"{filepath}: found '{ind}'")
except Exception:
pass
if matches:
(OUT / "repository-indicator-matches.txt").write_text("\n".join(matches) + "\n")
print(f"[!] Found {len(matches)} matches in codebase!")
# Optional Log Scanning
if LOG_ROOT and os.path.exists(LOG_ROOT):
print(f"[+] Scanning telemetry log directory: {LOG_ROOT}...")
log_matches = []
for root, _, filenames in os.walk(LOG_ROOT):
for filename in filenames:
filepath = Path(root) / filename
try:
content = filepath.read_text(errors="ignore")
for ind in indicators:
if ind in content:
log_matches.append(f"{filepath}: found '{ind}'")
except Exception:
pass
if log_matches:
(OUT / "exported-telemetry-indicator-matches.txt").write_text("\n".join(log_matches) + "\n")
print(f"[!] Found {len(log_matches)} matches in logs!")
if PACKAGES:
registry_dir = OUT / "registry"
registry_dir.mkdir(exist_ok=True)
for package in PACKAGES:
if not package: continue
safe_name = package.replace("/", "__")
print(f"[+] Querying npm view for {package}...")
res = subprocess.run(["npm", "view", package, "name", "version", "time", "versions", "dist-tags", "maintainers", "dist.tarball", "dist.integrity", "scripts", "--json"], capture_output=True, text=True)
if res.returncode == 0:
(registry_dir / f"npm-{safe_name}.json").write_text(res.stdout)
print(f"[+] Wrote scope artifacts under {OUT}")
Downstream Abuse Audits
Script: GitHub organization run, release, secret, and workflow audit
#!/usr/bin/env python3
import os
import sys
import json
import subprocess
from pathlib import Path
if "ORG" not in os.environ:
print("ERROR: Set ORG environment variable to the GitHub organization to audit", file=sys.stderr)
sys.exit(1)
ORG = os.environ["ORG"]
SINCE = "2026-04-30T00:00:00Z"
UNTIL = "2026-05-01T00:00:00Z"
OUT = Path(os.environ.get("OUT", "hp-intercom-client-npm-shai-hulud-github-audit"))
SELECTORS = [
"intercom-client",
"7.0.4",
"[email protected]",
"setup.mjs",
"router_runtime.js",
]
# Positive signal: a workflow run, release, secret, key, package, or workflow change overlaps the exposure window and references an incident selector.
# Remediation trigger: unauthorized post-exposure write activity or a secret-bearing run matching an incident selector requires token revocation and downstream cloud/registry review.
OUT.mkdir(parents=True, exist_ok=True)
(OUT / "runs").mkdir(exist_ok=True)
(OUT / "logs").mkdir(exist_ok=True)
(OUT / "repos").mkdir(exist_ok=True)
# 1. Write incident-selectors file
selectors_file = OUT / "incident-selectors.txt"
with open(selectors_file, "w") as sf:
for s in SELECTORS:
if s:
sf.write(s + "\n")
# 2. Get list of repos
print(f"[+] Fetching repositories for organization: {ORG}")
repo_res = subprocess.run(["gh", "repo", "list", ORG, "--limit", "1000", "--json", "nameWithOwner"], capture_output=True, text=True)
if repo_res.returncode != 0:
print(f"[-] Failed to fetch repos: {repo_res.stderr}", file=sys.stderr)
sys.exit(1)
repos = [r["nameWithOwner"] for r in json.loads(repo_res.stdout)]
for repo in repos:
safe_repo = repo.replace("/", "__")
print(f"[+] Auditing repository: {repo}")
# Check runs in the window
runs_res = subprocess.run([
"gh", "api", f"/repos/{repo}/actions/runs",
"-f", "per_page=100",
"-f", f"created=>={SINCE}",
"--paginate"
], capture_output=True, text=True)
if runs_res.returncode == 0:
try:
all_runs = json.loads(runs_res.stdout).get("workflow_runs", [])
filtered_runs = [r for r in all_runs if r["created_at"] <= UNTIL]
if filtered_runs:
with open(OUT / "runs" / f"{safe_repo}-runs.jsonl", "w") as rf:
for run in filtered_runs:
rf.write(json.dumps(run) + "\n")
# Fetch log dynamically
run_id = str(run["id"])
log_res = subprocess.run(["gh", "run", "view", run_id, "--repo", repo, "--log"], capture_output=True, text=True)
if log_res.returncode == 0:
(OUT / "logs" / f"{safe_repo}-{run_id}.log").write_text(log_res.stdout)
# Fetch details
view_res = subprocess.run(["gh", "run", "view", run_id, "--repo", repo, "--json", "databaseId,workflowName,headSha,event,createdAt,jobs"], capture_output=True, text=True)
if view_res.returncode == 0:
(OUT / "runs" / f"{safe_repo}-{run_id}.json").write_text(view_res.stdout)
except Exception as e:
print(f"[-] Error parsing runs for {repo}: {e}")
# Check releases in window
subprocess.run(["gh", "api", f"/repos/{repo}/releases", "-f", "per_page=100", "--paginate"], capture_output=True)
# Check repo secrets updated in window
subprocess.run(["gh", "api", f"/repos/{repo}/actions/secrets", "-f", "per_page=100", "--paginate"], capture_output=True)
# Check deploy keys
subprocess.run(["gh", "api", f"/repos/{repo}/keys", "-f", "per_page=100", "--paginate"], capture_output=True)
# Scan output directory for any indicator selector matches
print("[+] Scanning gathered telemetry for indicator matches...")
subprocess.run(["rg", "-n", "--hidden", "--fixed-strings", "-f", str(selectors_file), str(OUT)], capture_output=False)
print(f"[+] Wrote GitHub audit artifacts under {OUT}")
Script: cloud OIDC and deployment credential follow-on audit
#!/usr/bin/env python3
import os
import json
import subprocess
from pathlib import Path
SINCE = "2026-04-30T00:00:00Z"
UNTIL = "2026-05-01T00:00:00Z"
OUT = Path(os.environ.get("OUT", "hp-intercom-client-npm-shai-hulud-cloud-audit"))
AWS_REGIONS = os.environ.get("AWS_REGIONS", "us-east-1").split(",")
# Positive signal: token exchange or privileged write activity occurs in the exposure window from GitHub, CI/CD, package registry, or deployment automation identity.
# Remediation trigger: unexpected write, deploy, IAM, secret, or registry activity tied to an exposed CI/CD path requires trust-policy disablement and credential rotation.
OUT.mkdir(parents=True, exist_ok=True)
# 1. AWS CloudTrail Audit
print("[+] Querying AWS CloudTrail for Web Identity token exchanges...")
aws_events = []
for region in AWS_REGIONS:
res = subprocess.run([
"aws", "cloudtrail", "lookup-events",
"--region", region,
"--start-time", SINCE,
"--end-time", UNTIL,
"--lookup-attributes", "AttributeKey=EventName,AttributeValue=AssumeRoleWithWebIdentity",
"--output", "json"
], capture_output=True, text=True)
if res.returncode == 0:
try:
events = json.loads(res.stdout).get("Events", [])
for event in events:
ct = json.loads(event.get("CloudTrailEvent", "{}"))
ct["region"] = region
aws_events.append(ct)
except Exception as e:
print(f"[-] Error parsing AWS CloudTrail events: {e}")
if aws_events:
with open(OUT / "aws-assume-role-with-web-identity.jsonl", "w") as f:
for ev in aws_events:
f.write(json.dumps(ev) + "\n")
# Audit follow-on events for returned access keys
for ev in aws_events:
access_key = ev.get("responseElements", {}).get("credentials", {}).get("accessKeyId")
region = ev.get("region", "us-east-1")
if access_key:
print(f"[+] Enumerating AWS events for AccessKey: {access_key}")
f_res = subprocess.run([
"aws", "cloudtrail", "lookup-events",
"--region", region,
"--start-time", SINCE,
"--end-time", UNTIL,
"--lookup-attributes", f"AttributeKey=AccessKeyId,AttributeValue={access_key}",
"--output", "json"
], capture_output=True, text=True)
if f_res.returncode == 0:
try:
f_events = json.loads(f_res.stdout).get("Events", [])
with open(OUT / "aws-follow-on-api-calls.jsonl", "a") as ff:
for fe in f_events:
ff.write(fe.get("CloudTrailEvent", "{}") + "\n")
except Exception as e:
print(f"[-] Error writing follow-on events: {e}")
# 2. Azure Activity Log Audit
print("[+] Querying Azure activity logs...")
az_res = subprocess.run([
"az", "monitor", "activity-log", "list",
"--start-time", SINCE,
"--end-time", UNTIL,
"--query", "[?contains(operationName.value, 'write') || contains(operationName.value, 'delete') || contains(operationName.value, 'Microsoft.ManagedIdentity')]",
"-o", "json"
], capture_output=True, text=True)
if az_res.returncode == 0:
(OUT / "azure-write-delete-activity.json").write_text(az_res.stdout)
# 3. GCP Logging Audit
print("[+] Querying GCP Cloud Logging...")
gcp_filter = f'timestamp>="{SINCE}" AND timestamp<="{UNTIL}" AND (protoPayload.methodName="google.sts.v1.SecurityTokenService.ExchangeToken" OR protoPayload.methodName:"GenerateAccessToken" OR protoPayload.methodName:"CreateServiceAccountKey" OR protoPayload.methodName:"SetIamPolicy")'
gcp_res = subprocess.run([
"gcloud", "logging", "read", gcp_filter,
"--format", "json"
], capture_output=True, text=True)
if gcp_res.returncode == 0:
(OUT / "gcp-token-and-iam-activity.json").write_text(gcp_res.stdout)
print(f"[+] Wrote cloud audit artifacts under {OUT}")
Script: registry metadata and artifact audit
#!/usr/bin/env python3
import os
import json
import subprocess
from pathlib import Path
SINCE = "2026-04-30T00:00:00Z"
OUT = Path(os.environ.get("OUT", "hp-intercom-client-npm-shai-hulud-registry-audit"))
PACKAGES = [
"intercom-client",
]
VERSIONS = [
"7.0.4",
"[email protected]",
]
# Positive signal: registry metadata, package tarballs, or cached artifacts contain the exact affected package/version values.
# Remediation trigger: any internal package cache, build artifact, or deployment using these package/version values requires exposure scoping.
OUT.mkdir(parents=True, exist_ok=True)
with open(OUT / "affected-versions.txt", "w") as av:
for version in VERSIONS:
if version:
av.write(version + "\n")
# 1. Audit npm dependencies in lockfiles/package.json
print("[+] Scanning lockfiles for npm selectors...")
for file in ["package-lock.json", "npm-shrinkwrap.json", "pnpm-lock.yaml", "yarn.lock", "package.json"]:
if Path(file).exists():
subprocess.run(["rg", "-n", "--hidden", "--fixed-strings", "-f", str(OUT / "affected-versions.txt"), file])
# 2. Query registry metadata and fetch tarballs for local analysis
metadata_dir = OUT / "metadata"
tarballs_dir = OUT / "tarballs"
metadata_dir.mkdir(exist_ok=True)
tarballs_dir.mkdir(exist_ok=True)
for package in PACKAGES:
if not package: continue
safe_name = package.replace("/", "__")
print(f"[+] Querying npm view for {package}...")
res = subprocess.run(["npm", "view", package, "time", "versions", "dist-tags", "maintainers", "dist.tarball", "dist.integrity", "scripts", "--json"], capture_output=True, text=True)
if res.returncode == 0:
(metadata_dir / f"npm-{safe_name}.json").write_text(res.stdout)
subprocess.run(["npm", "pack", package, "--pack-destination", str(tarballs_dir)], capture_output=True)
# 3. HOW TO REVOKE AND ROTATE EXPOSED NPM PUBLISHING TOKENS:
# Revoke all compromised tokens via npm CLI:
# subprocess.run(["npm", "token", "list"])
# subprocess.run(["npm", "token", "revoke", "123456"])
# Or logout to revoke the current session:
# subprocess.run(["npm", "logout"])
# Generate a new publishing token with MFA protection:
# subprocess.run(["npm", "token", "create", "--read-only=false", "--cidr=0.0.0.0/0"])
print(f"[+] Wrote registry audit artifacts under {OUT}")
Sources
- StepSecurity: Shai-Hulud Worm Pivots to Multi-Cloud - Role: PRIMARY_RESEARCH - Impact: Timeline, package diff, provenance signal, GitHub exfiltration behavior.
- Upwind: intercom-client 7.0.4 Supply Chain Attack - Role: PRIMARY_RESEARCH - Impact: Independent detection, loader description, payload files.
- Intercom incident status mirror - Role: DIRECT_SOURCE - Impact: Vendor-status evidence of investigation and related PHP compromise timing.
- Snyk Vulnerability Database: intercom/intercom-php - Role: ENRICHMENT_DATA - Impact: Adjacent ecosystem corroboration for the same Bun and
router_runtime.jsbehavior. - Kodem: Mini Shai-Hulud Cross-Ecosystem Attack - Role: SECONDARY_ANALYSIS - Impact: Cross-ecosystem campaign framing across Lightning and Intercom.
IOC Clipboard
2 IOCssetup.mjs setup.mjs router_runtime.js router_runtime.js