Ask most Java shops what their software supply chain looks like and you’ll get a copy of a pom.xml or a build.gradle. Ask them what actually got loaded into the JVM at runtime and the room goes quiet. Those are different questions, and the gap between them is where supply chain risk quietly lives. A build file is a shopping list. It’s not a receipt. When a CVE drops on a Thursday afternoon, the shopping list doesn’t help you answer the question everyone in the room is about to ask: did we actually run that code?
The Three Faces of a Java Dependency
Java applications pull in code through three different doors, and each one has its own blind spot.
Static dependencies are the obvious ones, JARs sitting on the classpath at JVM startup, pulled in by your build tool, written down in your dependency manifest. These are relatively easy to inventory. Most SCA (Software Composition Analysis) tools do a good job here.
Dynamic dependencies are the ones that show up later. A plugin dropped into a directory at 3am. A class loaded through Class.forName() from a config file. A custom URLClassLoader pulling a JAR from S3. These never appear in your build manifest because they were never there when the build ran. Most SCA tools don’t see these at all.
Transitive dependencies are the libraries your libraries depend on. Most teams understand their direct dependencies reasonably well. Fewer understand the second ring. Almost nobody has a clear picture of the third and fourth rings, what you might call fourth-party code. The library you picked depends on a library someone else picked, which depends on a library nobody picked, and that bottom layer is often where the interesting CVEs live. Log4Shell taught the industry that lesson in December 2021, and the industry is still relearning it.
What Runtime Observability Adds
The JVMXRay project I’ve been working on approaches this from the runtime side. Rather than reading your build files, it watches the JVM directly. A LibSensor captures every JAR the JVM actually loaded, static and dynamic, along with a SHA-256 hash and whatever Maven coordinates it can recover from the JAR’s META-INF. A separate ReflectionSensor watches Class.forName() and Method.invoke() calls, which is where runtime class loading usually hides.
Here’s what a static load looks like when a Spring Boot application starts up:
C:AP | 2026.04.18 at 09:14:02 MDT | jvmxray.libsensor-1 | INFO | org.jvmxray.events.system.lib |
load_type=static|jar_path=/home/app/.m2/repository/org/springframework/spring-core/6.1.0/spring-core-6.1.0.jar|
sha256=a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2|
groupId=org.springframework|artifactId=spring-core|version=6.1.0|
implTitle=Spring Core|implVendor=Spring Framework|packages=org.springframework.core,org.springframework.util
Dynamic loads look nearly identical, with load_type=dynamic and often an unfamiliar jar_path. This is the event you want alerting on:
C:AP | 2026.04.18 at 09:17:41 MDT | jvmxray.libsensor-1 | INFO | org.jvmxray.events.system.lib |
load_type=dynamic|jar_path=/opt/app/plugins/custom-plugin.jar|
sha256=f0e1d2c3b4a5968778695a4b3c2d1e0fa9b8c7d6e5f4a3b2c1d0e9f8a7b6c5d4|
packages=com.example.plugin
And when something reaches for a class through reflection, particularly a sensitive one, the reflection sensor marks it:
C:AP | 2026.04.18 at 09:18:06 MDT | main | INFO | org.jvmxray.events.reflection.class_forname |
operation=class_forName|class_name=java.lang.ProcessBuilder|loaded_successfully=true|
suspicious_class=true|risk_level=HIGH|threat_type=privilege_escalation|
class_loader=sun.misc.Launcher$AppClassLoader
The LibSensor also runs a background thread that periodically rescans for new JARs, so a plugin dropped in hours after startup still gets reported. The monitor sensor rolls up totals at configurable intervals, lib_static_loaded=45|lib_dynamic_loaded=2|lib_total_packages=128, which is the kind of thing that graphs nicely in Grafana and immediately tells you when something unexpected arrived.
The Boardroom Conversation
Here’s the part that matters when the next Log4Shell-grade CVE lands. Your dependency manifest says you have some-vulnerable-library-2.14. The press is writing about it. Legal is on the phone. Someone asks if the company is exposed.
Without runtime visibility the honest answer is “probably, but we’re investigating.” With runtime visibility, you can answer in minutes, the JAR is on our classpath but the vulnerable class was never loaded in production, or the opposite, we loaded it 40,000 times last week on these seven hosts. Those are very different conversations. The first one ends. The second one starts a remediation plan with scope.
A vulnerable library that was never loaded is, in operational terms, not a vulnerability. It’s a liability on paper that has no corresponding exposure in practice. Being able to demonstrate that, with evidence, saves a lot of incident-bridge hours and a lot of customer emails.
Approved Versus Actually Running
Most regulated shops maintain an approved library list. Version X.Y of library Z is blessed. Nothing else. The problem is that build-time approval and runtime reality drift apart over time. A developer patches a bug by nudging a version. A container base image ships a different version of the same library. A shaded JAR bundles a copy of something that’s technically forbidden. A hot patch dropped by an SRE at 2am never made it back into source control.
Because LibSensor emits an event for every JAR actually loaded, with version and SHA-256, you can diff what ran against what was approved. That’s a straightforward query in Splunk or ELK once the structured events are landing there. Unapproved or unexpected versions surface on their own instead of being discovered during an audit six months later.
Transitive and Beyond
The key point is that LibSensor reports every JAR the JVM actually loads, direct, transitive, or otherwise. It doesn’t try to tell you whether a given JAR is a direct dependency, a transitive one, or something an app server injected from its own lib directory, and honestly it doesn’t need to. The static and dynamic labels are temporal rather than causal: static means the JAR was on java.class.path at startup, dynamic means it was discovered later by the polling scan. That’s it. Same supply chain either way, different label depending on when the JVM exposed it.
What matters for supply chain visibility is the completeness, not the taxonomy. Fourth-party code, the dependency of a dependency of a dependency, stops being invisible because something is loading it, and LibSensor logs it when it does. Pair the runtime inventory with a traditional SCA tool that already knows declared dependency trees, and you have both sides of the story: what was declared, and what actually loaded, including the code nobody wrote down.
Other Things That Fall Out of This
Once you have every JAR load flowing into your logging platform with a SHA-256 and a timestamp, a few other things become easy that used to be hard.
- SBOM from reality, not the build. Generating a runtime-accurate CycloneDX or SPDX document from the observed loads is a fairly mechanical transform.
- Tamper detection. A SHA-256 mismatch between a known-good baseline and what the JVM just loaded is a strong signal that something substituted a JAR. Typosquatted packages and repository compromises tend to light up here.
- Unused-dependency pruning. If a JAR is on the classpath but never shows up as loaded across a representative traffic window, it may not be needed. Smaller surface area, less to patch.
- Incident forensics. When an incident starts, the first question is usually “what changed?” A time-ordered stream of JAR loads and reflection events often answers it without pulling anyone off the pager.
- Classloader trust boundaries. Seeing that a bytecode manipulation framework (ASM, ByteBuddy, CGLib, Javassist) is reflectively invoked in production is not necessarily bad, plenty of frameworks use these, but it should be a known, expected signal, not a surprise.
Visibility First, Enforcement Later
I’ll repeat something from an earlier post: you can’t defend what you can’t see. Observable supply chains don’t, by themselves, stop a compromised dependency from being loaded. What they do is remove the excuse that nobody noticed, and they compress the time between “bad JAR landed” and “bad JAR was noticed” from weeks or months to minutes. Enforcement, allow-listing JAR hashes or blocking unapproved Class.forName() targets, is the natural follow-up, and it only works once you have the baseline visibility to define “normal” against.
The Java ecosystem has built a remarkable amount of its security posture on the assumption that the build is the truth. It isn’t. The runtime is the truth. Everything else is a best guess about what the runtime will do. Observable supply chains close that loop.
References
- Log4Shell, Wikipedia
- OWASP Top 10, A06:2021 Vulnerable and Outdated Components
- CycloneDX SBOM Standard, OWASP
- SPDX Software Bill of Materials, Linux Foundation
- CISA, Software Bill of Materials (SBOM)
- JVMXRay, Java Security Monitoring, GitHub