Back to glossary

Dependency Management

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.

What is dependency management?

#

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.

EcosystemManifest (intent)Lock (outcome)
npmpackage.jsonpackage-lock.json
pnpmpackage.json, pnpm-workspace.yamlpnpm-lock.yaml
Python with uvpyproject.tomluv.lock or pylock.toml
RustCargo.tomlCargo.lock
Gogo.modgo.mod (minimal version selection) plus go.sum checksums
RubyGemfileGemfile.lock
Mavenpom.xmlNone 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:

StageDecisionTypical controlWho usually decides
SelectShould we depend on this package at all?Package review, risk signalsA developer, in seconds
DeclareWhich versions do we accept?Ranges or exact pins in the manifestThe default save prefix, ^
ResolveWhich version do we get today?Lockfile, cooldown, allowed sourcesThe resolver, against the live registry
InstallWhat code runs on this machine?Install-script allowlist, pre-install analysisPackage manager defaults
UpdateWhen do we take new versions?Update bots, schedules, change reviewA bot
RetireWhen do we remove it?Unused and deprecated dependency checksUsually 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.

Your manifest does not describe your build

#

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.

Bash
# 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:00Z

We 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)

7 daysOct 24
40 · 8.0%
30 daysOct 1
60 · 12.0%
90 daysAug 2
138 · 27.5%
180 daysMay 4
179 · 35.7%
0%25%50%
Figure 1. Dependency drift with no manifest change. Bars are scaled to 50%.

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 ofAge of snapshotVersions that differ from todayShare of 502
October 24, 20267 days408.0%
October 1, 202630 days6012.0%
August 2, 202690 days13827.5%
May 4, 2026180 days17935.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.

Does pinning a dependency version pin its dependencies?

#

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.

JSON
{ "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:

Diff
-      "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:

Plain text
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 file

A 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:

ContextManifestLockVerdict
Published libraryRangesDo not ship oneRanges. Exact pins in a library make you the bottleneck for every downstream security fix.
Application or serviceRanges or exactCommit it, install frozenLockfile is mandatory. Exact manifest pins are optional and make each update an explicit diff.
CI tools and MCP serversExact, in a dedicated workspaceCommit it, install frozenRun them with npx --no --offline from that workspace, never resolved at run time.
Ad hoc npx and global installsNot applicableNone possibleSet 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.

How does dependency management reduce risk?

#

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.

  1. 123:59:12plain-crypto-js@4.2.1 published with a postinstall dropper
  2. 200:05:41Socket flags plain-crypto-js as malware
  3. 300:21:58axios@1.14.1 published, depending on it
  4. 403:25:11npm replaces plain-crypto-js with a security holding package
npm ci with a lockfile from before Mar 30Not exposed
npm install or lockfile refresh, ^1.x, no cooldownExposed
Update bot pull request opened in the windowExposed
npx of a CLI that depends on axios ^1.xExposed
24-hour release-age cooldownNot exposed
Install scripts off by default (npm 12, pnpm 10+)Dropper not run
Pre-install malware checkBlocked after event 2
Figure 2. The same three-hour window, seven workflows. Outcomes apply controls available as of October 2026.

The same replay as a table. It applies controls available as of October 2026 to workflows that ran during the window:

Workflow during the windowOutcomeWhy
npm ci with a lockfile committed before March 30Not exposedThe lockfile held axios 1.14.0 or older, and nothing re-resolved.
npm install or lockfile refresh with ^1.x, no cooldownExposedThe range resolved to 1.14.1, which pulled in plain-crypto-js@4.2.1.
Update bot pull request opened in the windowExposedCI installs the branch and runs the postinstall before anyone reviews it.
npx of a CLI that depends on axios ^1.xExposednpx resolves fresh, and no project lockfile applies.
24-hour cooldown (pnpm 11 default, npm min-release-age=1)Not exposedplain-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 installThe 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 UTCA 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:

ControlStopsDoes not stopCost
Committed lockfile, frozen installSilent drift in existing installsNew resolutions; a bad version already lockedLockfile churn and merge conflicts
Exact pins in the manifestDrift in direct dependenciesDrift in transitive dependenciesEvery fix needs a manifest edit
Release-age cooldownShort-lived malicious releasesMalware undetected longer than the window; git and URL sourcesDelays every fix by the window
Install-script allowlistInstall-time executionImport-time and runtime payloadsNative modules need per-version approval
Blocking git and URL dependenciesSources with no registry metadata or timestampsRegistry-published malwareBreaks a few legitimate git dependencies
Pre-install content analysisKnown-malicious and suspicious versionsWhatever the analysis misses; installs that bypass itAnother service in the install path
CVE scanningKnown vulnerable versionsMalware, which never gets a CVEAlert 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.

Update cadence sets your exposure window

#

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:

  • pnpm 11 sets minimumReleaseAge to 1,440 minutes by default.
  • npm supports min-release-age in days, but it is off by default; npm 12.2.1 reports its default as null.
  • Bundler 4.0.13 added an opt-in Bundler cooldown per gem source.
  • Rust 1.100, scheduled for November 12, 2026, brings an opt-in Cargo minimum publish age, with the default still zero.

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.

Dependency updates are change management

#

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:

Bash
$ 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]/g

That 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:

JSON
{
  "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.

What should a dependency policy contain?

#

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.

TOML, also INI
min-release-age=7
strict-allow-scripts=true
allow-git=none
allow-remote=none
YAML
minimumReleaseAge: 10080        # minutes; 7 days
minimumReleaseAgeExclude:
  - typescript@5.9.3            # exact version, never a bare name
blockExoticSubdeps: true
allowBuilds:
  esbuild: true
  unrs-resolver: false

The 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:

  • Sources: which registries are allowed, and no git or URL dependencies without an explicit exception.
  • Age: a minimum release age, with exemptions by exact version only.
  • Execution: install scripts denied by default and approved per version.
  • Content: known-malicious and high-risk versions blocked before install, not reported after deploy.
  • Lock: a committed lockfile for every application, and frozen installs in CI.
  • Ad hoc execution: CI tools and MCP servers run only from a pinned workspace.
  • Retirement: unused, deprecated, and abandoned dependencies removed on a schedule. Our pnpm run warned that eslint@9.39.5, a dev dependency of the demo app, is deprecated.

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.

What dependency management cannot see

#

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.