Running a Regular Dependency and Vulnerability Review with SBOMs and Gradle

When Log4Shell came out in December 2021, the first question most teams had to answer wasn't "how do we patch it?" It was "do we even use log4j-core, and where?" Plenty of teams couldn't answer that within a day. The library wasn't in anyone's build.gradle. It came in three levels deep, through a framework, through a logging bridge, through something someone added years ago and nobody remembered.

That's the real problem with dependency security. Fixing a vulnerable library is usually a one-line change. Knowing what you ship, where it came from, and which of the hundreds of alerts actually matter is the hard part, and it doesn't happen by accident.

Most teams already have something scanning their dependencies. Dependabot or Renovate opens pull requests, a scanner runs in CI, and a dashboard somewhere shows a number in red. What's usually missing is the step where a person sits down on a schedule, looks at the whole picture, and makes decisions. This article is about that step: a regular dependency and vulnerability review, built on a Software Bill of Materials, for Gradle projects.

Why continuous scanning isn't enough

Continuous scanning is necessary. It's also noisy, and noise gets ignored. A typical JVM service pulls in a couple of hundred transitive dependencies, and a scanner run against them will happily report dozens of findings, most of them in code paths the service never calls. After a few weeks of that, the alerts become wallpaper.

Automated update PRs have a similar failure mode. Patch bumps get merged, but the major upgrades, the ones with breaking changes, sit open for months because nobody owns them. Meanwhile the framework underneath slowly drifts towards end of life, and that never shows up as a CVE until it's too late to plan for.

A scheduled review fixes a different problem than the tooling does. The tooling answers "what's wrong right now?" The review answers "what are we going to do about it, who owns it, and what did we decide not to fix and why?" Those decisions need context a scanner doesn't have, and they need to be written down so the next review doesn't start from scratch.

How often depends on the team. Quarterly works well for many, monthly suits fast-moving codebases or regulated products, and some teams tie it to each release instead. What matters is that it's on the calendar, frequent enough that drift stays manageable and that suppressions and exceptions don't quietly become permanent. Critical issues don't wait for the review, of course. Anything actively exploited gets handled the day it shows up. The review is for everything else.

Start with an inventory you can trust: the SBOM

You can't review what you can't list. An Software Bill of Materials: a machine-readable list of every component in a piece of software, with versions, package identifiers, hashes, and how they relate to each other. is that list: every component in a build, direct and transitive, with its version, its package URL, its hashes, and the dependency graph between them.

The two common formats are CycloneDX and SPDX. For security work I default to CycloneDX, because it was designed around vulnerability management and supports Vulnerability Exploitability eXchange: a statement of whether a product is actually affected by a given vulnerability, and why. natively, which comes up later.

Generating one for a Gradle build takes a plugin:

// build.gradle.kts
plugins {
    java
    id("org.cyclonedx.bom") version "<latest>"
}
./gradlew cyclonedxBom

The plugin resolves the dependency graph and writes bom.json (and bom.xml) under build/reports. Check the plugin's documentation for the current version and options, since the configuration surface has changed between major releases.

A few practices make the SBOM much more useful:

  • Generate it in CI, for every release build. An SBOM produced on a developer's laptop describes that laptop's build, not what you shipped.
  • Only include what ships. Restrict it to the runtime classpath. Test frameworks and build plugins inflate the inventory with findings that can't affect production.
  • Store it next to the artifact. Publish the SBOM alongside the JAR or container image, with the same version. When a new CVE appears, the question becomes "which released versions contain this component?", and stored SBOMs answer it without rebuilding anything.
  • Generate it for containers too. The JVM dependencies aren't the whole story. The base image brings its own OS packages, and a tool like Syft or Trivy can produce an SBOM for the image as well.

There's also a regulatory reason to get this in place. The EU Cyber Resilience Act requires manufacturers of products with digital elements to document their components and handle vulnerabilities throughout the product's support period, and its vulnerability reporting obligations started applying this month. An SBOM pipeline and a documented review process are a large part of how you show you're doing that.

Make the build reproducible enough to inventory

An SBOM only describes the build that produced it. If two builds of the same commit can resolve different versions, the inventory isn't worth much. Gradle has two features that close that gap, and both are underused.

Dependency locking pins every resolved version, transitive ones included:

// build.gradle.kts
dependencyLocking {
    lockAllConfigurations()
}
./gradlew dependencies --write-locks

That writes a gradle.lockfile you commit. Dynamic versions and version ranges stop drifting silently, and every upgrade shows up as a diff in a pull request where someone can see it.

Dependency verification checks the checksums of every artifact Gradle downloads against a committed list:

./gradlew --write-verification-metadata sha256 help

That produces gradle/verification-metadata.xml. If a repository ever serves a tampered artifact, the build fails instead of quietly shipping it. It's one of the few supply chain controls that costs almost nothing once it's set up.

Keep versions in a version catalog (gradle/libs.versions.toml) as well. When every project in a repository declares versions in one place, an upgrade is one change, and the review has one file to look at instead of twenty build scripts.

Turn the inventory into findings

With an SBOM in hand, scanning becomes a separate step from building. That separation matters. You can rescan last month's release against today's vulnerability data without rebuilding it.

Most scanners read CycloneDX directly:

trivy sbom build/reports/bom.json
grype sbom:build/reports/bom.json

For anything beyond a single repository, OWASP Dependency-Track is worth running. You upload SBOMs to it, it keeps an inventory per project and version, and it re-evaluates everything continuously as new vulnerabilities are published. That's the piece that answers the Log4Shell question in minutes: search for the component, see every project and version that contains it.

When a finding points at a library you never declared, Gradle can tell you how it got there:

./gradlew dependencyInsight --dependency log4j-core --configuration runtimeClasspath

The output shows every path that pulls the library in and which version Gradle selected after conflict resolution. That's usually enough to decide whether the fix is upgrading the direct dependency that brought it in, or overriding the transitive version.

Triage by exploitability, not by score

This is where most vulnerability programs go wrong. Common Vulnerability Scoring System: a 0 to 10 severity score describing how bad a vulnerability could be in the worst case. describes how bad a vulnerability could be in the worst case. It says nothing about how likely it is to be exploited, or whether your application reaches the vulnerable code at all. Sorting the backlog by CVSS and working top-down burns time on findings that pose no real risk while the ones that do wait in line.

Better signals exist, and they're free:

  • CISA's Known Exploited Vulnerabilities catalog. If a CVE is on it, it's being exploited in the wild. That goes to the top regardless of score.
  • Exploit Prediction Scoring System: an estimated probability that a vulnerability will be exploited in the next 30 days, published daily by FIRST.. A daily probability that a vulnerability gets exploited in the next 30 days. It's a much better ordering signal than severity alone.
  • Reachability. Does the application actually call the vulnerable function, with attacker-controlled input? A deserialization flaw in a library you only use for date formatting is a very different finding from one sitting on your request path.
  • Exposure. An internet-facing API and an internal batch job with no inbound traffic don't carry the same risk from the same CVE.

Each finding ends up in one of a few buckets: fix now, fix on a schedule, or not affected. The last one is where most of the noise goes, and it needs to be recorded properly, not just suppressed.

Record decisions with VEX instead of suppressions

The usual way to silence a false positive is a suppression file: a list of CVE IDs the scanner should ignore. Those files rot. Nobody remembers why an entry was added, entries outlive the reason they were added for, and the file only works for the one scanner that reads it.

VEX is the structured alternative. It's a statement, attached to a specific product version, that says whether that product is affected by a given vulnerability and why. In CycloneDX it looks like this:

{
  "id": "CVE-2021-44228",
  "analysis": {
    "state": "not_affected",
    "justification": "code_not_reachable",
    "detail": "Transitive only. Logging goes through Logback."
  },
  "affects": [
    { "ref": "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1" }
  ]
}

The justification is a fixed vocabulary (code_not_present, code_not_reachable, requires_configuration, protected_by_mitigating_control, and a few others), so decisions stay comparable across teams. Dependency-Track and several scanners read VEX directly, and you can share it with customers who run their own scans against your SBOM and ask why a finding is still open.

Give every "not affected" decision an owner and a review date. A justification that was true when it was written can stop being true after a refactor.

Fixing transitive vulnerabilities in Gradle

When the vulnerable library is a direct dependency, the fix is a version bump. When it's transitive and the library that brings it in hasn't released a fix yet, Gradle's dependency constraints are the clean way to override it:

dependencies {
    constraints {
        implementation("org.apache.logging.log4j:log4j-core:2.17.1") {
            because("CVE-2021-44228. Remove once the parent upgrades.")
        }
    }
}

Constraints only take effect when the library is already part of the graph, so they don't add dependencies you didn't have. The because text shows up in dependencyInsight output, which means the next person to wonder why that version is pinned gets an answer without digging through git history.

When the vulnerable code comes through a framework BOM such as Spring Boot's, override the managed version property or platform the framework provides instead of fighting it with constraints. Then put "remove this override" on the agenda for the next review. Overrides are temporary fixes, and the review is where they get cleaned up.

The review itself

The review works best as a short, fixed agenda with the same inputs every time. A version I've found useful:

AreaInputQuestion to answer
Inventory driftSBOM diff against the last reviewed releaseWhich components were added or removed, and were they intended?
Open vulnerabilitiesScanner or Dependency-Track findingsWhich ones are fix now, fix on a schedule, or not affected?
AgingOpen findings against their target datesWhat's overdue, and why?
VEX and overridesVEX statements, constraints, suppressionsIs each justification still true? Can any override be removed?
LifecycleFramework and runtime support datesWhat reaches end of life in the next six months?
Pending major upgradesOpen Renovate or Dependabot PRsWhich ones need planned work, and who owns them?
LicensesLicense data from the SBOMDid any component change license or bring in an incompatible one?
Health of key librariesRelease activity, maintainers, advisoriesIs anything we depend on heavily abandoned or at risk?

Two inputs are easy to overlook. The SBOM diff often catches more than the scanner does: a new HTTP client nobody discussed, a second JSON library, a transitive dependency that appeared after an innocent-looking upgrade. And the lifecycle check is the one that prevents emergencies. Spring Boot, the JDK, and most major frameworks publish their support windows. A planned upgrade six months ahead is ordinary work. The same upgrade done because a CVE landed in an unsupported version is a fire drill.

Gradle can show what's out of date as part of the input. The community com.github.ben-manes.versions plugin adds a dependencyUpdates task that lists available upgrades for every declared dependency, which works well as a starting list for the upgrade discussion.

The output of the review should be concrete:

  • Tickets with owners and target dates for every "fix on a schedule" decision.
  • Updated VEX statements, each with an owner and a review date.
  • Removed overrides and constraints that are no longer needed.
  • An upgrade plan for anything approaching end of life.
  • A short written record: what was reviewed, what was decided, and what was accepted as risk.

That record is the part people skip, and it's the part that matters most. It's what lets the next review start from decisions instead of from the raw scanner output again. It's also what you show an auditor, a customer's security questionnaire, or a regulator when they ask how you manage vulnerabilities.

What runs between reviews

The review only works if the continuous layer handles the urgent cases, so it can stay a review and not a firefight. A setup that balances the two:

  • On every pull request: generate the SBOM, scan it, and fail the build on findings that are actively exploited or critical and reachable. Keep the gate narrow. A gate that fails on everything gets bypassed.
  • Weekly: let Renovate or Dependabot open grouped update PRs, and merge patch and minor upgrades routinely once tests pass.
  • Continuously: keep released SBOMs in Dependency-Track so new CVEs are matched against everything you've shipped, not only the main branch.
  • Immediately: treat anything that shows up in the KEV catalog and affects you as an incident, with its own process.
  • On a fixed cadence (monthly, quarterly, or per release): the review, with the agenda above.

Final thoughts

Dependency risk doesn't come from not having enough tools. Most teams have more scanners than they use. It comes from never stopping to decide what the findings mean, who owns them, and which risks are acceptable.

An SBOM gives you an inventory you can trust. Dependency locking and verification make that inventory reproducible. VEX turns triage decisions into something durable instead of a suppression file nobody understands. The regular review is what ties them together: an hour or two, on a cadence that fits the team, where someone looks at the whole picture and writes down what was decided.

It's not the most exciting meeting on the calendar. But the next time a Log4Shell-sized vulnerability lands, the teams that ran it will answer "are we affected?" in minutes, and spend the rest of the day fixing it instead of searching for it.