Safeguard
Open Source Security

How Snyk resolves Maven and Gradle dependency graphs incl...

Snyk doesn't parse pom.xml or build.gradle statically -- it invokes real Maven and Gradle tooling to compute the exact dependency graph your build produces.

Vikram Iyer
Security Researcher
Updated 7 min read

When a Java developer runs mvn dependency:tree or gradle dependencies, the build tool resolves a graph that can look nothing like the pom.xml or build.gradle file staring back at them. A pom declaring 12 direct dependencies routinely expands into 150-300 resolved jars once transitive dependencies, version mediation, and scope rules are applied. A security scanner that only parses the manifest file misses almost all of that surface -- and worse, it can report the wrong version entirely, because Maven and Gradle each apply their own conflict-resolution algorithm to decide which version of a shared dependency actually ends up on the classpath. A classic case is a log4j Maven dependency buried three levels deep in a BOM import -- exactly the kind of transitive coordinate a manifest-only scanner would miss during the Log4Shell fallout. Snyk's approach to this problem, as documented in its own CLI and plugin repositories, is to avoid re-implementing Maven and Gradle's resolution logic from scratch and instead drive the real build tooling to produce the graph it would use at build time. Here's how that mechanism works, and where it runs into limits.

Why is Maven and Gradle dependency resolution hard to reproduce accurately?

It's hard because Maven and Gradle use different, non-trivial conflict-resolution algorithms, and any tool that doesn't run the actual algorithm will eventually get the wrong version. Maven uses "nearest wins" mediation: if two branches of the dependency tree pull in different versions of the same artifact, the version declared closer to the root of the tree wins, and ties at equal depth are broken by declaration order in the pom. Gradle defaults to the opposite strategy -- "highest version wins" -- so if one branch requests guava:28.0 and another requests guava:31.1, Gradle picks 31.1 regardless of tree depth, unless a resolutionStrategy, a dependency constraint, or a version catalog entry overrides it. Both tools also layer in scopes (compile, runtime, test, provided in Maven; implementation, api, runtimeOnly in Gradle), exclusions, parent POM inheritance, <dependencyManagement> version pinning, and BOM imports. Statically parsing the manifest file cannot reproduce any of this with confidence -- which is why simple regex- or XML-based scanners routinely misreport transitive versions.

How does Snyk resolve a Maven project's dependency graph?

Snyk resolves Maven projects by invoking Maven itself rather than parsing the pom.xml in isolation. Its open source snyk-mvn-plugin shells out to a locally installed Maven executable (or the project's Maven wrapper) and uses Maven's own dependency-plugin machinery to compute the effective POM and the resolved dependency tree -- the same tree Maven would use to build the artifact. Because it runs the real build tool, this approach automatically accounts for parent POM inheritance, <dependencyManagement> overrides, BOM imports, active profiles, and property interpolation (for example, a version declared as ${spring.version} in a child module). The tradeoff is that resolution requires Maven and a JDK to be present on the machine running snyk test, and it requires network or repository-manager access to the same artifact repositories the build normally uses, since Maven needs to fetch POM metadata for transitive dependencies to compute the final tree -- Snyk does not maintain a separate, independent mirror of the Maven Central metadata graph for this step.

How does Snyk resolve a Gradle project's dependency graph?

The Snyk Gradle integration resolves Gradle projects by injecting a custom Gradle init script into the build and letting Gradle's own configuration-resolution engine do the work. The snyk-gradle-plugin generates an init script that hooks into the project's build, walks each relevant configuration (typically compileClasspath and runtimeClasspath, and their test equivalents), and asks Gradle to resolve each one -- the exact same resolution path Gradle uses when compiling or assembling the project. This means the reported graph already reflects Gradle's highest-version-wins default, any custom resolutionStrategy.force() rules, dependency constraints, platform/BOM imports, and, for projects using Gradle's version catalogs (a feature stabilized in Gradle 7.0, released in April 2021), the versions pinned in libs.versions.toml. As with Maven, this requires a working local Gradle installation or wrapper and the ability to actually configure the project -- if the build itself fails to configure (missing credentials for a private repository, an incompatible Gradle version, a plugin that isn't resolvable), Snyk cannot produce a dependency tree for it, because there is no independent tree to fall back on.

How does Snyk turn a resolved graph into vulnerability findings?

Once the graph is resolved, Snyk matches every node -- identified by its group ID, artifact ID, and exact resolved version -- against its vulnerability database and flags any coordinate that falls inside a known-affected version range. Because the match is against the resolved version rather than the version written in the manifest, a vulnerability in jackson-databind:2.9.8 -- or a vulnerable log4j Maven dependency such as log4j-core:2.14.1 -- will still surface even if the pom only ever mentions a parent BOM that happens to pin that version three levels away. For each flagged dependency, Snyk also computes the path from the direct dependency to the vulnerable transitive one, which is what lets it suggest a specific remediation -- either bumping the direct dependency to a version whose own transitive graph resolves to a patched version, or adding an explicit override (a <dependencyManagement> entry in Maven, a resolutionStrategy.force() or constraint in Gradle) when the maintainer of the direct dependency hasn't shipped a fix yet.

What are the practical limits of this build-invocation approach?

The main limits are environmental: the technique only works if the same build tooling, JDK version, and repository access the project normally needs are available wherever the scan runs. A multi-module Maven reactor or a large multi-project Gradle build with custom plugins, private artifact repositories, or environment-specific properties can fail to resolve in a CI runner that isn't configured identically to the developer's machine -- and when that happens, the scanner can't fall back to a partial or estimated graph, because there isn't one to fall back to. This is also why Snyk's documentation asks users to ensure Maven or Gradle can build the project locally before running snyk test -- the scan's accuracy is bounded by the build's ability to configure successfully. It's a reasonable tradeoff: real build tooling produces the same graph the artifact will actually ship with, at the cost of requiring the same environment a real build requires.

How Safeguard Helps

Understanding that dependency scanning accuracy is only as good as the graph it's built on is exactly why supply chain visibility needs to extend past manifest files and into what actually gets resolved, packaged, and shipped. Safeguard approaches Java dependency risk by tracking the full resolved dependency graph across Maven and Gradle projects over time, not just the direct dependencies declared in a pom.xml or build.gradle, so that a version bump buried three levels deep in a transitive chain doesn't go unnoticed. Because resolution behavior depends on build environment, repository configuration, and version mediation rules that differ between Maven and Gradle, Safeguard's platform is built to surface drift between what a project's manifest says and what its build actually resolves -- flagging cases where a private mirror, a stale lockfile, or an unpinned version range causes a different graph in CI than on a developer's machine. Combined with continuous monitoring for newly disclosed vulnerabilities against every artifact coordinate in that resolved graph, and policy controls that gate merges or releases when a build resolves to a known-vulnerable transitive dependency, Safeguard gives teams a way to act on dependency risk before it reaches production, rather than discovering it after an incident. For organizations running large multi-module Maven reactors or sprawling Gradle multi-project builds, that continuous, resolution-aware visibility is often the difference between catching a vulnerable transitive dependency in a pull request and finding it during an audit months later.

Never miss an update

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