# Phantom Dependencies

> Phantom dependencies are packages your code uses without declaring them in its manifest, such as a transitive npm package hoisted into node_modules.

Also known as: phantom dependency, phantom deps, ghost dependencies, undeclared dependencies

Source: https://socket.dev/glossary/phantom-dependencies

Last updated: 2026-10-26

Phantom dependencies, also called ghost or undeclared dependencies, are packages that code uses without declaring them in its manifest. npm's hoisted `node_modules` places transitive packages where Node.js resolution finds them, so an undeclared import works until the parent package drops it. Python also uses the term for native libraries bundled inside wheels. Either way, the manifest stops describing what runs.

The most important fact about a phantom dependency is that someone else picks its version. In our tests on October 25, 2026, [npm 12](https://socket.dev/blog/npm-12)'s default layout let an app import a package it never declared, and renaming an unrelated dependency changed which version that import loaded. Strict layouts protect your own code, but pnpm and Bun, by default, still let your dependencies' code import whatever happens to be installed. Our position is that the manifest should be a contract the installer enforces, and that security tools should read the imports in the code rather than trust the list in the manifest. Every JavaScript install mode we tested except Yarn Plug'n'Play still leaves a gap.

## What is a phantom dependency?

A phantom dependency is a package your code imports that your manifest does not list. It is almost always a [transitive dependency](https://socket.dev/glossary/transitive-dependency): a package one of your declared dependencies requires, installed somewhere your code can also reach.

The smallest reproduction takes two files. The app's [package.json](https://socket.dev/glossary/package-json) declares one dependency, `debug@4.4.3`, which declares `ms@^2.1.3`. The app's code requires ms directly.

```json
{
  "name": "app",
  "version": "1.0.0",
  "dependencies": {
    "debug": "4.4.3"
  }
}
```

```javascript
const ms = require('ms');
console.log(ms('2 days'));
```

With npm 12.2.1, the import works and nothing warns:

```console
$ npm install
added 2 packages in 312ms
$ node index.js
172800000
$ npm explain ms
ms@2.1.3
node_modules/ms
  ms@"^2.1.3" from debug@4.4.3
  node_modules/debug
    debug@"4.4.3" from the root project
```

npm installed ms because debug asked for it, and the app got it for free. `npm explain` shows that the only edge to ms runs through debug. The [lockfile](https://socket.dev/glossary/lockfile) agrees: `package-lock.json` records ms at 2.1.3, but the root entry lists only debug, so ms is pinned as debug's dependency and constrained by debug's range. If a future debug release drops ms, the app breaks on the first install that upgrades debug, and nothing in the app's own code explains why.

Run the same two files under [pnpm 12](https://socket.dev/blog/pnpm-12), version 12.15.0, and the import fails:

```console
$ pnpm install
dependencies:
+ debug 4.4.3

Done in 29ms using pnpm v12.15.0
$ node index.js
Error: Cannot find module 'ms'
Require stack:
- /srv/work/app/index.js
```

Microsoft's Rush documentation defines the term the same way: a phantom dependency "occurs when a project uses a package that is not defined in its `package.json` file" ([Rush docs](https://rushjs.io/pages/advanced/phantom_deps/)). As of npm 12.2.1, npm's own documentation uses the term too, in a section titled [Catching undeclared ("phantom") dependencies](https://docs.npmjs.com/cli/v12/using-npm/developers#catching-undeclared-phantom-dependencies).

## Why does npm let code import undeclared packages?

npm lets code import undeclared packages because Node.js resolves a bare specifier such as ms by searching directories, not by reading `package.json`. For `require('ms')` from `/srv/work/app/index.js`, Node.js checks `/srv/work/app/node_modules/ms`, then `/srv/work/node_modules/ms`, then each parent directory up to the filesystem root. If all of those miss, it tries `NODE_PATH` and three legacy global folders, which the [Node.js modules documentation](https://nodejs.org/api/modules.html#loading-from-node_modules-folders) says exist "mostly for historic reasons." The resolver never asks whether the importing package declared the module.

That design held up while every package had its own nested `node_modules`, because each package could only see what it had installed. npm 3.0.0, published in June 2015, changed the layout. The [npm folders documentation](https://docs.npmjs.com/cli/v12/configuring-npm/folders) states it plainly: "Since version 3, npm hoists dependencies by default." Hoisting installs each package at the highest directory where its version does not conflict. It cut duplication and path lengths, and it put most of the transitive tree in the first directory Node.js searches. [Yarn](https://socket.dev/glossary/yarn-package-manager) Classic 1.22.22 and [Bun](https://socket.dev/glossary/bun) 1.4.4's default single-package install produced the same flat tree in our tests, with ms at the top level.

Under hoisting, the phantom's version is chosen by the layout algorithm, not by any range you wrote. We added a second, unrelated dependency to the same app: `debug@2.6.9`, installed under an npm alias because the name debug was taken. `debug@2.6.9` depends on `ms@2.0.0`. The only difference between these two runs is the alias name:

```console
$ cat package.json
{ "name": "app", "version": "1.0.0", "dependencies": { "debug": "4.4.3", "z-legacy": "npm:debug@2.6.9" } }
$ npm install && node -p "require('ms/package.json').version"
2.1.3

$ cat package.json
{ "name": "app", "version": "1.0.0", "dependencies": { "debug": "4.4.3", "a-legacy": "npm:debug@2.6.9" } }
$ npm install && node -p "require('ms/package.json').version"
2.0.0
```

npm put one copy of ms at the root and nested the other under the package that needed it. Renaming the alias decided which copy won. The app's phantom import moved from ms 2.1.3, published December 2020, to ms 2.0.0, published May 2017, and `npm ls` exited 0 both times. A rename changed the code the app runs, and no file the app's authors control recorded it.

## How does pnpm prevent phantom dependencies?

pnpm prevents phantom dependencies in your own code by placing only declared packages in the project's `node_modules`. Every package lives in `node_modules/.pnpm/<name>@<version>/node_modules/`, with symlinks to its own dependencies beside it, so debug can reach ms and the app cannot. Our [pnpm](https://socket.dev/glossary/pnpm) entry walks through that layout. pnpm 12, the Rust rewrite released August 26, 2026, kept it.

pnpm's protection is deliberately partial. Its settings reference calls the default "a semistrict `node_modules`." The default `hoistPattern` of `['*']` links every installed package into a hidden `node_modules/.pnpm/node_modules` directory. That directory sits on the search path of every package inside .pnpm, but not on the app's ([pnpm node-modules settings](https://pnpm.io/settings/node-modules)). A dependency with its own phantom import keeps working. We tested that with a local package, lib-a, that declares only debug and requires ms. Bun's [isolated linker, added in Bun 1.2.19](https://socket.dev/blog/bun-1-2-19-adds-isolated-installs-for-better-monorepo-support), keeps a similar fallback in `node_modules/.bun/node_modules`.

We ran three checks across six JavaScript install modes on October 25 and 26, 2026, with Node.js 22.23.4. The checks were an undeclared require from the app, an undeclared require from inside lib-a, and a planted ms in the app's parent directory.

| Install mode | App requires undeclared ms | lib-a requires undeclared ms | Planted ../node\_modules/ms |
| --- | --- | --- | --- |
| npm 12.2.1, default (hoisted) | Resolves | Resolves | Never reached |
| npm 12.2.1, --install-strategy=linked | MODULE\_NOT\_FOUND | MODULE\_NOT\_FOUND | Loaded by the app |
| pnpm 12.15.0, default | MODULE\_NOT\_FOUND | Resolves through .pnpm/node\_modules | Loaded by the app |
| Bun 1.4.4, default single package | Resolves | Resolves | Never reached |
| Bun 1.4.4, --linker isolated | MODULE\_NOT\_FOUND | Resolves through .bun/node\_modules | Loaded by the app |
| Yarn 4.18.1, Plug'n'Play (default) | Error | Error | Not loaded |

Yarn Plug'n'Play was the only mode that refused all three. PnP replaces the directory walk with a resolution table generated at install time in `.pnp.cjs`, and its error message states the problem better than most documentation:

```console
$ yarn node index.js
Error: Your application tried to access ms, but it isn't declared in your dependencies; this makes the require call ambiguous and unsound.

Required package: ms
Required by: /srv/work/yarn-app/
```

The isolated `node_modules` layouts fixed step one of Node's search and left the rest. Figure 1 shows what that means for an app laid out by pnpm.

> Figure 1. The same undeclared import under two layouts, tested with Node.js 22.23.4 on October 25, 2026. An isolated layout removes the copy in step 1 but leaves steps 2 through 6.
>
> Where Node.js looks for require('ms') from /srv/work/app/index.js under a hoisted and an isolated node\_modules layout

Figure 1 as text. The app at `/srv/work/app/index.js` requires ms, and `package.json` declares only debug. Node.js searches, in order: `/srv/work/app/node_modules/ms`, `/srv/work/node_modules/ms`, `/srv/node_modules/ms`, `/node_modules/ms`, each `NODE_PATH` entry, then `~/.node_modules`, `~/.node_libraries`, and `$PREFIX/lib/node`. Under npm 12's hoisted layout, the first location holds `ms@2.1.3`, hoisted from debug, and the search stops there. Under pnpm 12's isolated layout, the first location is empty because ms lives in `node_modules/.pnpm/`, so Node.js keeps searching. If nothing is found, the app fails with `MODULE_NOT_FOUND`. If any parent directory holds a `node_modules/ms`, the app loads that copy instead.

We reproduced the second outcome with one directory and one file:

```console
$ mkdir -p /srv/work/node_modules/ms
$ echo "module.exports = () => 'loaded from ' + __filename;" > /srv/work/node_modules/ms/index.js
$ node index.js
loaded from /srv/work/node_modules/ms/index.js
```

On a single-user laptop, the parent directory is yours. On a shared build host, a mounted workspace, or a container image with a writable `/node_modules`, a phantom import extends your dependency tree to whoever can write to an ancestor directory. Declaring the dependency closes that path, because the declared copy is found first.

npm now recommends its strict mode for package authors. The npm 12 documentation says the linked strategy "only exposes a package's declared dependencies," and adds its own caveat: a dependency still satisfied by a devDependency or a workspace root "can resolve fine for you and still be missing for whoever installs your package." Newer package managers start strict. [upm](https://socket.dev/blog/upm-package-manager), a TypeScript npm client launched in September 2026, gives each package access only to the dependencies it declares.

Our verdict: for applications, pnpm's default layout is the practical choice. It stops phantom imports in your own code and brings the install-time defaults covered in our pnpm entry. Yarn Plug'n'Play is stricter on this one axis, if your toolchain tolerates it. Library authors should also run their test suite under a layout with no fallback, such as npm's `--install-strategy=linked` or pnpm with `hoist: false`, because some of their users install with Yarn Plug'n'Play. Settings that undo the isolation, like pnpm's `shamefullyHoist` and `nodeLinker: hoisted`, belong only in projects whose tools cannot work any other way.

## Phantom dependencies in Python mean two different things

Python inherits the import problem in a stronger form. pip, uv, and Poetry install every distribution in an environment into one flat `site-packages` directory, and Python's import system has no record of which distribution declared which. Every installed package is importable by every module, with no option like pnpm's isolated layout. Here requests 2.34.2 is the only declared dependency, and the code imports urllib3 as well:

```toml
[project]
name = "app"
version = "1.0.0"
dependencies = ["requests==2.34.2"]
```

```python
import requests
import urllib3

urllib3.disable_warnings()
```

```console
$ deptry .
Scanning 1 file...

app/main.py:2:8: DEP003 'urllib3' imported but it is a transitive dependency
Found 1 dependency issue.
```

requests 2.34.2 accepts `urllib3>=1.26,<3`, a range that crosses urllib3's 2.0 major release from April 2023. The app's urllib3 code runs against whichever side of that boundary the resolver picks. Because the interpreter cannot enforce declarations, Python's practical defense is a static check like [deptry's DEP003 rule](https://deptry.com/rules-violations/), which maps imports to distributions. Static checks miss imports built at runtime, such as `importlib.import_module` with a computed name.

The second Python meaning comes from packaging, not imports. [PEP 770](https://socket.dev/blog/new-python-packaging-proposal-aims-to-solve-phantom-dependency-problem-with-sboms), by PSF Security Developer-in-Residence Seth Larson, says "Python packages are particularly affected by the 'phantom dependency' problem," meaning compiled code bundled inside a wheel that Python package metadata cannot describe. In an earlier discussion of SBOMs for Python packages, Larson called it a named problem "where Python is the unfortunate star." These are the same failure seen from the other side: code that runs, missing from the declared inventory, invisible to any software composition analysis tool that reads only metadata.

[PEP 770 was accepted](https://peps.python.org/pep-0770/) on April 11, 2025. It reserves a `.dist-info/sboms/` directory inside the wheel for [software bill of materials](https://socket.dev/glossary/software-bill-of-materials) documents in CycloneDX or SPDX format. Its motivating example: Pillow's wheel bundled 16 shared libraries that common SCA tools did not detect. By [Larson's count on December 22, 2025](https://sethmlarson.dev/pep-770-sbom-data-from-pypi-fedora-and-redhat), auditwheel 6.5.0 had started generating these SBOMs automatically, and 332 projects on PyPI were shipping SBOM data in their wheels.

We checked whether that fixes the Pillow case. Pillow 13.0.0's CPython 3.13 manylinux x86\_64 wheel was uploaded to PyPI on October 16, 2026:

```console
$ pip download pillow==13.0.0 --no-deps --only-binary=:all: \
    --platform manylinux_2_28_x86_64 --python-version 3.13
$ W=pillow-13.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
$ unzip -Z1 $W | grep -c '^pillow.libs/.\+\.so'
18
$ unzip -Z1 $W | grep sboms/
pillow-13.0.0.dist-info/sboms/
pillow-13.0.0.dist-info/sboms/auditwheel.cdx.json
pillow-13.0.0.dist-info/sboms/pillow-13.0.0.cdx.json
$ unzip -p $W pillow-13.0.0.dist-info/sboms/auditwheel.cdx.json | jq -c '[.components[].purl]'
["pkg:pypi/pillow@13.0.0?file_name=pillow-13.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl","pkg:rpm/almalinux/libXau@1.0.9-3.el8"]
$ unzip -p $W pillow-13.0.0.dist-info/sboms/pillow-13.0.0.cdx.json \
    | jq -c '.components[] | select(.name=="FreeType") | {"bom-ref", name, version, purl, cpe}'
{"bom-ref":"pkg:generic/freetype2","name":"FreeType","version":"2.14.3","purl":null,"cpe":null}
```

> Figure 2. What the two SBOMs inside the Pillow 13.0.0 wheel account for, inspected October 25, 2026. libwebpdemux, libwebpmux, and libsharpyuv are counted under the libwebp entry, since they ship in the libwebp source tree.
>
> The 18 shared libraries bundled in the Pillow 13.0.0 manylinux x86\_64 wheel for CPython 3.13, grouped by which SBOM names them

Figure 2 as text. The wheel bundles 18 shared library files and declares none of them as runtime dependencies in `METADATA`. All 25 of its `Requires-Dist` entries are optional extras.

| SBOM in the wheel | Bundled files it names | Files |
| --- | --- | --- |
| auditwheel.cdx.json (auditwheel 6.8.2) | libXau, as pkg:rpm/almalinux/libXau@1.0.9-3.el8 | 1 |
| pillow-13.0.0.cdx.json (Pillow's build script) | libavif, libfreetype, libharfbuzz, libjpeg, liblcms2, libopenjp2, libtiff, libwebp, libwebpdemux, libwebpmux, libsharpyuv, libxcb | 12 |
| Neither | libpng16, libbrotlicommon, libbrotlidec, liblzma, libzstd | 5 |

PEP 770 turned 13 of Pillow's 18 bundled files from phantoms into declared components. The other five, the libraries behind PNG, Brotli, XZ, and Zstandard support, are still phantoms. Pillow's entries for bundled libraries also carry only a `pkg:generic` reference, with no purl or CPE field, so a scanner that matches vulnerabilities by those identifiers has nothing to match. Pillow is ahead of most projects here. The lesson is that an SBOM covers what its generator can see. For bundled code, the build that compiles a library is the only step that reliably knows it is there.

## Compilers in other ecosystems refuse the import

Rust and Go never had the JavaScript form of the problem, because their toolchains check declarations at build time. Cargo passes rustc an `--extern` flag only for crates declared in `Cargo.toml`. aho-corasick depends on memchr, and a crate that declares only aho-corasick cannot use memchr, even though memchr is compiled in the same build:

```toml
[dependencies]
aho-corasick = "1.1.3"
```

```console
$ cargo build
error[E0433]: cannot find module or crate `memchr` in this scope
 --> src/main.rs:3:22
  |
3 |     println!("{:?}", memchr::memchr(b'x', b"abx"));
  |                      ^^^^^^ use of unresolved module or unlinked crate `memchr`
  |
  = help: if you wanted to use a crate named `memchr`, use `cargo add memchr` to add it to your `Cargo.toml`
```

That output is from rustc 1.97.0. Go reaches the same result by a different rule. At go 1.17 and above, `go.mod` lists "an explicit require directive for each module that provides any package transitively imported" by the main module, according to the [Go modules reference](https://go.dev/ref/mod). A transitive package your code imports therefore already has a version in your own `go.mod`. The only stale detail is the requirement's `// indirect` comment, which `go mod tidy` corrects. An import that no listed module provides fails the build:

```console
$ go build ./...
main.go:6:2: no required module provides package github.com/spf13/pflag; to add it:
	go get github.com/spf13/pflag
```

Maven behaves like npm by default. Its compile scope dependencies "are propagated to dependent projects," so a transitive library is on your compile classpath, and the [Maven dependency mechanism guide](https://maven.apache.org/guides/introduction/introduction-to-dependency-mechanism.html) only recommends declaring what you use directly. `mvn dependency:analyze` reports "used and undeclared" dependencies, and its `failOnWarning` parameter defaults to false. [Gradle's java-library plugin](https://docs.gradle.org/current/userguide/java_library_plugin.html) is stricter: implementation dependencies do not "leak into the consumers' compile classpath," while api dependencies do.

| Ecosystem | Undeclared transitive import by default | What enforces declarations | Check to add |
| --- | --- | --- | --- |
| npm (hoisted), Yarn Classic, Bun default | Works | Nothing | knip, eslint-plugin-import no-extraneous-dependencies |
| pnpm, Bun isolated, npm linked | Fails for your code, works for dependencies (except npm linked) | Directory layout | pnpm hoist=false or npm linked in CI |
| Yarn Plug'n'Play | Fails | Runtime resolver | Built in |
| Python (pip, uv, Poetry) | Works | Nothing | deptry DEP003 |
| Maven | Works | Nothing | dependency:analyze with failOnWarning |
| Gradle java-library | Works only for api dependencies | Compile classpath | Built in |
| Go modules | Works, with the version in your go.mod | go command | go mod tidy -diff |
| Cargo | Fails | rustc --extern | Built in |

Verdict: Cargo's model is the one to copy. It makes declarations the only way to reach a dependency, with no fallback for anyone. Go's model is nearly as good, because every module in the build already has a version in your own `go.mod`. JavaScript and Java can get close only with tooling that teams must opt into. Python cannot get there at the interpreter level, so it depends on static checks.

## Why are phantom dependencies a security problem?

Phantom dependencies are a security problem because many supply chain controls key off the manifest, and a phantom dependency never appears in it. Four failures follow.

First, nobody reviews the choice. A pull request that adds `require('ms')` changes no manifest, so dependency review that triggers on `package.json` or `pyproject.toml` changes never runs.

Second, you cannot set a floor. If a fixed ms release ships after a vulnerability, the app has no range of its own to raise. The version moves only when debug's range or the hoisting outcome moves it.

Third, analysis that starts from the manifest draws the wrong graph. A tool that infers usage from declared dependencies believes ms is used only by debug. If debug never calls a vulnerable function in ms, that function looks unreachable, even when the app calls it directly. Socket's [reachability analysis](https://socket.dev/glossary/reachability-analysis) handles this case by scanning all dependencies "regardless of how they are declared," and the Socket dashboard labels a reachable vulnerability found in a phantom dependency as one, per our [phantom dependencies documentation](https://docs.socket.dev/docs/phantom-dependencies). That is a specific instance of a broader rule we hold: analyze what the code does, not what its metadata claims.

Fourth, the failure ships to users. mcp-dataverse 0.7.6, published to npm on July 21, 2026, had a `dist/server.js` that imported `@modelcontextprotocol/ext-apps/server`. Its `package.json` declared four dependencies, and ext-apps was not one of them. A user who ran it with `npx -y mcp-dataverse` reported `ERR_MODULE_NOT_FOUND` on the first request in [issue #5](https://github.com/codeurali/mcp-dataverse/issues/5). Versions 0.7.6, 0.7.7, and 0.7.8 all shipped the import. The fix, 0.7.9, arrived on October 13, 2026, 84 days after 0.7.6. The maintainer then added a build check that fails when dist imports an undeclared dependency, which is the right fix and a check npm publish does not perform.

## Finding and fixing phantom dependencies

Finding phantom dependencies takes either a static check that compares imports with the manifest, or a strict install that makes undeclared imports fail. Use both, because each misses what the other catches.

Static checks read import statements. knip 6.43.0 reported the demo app's phantom with a file and position:

```console
$ npx knip --include unlisted
Unlisted dependencies (1)
ms  index.js:1:20
```

eslint-plugin-import's `no-extraneous-dependencies` rule does the same check inside the editor, and deptry, `mvn dependency:analyze`, and `go mod tidy -diff` cover Python, Java, and Go. Static checks miss require calls with computed names and anything loaded through a plugin system.

Strict installs catch only the code paths your tests execute. On npm, run `npm install --install-strategy=linked` followed by `npm test`. On pnpm, add `hoist: false` to `pnpm-workspace.yaml` in CI so dependencies lose the hidden fallback too. Neither catches an import that a devDependency satisfies during development, which npm's documentation calls out.

The fix is one line. Declare the package with a range you chose, for example `npm install ms@^2.1.3`, or stop importing it. Do not paper over a failure with `publicHoistPattern` or `shamefullyHoist`, which reopen the gap for everything that matches.

## The manifest should be a contract

Most of the JavaScript stack still treats the manifest as advisory. Node.js resolves by directory, pnpm and Bun keep fallbacks for dependency code, and the npm registry accepted three releases of mcp-dataverse whose published code imported a package their manifest never named. That registry check would be cheap: at publish time, the registry holds both the tarball and the manifest. In Python, the interpreter cannot enforce declarations at all, and PEP 770 inventories only what a build tool or maintainer chooses to record. Until registries compare published code with published manifests, the only reliable list of a package's dependencies is the one derived from its code. A phantom dependency is invisible only to tools that read the manifest instead of the code.
