Safeguard
Open Source Security

How Snyk Open Source builds a full dependency tree from p...

How Snyk Open Source turns package-lock.json and yarn.lock files into a full dependency graph to power vulnerability matching and fix advice.

Vikram Iyer
Security Researcher
8 min read

When you run snyk test against a Node.js project, the tool doesn't just read your package.json and stop there. It opens package-lock.json or yarn.lock, walks every resolved entry inside it, and reconstructs the exact set of packages npm or Yarn actually installed on disk — including the three different versions of lodash that ended up nested under different parents. That reconstructed structure, which Snyk calls a dependency graph, is the foundation every vulnerability match and every "upgrade to fix" suggestion is built on. Get the graph wrong, and a real vulnerability three levels deep in node_modules never surfaces in a scan. This post walks through the publicly documented mechanics of how Snyk Open Source turns a lockfile into that graph, why it models dependencies as a graph rather than a literal tree, and where the approach runs into hard edges.

What does Snyk actually parse to build the tree?

Snyk parses the lockfile, not just the manifest, because the manifest only tells you intent while the lockfile tells you what was actually resolved. A package.json might declare "express": "^4.18.0", but that range could resolve to 4.18.2 today and 4.19.1 next week depending on when npm install ran. Snyk's open-source parser, snyk-nodejs-lockfile-parser (published on GitHub under the snyk organization), reads the concrete, pinned versions out of package-lock.json or yarn.lock and uses those as the nodes in the resulting graph. The same principle carries across ecosystems: snyk-python-plugin prefers poetry.lock or Pipfile.lock over requirements.txt when available, composer.lock is preferred over composer.json for PHP, and Gemfile.lock over Gemfile for Ruby. Snyk's own documentation lists lockfile support across npm, Yarn Classic and Yarn Berry, pnpm (pnpm-lock.yaml), NuGet (packages.lock.json), and Cargo (Cargo.lock), among others — in every case the lockfile, when present, is the authoritative source for exact resolved versions.

Why does a flat lockfile need to be turned into a graph at all?

Because a lockfile is a list, not a structure — it records which versions were resolved, but the parser still has to reconstruct who depends on whom. This differs by format, and it's the part of the job that varies most between package managers. npm's lockfile format v1 (npm 5 and 6) nests dependencies inside a dependencies object that roughly mirrors the node_modules folder tree, so parent-child edges are mostly already implicit in the nesting. npm's lockfile v2/v3 (npm 7+) changed this: it added a flat packages map keyed by install path (e.g., node_modules/express/node_modules/debug) alongside a legacy nested dependencies block kept for backward compatibility, and Snyk's parser has to reconcile both representations to get an accurate result. Yarn Classic's yarn.lock is flatter still — a list of resolved entries keyed by the semver request string (like debug@^2.6.9, debug@2.6.9) — so the parser has to walk each package's declared dependency ranges and match them back against these keys to rebuild the edges. pnpm's pnpm-lock.yaml is closer to a true graph on disk already, with explicit importers sections for workspace packages and a packages map recording resolved dependency-to-dependency edges directly. In every case, the end product is the same internal shape: a set of nodes (package name + resolved version) connected by directed edges representing "depends on."

Why does Snyk call it a graph instead of a tree?

Because the same package version can be depended on by more than one parent, and modeling that as a strict tree would mean duplicating nodes and losing information. Snyk's own open-source library for this data structure, @snyk/dep-graph (public on GitHub and npm), is explicit about this: it stores a directed acyclic graph (DAG) where a single node for debug@2.6.9 can have edges coming in from both express and body-parser, rather than appearing as two separate leaves in two separate tree branches. This matters for two practical reasons. First, npm's own resolution algorithm frequently does exactly this kind of deduplication — hoisting a shared version to a common location in node_modules when ranges allow it — so a graph is a more faithful model of what's actually on disk than a naive tree walk of package.json files would produce. Second, it makes vulnerability reporting more useful: when a graph node is flagged as vulnerable, Snyk can enumerate every distinct path from the project root to that node, which is exactly what powers the "this issue is introduced via A > B > C" trace that shows up in snyk test output and in the Snyk web UI.

What happens when there's no lockfile to read?

When there's no lockfile — as is common for Java, Gradle, and some Go projects — Snyk delegates resolution to the build tool itself instead of trying to reimplement its algorithm. For Maven projects, the snyk-mvn-plugin invokes Maven's own dependency-resolution machinery (comparable to running mvn dependency:tree) to get the fully resolved graph, including the version-mediation results of Maven's nearest-wins conflict resolution. For Gradle, snyk-gradle-plugin injects a small init script that calls Gradle's configuration resolution APIs directly, because Gradle's conflict resolution (highest-version-wins by default, but configurable per project) can only be computed correctly by Gradle itself. Go is a partial exception: go.sum records cryptographic checksums for every module version ever touched during resolution, not just the ones in the final build list, so Snyk's Go tooling cross-references go.sum against go.mod's require directives (and go list -m all output) to isolate the actual resolved graph. The common thread is the same design decision as with lockfiles: don't re-derive a resolution algorithm Snyk doesn't own — ask the tool that already computed it.

How does the finished graph turn into vulnerability findings?

Once the graph exists, Snyk overlays its vulnerability database onto it by matching each node's package name and resolved version against known affected version ranges, then walks the graph to find every root-to-node path. A CVE that only affects minimist versions <1.2.6 gets checked directly against whatever exact version is sitting in the graph node — not against a semver range from package.json — which is why lockfile-based scanning catches issues that a manifest-only scan would miss (a project can declare ^1.2.0 in package.json and still have 1.2.2 actually installed if the lockfile predates the fix). For remediation, Snyk uses the same graph to compute upgrade paths: because it knows every parent that introduces a vulnerable node, it can suggest the minimal version bump on a direct dependency that would cause the resolver to pull a patched transitive version, rather than just telling a developer to "upgrade the vulnerable package," which frequently isn't a package they control directly.

What are the known limits of building a graph this way?

The main limits are staleness and drift: a graph built from a lockfile is only as current as the last time that lockfile was regenerated, so a repository with a package-lock.json that hasn't been refreshed in months will scan against stale resolved versions even though npm install today would pull something different. Monorepos and workspaces (Yarn workspaces, npm workspaces, pnpm workspaces) add another layer of complexity, since a single lockfile can back multiple projects that each need their own graph scoped correctly, and hoisting behavior across workspace packages can obscure which package actually introduced a given transitive dependency. Lockfile format churn is a recurring maintenance burden too — npm's v1-to-v3 format change and Yarn's Classic-to-Berry rewrite both required parser updates to keep resolving correctly, and any newly introduced package manager or lockfile version needs the same treatment before it can be parsed with confidence. None of this is unique to Snyk; it's an inherent property of lockfile-based dependency analysis across the SCA tooling category.

How Safeguard Helps

Understanding how a scanner builds its dependency graph matters because the accuracy of every downstream signal — vulnerability matches, license findings, malicious-package detection — inherits directly from how faithfully that graph reflects what's actually running in production. Safeguard approaches this from the supply chain side: rather than relying solely on a point-in-time lockfile parse, Safeguard continuously monitors dependency changes as they land, generates and tracks software bills of materials (SBOMs) across a project's full history, and flags newly introduced transitive packages — including typosquats and compromised releases — as they enter the graph rather than only at the next scheduled scan. For teams that already rely on lockfile-based SCA tools for vulnerability coverage, Safeguard adds the layer that watches the dependency graph itself for suspicious change patterns, giving security teams visibility into how and when a risky package actually entered the tree, not just that it's present today.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.