Stop Running `npm install`
This week, I watched the sha1-hulud attack spread through the JavaScript ecosystem in real time. Over 830 npm packages compromised. 28,000 repositories infected. More than 11,000 unique secrets leaked, with over 2,000 still valid and publicly exposed as of November 24.
The response focused on securing accounts: enable MFA, audit dependencies, rotate secrets. Necessary, but reactive. There’s a prevention strategy that gets overlooked: stop running npm install.
The Problem
npm install pulls whatever version satisfies your semver range. Without a lockfile, you get the latest compatible version published in the last few seconds. With a lockfile, you’re still trusting that maintainer accounts haven’t been compromised, that transitive dependencies are clean, that post-install scripts aren’t malicious.
Every npm install is a bet that nothing malicious has been published since your last install. Sha1-hulud proved that bet loses.
Version Pinning as Defense
Dependency cooldowns are a valuable concept. (I encountered it on Simon Willison’s blog this week.) Wait a configurable period after a package is published before allowing it into your project. If a malicious version gets published, you have time to catch it before it reaches production.
For projects with serious security requirements, treat dependency updates as security events. They deserve dedicated review time and careful examination of changelogs. Staying several versions behind might actually reduce risk when attacks exploit the usual advice to patch quickly.
After the last major attack, we added explicit rules to Wormhole’s Guardian node CONTRIBUTING.md:
For existing projects, use npm ci. While npm install updates dependencies to newer versions within the semver range and might pull down a compromised package published five minutes ago, npm ci installs exactly what’s in package-lock.json.
When adding packages, pin versions. Use npm i package@version to specify exact versions. Specific versions can’t be overwritten after release, which blocks an entire class of attacks.
The same principles apply to Docker containers. Copy package.json and package-lock.json first, then run npm ci. For global packages, specify versions: npm install -g somepackage@1.2.3. Pin base images with SHA-256 hashes: FROM node:18.19.0-alpine@sha256:12345....
Building lockfile-guard
Manual discipline doesn’t scale. Developers will continue using the unsafe default workflow unless something stops them. I wanted a tool that would catch these mistakes before they reached production, so I wrote lockfile-guard.
It’s a GitHub Action that scans your repository for dangerous package manager commands. It checks Dockerfiles, markdown documentation, and shell scripts. When it finds violations, it fails the build.
The rules:
# ✅ Safe patterns
npm ci
npm i package@version
pnpm install --frozen-lockfile
yarn install --immutable
bun install --frozen-lockfile
# ❌ Dangerous patterns
npm install
npm i package
pnpm install
yarn install
bun install
The output:
✗ ./Dockerfile
Line 15: Use 'npm ci' instead of 'npm install' for lockfile-based installations
> npm install
✗ ./.github/workflows/deploy.yml
Line 42: Use 'pnpm install --frozen-lockfile' to respect lockfile
> pnpm install
Nothing advanced here, just pattern matching in files that matter. Simple tools are valuable when they encode protections that might otherwise depend on institutional memory.
Trust Is Not a Security Model
We trust that packages do what they claim, that maintainer accounts are protected, that the registry enforces security boundaries. Sha1-hulud exploited that trust. A single compromise rippled through thousands of projects in hours.
Use npm ci. Pin your versions. Audit your dependencies. Harden your CI/CD pipelines with least-privilege access. When the next supply chain attack arrives, and it will, you’ll be ready.