Same manifest, nothing edited, different build
Share of the 502 package versions resolved on Oct 31, 2026 that differ when the identical manifest is resolved against an older registry snapshot (npm 12.2.1, platform binaries excluded)
Dependency management is how a project chooses, versions, locks, updates, and removes the packages it depends on, and decides what may install and run.
Also known as Software dependency management, Package dependency management, Dependency governance
Dependency management, also called software dependency management, is the practice of choosing the packages a project uses, declaring the versions it accepts, locking the versions it installs, and deciding when to update or remove them. The package manager resolves versions; dependency management sets the rules it resolves under, and those rules decide whether a malicious release reaches your build.
We measured how much those rules matter. On October 31, 2026, we resolved a Node.js app with nine direct dependencies to 502 packages. Then we resolved the identical manifest as the npm registry stood one week earlier. 40 of the 502 resolved versions (8.0%) were different. Nobody had edited a file.
That is the most important fact about dependency management: a manifest full of version ranges does not describe a build. It describes a set of possible builds that grows every time anyone publishes. Our position follows from it. Dependency management is a control problem at resolution time, not a bookkeeping problem in package.json. Libraries should publish ranges. Applications should own the lock, the update cadence, and a written policy for what may resolve and what may run at install.
Dependency management is the set of decisions and controls that govern third-party code in a project, from the first install to its removal. A package manager executes those decisions. It does not make most of them.
Every ecosystem splits the record across two files. The manifest records intent: the packages you chose and the version ranges you accept. The lockfile records outcome: the exact version, download URL, and content hash the resolver picked for every package in the tree, including each transitive dependency you never named.
| Ecosystem | Manifest (intent) | Lock (outcome) |
|---|---|---|
| npm | package.json | package-lock.json |
| pnpm | package.json, pnpm-workspace.yaml | pnpm-lock.yaml |
| Python with uv | pyproject.toml | uv.lock or pylock.toml |
| Rust | Cargo.toml | Cargo.lock |
| Go | go.mod | go.mod (minimal version selection) plus go.sum checksums |
| Ruby | Gemfile | Gemfile.lock |
| Maven | pom.xml | None built in |
Ranges exist for good reasons. A semantic versioning range such as ^1.13.5 lets a maintainer's bug fix reach every dependent without a manifest edit. It also lets the package manager share one copy of a package across many parents. The cost is that the same mechanism delivers a hijacked release just as efficiently. Nothing in a range distinguishes a fix from a payload.
Dependency management covers six decisions, and most of them are not made by a person:
| Stage | Decision | Typical control | Who usually decides |
|---|---|---|---|
| Select | Should we depend on this package at all? | Package review, risk signals | A developer, in seconds |
| Declare | Which versions do we accept? | Ranges or exact pins in the manifest | The default save prefix, ^ |
| Resolve | Which version do we get today? | Lockfile, cooldown, allowed sources | The resolver, against the live registry |
| Install | What code runs on this machine? | Install-script allowlist, pre-install analysis | Package manager defaults |
| Update | When do we take new versions? | Update bots, schedules, change review | A bot |
| Retire | When do we remove it? | Unused and deprecated dependency checks | Usually nobody |
Read the last column. In most projects a human makes only the first decision. npm 12.2.1 still saves new dependencies with a caret range by default: npm install axios wrote "axios": "^1.21.0" in our test. Our nine-dependency app resolved to 502 packages, so its authors chose 9 of them. Dependency management is the work of deciding the other 493 on purpose.
A manifest with ranges is a query, and the registry answers it differently every day. We made that concrete with one manifest and npm's --before flag, which resolves as if the registry held only versions published before a given time.
# dependencies: express ^5.1.0, mongoose ^8.0.0, axios ^1.12.0,
# @aws-sdk/client-s3 ^3.0.0, @slack/bolt ^4.0.0
# devDependencies: jest ^30.0.0, eslint ^9.0.0, typescript ^5.0.0, vite ^7.0.0
npm install --package-lock-only --ignore-scripts
npm install --package-lock-only --ignore-scripts --before=2026-10-24T20:00:00ZWe compared the lockfiles entry by entry. We excluded 87 platform-specific binary packages, such as rollup's per-OS builds, because a given machine installs only one of each. That left 502 portable packages.
Same manifest, nothing edited, different build
Share of the 502 package versions resolved on Oct 31, 2026 that differ when the identical manifest is resolved against an older registry snapshot (npm 12.2.1, platform binaries excluded)
The chart above, as data. Each row resolves the same manifest as the registry stood on that date and counts how many of today's 502 resolved package versions differ:
| Resolved as of | Age of snapshot | Versions that differ from today | Share of 502 |
|---|---|---|---|
| October 24, 2026 | 7 days | 40 | 8.0% |
| October 1, 2026 | 30 days | 60 | 12.0% |
| August 2, 2026 | 90 days | 138 | 27.5% |
| May 4, 2026 | 180 days | 179 | 35.7% |
The churn is concentrated. 19 of the 40 weekly changes came from the AWS SDK family (16 @aws-sdk/* and 3 @smithy/* packages), which publishes almost daily. The rest included axios, qs, ws, rollup, and three Babel packages. A project that depends on a fast-moving SDK inherits its release cadence across dozens of packages.
We also looked up every resolved version's publish time in the registry's time map. Of the 497 versions we could match to a registry timestamp (the other 5 are npm aliases), 7 were less than 24 hours old when we resolved them. The newest, rollup@4.67.3, had been public for 72 minutes. 40 versions (8.0%) were under 7 days old, and 58 (11.7%) were under 30 days old.
A fresh resolution is a bet on the newest minutes of the registry. Each of those young versions came from a publisher account that could, on a bad day, belong to someone else.
Pinning a dependency to an exact version does not pin its dependencies: it fixes one node in the graph, and every range inside that package keeps floating. We pinned express to exactly 5.3.0. express@5.3.0 declares 28 dependencies, and every one is a caret range.
{ "dependencies": { "express": "5.3.0" } }Resolved on October 31 and as of October 24, both trees had 65 packages. Two of them differed, and both changes came from versions published on October 28, 2026:
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.1.tgz",
...
- "version": "6.16.1",
- "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz",
+ "version": "6.17.1",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.17.1.tgz",The lockfile, not the manifest, pins the tree. A committed lockfile plus a frozen install command (npm ci, pnpm install --frozen-lockfile, uv sync --locked) makes the next install reproduce the last one. npm's documentation describes npm ci this way: "It will never write to package.json or any of the package-locks: installs are essentially frozen." If the manifest asks for something the lockfile does not contain, the install fails instead of resolving:
npm error code EUSAGE
npm error `npm ci` can only install packages when your package.json and package-lock.json are in sync. Please update your lock file with `npm install` before continuing.
npm error Missing: zod@4.6.7 from lock fileA lockfile has a precise scope: it freezes versions you already resolved and does nothing for the next resolution. Darcy Clarke, former npm engineering manager and founder of vlt, put it plainly in our analysis of the hidden blast radius of the axios compromise: "Lockfiles don't prevent net-new installs when updating/adding new dependencies. That's the point."
New resolutions happen more often than teams assume. Adding a package, running npm update, accepting an update bot's pull request, running a tool with npx, installing globally, and building in CI without a committed lockfile all resolve against the live registry. Published packages do not carry their package-lock.json, so npx foo@1.2.3 pins foo and resolves everything under it fresh. Repositories with no lockfile at all are the worst case. Socket's Manifest Alerts flag an application manifest that has no corresponding lockfile, because that project's dependency tree is not reproducible from the repository.
Where pins belong depends on what you ship:
| Context | Manifest | Lock | Verdict |
|---|---|---|---|
| Published library | Ranges | Do not ship one | Ranges. Exact pins in a library make you the bottleneck for every downstream security fix. |
| Application or service | Ranges or exact | Commit it, install frozen | Lockfile is mandatory. Exact manifest pins are optional and make each update an explicit diff. |
| CI tools and MCP servers | Exact, in a dedicated workspace | Commit it, install frozen | Run them with npx --no --offline from that workspace, never resolved at run time. |
| Ad hoc npx and global installs | Not applicable | None possible | Set a user-level release-age window, or avoid them in CI. |
Our entry on transitive dependencies walks through what happened when one popular build tool flattened its whole tree into exact pins. Security fixes stalled downstream until the maintainers moved again.
Dependency management reduces risk by controlling three things: which versions may enter the tree, whether package code may run during install, and how each change is reviewed before it ships. Each control covers a different failure, and none covers all of them.
The axios compromise is a useful test because the attacker did everything that usually works. axios had 100 million weekly downloads at the time. On March 30, 2026 at 23:59:12 UTC, plain-crypto-js@4.2.1 was published with a postinstall dropper. axios@1.14.1, which added it as a dependency, followed at 00:21:58 UTC on March 31. Socket's scanner flagged plain-crypto-js at 00:05:41 UTC, 16 minutes before axios@1.14.1 existed. npm replaced plain-crypto-js with a security holding package at 03:25:11 UTC, according to the registry's time map.
Replaying the axios compromise against each control
Mar 30 to 31, 2026, UTC. The shaded bar is the 3 h 26 min that plain-crypto-js@4.2.1 was installable.
npm ci with a lockfile from before Mar 30Not exposednpm install or lockfile refresh, ^1.x, no cooldownExposednpx of a CLI that depends on axios ^1.xExposedThe same replay as a table. It applies controls available as of October 2026 to workflows that ran during the window:
| Workflow during the window | Outcome | Why |
|---|---|---|
| npm ci with a lockfile committed before March 30 | Not exposed | The lockfile held axios 1.14.0 or older, and nothing re-resolved. |
| npm install or lockfile refresh with ^1.x, no cooldown | Exposed | The range resolved to 1.14.1, which pulled in plain-crypto-js@4.2.1. |
| Update bot pull request opened in the window | Exposed | CI installs the branch and runs the postinstall before anyone reviews it. |
| npx of a CLI that depends on axios ^1.x | Exposed | npx resolves fresh, and no project lockfile applies. |
| 24-hour cooldown (pnpm 11 default, npm min-release-age=1) | Not exposed | plain-crypto-js@4.2.1 was gone after 3 hours 26 minutes, long before it became eligible. |
| Install scripts off by default (npm 12, pnpm 10 and later) | Dropper not run at install | The payload was a postinstall hook. The package still lands in node_modules. |
| Pre-install malware check (pull request scan or package firewall) | Blocked from 00:05:41 UTC | A verdict existed before the axios release did. |
The general version of that replay is a control matrix. Every row is real, and every row has a gap:
| Control | Stops | Does not stop | Cost |
|---|---|---|---|
| Committed lockfile, frozen install | Silent drift in existing installs | New resolutions; a bad version already locked | Lockfile churn and merge conflicts |
| Exact pins in the manifest | Drift in direct dependencies | Drift in transitive dependencies | Every fix needs a manifest edit |
| Release-age cooldown | Short-lived malicious releases | Malware undetected longer than the window; git and URL sources | Delays every fix by the window |
| Install-script allowlist | Install-time execution | Import-time and runtime payloads | Native modules need per-version approval |
| Blocking git and URL dependencies | Sources with no registry metadata or timestamps | Registry-published malware | Breaks a few legitimate git dependencies |
| Pre-install content analysis | Known-malicious and suspicious versions | Whatever the analysis misses; installs that bypass it | Another service in the install path |
| CVE scanning | Known vulnerable versions | Malware, which never gets a CVE | Alert volume, mostly on unreachable code |
Verdict: no single row is sufficient, and the cheapest rows are the ones teams skip. Commit a lockfile and install frozen. Set a release-age window. Keep install scripts off, the default since npm 12 and pnpm 10, and approve them by exact version. Put content analysis where resolutions happen. CVE scanning alone misses most supply chain attacks, because malware does not get a CVE.
Every update is a fresh resolution, so the update process inherits all the risk of an unlocked install. The questions are how often you resolve, how new a version may be when you accept it, and who looks before it runs.
A dependency cooldown answers the second question. In our demo, setting min-release-age=7 in npm 12.2.1 changed exactly 40 of 502 resolutions to older versions, the same 40 packages that had changed in the past week. That is the trade in one number: a 7-day window kept the newest 8% of the tree out, and it will delay every legitimate fix in that 8% by up to a week. Ship urgent patches by exempting the exact fixed version, not by turning the window off.
Package managers are converging on the control, at different speeds. As of October 2026:
minimumReleaseAge to 1,440 minutes by default.min-release-age in days, but it is off by default; npm 12.2.1 reports its default as null.Automated dependency updates answer the first question, and they are where most teams lose control. Schedule bot updates in batches, group related packages, and configure the bot's release-age window and the package manager's together. A bot cooldown usually gates only the direct dependency it bumps, not the transitive versions its lockfile refresh pulls in. Never auto-merge on the strength of who published a release. Compromised maintainers publish from legitimate accounts.
Change management is the process of proposing, reviewing, approving, and recording changes before they reach production. A dependency update is a production change written by someone outside your organization, and most teams approve it with less scrutiny than a one-line config edit.
The lockfile diff is the change record, but it records identity, not behavior. The diff in the pinning example says encodeurl moved from 2.0.0 to 2.0.1 and its integrity hash changed. It says nothing about what the new code does. npm diff shows the content:
$ npm diff --diff=encodeurl@2.0.0 --diff=encodeurl@2.0.1 --diff-name-only
index.js
package.json
README.md
$ npm diff --diff=encodeurl@2.0.0 --diff=encodeurl@2.0.1 index.js # trimmed to the key change
-var UNMATCHED_SURROGATE_PAIR_REGEXP = /(^|[^\uD800-\uDBFF])[\uDC00-\uDFFF]|[\uD800-\uDBFF]([^\uDC00-\uDFFF]|$)/g
+var SURROGATE_REGEXP = /([\uD800-\uDBFF][\uDC00-\uDFFF])|[\uD800-\uDFFF]/gThat change is small and benign. The qs update from the same week, 6.16.1 to 6.17.1, produces 1,640 lines of diff output across 13 files. Multiply by 40 changed packages a week, and line-by-line human review stops being realistic. Review has to shift from reading code to checking behavior: a new install script, new network, filesystem, or shell access, a new dependency, or obfuscated code that the previous version did not have.
That is the review Socket automates. Socket for GitHub watches changes to manifests and lockfiles in a pull request and analyzes each added or updated package, for example flagging when a new version of an existing package introduces an install script. Each alert type maps to an action, Block, Warn, Monitor, or Ignore, and Block fails the pull request check, as described in Socket's alert action documentation.
Install-script approvals are change control too. npm 12 blocks dependency install scripts until you approve them, and npm approve-scripts records the approval against the exact version you reviewed:
{
"devDependencies": { "vite": "^7.0.0" },
"allowScripts": { "esbuild@0.28.2": true }
}When esbuild ships a new version, its postinstall is blocked again until someone approves the new one. That is the right default: an approval covers code someone saw, not every future version of a name.
A dependency policy is a written, enforced set of rules for what may resolve, from where, how old it must be, and what may run at install. Write it as configuration in the repository, in the spirit of policy as code, so changes to the policy get reviewed like any other change.
min-release-age=7
strict-allow-scripts=true
allow-git=none
allow-remote=noneminimumReleaseAge: 10080 # minutes; 7 days
minimumReleaseAgeExclude:
- typescript@5.9.3 # exact version, never a bare name
blockExoticSubdeps: true
allowBuilds:
esbuild: true
unrs-resolver: falseThe last two npm lines restate npm 12 defaults; writing them down makes the policy reviewable. strict-allow-scripts turns npm's default soft skip of unapproved scripts into a hard error, which is what CI should see. pnpm 12.16.1 accepted every key above without an unrecognized-setting warning in our test.
A complete policy covers seven areas:
Enforce the policy somewhere project configuration cannot override. A project .npmrc takes precedence over the user's, so any repository you clone can set min-release-age=0 and quietly turn your window off. A required CI status check or a registry proxy holds the line that a config file cannot.
Every control above acts on metadata: version numbers, publish times, sources, and script declarations. The hardest attacks keep all of that clean. A compromised maintainer publishes through the project's own pipeline, sometimes with valid provenance, and the release passes every rule except one that reads the code.
Dependency resolution is also time-dependent, and the registry keeps no queryable history of what a range resolved to at a given minute. After axios@1.14.1 was removed, every reinstall produced a clean tree. A team that ran npx during those three hours cannot reproduce what it installed. More resolution now happens where no lockfile, bot, or reviewer exists at all: AI agents install packages into throwaway sandboxes to finish a task, and the record disappears with the sandbox.
The manifest says what you trust. The lockfile says what you got. Neither says what the code does, and that is the question every dependency decision is actually asking.
Manifest confusion is a mismatch between an npm package's registry manifest and the package.json in its tarball, two copies that tools trust inconsistently.
A software bill of materials (SBOM) is a machine-readable inventory of the components, versions, and dependency relationships inside a piece of software.
Software composition analysis (SCA) inventories the third-party code in an application and checks each component for vulnerabilities, licenses, and malware.