Security
Nov 18, 2025

The Transitive Dependency Trap: How “Safe” CVE Fixes Break Your Java Apps

How a simple CVE fix in a transitive dependency can silently destabilize your Java application—and what engineers should do instead.

Give me the TL;DR
The Transitive Dependency Trap: How “Safe” CVE Fixes Break Your Java Apps
For Qualys admins, NES for .NET directly resolves the EOL/Obsolete Software:   Microsoft .NET Version 6 Detected vulnerability, ensuring your systems remain secure and compliant. Fill out the form to get pricing details and learn more.

Suppose you're working on an application and your security scanner flags a CVE in a transitive dependency, which is a dependency you don’t use directly but is pulled in by something else you do use. Should you manually bump that insecure version? You could silence the alert by "fixing" the CVE, but is that the right move? The answer isn't as straightforward as it seems…

Engineers rarely think about dependency management. Tools like Maven or Gradle handle classpath complexities behind the scenes, while frameworks like Spring Boot ensure everything works together seamlessly. This innovation frees up developers to tackle bigger challenges.

But Maven dependency resolution should be understood so engineers have a better understanding of the implications of a version change. What ends up in your classpath isn’t always as straightforward as what is in the build files. Let’s explore.

In this example there are 3 applications. All of them are simple Java applications with no dependencies (other than depending on one another).

`hello-world-lib`

Contains a single `HelloWorldLib` class that has a `hello()` and `goodbye()` method. (Note that the typo is intentional - we will get to that soon.)

public class HelloWorldLib {


   public String hello() {
       String helloMessage = "eHllo! Welcome!";
       System.out.println("HelloWorldLib:helloWorld " + helloMessage);
       return helloMessage;
   }


   public String goodbye() {
       String goodbyeMessage = "Goodbye world!";
       System.out.println("HelloWorldLib:goodbyeWorld " + goodbyeMessage);
       return goodbyeMessage;
   }


}

`framework`

This application calls `hello-world-lib` through methods `FrameworkService:sayHello()` and `FrameworkService:sayGoodbye()`. It is intended to be a wrapper over the `hello-world-lib` library that adds in a bit nicer hello message.

public class FrameworkService {


   private final HelloWorldLib helloWorldLib;


   public FrameworkService() {
       helloWorldLib = new HelloWorldLib();
   }


   public String sayHello() {
       String helloMessage = helloWorldLib.hello();
       String niceHelloMessage = helloMessage.concat(" We are glad you are here!");
       System.out.println("FrameworkService:sayHello " + niceHelloMessage);
       return niceHelloMessage;
   }




   public String sayGoodbye() {
       String goodbyeMessage = helloWorldLib.goodbye();
       System.out.println("FrameworkService:sayGoodbye " + goodbyeMessage);
       return goodbyeMessage;
   }


}

`platform`

This application calls `framework` through the class `PlatformService`. It has a main method and can be run standalone.

public class PlatformMain {


   public static void main(String[] args) {
       PlatformService platformService = new PlatformService();
       platformService.sayHello();
   }
}


class PlatformService {
   private final FrameworkService frameworkService;


   public PlatformService() {
       frameworkService = new FrameworkService();
   }


   public String sayHello() {
       String helloMessage = frameworkService.sayHello();
       System.out.println("PlatformService:sayHello " + helloMessage);
       return helloMessage;
   }


  public String sayGoodbye() {
       String goodbyeMessage = frameworkService.sayGoodbye();
       System.out.println("PlatformService:sayGoodbye " + goodbyeMessage);
       return goodbyeMessage;
   }
}

Let’s focus on the “hello” chain first. The call chain is:

PlatformService:main 
 └── PlatformService:sayHello()
	└── FrameworkService:sayHello()
		└── HelloWorldLib:hello()

Running `mvn dependency:tree` will output the dependency graph:

[INFO] com.herodevs.dependency-management:platform:jar:1.0.0
[INFO] +- com.herodevs.dependency-management:framework:jar:1.0.0:compile
[INFO] \- com.herodevs.dependency-management:hello-world-lib:jar:1.0.0:compile

When we run the application, we can see the call chain in action from basic print statements:

HelloWorldLib:helloWorld eHllo world!
FrameworkService:sayHello eHllo world! We are glad you are here!
PlatformService:sayHello eHllo world! We are glad you are here!

Bug Fix: The Simple Case

Let’s assume we are in production, but we notice a bug in the `hello-world-lib` application.

   public String hello() {
       String helloMessage = "eHllo! Welcome!";
...

We will make the simple change to fix the typo:

   public String hello() {
       String helloMessage = "Hello! Welcome!";
...

But we need to update the version:

<groupId>com.herodevs.dependency-management</groupId>
<artifactId>hello-world-lib</artifactId>
<version>1.0.1</version>

We then “push to production” by running `mvn clean install` and put this new version in our Maven repository.

Now that we've fixed the bug in the library, how can we adopt that change in our `platform` application? The most straightforward approach is to re-release `framework` with a new version that pulls in the new `hello-world-lib` and then release a new `platform` application that pulls in `framework`. This keeps our same clean dependency graph in place, just with updates to incorporate the bug fix.

But what if the real world isn’t that simple? What if we own the `platform` application and we don’t want to wait for a `framework` release before we fix our own application? Even worse, what if `framework` is end-of-life and will never get an update? How can we update only `platform` and pull in the change from `hello-world-lib`? This is where Maven’s dependency overrides come in. 

Dependency Overrides

The `<dependencies>` section for `platform` starts with a single dependency on `framework`.

<dependencies>
   <dependency>
      <groupId>com.herodevs.dependency-management</groupId>
      <artifactId>framework</artifactId>
      <version>1.0.0</version>
   </dependency>
</dependencies>

Since it is pulled in through `framework`, the `hello-world-lib` is a transitive dependency of platform. Instead of pulling in `hello-world-lib` through `framework`, we have the option to specify it in `platform` `pom.xml` with the version we want. Through Maven’s Dependency Mechanism, we “can guarantee a version by declaring it explicitly in your project’s POM”.

<dependencies>
   <dependency>
      <groupId>com.herodevs.dependency-management</groupId>
      <artifactId>framework</artifactId>
      <version>1.0.0</version>
   </dependency>
   <dependency>
      <groupId>com.herodevs.dependency-management</groupId>
      <artifactId>hello-world-lib</artifactId>
      <version>1.0.1</version>
   </dependency>
</dependencies>

Specifying `hello-world-lib` in the `<dependencies>` section explicitly will change the version of `hello-world-lib` that is being used by `framework`.

[INFO] com.herodevs.dependency-management:platform:jar:1.0.1
[INFO] +- com.herodevs.dependency-management:framework:jar:1.0.0:compile
[INFO] \- com.herodevs.dependency-management:hello-world-lib:jar:1.0.1:compile

When we run `PlatformMain`, the bug fix from `hello-world-lib:1.0.1` will get pulled in.

HelloWorldLib:helloWorld Hello world!
FrameworkService:sayHello Hello world! We are glad you are here!
PlatformService:sayHello Hello world! We are glad you are here!

This has powerful implications! We didn't change any Java code in `platform` or anything in `framework`. But we’ve altered the `platform` application behavior simply by pulling in a new dependency. This complex dependency resolution is now working as designed.

API Changes: The Real Risk

Let’s consider what happens when `hello-world-lib` adds more functionality than simply fixing a bug. Let’s assume that `hello-world-lib` makes an API change which changes the method signature of `hello()`. According to semver, this is a breaking change that should be put into a major version.

First, let’s make the code change to `hello-world-lib:HelloWorldLib`. Let’s add a `name` parameter to make the greeting more custom.

public String hello(String name) {
   String helloMessage = "Hello, " + name + "! Welcome!";
   System.out.println("HelloWorldLib:helloWorld " + helloMessage);
   return helloMessage;
}

Since this is a breaking API change, we need to release it in a new major version.

<groupId>com.herodevs.dependency-management</groupId>
<artifactId>hello-world-lib</artifactId>
<version>2.0.0</version>

We will “release to production” here with a `mvn clean install` to put this new library in the repository.

If we maintain `platform` we may notice a new 2.0.0 release of `hello-world-lib` is available. We know that `framework` won’t make the change to get this latest version, and we’ve already upgraded to `1.0.1` manually, so we decide to jump to `2.0.0`.

The new dependency graph with `mvn dependency:tree` now looks like this:

[INFO] com.herodevs.dependency-management:platform:jar:2.0.0
[INFO] +- com.herodevs.dependency-management:framework:jar:1.0.0:compile
[INFO] \- com.herodevs.dependency-management:hello-world-lib:jar:2.0.0:compile

When we run `mvn clean package` on `platform`, everything looks fine and we get a clean compile. 

mcnees@mac platform % mvn clean package

[INFO] ------------< com.herodevs.dependency-management:platform >-------------
[INFO] Building framework 2.0.0
[INFO]   from pom.xml
[INFO] --------------------------------[ jar ]---------------------------------


.....

[INFO] Building jar: /Users/mcnees/code/dependency-example/platform/target/platform-2.0.0.jar
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------

Not until we run the Main class in `platform` do we see an error:

java.lang.NoSuchMethodError: 'java.lang.String com.herodevs.lib.HelloWorldLib.hello()'
    at com.herodevs.framework.FrameworkService.sayHello (FrameworkService.java:14)
    at com.herodevs.platform.PlatformService.sayHello (PlatformMain.java:21)
    at com.herodevs.platform.PlatformMain.main (PlatformMain.java:9)

The problem occurs because the API of `hello-world-lib` changed. The version required by `framework` is not API compatible with the version that we are specifying in `platform`. The disconnect is evident in the methods:

`platform:2.0.0`

public String sayHello() {
   String helloMessage = frameworkService.sayHello();
...

`framework:1.0.0`

public String sayHello() {
   String helloMessage = helloWorldLib.hello();
...

`hello-world-lib:2.0.0`

public String hello(String name) {
   String helloMessage = "Hello, " + name + "! Welcome!";
...

The `platform` application is stuck at this point. We can no longer adopt the latest changes from `hello-world-lib:2.0.0` without risking a runtime error.

The Hidden Problem

The maintainers of `platform` now face a difficult situation. As bad as an immediate problem in production is, perhaps worse is accidentally creating an unstable application that will manifest a seemingly random error later. Let’s examine how everything can seem fine at first, but cause problems later on. For this example we’ll use the same dependency graph when the error was thrown:

[INFO] com.herodevs.dependency-management:platform:jar:2.0.0
[INFO] +- com.herodevs.dependency-management:framework:jar:1.0.0:compile
[INFO] \- com.herodevs.dependency-management:hello-world-lib:jar:2.0.0:compile

The original code includes another method chain for `goodbye` whose API remained unchanged between the `hello-world-lib:goodbye1.0.0` and the 2.0.0 version.

If our `platform` code used “goodbye” instead of “hello”, it would look like this:

public static void main(String[] args) {
   PlatformService platformService = new PlatformService();
   //platformService.sayHello();
   platformService.sayGoodbye();
}

All of the other code remains the same. Running this code we now see this result:

HelloWorldLib:goodbyeWorld Goodbye world!
FrameworkService:sayGoodbye Goodbye world!
PlatformService:sayGoodbye Goodbye world!

It works! We never invoked the method with the breaking API change, so we never encountered the runtime error. This creates a dangerous situation where `platform` appears to be working perfectly: we are using the latest `hello-world-lib` version and everything seems fine, but we're actually in a precarious state.

The trouble arises when `platformService.sayHello()` is invoked. This method will never work and is a problem laying dormant in the code. The first time this code is invoked a runtime error will occur. This could be immediately after a release, or, even worse, it could happen months later when the business logic to trigger the problematic code is invoked for the first time. Either way, debugging this will be difficult since the root cause lies in dependency resolution, not the actual Java code.

Three Choices: Security, Stability, or Both

Why does this matter in the real world? Developers actually encounter this scenario frequently. 

Imagine that instead of fixing the simple typo `"eHllo! Welcome!";` this change is instead a CVE remediation. Of course `platform` needs to pull in the latest version of `hello-world-lib` to stay secure. No problem, because it’s a patch release in `hello-world-lib` and as long as `hello-world-lib` plays by semver rules the override is safe.

If `hello-world-lib` moves on to a major version but `framework` does not, that leaves `platform` owners in a tough spot. `hello-world-lib` may have even more CVE fixes that are only published to a new major version. Owners of `platform` could override `hello-world-lib` to a new major, but we see how that carries serious risks - both realized and unrealized.

Owners of the `platform` application have three choices:

  1. Upgrade to the latest version of `hello-world-lib`, even if it is a major change. Then hope the application works and that breaking API changes won’t surface. This keeps `platform` secure but at the risk of stability.
  2. Keep `hello-world-lib` on an older, unsupported version. This maintains stability of `platform` but leaves it exposed to security issues in an outdated library.
  3. Obtain a secure fork of `hello-world-lib` at the latest 1.0.x version. This can be done by internally maintaining and publishing a secure fork or by finding a vendor to do this work for you. This maintains the stability of ‘platform’ while also eliminating security issues.

Real World Scenarios

In the Java ecosystem, Spring Boot is widely used and is first class at bringing in versions of many different libraries that all work together. When a version of Spring Boot goes out of support, the list of dependencies that were stitched together is fixed in time. If you are running EOL Spring Boot versions, you are likely exposed to CVEs in the managed or transitive dependencies.

When CVEs appear in transitive dependencies deep in your dependency tree, you are faced with the scenario in this post. Do you upgrade the transitive dependency in your application? If it is a patch update, you’re safe to make that upgrade with confidence. The issue becomes more difficult when there is no patch update available that will remediate the CVE. You now have the three choices above - will you risk stability, risk security, or opt for a third party vendor?

Summary

Dependency version updates are routine engineering tasks that often escape the scrutiny of code changes, but they can significantly alter runtime behavior.  This is actually Maven functioning as designed, but engineers must be aware of the implications!  This post aims to raise awareness of the risks and tradeoffs in dependency management. By understanding the implications of each approach, developers can make more informed decisions when updating dependencies. 

Table of Contents
Author
Bob McNees
Engineering Director
Open Source Insights Delivered Monthly