Safeguard
Best Practices

Auditing and pinning transitive Java dependencies with Maven and Gradle

Maven resolves version conflicts by nearest path, not highest version — one new direct dependency can silently reintroduce a patched CVE.

Safeguard Research Team
Research
6 min read

On November 24, 2021, Alibaba Cloud researcher Chen Zhaojun privately reported a remote code execution flaw in Apache Log4j to the project team. By the time CVE-2021-44228 — Log4Shell — was publicly disclosed on December 9 and formally assigned a CVE ID on December 10, exploitation was already underway in the wild, with evidence pointing to activity as early as December 1. What made Log4Shell so hard to inventory wasn't that teams had knowingly chosen a vulnerable logging library; it's that most of them never chose it at all. log4j-core arrived as a transitive dependency, pulled in silently by other libraries several levels down the tree, invisible to anyone reading a top-level pom.xml. That single fact — that the most consequential Java vulnerability of the decade lived in a dependency nobody directly declared — is why transitive dependency auditing deserves the same rigor as code review. This post walks through how Maven and Gradle actually resolve conflicting versions, why that resolution logic causes silent drift, and the concrete mechanisms — dependencyManagement, the enforcer plugin, and Gradle's dependency locking — that let a team pin exact transitive versions and fail a build before a patched vulnerability quietly reappears.

How does Maven actually pick a version when two dependencies disagree?

Maven resolves version conflicts using "nearest definition wins" — it picks whichever version sits at the shallowest depth in the dependency tree, not the highest version number, and this is documented, verifiable behavior you can inspect yourself with mvn dependency:tree. If your project directly declares libraryA:1.0, and a second direct dependency transitively pulls in libraryA:2.5 two levels down, Maven keeps 1.0 because it's nearer to the root — even though 2.5 might contain a critical security fix. Ties at equal depth are broken by declaration order in the POM. This means the outcome of a version conflict often has nothing to do with which version is safer or newer; it depends on tree shape. Adding a single new direct dependency can shift what's "nearest," silently downgrading or upgrading a library several layers deep, with no warning unless someone diffs the resolved tree before and after. That's the mechanism behind most unintentional Java version drift: nobody edited the vulnerable dependency's version — someone added an unrelated one.

What tools force Maven to stop guessing and pin exact versions?

Maven's <dependencyManagement> block lets a team declare the exact version of any dependency — direct or transitive — that should win regardless of tree depth, overriding nearest-wins resolution with an explicit, auditable decision. On top of that, the maven-enforcer-plugin ships a dependencyConvergence rule that fails the build outright if two paths in the tree resolve to different versions of the same artifact, forcing the conflict to be resolved in the POM rather than left to depth-based chance. The companion Extra Enforcer Rules module adds enforceBytecodeVersion, which catches class-file compatibility mismatches from mixed dependency versions. Together, these turn version resolution from an implicit side effect of tree shape into an explicit, reviewable line in source control — so when a transitive library needs bumping to fix a CVE, that bump is a diff someone approves, not a side effect of an unrelated dependency addition three sprints later.

How does Gradle solve the same problem, and where does it fall short?

Gradle's answer is dependency locking, documented in the official Gradle User Manual: running gradle dependencies --write-locks generates lockfiles per resolvable configuration, recording the exact resolved version of every dependency — direct and transitive — for that build. Commit those lockfiles, and every developer, CI runner, and production build resolves the identical dependency graph, regardless of new versions published upstream since the lockfile was written. Without locking, a Gradle build with loose version ranges or dynamic versions like 1.+ can resolve differently on Monday than it did on Friday, purely because a transitive publisher shipped a new release. The rough edge: locking is per-project and per-configuration, so multi-module Gradle builds need explicit configuration to lock every subproject, since the base dependencies task only inspects the root project by default — a gap that has generated real friction reports in Gradle's own issue tracker around --write-locks behavior in multi-project builds.

Why does "it built fine last time" not mean the dependency set is unchanged?

Because Maven and Gradle both resolve dependency ranges and transitive graphs at build time by default, not at commit time — so a build that used libraryA:2.5 last month can silently pick up libraryA:2.5.1 (or a completely different transitive path) today if any upstream POM or Gradle metadata changed, even with zero edits to your own code. This is the reintroduction risk in its purest form: a team patches a vulnerable transitive dependency by bumping a version override, ships it, and considers the issue closed — but six months later a routine dependency bump on an unrelated library shifts the "nearest" resolution back to the old, vulnerable path, and the fix silently reverts. Without a committed lockfile (Gradle) or an enforced dependencyManagement entry plus dependencyConvergence check (Maven), there is no build-time signal that this happened; the CVE simply comes back. Pinning isn't a one-time fix — it's a standing constraint the build must keep re-verifying on every dependency change.

What role does SCA tooling play alongside pinning?

Pinning controls what version resolves; software composition analysis (SCA) tooling tells you whether that pinned version is still safe as the vulnerability landscape moves. OWASP Dependency-Check is a real, actively maintained open-source SCA project built specifically for Java, Maven, and Gradle — it cross-references your resolved dependency tree, including transitive artifacts, against known-CVE data at build time and can fail a build on a match. This matters because pinning alone freezes you at a point-in-time decision; a dependency you pinned as safe in January can have a CVE published against it in June, and a frozen lockfile with no CVE scan will happily keep shipping it forever. The two controls are complementary: pinning stops accidental, un-reviewed drift; SCA scanning catches the case where the pinned version itself becomes newly known-vulnerable and needs a deliberate, reviewed bump.

How Safeguard Helps

Safeguard's dependency scanning resolves Maven pom.xml and Gradle build.gradle files, along with their lockfiles, to a dependency depth of 100 — well past the roughly 50-60 levels most SCA tools stop at — so a vulnerable artifact hiding behind several transitive hops isn't missed simply because it's deep in the tree. Every resolved node captures its full root-to-leaf path, so when a finding surfaces, engineers can see exactly which direct dependency pulled in the vulnerable transitive one, rather than reverse-engineering it by hand from dependency:tree output. Safeguard also supports policy rules like condition: any(components, depth > 50 AND vulnerabilities.critical > 0), letting teams codify "block any critical vulnerability past depth 50" directly into CI — turning the kind of silent, depth-driven version drift that let Log4Shell hide in production for years into a build-time gate instead of a post-incident discovery.

Never miss an update

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