Back to glossary

Dependency Graph

A dependency graph is a directed graph of the packages a project resolves and the requirements between them, the basis of dependency mapping.

Also known as dependency tree, dependency mapping, dependency map, package dependency graph, dep graph

A dependency graph, often called a dependency tree, is a directed graph of the packages a project uses: each node is a resolved package version, and each edge is a requirement one package declares on another. Package managers build it from manifests and record it in lockfiles. Security teams use it to trace exposure, blast radius, and fixes.

The most important fact about a dependency graph is that it is not a tree. On October 31, 2026, we resolved a Node.js app with nine direct dependencies using npm 10.9.9. For linux-x64, it produced 523 packages joined by 1,113 edges. Written out as a tree, those edges become 201,841 distinct root-to-package paths, 386 times the number of packages. One package, debug@4.4.3, sits under 6 of the 9 dependencies the authors chose. Our position is that the graph, not the manifest, is the unit of security analysis. It should be built from what the package manager actually resolved and what each tarball actually contains. And it only describes what was installed through that project.

What is a dependency graph?

#

A dependency graph is the set of resolved package versions in a build plus the requirement edges between them. If your app requires express, and express requires debug, the graph has the edges app → express and express → debug. Edges point from the dependent to the dependency. Following edges forward answers "what does this pull in?" Following them backward answers "who pulls this in?", and that reverse question drives most security work.

A few terms recur. Direct dependencies are the nodes one edge from the root, the ones you listed in your manifest. Everything deeper is a transitive dependency. Dependents are the packages with an edge pointing at a given node. Leaves have no dependencies of their own.

The graph is computed, not written. Manifests declare constraints such as "debug": "^4.4.0", using semantic versioning ranges, and the package manager picks one concrete version that satisfies them. The Go modules reference states the model precisely: "Each vertex in the graph represents a module version. Each edge represents a minimum required version of a dependency." Two projects with identical manifests can therefore have different graphs, depending on when and with which tool they resolved.

Why a dependency graph is not a tree

#

Package managers share a single copy of a package across every dependent whose range it satisfies. That deduplication is a deliberate design choice. It saves disk and download time, and it keeps modules that hold global state, such as a plugin registry or a framework instance, from being loaded twice. The side effect is that the structure stops being a tree. When two packages depend on the same third package, the graph forms a diamond, and real graphs are mostly diamonds.

Figure 1 shows the effect on a single framework. express@5.3.0 resolves to 65 packages and 125 edges. debug@4.4.3 has five direct dependents inside that graph: express itself, body-parser, finalhandler, router, and send.

Figure 1 in text: the left panel draws the express@5.3.0 slice as a tree, where debug@4.4.3 appears 5 times and ms@2.1.3 appears 5 times, once under each of express, body-parser, finalhandler, router, and send. The right panel draws the same edges as a graph, where debug and ms each appear once and five edges converge on debug. Across the whole express graph, the 65 packages expand to 467 root-to-package paths.

The tree view is what npm ls prints, and npm hides the repetition by marking later copies as deduped. This is the reverse query for debug in our nine-dependency app, abridged:

Bash
$ npm ls debug --package-lock-only
dg-demo@1.0.0
+-- axios@1.21.0
| `-- https-proxy-agent@5.0.1
|   +-- agent-base@6.0.2
|   | `-- debug@4.4.3 deduped
|   `-- debug@4.4.3 deduped
+-- eslint@9.39.5
| `-- debug@4.4.3
+-- express@5.3.0
| +-- body-parser@2.4.0
| | `-- debug@4.4.3 deduped
| +-- router@2.2.0
| | `-- debug@4.4.3 deduped
...
`-- mongoose@8.24.7
  `-- mquery@5.0.1
    `-- debug@4.4.3 deduped

The full output lists debug under 14 different parents. A version change in that one node touches every one of those edges at once.

Dependency graphs are also not strictly acyclic. Our app's graph contained 3 cycles, and every one was closed by a peerDependencies edge. browserslist depends on update-browserslist-db, which declares browserslist as a peer. @babel/core depends on @babel/helper-module-transforms, which declares @babel/core as a peer. eslint depends on @eslint-community/eslint-utils, which declares eslint as a peer. Tools that assume a directed acyclic graph, for topological ordering or path counting, need to collapse these cycles first.

How package managers record the dependency graph

#

A lockfile is the persisted output of resolution: the exact node set, and enough information to rebuild every edge. Formats differ in whether edges are written down or left for the installer to recompute, and that difference decides how easily a tool can answer graph questions offline.

npm's package-lock.json keys each package by its install path, not its name. npm's documentation says the packages section "maps package locations to an object containing the information about that package." Each entry copies its dependency ranges from the package's own manifest:

JSON
"node_modules/router": {
  "version": "2.2.0",
  "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
  "dependencies": {
    "debug": "^4.4.0",
    "depd": "^2.0.0",
    "is-promise": "^4.0.0",
    "parseurl": "^1.3.3",
    "path-to-regexp": "^8.0.0"
  }
}

The entry says router wants debug@^4.4.0, not which debug it got. To recover the edge, a tool must replay Node's module resolution: look for node_modules/router/node_modules/debug, then walk up to node_modules/debug. Our graph numbers above come from exactly that replay over the lockfile's 608 package entries, keeping the platform binaries for linux-x64 and dropping 5 WebAssembly fallbacks that nothing on that platform reaches.

The pnpm lockfile writes the resolved edge itself. In lockfile format 9.0, which pnpm 10.28.0 wrote for us, the snapshots section maps each package to the exact versions of its dependencies:

YAML
snapshots:
  router@2.2.0:
    dependencies:
      debug: 4.4.3
      depd: 2.0.0
      is-promise: 4.0.0
      parseurl: 1.3.3
      path-to-regexp: 8.4.2

Go modules expose the graph as an edge list. go mod graph prints one requirement per line, two space-separated fields: a module version and one of its dependencies. The graph Go stores is the requirement graph, though, and the versions actually built are chosen from it by minimal version selection. A line in go mod graph does not mean that version ships in your binary.

CycloneDX software bills of materials carry the graph in a top-level dependencies array, where each ref lists the components it dependsOn. The CycloneDX specification closes a common gap: "Components or services that do not have their own dependencies must be declared as empty elements within the graph." Without that rule, a consumer cannot tell a leaf from a component whose dependencies were never recorded. An SBOM that omits the dependencies array is a flat inventory, not a graph.

FormatNodesEdgesWhat a tool must do to query the graph
package-lock.json (v3)Exact versions, keyed by install pathRanges onlyReplay Node module resolution
pnpm-lock.yaml (9.0)Exact versionsExact versionsRead directly
go mod graphModule versionsMinimum requirementsRun minimal version selection to get the build list
CycloneDX dependenciesComponents by bom-refExact refsRead directly, if the producer emitted it

Verdict: for graph queries, a format that records resolved edges, such as pnpm's snapshots or a complete CycloneDX dependencies array, beats one that records ranges. package-lock.json is complete, but only a tool that reimplements npm's resolution can read its edges. Treat any SBOM without a dependencies array as an inventory, not a graph.

How do you see a project's dependency graph?

#

You see a dependency graph by asking the package manager that resolved it. Every major ecosystem ships both a forward query (print the tree) and a reverse query (why is this here):

EcosystemPrint the graphExplain one package
npmnpm ls --allnpm explain <pkg>
pnpmpnpm list --depth Infinitypnpm why <pkg>
Gogo mod graphgo mod why -m <module>
Mavenmvn dependency:treemvn dependency:tree -Dincludes=<groupId>:<artifactId>
Cargocargo treecargo tree -i <crate>
uv (Python)uv treeuv tree --invert --package <pkg>

Two details matter in practice. First, these commands read different sources. As of npm 10.9.9, npm ls --package-lock-only answers from the lockfile alone, while npm explain reported "No dependencies found matching debug" for the same package until node_modules existed. Second, a JavaScript dependency graph at the package level is not the same as the module graph a bundler builds from import statements. The package graph says debug is installed. The module graph says whether any file actually imports it.

How do you measure blast radius in a dependency graph?

#

Blast radius in a dependency graph is the set of nodes that can reach a given package by following edges forward. Inside one project, that means which of your direct dependencies would change if that package changed. Across a registry, it means every package and application that can resolve to it. Three metrics answer different versions of the question, and they rank packages very differently.

MetricTop package in our appValueWhat it tells you
In-degree (direct dependents)@types/node@26.6.629 parentsHow many declared constraints a replacement version must satisfy
Root-to-node paths@babel/helper-validator-identifier@7.29.722,457 pathsHow noisy tree output and per-path alerts will be
Direct dependencies that reach itdebug@4.4.3 and ms@2.1.36 of 9How much of your own choice set a compromise would ride in on

Of the 523 packages in the graph, 108 are reached through at least two direct dependencies, and 16 through at least three. Path count is the most misleading of the three. A package with 22,457 paths is not 22,457 times as dangerous as one with a single path, because malware needs only one path to execute.

Figure 2 in text: the nine direct dependencies are listed on the left. Six of them, @slack/bolt, axios, eslint, express, jest, and mongoose, have paths that converge on debug@4.4.3 and then ms@2.1.3. The other three, @aws-sdk/client-s3, typescript, and vite, have no path to either package. @slack/bolt reaches debug through express, and reaches ms separately through @slack/oauth and jsonwebtoken.

The ecosystem-wide version of this question is what made the axios compromise so wide. axios has "100 million weekly downloads on npm," according to our report on the attack. The attacker did not need to touch any of axios's dependents. They published axios@1.14.1 with a new dependency, plain-crypto-js@4.2.1, and every range above it, such as ^1.13.5, carried the payload to whoever resolved during the window. Our analysis of the axios blast radius traced that path through CI tools, CLIs, SDKs, and MCP servers that were themselves never compromised.

Dependency mapping: package, import, and call graphs

#

Dependency mapping is the practice of building and querying these graphs across an organization's projects. The phrase is also used in IT operations for maps of which services call which hosts. This page covers the software package sense. The useful distinction is granularity. The same project has three nested graphs, and each one answers a question the coarser one cannot.

GraphNodeEdgeBuilt fromAnswers
Package graphPackage versionDeclared requirementManifests and lockfilesIs it installed? Who pulls it in?
Module (import) graphModule or package actually loadedimport or requireSource codeIs it ever loaded?
Call graphFunction or methodCall siteStatic analysis of your code and dependenciesCan the vulnerable function run?

Socket's reachability analysis works at each of these levels, which shows what each one adds. Its dependency reachability takes the package graph from manifests, scans source for import statements, and marks transitive packages that are never imported as dead. According to our announcement of module reachability, that filters out 25 to 35% of transitive dependencies on average. Precomputed reachability goes further. Per Socket's documentation, it traces each dependency path to a vulnerable package, checks whether the vulnerable function is reached along it, and marks about 60% of vulnerabilities in transitive dependencies as irrelevant. Full application analysis, which models how your own code calls into dependencies, typically finds more than 80% of vulnerabilities unreachable, per the static reachability documentation.

Each level costs more and misses different things. Precomputed reachability assumes your app loads only declared dependencies, so Socket's documentation notes that phantom dependencies can be misclassified as unreachable. The call graph sees only code-level interactions, so a build tool you run from the command line produces no edges at all.

Verdict: use the package graph for inventory and for malware, because a malicious install script runs whether or not anything imports it. Use the call graph to triage known vulnerabilities, because most CVE alerts on the package graph are noise. The import graph is the cheap middle step when you cannot afford full call-graph analysis.

What a dependency graph does not show

#

A dependency graph built from manifests is a claim about the code, and every security decision built on it inherits its blind spots. Four matter most.

The graph can disagree with the tarball. On npm, a package's registry metadata and its tarball are uploaded separately, and the registry does not fully check one against the other. That gap, called manifest confusion, lets a package carry dependencies or install scripts that the metadata never lists. A graph built from registry metadata will be missing those edges. Socket builds its graph from the package.json inside each tarball, the same file the npm CLI installs from, as we explained in our manifest confusion write-up.

The graph is a function of time. A range such as ^1.13.5 names a set of versions that grows every time someone publishes. In the axios case, plain-crypto-js@4.2.1 was published at 23:59:12 UTC on March 30, 2026. Installs that resolved axios@1.14.1 in the hours that followed got a graph with a dropper in it, and installs a day earlier or a day later did not. The code in the repository never changed. As our axios analysis put it, "The only thing that changed is the registry." A lockfile freezes one answer, and Socket raises a Missing lockfile alert when a manifest has none, because "dependency resolution is non-deterministic" without it.

The graph covers one project. Tools run with npx, global installs, and CLIs that CI jobs fetch on the fly each resolve their own graph, and none of them appears in your lockfile. A clean lockfile after an incident is not evidence that nothing you executed resolved the bad version.

The graph stops at the registry boundary. Install scripts that download binaries, and packages that fetch code from a URL at runtime, add code with no node in the graph at all.

The open problem is the history. Package managers persist the current graph. Nothing in npm, pnpm, or the registry records which graph a given CI job resolved at 00:10 UTC last Tuesday. Until resolutions are logged as timestamped, queryable events, a dependency graph tells you what you depend on now, and leaves you reconstructing what you depended on at the moment it mattered.