Skip to content
← Blog

Technical explainer

The semantic versioning security trap

2 min read

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: accepts 4.17.0, 4.17.x, 4.18.x, 4.19.x, up to but not including 5.0.0
  • ~4.18.0: accepts 4.18.0, 4.18.x — minor versions and above are locked
  • 4.1.0 (exact): only accepts 4.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 update to refresh dependencies
  • In CI pipelines that were set up with npm install instead of npm 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.