Skip to content

Deno nodeModulesDir: transitive npm packages under node_modules/.deno are "not installed", and apply/scan exit 0 leaving them unpatched #373

Description

[agent] Found by the scheduled Deno bug-hunt routine (ledger #308).

Summary

When a Deno project materialises npm packages into a local node_modules ("nodeModulesDir": "auto" / "manual" on Deno 2, "nodeModulesDir": true on Deno 1.x), Deno uses a pnpm-like store. Every package physically lives at node_modules/.deno/<name>@<version>/node_modules/<name>, and only direct dependencies get a top-level symlink (node_modules/is-odd -> .deno/is-odd@3.0.1/node_modules/is-odd). The npm crawler knows the .pnpm, .vlt and legacy .<registry-host> stores, but it drops .deno in its generic hidden-entry skip. As a result:

  • apply reports a transitive-only dependency as skipped / package_not_installed, yet the run's overall status is success with exit code 0.
  • scan never discovers the transitive packages at all. The batch query sent to the API contains only the direct deps ({"components":[{"purl":"pkg:npm/is-odd@3.0.1"}]} for the repro below), and lockfileOnlyPackages is 0. So a patch for a transitive package is never even offered.

Deno loads the transitive package from exactly that .deno path at runtime, so the vulnerable code keeps running while socket-patch reports success.

Impact

In Deno projects that use a local node_modules (the documented agent-mode path for Deno npm deps, and the layout tests/docker_e2e_deno.rs exercises), only direct dependencies are patchable. Transitive dependencies, usually most of the tree, are silently left vulnerable, and the exit code gives CI nothing to fail on. This is the Deno counterpart of #359 (npm .store) and #366 (bun .bun), but it is a separate store directory with its own code path.

Repro (Linux; Deno 2.9.6; no API needed)

set -eu
SP=/path/to/socket-patch          # built from main f6b7fb9
mkdir deno-store && cd deno-store
export DENO_DIR=$PWD/.denodir
echo '{"nodeModulesDir":"auto","imports":{"is-odd":"npm:is-odd@3.0.1"}}' > deno.json
echo 'import isOdd from "is-odd"; isOdd(3); console.log("loaded-patched=" + JSON.stringify((globalThis as any).__SP || []));' > probe.ts
deno cache probe.ts
ls -l node_modules            # only: is-odd -> .deno/is-odd@3.0.1/node_modules/is-odd
S=node_modules/.deno
# Local manifest + blobs for two patches (direct is-odd, transitive is-number). Each patch
# prepends: globalThis.__SP=(globalThis.__SP||[]).concat(["<name>"]);
python3 mkman.py . "pkg:npm/is-odd@3.0.1=$S/is-odd@3.0.1/node_modules/is-odd" \
                   "pkg:npm/is-number@6.0.0=$S/is-number@6.0.0/node_modules/is-number"
$SP apply --offline --json; echo "rc=$?"
deno run -A probe.ts

mkman.py is a ~25-line helper that writes .socket/manifest.json and before/after blobs with git-sha256 hashes. It's inlined verbatim in the probe workflow linked below.

Output:

"status": "success"
  pkg:npm/is-odd@3.0.1    applied
  pkg:npm/is-number@6.0.0 skipped  errorCode=package_not_installed
rc=0
loaded-patched=["is-odd"]        # is-number is loaded (is-odd requires it) but unpatched

node_modules/.deno/is-number@6.0.0/node_modules/is-number/index.js exists and is the file Deno resolves (createRequire(...).resolve("is-number") points at it).

Expected vs actual

  • Expected: docs/ecosystems.md lists npm agent mode as "✅ any install layout", and the Deno row lists agent mode as supported. Every installed copy Deno can load should be found and patched, the way the .pnpm / .vlt stores are (npm_crawler.rs comments: "the store is the ONLY physical home of transitive dependencies"). If a patch can't be applied, the run shouldn't report success / exit 0 (CLI_CONTRACT.md status semantics).
  • Actual: transitive packages are invisible to scan and apply, and the run exits 0.

Matrix

Every cell was run with the real Deno install plus a runtime check (deno run) of which patched modules actually loaded.

OS Deno nodeModulesDir auto (deno.json imports) manual (package.json + deno install)
Linux (sandbox) 1.46.3 (true) fail n/a ("manual" is 2.x-only)
Linux (sandbox) 2.0.6 / 2.2.15 / 2.9.6 fail fail
ubuntu-latest 1.46.3 / 2.2.15 / 2.9.6 fail fail
macos-latest 1.46.3 / 2.2.15 / 2.9.6 fail fail
windows-latest 1.46.3 / 2.2.15 / 2.9.6 fail fail

Direct dependencies pass in every cell. Each cell was reproduced at least twice on Linux.

Not a regression: releases 3.3.0 and 4.0.0 (npm @socketsecurity/socket-patch) behave identically.

Suspect code

  • crates/socket-patch-core/src/crawlers/npm_crawler.rs:1069: nested_node_modules_of special-cases .pnpm, .vlt and legacy pnpm stores, then name_str.starts_with('.') drops .deno.
  • crates/socket-patch-core/src/crawlers/npm_crawler.rs:1392: the same skip in the crawl_all / scan walker.
  • crates/socket-patch-core/src/crawlers/npm_crawler.rs:1926: find_store_peer_variant_copies only knows .pnpm / .vlt, so peer-variant copies in .deno/<name>@<ver>_<peer>@<ver> would also be missed. That part is code-read only, not reproduced.

The .deno layout is <name>@<version>[_peer...]/node_modules/<name>, the same shape as pnpm's store entries (scoped packages use @scope+name@ver).

Probe run

https://github.com/SocketDev/socket-patch/actions/runs/36769893940 (3 OS × Deno 1.46.3 / 2.2.15 / 2.9.6 × auto/manual, all 18 cells reproduce)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions