Gradle SBOM: Why Most Are Incomplete and How to Check
Ask for an SBOM of a Java service built with Gradle and you will get one. Whether it lists what actually ships is a different question. Gradle builds are programs, not manifests, and a great deal of what decides the final dependency set lives outside the dependencies block you are looking at: in a BOM, in a root project, in a version catalog, in a plugin.
A Gradle SBOM that misses those layers is not slightly wrong. It is wrong in the direction that hurts: missing components mean missing vulnerabilities, and a vulnerability you cannot see in the SBOM is one you will not find when the next Log4Shell lands.
This post covers the five most common reasons Gradle SBOMs come out incomplete, and practical commands to check your own.
Why Gradle is harder than Maven or npm
An npm project has a package-lock.json. A Maven project has a pom.xml that is at least declarative. A Gradle project has build scripts in Groovy or Kotlin that can compute dependencies, apply plugins that add more, and inherit configuration from parent projects. The resolved dependency graph only exists after Gradle evaluates the build.
That leaves two ways to produce a Gradle SBOM:
- Resolve the build. Run Gradle, let it evaluate everything, and read the resolved graph. Accurate, but it needs a working build: the right JDK, access to every repository, credentials for private artifacts.
- Parse the files. Read the build scripts and catalog without running them. Fast and works anywhere, but every Gradle feature the parser does not model becomes a gap.
Most SBOM tools fall back to parsing when resolution fails, and resolution fails often on real repositories. So the question is which gaps the parser has.
Five reasons Gradle SBOMs come out incomplete
1. BOM-managed versions are left unresolved
Spring Boot projects rarely declare versions directly. They import a platform or BOM and declare coordinates without versions:
implementation(platform("org.springframework.boot:spring-boot-dependencies:3.3.0"))
implementation("com.fasterxml.jackson.core:jackson-databind")
A parser that does not expand the BOM records jackson-databind with no version, which means no vulnerability match. Multiply that by every managed dependency in a Spring Boot or Spring Cloud project and the SBOM has names but not versions for most of its components. See Gradle's docs on platforms for how these resolve.
2. Dependencies inherited from the root project are dropped
Multi-project builds often declare shared dependencies once, at the root:
subprojects { dependencies { implementation("org.apache.commons:commons-lang3:3.14.0") } }
A tool that reads each subproject's build.gradle in isolation never sees these. Every module shows fewer dependencies than it actually has.
3. Version catalogs in non-standard locations
Version catalogs centralize versions in gradle/libs.versions.toml by default, and build scripts reference them as libs.jackson.databind. Many projects put the catalog elsewhere or declare several. A parser that only looks in the default location resolves none of the aliases and records nothing.
4. Multi-module reports that lose their module boundaries
When a tool does run Gradle, it often runs an aggregate dependency report across all projects. Some Gradle versions omit the markers that say which project each section belongs to. If the tool cannot attribute dependencies to modules, it may discard a perfectly good resolve and fall back to parsing.
5. Builds that cannot be run as checked in
Wrapper-less projects, a gradlew without the execute bit, a private plugin repository the scanner cannot reach, a build that expects a specific JDK. Each forces a fallback to parsing, which brings back gaps one to four.
How to check your own Gradle SBOM
You do not need a special tool to test whether an SBOM is complete. You need the resolved graph to compare it with. Pick one service and run these from the repository root.
Get the resolved runtime graph
./gradlew :app:dependencies --configuration runtimeClasspath
Replace :app with the module that produces the deployable artifact. runtimeClasspath is what ships; compileClasspath and test configurations are not.
Check a specific dependency
./gradlew :app:dependencyInsight --dependency jackson-databind --configuration runtimeClasspath
This shows the resolved version and why it won, including BOM constraints and conflict resolution. If the SBOM lists a different version, or none, the SBOM is wrong.
Compare counts
- Count unique
group:name:versionentries in the resolved runtime graph. - Count components in the SBOM for the same module.
- If the SBOM is materially smaller, look for managed dependencies with no version and root-level dependencies missing from subprojects.
Spot-check the patterns above
- Pick three dependencies managed by a BOM. Do they have versions in the SBOM?
- Pick one dependency declared in a root
subprojectsblock. Does it appear under each subproject? - Pick one catalog alias. Is it resolved to real coordinates?
What an incomplete Gradle SBOM costs
Consider a Spring Boot service whose SBOM was produced by parsing. Every Spring-managed dependency appears without a version. A new critical advisory is published against a specific range of a JSON library that Spring Boot manages. Your SBOM search for the library returns the name with no version, which most tooling treats as "cannot determine" and many dashboards treat as "not affected".
Now the questions that matter during an incident take hours instead of seconds:
- Which services run an affected version?
- Which of those are internet-facing?
- Which teams own them?
- Is there a fixed version, and does the BOM we import already pull it in?
Each of those answers depends on the resolved version, and the resolved version is exactly what the incomplete SBOM left out. Teams end up running Gradle by hand across dozens of repositories during the incident, which is the work the SBOM was supposed to have done already.
The quieter cost is the same gap every other day. Vulnerability matching, license checks and dependency-age policy all run against the SBOM. A component with no version passes all of them by default.
Reading the resolved graph
The dependencies task output is dense, but three markers tell you most of what you need:
1.2.3 -> 1.4.0means a version was requested and a different one resolved, usually from conflict resolution or a BOM constraint. The right-hand version is what ships.(*)means the subtree was already listed elsewhere in the report and is omitted to save space. It is not missing from the build.(c)marks a dependency constraint, such as those a platform or BOM contributes, rather than a direct declaration.
When the SBOM version disagrees with the right-hand side of an arrow, trust the arrow.
Make your builds easier to inventory
You can also make any tool's job easier.
- Turn on dependency locking. Gradle dependency locking writes the resolved versions to
gradle.lockfile. A lockfile is the closest Gradle gets to a declarative record of what resolves, and it makes builds reproducible as a side effect. - Commit the wrapper, executable.
gradlewwith the execute bit set, plusgradle/wrapper/gradle-wrapper.properties. - Keep the catalog in the default location unless there is a reason not to.
- Generate an SBOM in CI from the real build. The CycloneDX Gradle plugin produces an SBOM from the resolved graph during the build, which is the most accurate source you have.
- Separate what ships from what builds. Make sure test and build-only dependencies stay out of
runtimeClasspath, so the SBOM does not overstate your exposure either.
Why completeness matters more than format
Most SBOM discussions are about format: CycloneDX or SPDX, which fields, which spec version. Those matter for exchange. But a perfectly formatted SBOM with a third of its components missing is worse than no SBOM, because it gives a confident wrong answer to "are we affected?" The question to ask of any SBOM, Gradle or otherwise, is whether it matches the resolved graph of the artifact you deploy.
How Heeler handles Gradle projects
Heeler's SCA resolves Gradle projects and, when a project does not ship a lockfile or cannot be resolved, falls back to a best-effort parser that models the gaps above:
- It expands Spring Boot and Spring Cloud BOM coordinates to their resolved versions.
- It inherits dependencies declared at the root through
allprojectsandsubprojects. - It handles non-catalog projects, non-standard
libs.versions.tomllocations and wrapper-less projects. - In Gradle 7 multi-module repositories, modules are identified by their coordinates, so a successful resolve is used rather than discarded.
Findings on Gradle projects point to the exact line in build.gradle, build.gradle.kts or libs.versions.toml. SBOMs are exported in CycloneDX at repository, application, service and deployment scope, and a repository SBOM merges every module into one deduplicated document. See the SBOM docs for scopes and export options.
FAQ
Why is my Gradle SBOM missing dependencies?
Usually because the tool parsed the build files instead of resolving the build, and missed BOM-managed versions, root-level dependencies, or version catalog aliases.
Which Gradle configuration should an SBOM describe?
For a deployable service, runtimeClasspath of the module that produces the artifact, since that is what ships.
Does Gradle have a lockfile?
Yes, when dependency locking is enabled. It writes resolved versions to gradle.lockfile, which gives tools a declarative record of the resolved graph.
How can I verify a Gradle SBOM?
Compare it with the output of gradle dependencies --configuration runtimeClasspath, and use dependencyInsight to confirm the resolved version of specific components.
If your Java services run on Gradle and your SBOMs have never been checked against the resolved graph, it is worth finding out how complete they are. Get a demo


.jpg)
