Skip to main content
raphael.li

Handling Vulnerable Transitive Dependencies in Gradle

Table of contents

When working on projects that use Gradle, I frequently encounter a well-intentioned but ultimately harmful dependency management pattern. It's a mistake that often comes from a good place, security awareness, and yet it ends up introducing more problems than it solves.

Let's break it down with an example

The Good Intentions

Security scanners like OWASP Dependency-Check, Snyk, or GitHub's Dependabot are helpful tools. They'll notify us about known vulnerabilities in (transitive) dependencies of our code base.

Let's say such a tool warns us about a vulnerability in one of the libraries our project uses. Dependency example-library pulls in log4j version 2.14.1, which has known vulnerabilities (among others, the famous Log4Shell). We can verify that by running gradle dependencies

...
+--- com.example:example-library:1.42.0
|    +--- org.apache.logging.log4j:log4j-core:2.14.1
...

Sadly, there is no newer version of example-library available (yet) that updates this transitive dependency.

The (wrong) fix: Adding a Direct Dependency

What often follows is this:

dependencies {
    // our existing, direct dependency
    implementation("com.example:example-library:1.42.0")

    // not a direct dependency, but added to upgrade the transitive dependency of example-library
    implementation("org.apache.logging.log4j:log4j-core:2.15.0")
}

So instead of letting example-library control what version of log4j it uses, we pin it ourselves.

To see if it works, let's run gradle dependencies again:

...
+--- com.example:example-library:1.42.0
|    +--- org.apache.logging.log4j:log4j-core:2.14.1 -> 2.15.0
+--- org.apache.logging.log4j:log4j-core:2.15.0 (*)
...

(*) - Indicates repeated occurrences of a transitive dependency subtree. Gradle expands transitive dependency subtrees only once per project; repeat occurrences only display the root of the subtree, followed by this annotation.

And it is working: The newer version of log4j is being used and the scanner stops complaining.

But note what happens if we remove example-library from our dependencies block:

...
+--- org.apache.logging.log4j:log4j-core:2.15.0
...

The Cost of That Direct Dependency

Let's unpack the implications of what we did:

Luckily, there's a better way.

Use Dependency Constraints Instead

We don't want to take over transitive dependencies, but instead formulate dependency constraints.

And Gradle has a dedicated DSL for that:

dependencies {
    // Note: The `constraints` block lives inside the `dependencies` block
    constraints {
        implementation("org.apache.logging.log4j:log4j-core") {
            because("earlier versions are vulnerable to the Log4Shell vulnerability")
            version {
                require("2.15.0")
            }
        }
    }
}

This DSL is quite flexible. We used require here but depending on your use case, reject or strictly are what you want.

Let's run gradle dependencies again:

...
+--- com.example:example-library:1.42.0
|    +--- org.apache.logging.log4j:log4j-core:2.14.1 -> 2.15.0
+--- org.apache.logging.log4j:log4j-core:2.15.0 (c)
...

(c) - A dependency constraint, not a dependency. The dependency affected by the constraint occurs elsewhere in the tree.

The effect is the same, but if we remove example-library from our dependencies block, log4j is gone.

I think the biggest benefit, apart from properly naming that it's a constraint, is that we can forget about it! Once added, we don't have to think about it again.

Bonus: Sharing Constraints via Convention Plugins and Platforms

In smaller projects, such constraints can be shared via convention plugin.

For larger or company-wide setups, we can define constraints like this in shared platform modules. That way, our policy is centralized and explicit.

Note that we can't express such constraints with version catalogs. See Benedikt Ritter's excellent write-up for further details

In Summary

Next time a security scanner tells you a transitive dependency is vulnerable, use Gradle's constraints DSL to express your intent clearly.