Vu Nguyen
← Essays & Opinions
6 min read

Pin Everything: Why I Dropped Semver Ranges

Semver ranges let untested code into production. The catalog approach in pnpm-workspace.yaml pins everything. Here is why I accept that friction.

engineeringsecuritysupply-chaindependencies

The npm default is wrong. When you write "lodash": "^4.17.21" you are not saying use version 4.17.21. You are saying use any version from 4.17.21 to 4.x.x that a stranger publishes in the future.

That is a strange default. You tested 4.17.21. You reviewed 4.17.21. You have no idea what 4.18.0 will contain, or who will publish it, or when. But the ecosystem has decided that accepting untested code is the path of least resistance.

I stopped doing that. Every dependency in my monorepo is pinned to an exact version. No caret, no tilde, no ranges. The friction is real. I think the tradeoff is correct.

The Trust Model of Semver Ranges

When you use a semver range, you are implicitly trusting several things. You trust that every future minor and patch release will be backwards compatible. You trust that the maintainer will never make a mistake. You trust that their npm credentials will never be compromised. You trust that no one will social-engineer their way into commit access.

That is a lot of trust to hand out silently. Most developers do not think of it this way because the default feels safe. Everyone does it. The ecosystem assumes it.

But the actual security model is: any maintainer of any transitive dependency can push code that runs on your production servers, and your CI will pull it automatically the next time you install.

SEMVER RANGE^1.2.3allows1.2.31.5.01.8.0 ⚠1.9.9PINNED EXACT1.2.3allows1.2.3✓ testeduntested code in prodonly what you chose
semver ranges delegate version selection to external maintainers

The Attacks Are Not Theoretical

Supply chain attacks used to feel abstract. They are not anymore. The last few years have produced a steady stream of real incidents that exploited exactly this trust model.

event-stream (2018): A maintainer handed off a popular npm package to a new contributor who seemed helpful. That contributor added a dependency that injected malicious code targeting a specific Bitcoin wallet. Millions of downloads before anyone noticed.

ua-parser-js (2021): The maintainer's npm account was compromised. Attackers published versions containing a cryptominer and password stealer. The package had 8 million weekly downloads. If your lockfile allowed patches, you pulled it automatically.

colors and faker (2022): The maintainer intentionally sabotaged his own packages to protest unpaid open-source labor. Anyone on a range got an infinite loop or corrupted output on their next install.

node-ipc (2022): The maintainer added protestware that wiped files on machines with Russian or Belarusian IP addresses. A direct dependency of vue-cli. Ranges made it trivially deployable.

polyfill.io (2024): A Chinese company acquired the domain and CDN for a widely-used polyfill service, then started injecting malicious redirects. Over 100,000 websites affected. Not an npm issue per se, but the same trust model: someone you never met now controls code running on your site.

xz-utils (2024): A sophisticated multi-year social engineering campaign. An attacker built trust in the xz compression library, eventually adding a backdoor to SSH authentication. Nearly made it into major Linux distributions. This was not a drive-by; it was patient and deliberate.

These are not edge cases. This is the actual threat model. The ecosystem is large, maintainers are often solo, credentials get compromised, and social engineering works. Semver ranges mean any of these failure modes can reach your production environment without any explicit action on your part.

The Catalog Approach

In pnpm, you can define a version catalog in your workspace root. Every package in the monorepo references the catalog instead of specifying versions directly. The catalog is a single file that pins every external dependency to an exact version.

Here is what it looks like in practice. In pnpm-workspace.yaml, you have a catalog section:

catalog:
  react: 19.2.4
  next: 16.2.3
  drizzle-orm: 0.45.2
  typescript: 6.0.3

In each package.json, dependencies reference the catalog:

"dependencies": {
  "react": "catalog:",
  "next": "catalog:"
}

No version in the package.json. The catalog is the single source of truth. When you want to update, you change one line in the catalog, run your tests, review the PR, and merge. Every package in the monorepo moves together.

This is not specific to pnpm. Yarn has similar mechanisms. The principle is the same: centralize version decisions, pin exactly, update explicitly.

What Breaks When You Do Not Pin

Beyond security, semver ranges create reproducibility problems. Your CI might install different versions than your local machine. A coworker's fresh clone might behave differently than your six-month-old node_modules. Debugging becomes archaeology.

Lockfiles help, but they are not enough. Lockfiles freeze transitive dependencies, but many workflows regenerate lockfiles on install. Some CI configurations do. Some deploy pipelines do. If your direct dependency is ranged, a lockfile regeneration can pull new code.

The failure mode is silent. Your tests pass. Your build succeeds. But the code running in production is not the code you reviewed. You only find out when something breaks, and by then the blast radius is unclear.

The Friction You Accept

Pinning everything is not free. You will have more Dependabot PRs. Minor version bumps become explicit decisions. Security patches require human action.

This is the point. Every version change becomes a deliberate act. You see the diff. You run the tests. You decide whether this version, specifically, belongs in your codebase.

DEPENDABOTPRCI TESTS+ LINTHUMANREVIEWUPDATECATALOGDEPLOYtriggergate 1gate 2commitshipCONTROLLED UPDATE FLOW
every version change is an explicit, reviewable decision

The friction is proportional to how many dependencies you have and how often they update. For a small project with a few dozen dependencies, it is negligible. For a large project with hundreds, you need automation and triage.

Dependabot helps. You can batch updates, auto-merge patches for low-risk packages, and ignore updates you do not care about. The tooling exists. It just requires setup.

The tradeoff is: manual overhead in exchange for control. You will spend more time on dependency updates. You will not wake up to a compromised build because a maintainer's credentials leaked at 2am.

What You Get Back

Reproducibility: Every build uses exactly the versions you specified. No drift. No surprises. Your production environment matches your test environment matches your local environment.

Auditability: Your git history shows exactly when each version changed and who approved it. If something breaks, you can bisect. If a vulnerability is announced, you know immediately whether you are affected.

Defense in depth: You are no longer one compromised maintainer away from a breach. An attacker has to compromise the specific version you pinned, not any future version. They have to do it before you pin, not after. The window of exposure shrinks.

Intentionality: Every dependency is a choice you made consciously. You know what is in your codebase because you put it there. Nothing arrived silently.

The Overrides Escape Hatch

Pinning direct dependencies is not enough. Your dependencies have dependencies, and those have ranges. A vulnerability in a transitive dependency can still reach you.

pnpm provides an overrides field for this. You can force transitive dependencies to specific versions, patching the tree without waiting for upstream maintainers to update.

overrides:
  "cross-spawn@<6.0.6": ">=6.0.6"
  "cookie@<0.7.0": ">=0.7.0"

This is where ranges are acceptable: in overrides, specifying a minimum version floor for transitive dependencies that have known vulnerabilities. You are not accepting arbitrary future versions; you are forcing a patch.

This Is Not Paranoia

The npm ecosystem has over two million packages. Most are maintained by individuals with no security budget. Account takeovers happen. Social engineering works. People burn out and hand off projects to strangers.

The default trust model assumes all of this will be fine, indefinitely, across every package in your dependency tree. That assumption is optimistic.

Pinning is not paranoia. It is an acknowledgment of how the ecosystem actually works. It is a choice to see every version change before it reaches your users.

The friction is worth it. I would rather review a hundred Dependabot PRs than explain one supply chain incident.


← Essays & Opinions