Dependency prefixes are a supply chain risk: let's fix them
Dependency prefixes like ^ and ~ make updates easy, but the version ranges they create widen the path a compromised package can take into production.

Dependency prefixes like ^ and ~ make updates easy, but the version ranges they create widen the path a compromised package can take into production.
Most npm, pnpm, and Yarn projects treat dependency prefixes as routine syntax. A caret in ^1.2.3, a tilde in ~1.2.3, or a wildcard in 1.x often gets added without debate. The dependency installs, the tests pass, and the pull request moves on. But that bit of syntax is policy. It decides which code your package manager is allowed to fetch later, often in CI, during release builds, or on developer machines you don't directly control.
Version ranges have legitimate use cases. They let teams pick up compatible bug fixes without changing package.json every time. The problem is the broad trust boundary: ^1.2.3 can accept newer compatible releases, ~1.2.3 can accept newer patch releases, and * can accept almost anything. So when a maintainer account is compromised, a package is hijacked, or a malicious update lands in a transitive dependency, a permissive range can turn the next install into the delivery mechanism.
Treat version ranges like any other policy that controls what reaches production: they deserve the same scrutiny you'd give a CI secret or a deployment credential. When an attack actually lands, that policy decides who's exposed—in a recent PyPI supply chain incident, the repositories that pinned their versions were the ones that stayed safe.
Commit your lockfile. For npm projects, that usually means package-lock.json. In CI/CD, prefer npm ci over npm install. npm ci requires an existing lockfile, fails when the lockfile and package.json disagree, removes any existing node_modules, and installs from the frozen dependency graph. pnpm and Yarn have equivalents: commit pnpm-lock.yaml and install with pnpm install --frozen-lockfile, or commit yarn.lock and install with yarn install --immutable.
That does not make the dependency tree safe forever, but it does make installs repeatable, which is the prerequisite for auditing or incident response. If a malicious version appears upstream, the lockfile is what stops the next install from quietly pulling it in.
It also makes version changes visible: because the lockfile records exact resolved versions and lives in version control, any change to what you actually install shows up as a reviewable diff instead of happening silently.
For high-risk packages, remove prefixes and pin exact versions:
{
"dependencies": {
"react": "19.2.6",
"react-dom": "19.2.6"
}
}
Exact pins shift the responsibility for updates from npm onto you, increasing your workload, so you don't want to use them everywhere. Save exact pins for places where the blast radius justifies the cost: build tools, install-time packages, authentication libraries, deployment tooling, packages with native bindings, and dependencies that run in privileged CI contexts.
The React ecosystem is a good example. When a critical React Server Components vulnerability affects packages across many repositories, exact pins make it clear which apps are already on the approved version and which apps still need remediation.
You can also reduce accidental range creation in new installs:
# npm
npm config set save-exact true
# pnpm
pnpm config set save-exact true
# Yarn 2+
yarn config set defaultSemverRangePrefix ""
For one-off installs, use npm install --save-exact, pnpm add --save-exact, or yarn add --exact. That keeps future package additions from writing ^ ranges by default.
Malicious packages often use lifecycle scripts because install time is a convenient execution point. Disable scripts during locked installs where your build can tolerate it:
# npm
npm ci --ignore-scripts
# pnpm
pnpm install --frozen-lockfile --ignore-scripts
# Yarn 2+
yarn install --immutable --mode=skip-builds
Some packages need install scripts to compile native modules or download assets. Treat those as allowlist cases. The goal is not to break builds, but to make sure any code execution during dependency installation is a deliberate decision rather than a default.
Run npm audit against the lockfile-backed dependency graph, not a hypothetical tree. In CI, consider npm audit --audit-level=high or a threshold that matches your risk model. pnpm and Yarn offer the same check through pnpm audit and yarn npm audit. Audits will not catch every malicious package, but they do catch known vulnerabilities and give you a repeatable signal.
The harder part is applying policy across many repositories. Fixing one repo by hand is easy; two hundred repos need a rollout.
Sourcegraph Batch Changes is designed for exactly this kind of challenge: you define a code change once, run it against every matching repository, preview the resulting diffs, and open merge requests for each one. Applied to dependency prefixes, that means you can hunt down risky ranges, update package.json, refresh the lockfile, and track review status from one place.
For demo purposes, this package.json gives the batch spec something to update:
{
"name": "react-demo-app",
"private": true,
"dependencies": {
"react": "^19.0.0",
"react-dom": "~19.0.0"
},
"devDependencies": {
"react-server-dom-webpack": "19.x"
}
}
And here's a starting batch spec that swaps those ranges for the approved exact versions:
version: 2
name: pin-risky-npm-dependencies
description: Pin selected npm dependencies by removing SemVer range prefixes.
on:
- repositoriesMatchingQuery: repo:github.com/YOUR-ORG/.* repohasfile:package.json
workspaces:
- rootAtLocationOf: package.json
in: '*'
onlyFetchWorkspace: true
steps:
- run: |
bash <<'BASH'
set -euo pipefail
node <<'JS'
const fs = require('fs')
const packageJsonPath = 'package.json'
const manifest = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'))
const approvedPins = {
'react': '19.2.6',
'react-dom': '19.2.6',
'react-server-dom-webpack': '19.2.6',
'react-server-dom-turbopack': '19.2.6',
'react-server-dom-parcel': '19.2.6',
}
let changed = false
for (const section of ['dependencies', 'devDependencies', 'optionalDependencies']) {
const deps = manifest[section] || {}
for (const [name, pinnedVersion] of Object.entries(approvedPins)) {
if (!Object.prototype.hasOwnProperty.call(deps, name)) continue
if (deps[name] !== pinnedVersion) {
deps[name] = pinnedVersion
changed = true
}
}
}
if (changed) {
fs.writeFileSync(packageJsonPath, `${JSON.stringify(manifest, null, 2)}\n`)
}
JS
if [[ -f package-lock.json ]]; then npm install --package-lock-only --ignore-scripts; fi
BASH
container: node:22
changesetTemplate:
title: Pin high-risk npm dependency versions
body: |
This pins selected high-risk npm dependencies by removing permissive SemVer prefixes.
The lockfile was refreshed without running install scripts.
branch: security/pin-risky-npm-dependencies-${{ replace steps.path "/" "-" }}
commit:
message: Pin high-risk npm dependency versions
The preview should make the policy visible as a small, reviewable diff:

The same pattern works for other controls: replace npm install with npm ci in CI files, add exact-version settings for npm, pnpm, or Yarn, or add --ignore-scripts to trusted build steps. The important part is making the policy concrete in code, then applying it everywhere the risk exists.
Dependency ranges will always have a place, but they should be a deliberate choice. With lockfiles, exact pins for sensitive packages, and Batch Changes for the rollout, teams can keep version drift visible and controlled.
For future updates on Batch Changes, subscribe to our changelog.
A special thanks to Peter Guy, Robert Lin, and Stephanie Jarmak for their contributions to this blog post.

With Sourcegraph, the code understanding platform for enterprise.
Schedule a demo