Supply Chain Security: Defending Against Dependency Attacks
A complete guide to protecting your software supply chain from dependency confusion, typosquatting, compromised packages and build pipeline attacks.

The Attack Surface You Cannot See
Your application is 10% your code and 90% other people's code. A typical Node.js project pulls in hundreds of transitive dependencies. Each one is a trust decision—you trust that the maintainer's npm account wasn't compromised, that the package registry wasn't tampered with, and that every transitive dependency three levels deep is equally trustworthy.
The SolarWinds attack, the ua-parser-js compromise, the event-stream backdoor, the colors.js sabotage—these are not theoretical risks. They are documented incidents where trusted packages became attack vectors. Supply chain security is no longer optional; it is a core engineering responsibility.
Dependency Confusion: The Internal Package Trap
Dependency confusion exploits how package managers resolve names. If your company uses a private package called @company/auth-utils, an attacker publishes a package with the same name to the public npm registry—but with a higher version number. If your package manager checks the public registry first, it installs the attacker's version.
// ❌ Bad: No registry scoping — vulnerable to dependency confusion
{
"dependencies": {
"auth-utils": "^2.0.0",
"payment-sdk": "^1.5.0"
}
}// ✅ Good: Scoped packages with registry pinning
{
"dependencies": {
"@mycompany/auth-utils": "^2.0.0",
"@mycompany/payment-sdk": "^1.5.0"
}
}# .npmrc — Pin scoped packages to your private registry
@mycompany:registry=https://npm.mycompany.com/
//npm.mycompany.com/:_authToken=${NPM_PRIVATE_TOKEN}Scoped packages with explicit registry pinning in .npmrc prevent the package manager from ever looking at the public registry for your internal packages. This is the simplest and most effective defense against dependency confusion.
Lockfile Integrity: Your First Line of Defense
The lockfile pins exact dependency versions and includes integrity hashes. If someone modifies a dependency between installs—whether through a compromised registry or a supply chain attack—the integrity check fails.
// Script to verify lockfile integrity in CI
import { readFileSync } from "fs";
import crypto from "crypto";
interface LockfilePackage {
version: string;
resolved: string;
integrity: string;
}
function verifyLockfileIntegrity(lockfilePath: string): {
valid: boolean;
issues: string[];
} {
const issues: string[] = [];
const lockfile = JSON.parse(readFileSync(lockfilePath, "utf-8"));
const packages: Record<string, LockfilePackage> =
lockfile.packages || {};
for (const [name, pkg] of Object.entries(packages)) {
if (!name) continue; // Skip root
// Check for missing integrity hashes
if (!pkg.integrity) {
issues.push(`Missing integrity hash: ${name}@${pkg.version}`);
}
// Check for non-registry URLs
if (
pkg.resolved &&
!pkg.resolved.startsWith("https://registry.npmjs.org/") &&
!pkg.resolved.startsWith("https://npm.mycompany.com/")
) {
issues.push(`Unexpected registry URL: ${name} → ${pkg.resolved}`);
}
// Check for git dependencies (potential risk)
if (pkg.resolved?.startsWith("git+")) {
issues.push(`Git dependency detected: ${name} → ${pkg.resolved}`);
}
}
return { valid: issues.length === 0, issues };
}# CI pipeline step: Verify lockfile before install
- name: Verify dependency integrity
run: |
# Fail if lockfile would change (ensures it's committed and current)
npm ci --ignore-scripts
# The --ignore-scripts flag prevents pre/post install scripts from running
# during the verification phaseAlways use npm ci (or bun install --frozen-lockfile) in CI, never npm install. The ci command fails if the lockfile doesn't match package.json, preventing silent dependency drift.
Detecting Typosquatting and Malicious Packages
Typosquatting packages have names that look like legitimate ones: lodahs instead of lodash, cross-env2 instead of cross-env. Automated scanning catches these before they enter your dependency tree.
// Pre-install hook to check for suspicious packages
import { execSync } from "child_process";
interface PackageAudit {
name: string;
version: string;
riskLevel: "low" | "medium" | "high" | "critical";
reasons: string[];
}
function auditNewDependency(packageName: string): PackageAudit {
const reasons: string[] = [];
let riskLevel: "low" | "medium" | "high" | "critical" = "low";
// Check package age
const info = JSON.parse(
execSync(`npm view ${packageName} --json`, {
encoding: "utf-8",
})
);
const createdDate = new Date(info.time?.created);
const ageInDays =
(Date.now() - createdDate.getTime()) / (1000 * 60 * 60 * 24);
if (ageInDays < 30) {
reasons.push(`Package is only ${Math.floor(ageInDays)} days old`);
riskLevel = "high";
}
// Check download count
const downloads = JSON.parse(
execSync(
`npm view ${packageName} --json | jq '.downloads'`,
{ encoding: "utf-8" }
).trim() || "0"
);
if (typeof downloads === "number" && downloads < 100) {
reasons.push(`Low download count: ${downloads}`);
riskLevel = riskLevel === "high" ? "critical" : "high";
}
// Check for install scripts
if (info.scripts?.preinstall || info.scripts?.postinstall) {
reasons.push("Contains install scripts");
riskLevel = "medium";
}
// Check maintainer count
const maintainers = info.maintainers || [];
if (maintainers.length === 1) {
reasons.push("Single maintainer");
}
return {
name: packageName,
version: info["dist-tags"]?.latest,
riskLevel,
reasons,
};
}The red flags: new packages with install scripts, single maintainers, low download counts, and names similar to popular packages. No single signal is definitive, but the combination paints a clear picture.
Pinning and Automating Updates
Version ranges (^ and ~) allow automatic minor and patch updates—which is exactly how compromised versions spread. Pinning exact versions gives you control over when updates happen.
// ❌ Bad: Version ranges allow silent updates
{
"dependencies": {
"express": "^4.18.0",
"lodash": "~4.17.0"
}
}// ✅ Good: Exact versions with automated update PRs
{
"dependencies": {
"express": "4.18.2",
"lodash": "4.17.21"
}
}# Renovate config for controlled dependency updates
# renovate.json
{
"extends": ["config:base"],
"rangeStrategy": "pin",
"schedule": ["after 10pm every weekday", "before 5am every weekday"],
"vulnerabilityAlerts": {
"enabled": true,
"labels": ["security"],
"schedule": ["at any time"]
},
"packageRules": [
{
"matchDepTypes": ["devDependencies"],
"automerge": true,
"automergeType": "pr",
"requiredStatusChecks": ["ci"]
},
{
"matchDepTypes": ["dependencies"],
"automerge": false,
"reviewers": ["team:security"]
}
]
}Pin exact versions, then use Renovate or Dependabot to create pull requests for updates. Dev dependencies can auto-merge after CI passes. Production dependencies require human review. Security vulnerabilities trigger immediate PRs regardless of schedule.
Build Pipeline Hardening
The build pipeline is another attack surface. If an attacker compromises your CI environment, they can inject code during the build process—even if your source code is clean.
# GitHub Actions with security hardening
name: Build and Deploy
on:
push:
branches: [main]
permissions:
contents: read # Minimum required permissions
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false # Don't leak tokens
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version-file: ".node-version"
- name: Install dependencies
run: npm ci --ignore-scripts
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Run allowed install scripts explicitly
run: |
npx --yes node-gyp rebuild || true # Only if needed
- name: Audit dependencies
run: npm audit --audit-level=high
- name: Build
run: npm run build
- name: Verify no unexpected files
run: |
# Check for suspicious files added during build
git diff --name-only
if [ -n "$(git diff --name-only)" ]; then
echo "WARNING: Build modified tracked files"
git diff --name-only
exit 1
fiThe --ignore-scripts flag during install prevents pre/post install scripts from executing automatically. This blocks the most common supply chain attack vector: malicious install scripts that run during npm install. Run only the specific scripts you need explicitly afterward.
Runtime Protection: Detecting Compromised Dependencies
Even with all preventive measures, a compromised dependency might slip through. Runtime monitoring provides a last line of defense.
// Detect suspicious runtime behavior from dependencies
const originalFetch = globalThis.fetch;
globalThis.fetch = async function monitoredFetch(
input: RequestInfo | URL,
init?: RequestInit
): Promise<Response> {
const url = typeof input === "string" ? input : input.toString();
// Log all outbound network requests for audit
const allowedDomains = [
"api.myapp.com",
"cdn.myapp.com",
"sentry.io",
];
const requestUrl = new URL(url);
if (!allowedDomains.some((d) => requestUrl.hostname.endsWith(d))) {
console.warn(
`[SECURITY] Unexpected outbound request: ${requestUrl.hostname}${requestUrl.pathname}`
);
// In strict mode, block the request entirely
if (process.env.STRICT_NETWORK_POLICY === "true") {
throw new Error(`Blocked request to unauthorized domain: ${requestUrl.hostname}`);
}
}
return originalFetch(input, init);
};Network monitoring catches compromised packages that try to exfiltrate data. If a package you installed for date formatting suddenly makes HTTP requests to unknown servers, something is wrong. Alerting on unexpected outbound connections provides early warning.
Key Takeaways
Software supply chain security is a spectrum, not a binary state. Start with the highest-impact defenses: lockfile integrity verification (npm ci), scoped packages with registry pinning, and --ignore-scripts during CI installs. These three measures block the majority of known supply chain attacks.
Layer additional defenses as your risk profile demands: exact version pinning with automated update PRs, pre-install package auditing, build pipeline hardening with minimal permissions, and runtime network monitoring.
The uncomfortable truth is that you cannot fully trust your dependency tree. You can only reduce the attack surface, detect anomalies quickly, and have a response plan when something gets through. Treat dependencies like external input—validate, verify, and monitor.


