The semantic versioning security trap
Semantic versioning is one of npm's most important conventions. Packages follow MAJOR.MINOR.PATCH, with clear expectations about what each increment means. ^ and ~ in package.json version ranges let npm automatically pick up updates within defined limits.
The intention is good: developers get security patches and bug fixes automatically. In practice, these version ranges create a supply chain attack surface that is widely underestimated.
What ^ and ~ actually do
{
"dependencies": {
"lodash": "^4.17.0",
"express": "~4.18.0",
"axios": "4.1.0"
}
}
^4.17.0: accepts4.17.0,4.17.x,4.18.x,4.19.x, up to but not including5.0.0~4.18.0: accepts4.18.0,4.18.x— minor versions and above are locked4.1.0(exact): only accepts4.1.0— no automatic updates
The ^ range is the default when you run npm install some-package — npm writes "^x.y.z" to your package.json automatically. Most package.json files use ^ for most dependencies.
The attack surface
Suppose a dependency you use (popular-utils) has maintainership transferred to a malicious actor. The new maintainer publishes popular-utils@4.18.0 — a minor version bump with malicious code added. Your package.json specifies "popular-utils": "^4.17.0".
If you run npm install (not npm ci), npm resolves ^4.17.0 to 4.18.0 — the highest available minor version. The malicious version is installed automatically.
Even if you run npm ci, the lockfile only saves you if it was already committed with a pinned version. If you run npm install to refresh the lockfile (common during development), you get the malicious version, and then commit the updated lockfile.
The lockfile is necessary but not sufficient
The package-lock.json pins the exact resolved versions — as long as you only run npm ci and never npm install. But developers do run npm install, especially:
- When adding new packages
- When a teammate's PR changes
package.json - When running
npm updateto refresh dependencies - In CI pipelines that were set up with
npm installinstead ofnpm ci
Each of these is an opportunity for a version range to resolve to a new, potentially malicious version.
The correct practices
Commit your lockfile and use npm ci in CI. This prevents automatic upgrades in CI while still allowing controlled upgrades on developer machines.
When you update dependencies, do it deliberately. Don't run npm update as a routine maintenance step without reviewing what changed. Review the diff to package-lock.json before committing.
Consider exact pinning for critical packages. For security-critical or deeply-embedded packages, consider specifying exact versions without a range operator:
{
"dependencies": {
"jsonwebtoken": "9.0.2",
"bcrypt": "5.1.1"
}
}
Exact pinning means you don't get security updates automatically — but it also means you don't get malicious updates automatically. For packages that change rarely and require security review when they do, the trade-off is reasonable.
Use Veln for the transition moments. Every time you run npm install and the lockfile changes, Veln runs full analysis on every changed package. The transition moment — when you update from 4.17.0 to 4.18.0 — is exactly when Veln's analysis matters most.
The Python equivalent
In Python, the same problem exists with unpinned requirements:
requests>=2.28.0 # resolves to any version >= 2.28.0
requests~=2.28.0 # resolves to 2.28.x (compatible release)
requests==2.31.0 # exact pin
Use uv lock or pip-compile --generate-hashes to generate exact-pinned, hash-verified requirements. Use uv sync --frozen or pip install --require-hashes -r requirements.txt to install from them.
Version ranges are convenient and dangerous. Veln catches supply chain attacks introduced during version updates.