require('ms') from /srv/work/app/index.js · package.json declares only debug
~/.node_libraries
$PREFIX/lib/node
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
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'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.
A phantom dependency is a package your code imports that your manifest does not list. It is almost always a 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 declares one dependency, debug@4.4.3, which declares ms@^2.1.3. The app's code requires ms directly.
{
"name": "app",
"version": "1.0.0",
"dependencies": {
"debug": "4.4.3"
}
}const ms = require('ms');
console.log(ms('2 days'));With npm 12.2.1, the import works and nothing warns:
$ 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 projectnpm 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 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, version 12.15.0, and the import fails:
$ 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.jsMicrosoft'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). As of npm 12.2.1, npm's own documentation uses the term too, in a section titled Catching undeclared ("phantom") dependencies.
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 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 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 Classic 1.22.22 and 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:
$ 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.0npm 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.
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 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). 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, 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:
$ 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.
require('ms') from /srv/work/app/index.js · package.json declares only debug
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:
$ 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.jsOn 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, 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.
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:
[project]
name = "app"
version = "1.0.0"
dependencies = ["requests==2.34.2"]import requests
import urllib3
urllib3.disable_warnings()$ 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, 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, 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 on April 11, 2025. It reserves a .dist-info/sboms/ directory inside the wheel for 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, 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:
$ 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}Named in auditwheel.cdx.json (auditwheel 6.8.2) · 1
Named in pillow-13.0.0.cdx.json (Pillow's own build script) · 12
Named in neither SBOM · 5
Declared in METADATA as runtime dependencies: 0
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.
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:
[dependencies]
aho-corasick = "1.1.3"$ 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. 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:
$ go build ./...
main.go:6:2: no required module provides package github.com/spf13/pflag; to add it:
go get github.com/spf13/pflagMaven 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 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 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.
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 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. 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. 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 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:
$ npx knip --include unlisted
Unlisted dependencies (1)
ms index.js:1:20eslint-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.
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.
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.