Skip to content

Supply Chain Security

Published: March 31, 2026

Your dependencies are the largest attack surface you don't control. A single compromised package in your dependency tree — direct or transitive — can steal credentials, install backdoors, or mine cryptocurrency on your CI runners. This guide covers practical defenses for the two most targeted ecosystems (npm and pip) and the CI/CD layer that ties them together (GitHub Actions).

Why This Matters

The npm registry hosts over 2 million packages. PyPI has over 500,000. You install a handful directly — but each one pulls in a tree of transitive dependencies you never audited. Attackers know this. Supply chain attacks have grown from rare, sophisticated operations to a routine tactic.

The pattern is almost always the same: compromise a package maintainer's account, push a malicious version, and wait for automated installs to pull it in. The window of exposure can be as short as four hours — but that's enough when millions of installs happen daily.

1. Real-World Incidents

These are not hypothetical risks. Every entry below happened.

npm

IncidentDateImpact
event-stream — attacker social-engineered maintainership, added crypto-stealing payload via flatmap-streamNov 2018Targeted Copay Bitcoin wallet
eslint-scope — maintainer account hijacked (password reuse, no 2FA), postinstall exfiltrated .npmrc tokensJul 2018Triggered npm's push for mandatory 2FA
ua-parser-js — account hijacked, cryptominer + password stealer in versions with 8M weekly downloadsOct 2021CISA advisory issued, 4-hour window
colors.js / faker.js — maintainer deliberately sabotaged own packages (protestware)Jan 202223M weekly downloads disrupted
Axios — account takeover injected RAT dropper via plain-crypto-js, self-deleting after execution2025300M weekly downloads exposed
chalk/debug ecosystem — phished maintainer led to compromise of 18 packages totaling 2.6B weekly downloadsSep 2025Largest npm compromise by volume

PyPI

IncidentDateImpact
ctx — expired email domain hijacking gave attacker account reset, exfiltrated AWS credentialsMay 2022~2,000 daily downloads for 10 days
Ultralytics YOLO — GitHub Actions script injection stole PyPI upload token, published cryptominerDec 202480M monthly downloads
litellm — malicious versions with multi-stage credential stealer, live for 2+ hoursMar 20253M daily downloads

Dependency Confusion

In February 2021, researcher Alex Birsan published public packages with the same names as internal packages at Apple, Microsoft, Uber, Tesla, and Shopify — but with higher version numbers. Package managers defaulted to the higher-versioned public package. He earned $130,000+ in bug bounties across npm, PyPI, and RubyGems simultaneously.

WARNING

Most of these attacks exploited install scripts (postinstall hooks) as the delivery mechanism. A single .npmrc setting — ignore-scripts=true — would have blocked the majority of them.

2. npm Security

Audit for Known Vulnerabilities

bash
# Scan for known CVEs
npm audit

# Production dependencies only (skip devDependencies)
npm audit --omit=dev

# Auto-fix with semver-compatible updates
npm audit fix

# JSON output for CI pipelines
npm audit --json

DANGER

npm audit fix --force ignores semver constraints and can install breaking major version bumps. Never run it blindly. Prefer npm audit fix (no --force) and manually address remaining issues.

Block Install Scripts

Install scripts (preinstall, postinstall) are the primary delivery mechanism for npm malware. Block them by default.

ini
ignore-scripts=true

The trade-off: some legitimate packages need postinstall scripts for native module compilation (node-gyp). When you hit one, run its scripts explicitly:

bash
# Allow scripts for a specific package
npm rebuild <package-name>

For a more granular approach, use @lavamoat/allow-scripts to maintain an allowlist of packages permitted to run lifecycle scripts.

Use npm ci in CI/CD

bash
# Development: may modify lockfile
npm install

# CI/CD: strict lockfile enforcement
npm ci

npm ci deletes node_modules and installs exactly what the lockfile specifies. It never modifies the lockfile. It errors if package.json and package-lock.json are out of sync. Always use npm ci in pipelines.

Override Vulnerable Transitive Dependencies

When a vulnerability exists deep in your dependency tree and the direct dependency hasn't released a fix:

json
{
  "overrides": {
    "vulnerable-transitive-dep": ">=1.5.3"
  }
}

Scope overrides to a specific parent when the vulnerable package appears in multiple places:

json
{
  "overrides": {
    "some-package": {
      "vulnerable-transitive-dep": ">=1.5.3"
    }
  }
}

Available since npm v8.3.0. Always test after applying overrides — the parent package may not be compatible with the forced version.

Verify Package Provenance

bash
# Verify signatures on installed packages
npm audit signatures

Provenance links a published package to its source repository and CI/CD build via Sigstore. Look for the "Provenance" badge on npmjs.com package pages. When publishing your own packages:

bash
# Publish with provenance (from GitHub Actions or GitLab CI)
npm publish --provenance

Harden .npmrc

ini
# Block lifecycle scripts by default
ignore-scripts=true

# Pin to official registry (prevents dependency confusion)
registry=https://registry.npmjs.org/

# Scope private packages to your org registry
@yourorg:registry=https://npm.yourcompany.com/

# Save exact versions (no ^ or ~ ranges)
save-exact=true

# Enforce lockfile
package-lock=true

# Audit on every install
audit=true

# Minimum severity threshold for audit failures
audit-level=high

Protect Your npm Account

bash
# Enable 2FA for publishing AND login
npm profile enable-2fa auth-and-writes

# Create read-only CI tokens
npm token create --read-only

# List active tokens
npm token list

# Revoke a compromised token immediately
npm token revoke <token-id>

Watch for npx Typosquatting

npx some-package downloads and executes a package if it's not installed locally. A typo like npx creat-react-app (missing an "e") could execute a malicious package.

bash
# Require explicit confirmation before downloading
npx --no some-package

# Or use npm exec with separator
npm exec -- some-package

Avoid Recently Published Packages

Most malicious packages are detected and removed from registries within days — but the damage happens in those first hours. Treat any package published less than seven days ago as untrusted.

Before adding a new dependency:

bash
# Check when a package was last published
npm view <package> time --json

What to look for:

  • Created date — a brand-new package with no history is higher risk than one with years of releases
  • Latest version age — if the latest version was published today and the previous was months ago, investigate why
  • Maintainer changes — a new maintainer pushing a release shortly after gaining access is the classic account takeover pattern

WARNING

This is a team policy, not a technical control — npm has no built-in setting to enforce it. Tools like Socket flag recently published packages automatically. Use socket npm install or the Socket GitHub App to catch these before they enter your dependency tree.

For automated enforcement in CI, query the npm registry API and fail the build if any dependency was published within your quarantine window:

bash
# Check age of a specific package version
published=$(npm view <package> time.<version> --json | tr -d '"')
age_days=$(( ($(date +%s) - $(date -d "$published" +%s)) / 86400 ))
if [ "$age_days" -lt 7 ]; then
  echo "WARN: <package>@<version> is only $age_days days old"
  exit 1
fi

3. pip Security

Audit for Known Vulnerabilities

pip-audit is maintained by PyPA (Python Packaging Authority) and Trail of Bits. It checks installed packages against the Python Packaging Advisory Database (OSV).

bash
# Install
pip install pip-audit

# Audit current environment
pip-audit

# Audit a requirements file
pip-audit -r requirements.txt

# Auto-fix (installs minimum safe version)
pip-audit --fix

# JSON output for CI
pip-audit -f json

Hash-Verified Installs

Hash checking mode ensures every installed package matches a known-good hash. This prevents tampered packages and man-in-the-middle attacks.

bash
pip install --require-hashes -r requirements.txt

Requirements file with hashes:

text
requests==2.31.0 \
    --hash=sha256:942c5a758f98d790eaed1a29cb6eefc7f0d329b5e0be51dc41b9a15f8c95c2a5 \
    --hash=sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003e
certifi==2024.2.2 \
    --hash=sha256:dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1

Key rules:

  • Hash checking is all-or-nothing — if one package has a hash, all must
  • All requirements must be pinned with == (no ranges)
  • Use sha256 or stronger
  • Generate hashes automatically with pip-compile --generate-hashes (from pip-tools)

Force Virtual Environments

Never install packages into your system Python. Enforce this globally:

bash
pip config set global.require-virtualenv true

Or in your pip config file:

ini
[global]
require-virtualenv = true
ini
[global]
require-virtualenv = true

This makes pip install fail unless you're inside an active virtual environment.

Use Constraints Files

Constraints restrict which versions of transitive dependencies can be installed without declaring them as direct dependencies. Useful for organization-wide security policies.

bash
pip install -c constraints.txt -r requirements.txt
text
urllib3==2.1.0
cryptography==42.0.0
setuptools>=70.0.0

Harden pip.conf

ini
[global]
# Only install inside virtual environments
require-virtualenv = true

# Pin to official PyPI
index-url = https://pypi.org/simple/

# Timeout for network operations
timeout = 30

WARNING

The trusted-host setting disables TLS verification for that host. Never add pypi.org as a trusted host. Only use it for internal registries where you control the network.

Use Isolated Mode in Scripts

bash
# Ignore all config files and environment variables
pip install --isolated -r requirements.txt

This prevents a malicious pip.conf placed in the project directory from redirecting installs to an attacker-controlled registry.

4. Lock File Security

Lock files are your single most important defense against supply chain drift.

Why Lock Files Matter

Without a lockfile, npm install or pip install resolves the latest compatible version at install time. If an attacker publishes a malicious version of a transitive dependency between two installs, the second install gets the compromised version silently.

Lock files prevent this by recording:

  • Exact resolved version of every dependency (direct and transitive)
  • Integrity hash (SHA-512 in npm) of each package tarball
  • Resolved registry URL for each package

Always Commit Your Lock Files

Package ManagerLock FileCommit It?
npmpackage-lock.jsonYes
Yarnyarn.lockYes
pnpmpnpm-lock.yamlYes
pip-toolsrequirements.txt (compiled)Yes
Poetrypoetry.lockYes
PipenvPipfile.lockYes

Not committing the lockfile means every install is a gamble.

Integrity Verification

Each entry in package-lock.json includes an integrity field:

json
"lodash": {
  "version": "4.17.21",
  "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
  "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
}

When npm ci installs packages, it recalculates the hash of the downloaded tarball and compares it to the stored value. Mismatch means installation aborts. This catches tampered packages and MITM attacks.

Watch for Lock File Poisoning

A malicious PR can modify the lockfile to point to a different package version or registry URL while the package.json diff looks clean. Reviewers often skip lockfile diffs because they're large and auto-generated.

TIP

Always review lockfile changes in PRs. Tools like Socket and Snyk flag suspicious lockfile modifications automatically.

5. GitHub Actions Security

GitHub Actions is the CI/CD layer where all your dependency management happens. A compromised workflow can steal every secret in your repository.

Pin Actions to Full Commit SHAs

Git tags are mutable. An attacker who compromises an action's repository can move a tag (v4) to point to malicious code. Every workflow referencing @v4 then executes the attacker's code. This is exactly what happened in the tj-actions/changed-files incident (March 2025, CVE-2025-30066).

yaml
# DANGEROUS — mutable tag
- uses: actions/checkout@v4

# SAFE — immutable commit SHA
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

The trailing comment preserves readability. The SHA is immutable — even if the repository is compromised, your workflow runs the exact code you audited.

Set up Dependabot to keep pinned SHAs current:

yaml
version: 2
updates:
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "weekly"

Restrict GITHUB_TOKEN Permissions

By default, GITHUB_TOKEN can have broad read/write access. A compromised step with an over-permissioned token can modify code, create releases, or exfiltrate data.

yaml
# Workflow-level: deny all by default
permissions: {}

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read    # only what this job needs
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
        with:
          persist-credentials: false  # don't leave token in git config

Always set permissions: {} at the workflow level, then grant per-job.

Prevent Workflow Injection

GitHub Actions expressions ($) are interpolated before the YAML is parsed. Untrusted input in a run: block becomes arbitrary code execution.

yaml
# DANGEROUS — attacker controls PR title
- run: echo "PR: ${{ github.event.pull_request.title }}"

# SAFE — environment variable is data, not code
- env:
    PR_TITLE: ${{ github.event.pull_request.title }}
  run: echo "PR: $PR_TITLE"

Attacker-controlled fields to never interpolate directly:

  • github.event.pull_request.title / .body
  • github.event.issue.title / .body
  • github.event.comment.body
  • github.event.head_commit.message
  • github.head_ref (branch name)

Use OIDC Instead of Long-Lived Secrets

Instead of storing cloud provider credentials as repository secrets, use OpenID Connect for short-lived tokens:

yaml
jobs:
  deploy:
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
        with:
          role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsRole
          aws-region: us-east-1
          # No access key — uses OIDC token exchange
yaml
jobs:
  deploy:
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: google-github-actions/auth@6fc4af4b145ae7821d527454aa9bd537d1f2dc5f # v2.1.7
        with:
          workload_identity_provider: 'projects/ID/locations/global/workloadIdentityPools/POOL/providers/PROVIDER'
          service_account: 'sa@project.iam.gserviceaccount.com'
yaml
jobs:
  deploy:
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: azure/login@a65d910e8af852a8061c627c456678983e180302 # v2.2.0
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
          # Uses federated credentials, no client secret

OIDC tokens are scoped to a specific workflow run and expire in minutes. No secrets to rotate or leak.

Add Dependency Review to PRs

yaml
name: Dependency Review
on: pull_request

permissions:
  contents: read
  pull-requests: write

jobs:
  review:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
      - uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4.5.0
        with:
          fail-on-severity: moderate
          deny-licenses: GPL-3.0, AGPL-3.0
          comment-summary-in-pr: always

This compares the dependency graph of the PR branch against the base branch and flags newly introduced CVEs or problematic licenses.

Self-Hosted Runner Rules

DANGER

Never use self-hosted runners with public repositories unless they are ephemeral and disposable. Any user who can open a pull request can execute code on your runner — accessing your network, installing persistent malware, or stealing credentials from previous jobs.

If you must use self-hosted runners:

  • Use --ephemeral mode — the runner picks up one job and de-registers
  • Use runner groups to restrict which repos can target which runners
  • Put runners in an isolated network segment
  • Consider Actions Runner Controller (ARC) for Kubernetes-based ephemeral pods

6. Dependency Scanning Tools

No single tool catches everything. Layer them.

ToolApproachCatchesFree?
npm auditCVE database lookupKnown vulnerabilitiesYes
pip-auditCVE database (OSV)Known vulnerabilitiesYes
GitHub DependabotCVE alerts + auto-update PRsKnown vulns, outdated depsYes
SnykCVE database + proprietary researchKnown vulns (larger DB)Free tier
SocketBehavioral code analysisMalicious code, typosquats, install scriptsFree for OSS

Socket — Why It's Different

Traditional scanners (npm audit, Snyk, Dependabot) check packages against databases of known, reported CVEs. Socket takes a fundamentally different approach — it performs behavioral analysis on package source code to detect intentionally malicious patterns even when no CVE exists yet.

Socket monitors 70+ behavioral signals including:

  • Install scripts that execute shell commands
  • Network requests to unknown endpoints
  • Environment variable access and exfiltration
  • Obfuscated or dynamically evaluated code
  • Typosquatting via name similarity analysis
  • Maintainer account changes and suspicious ownership transfers

CLI usage:

bash
# Install
npm install -g @socketsecurity/cli

# Safe npm wrapper — scans before installing
socket npm install <package>

# Safe npx wrapper
socket npx <package>

# Look up risks for a specific package
socket info <package>

Socket Firewall Free — a standalone binary that blocks confirmed malware at any dependency depth, no account required:

bash
npm install -g sfw

# Wraps your package manager
sfw npm install <package>
sfw pip install <package>

GitHub App — automatically analyzes dependency changes in PRs, posts comments highlighting security concerns, and supports branch protection rules to block merges.

TIP

The recommended layered approach: Dependabot for keeping dependencies current, Snyk for known vulnerability management, Socket for supply chain attack detection. These tools are complementary, not competitive.

7. Hardened CI Workflow Template

A complete example combining all the practices above:

yaml
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

permissions: {}

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
        with:
          persist-credentials: false

      - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
        with:
          node-version: 20
          cache: 'npm'

      - run: npm ci
      - run: npm test

  dependency-review:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: write
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
      - uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4.5.0
        with:
          fail-on-severity: moderate

  deploy:
    needs: build
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: production
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

      - uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
        with:
          role-to-assume: arn:aws:iam::123456789012:role/DeployRole
          aws-region: us-east-1

      - env:
          COMMIT_MSG: ${{ github.event.head_commit.message }}
        run: ./deploy.sh

8. Attack Vectors Quick Reference

VectorHow It WorksPrimary Defense
TyposquattingPackages named lodsah, creat-react-appSocket detection, careful install commands
Dependency confusionPublic package with same name as internal, higher versionRegistry pinning, scoped packages in .npmrc
Install scriptspostinstall hooks execute arbitrary codeignore-scripts=true in .npmrc
Account takeoverPhishing/credential stuffing gives access to maintainer account2FA, provenance verification, Trusted Publishing
ProtestwareMaintainer deliberately sabotages own packageVersion pinning, lockfiles
Expired domain hijackAttacker registers maintainer's expired email domain, resets passwordMonitor domain registration
Lockfile poisoningPR modifies lockfile to point to malicious versionReview lockfile diffs, use Socket/Snyk
Mutable action tagsAttacker moves v4 tag to malicious commitPin actions to full commit SHA
Workflow injectionUntrusted input interpolated in run: blocksUse intermediate environment variables
CI secret exfiltrationCompromised action dumps secrets to logsLeast-privilege GITHUB_TOKEN, pin SHAs
New package / versionMalicious code published under fresh name or hijacked release7-day quarantine policy, Socket alerts

9. Defensive Commands Cheatsheet

npm

bash
npm ci                              # Strict lockfile install for CI
npm audit                           # Check for known vulnerabilities
npm audit signatures                # Verify package provenance
npm audit fix                       # Auto-fix semver-compatible vulns
npm install --ignore-scripts        # Skip all lifecycle scripts
npm publish --provenance            # Publish with Sigstore provenance
npm config set ignore-scripts true  # Persist ignore-scripts globally
npm token create --read-only        # Create restricted CI token
npm profile enable-2fa auth-and-writes  # Enable full 2FA
npm view <pkg> time --json              # Check publish dates (7-day rule)

pip

bash
pip-audit                           # Scan for known vulnerabilities
pip-audit --fix                     # Auto-fix vulnerable packages
pip-audit -r requirements.txt       # Audit a requirements file
pip install --require-hashes -r requirements.txt  # Hash-verified install
pip install --isolated              # Ignore all config files
pip config set global.require-virtualenv true  # Force venv usage
pip-compile --generate-hashes       # Generate hashed requirements

GitHub Actions

yaml
permissions: {}                     # Deny all at workflow level
persist-credentials: false          # Don't leave tokens in git config
uses: action@<full-sha> # vX.Y.Z   # Pin to immutable commit
id-token: write                     # Enable OIDC (per-job only)
  • GitHub Setup — SSH keys, credential management, and pre-commit hooks that complement supply chain defenses
  • Git Branch Hygiene — PR discipline and branch workflows that reduce the surface area for lockfile poisoning
  • Docker Quick Reference — Container image security shares many supply chain principles with package security