# 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

Source: https://socket.dev/glossary/dependency-management

Last updated: 2026-10-31

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](https://socket.dev/glossary/lockfile) records outcome: the exact version, download URL, and content hash the resolver picked for every package in the tree, including each [transitive dependency](https://socket.dev/glossary/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](https://socket.dev/glossary/semantic-versioning-semver) 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.

## 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.

> 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 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.

## 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](https://docs.npmjs.com/cli/v11/commands/npm-ci) 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:

```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](https://socket.dev/blog/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](https://socket.dev/glossary/npx-npm-package-runner), 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](https://socket.dev/blog/introducing-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.

## 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](https://socket.dev/blog/axios-npm-package-compromised) 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](https://registry.npmjs.org/plain-crypto-js).

> 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 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](https://socket.dev/glossary/postinstall-scripts) off, the default since [npm 12](https://socket.dev/blog/npm-12) and [pnpm 10](https://socket.dev/blog/pnpm-10-0-0-blocks-lifecycle-scripts-by-default), 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](https://socket.dev/glossary/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](https://socket.dev/blog/pnpm-11-adds-new-supply-chain-protection-defaults) 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](https://socket.dev/blog/rubygems-adds-bundler-cooldown) per gem source.
- Rust 1.100, scheduled for November 12, 2026, brings an opt-in [Cargo minimum publish age](https://socket.dev/blog/rust-cargo-cooldowns), with the default still zero.

[Automated dependency updates](https://socket.dev/glossary/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](https://docs.socket.dev/docs/security-policy-defaults).

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](https://socket.dev/glossary/policy-as-code-pac), so changes to the policy get reviewed like any other change.

```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.
