On December 10, 2021, the Log4Shell disclosure (CVE-2021-44228) turned a single transitive dependency — log4j-core — into a remote-code-execution vector inside thousands of Java and Android codebases. Most of those codebases never listed log4j-core in build.gradle at all; it arrived three or four layers deep through Spring Boot starters, Hadoop client libraries, or internal platform SDKs. That gap between what you wrote and what you actually ship is the core problem with fixing vulnerabilities in Gradle projects: Gradle's dependency resolution graph can pull in dozens of competing versions of the same library, pick a winner through its own conflict-resolution rules, and hand you a final classpath that matches nothing in your source files. Remediation, in practice, means finding the vulnerable jar inside that resolved graph, forcing a safe version without breaking binary compatibility, and locking the result so it doesn't drift back on the next build. Here's how that actually works, task by task.
Why do Gradle projects get vulnerable dependencies that never appear in build.gradle?
Because Gradle resolves a full transitive dependency graph at build time, and most vulnerable libraries arrive as second- or third-order dependencies pulled in by a direct dependency you do control. A Spring Boot 2.7 service with 15 entries in build.gradle — spring-boot-starter-web, spring-boot-starter-data-jpa, a couple of AWS SDK modules — routinely resolves to 180 to 250 jars on the runtimeClasspath configuration once Spring, Jackson, Tomcat, Netty, and Guava each drag in their own dependency trees. spring-boot-starter-web alone brings in jackson-databind, tomcat-embed-core, and spring-webmvc without any of those names showing up in your file. When CVE-2020-36518 (a denial-of-service flaw in jackson-databind versions before 2.9.10.8 and 2.12.6.1) or CVE-2021-21295 (Netty's HTTP/2 flaw, fixed in 4.1.60.Final) gets disclosed, the affected jar is almost always sitting several hops away from anything a developer manually added.
How do you find which direct dependency introduced a specific vulnerable Gradle dependency?
Run gradle dependencyInsight --dependency <artifact> --configuration runtimeClasspath, which prints the exact path from your direct dependency down to the vulnerable transitive jar and shows which version won Gradle's conflict resolution. For example:
./gradlew dependencyInsight --dependency log4j-core --configuration runtimeClasspath
returns output showing log4j-core:2.14.1 selected over 2.13.0 because of Gradle's "highest version wins" default strategy, along with the chain: org.apache.logging.log4j:log4j-core:2.14.1 -> your-app:0.1 -> com.internal:platform-sdk:3.2.0. That chain tells you the fix belongs in platform-sdk's own dependency declaration, not in a stray override buried in your app module. Skipping this step and just bumping a version number in build.gradle is the single most common reason Gradle remediation "fixes" don't actually apply — Gradle's resolution strategy can still select the old version from a different branch of the graph, and the CVE stays exploitable in the built artifact even though the source file looks patched.
What's the fastest way to force a fixed version in Gradle without breaking the build?
Use a dependency constraint or resolutionStrategy.force pinned to the exact patched version, then re-run dependencyInsight to confirm no lower version is still winning resolution somewhere else in the graph. Constraints are the safer of the two, because they only take effect if the dependency is already present, and they don't silently override a newer version a different module might need:
dependencies {
constraints {
implementation('org.apache.logging.log4j:log4j-core:2.17.1') {
because 'CVE-2021-44228 / CVE-2021-45046 — Log4Shell'
}
implementation('com.fasterxml.jackson.core:jackson-databind:2.12.6.1') {
because 'CVE-2020-36518'
}
}
}
resolutionStrategy.force is blunter and will override even a legitimately newer version request, which is why forcing a major-version jump — say jackson-databind 2.9 to 2.15 — can break serialization behavior that depended on 2.9's laxer type handling. For libraries shipped as a BOM, such as Spring Boot's dependency-management plugin or platform('com.google.cloud:libraries-bom:26.32.0'), updating the BOM version is usually cheaper than forcing individual artifacts, since the BOM authors have already tested the combination.
Do Gradle lockfiles actually stop a fixed vulnerability from creeping back in?
Yes, but only if you regenerate and commit the lockfile after every remediation and enforce lock verification in CI. Gradle's dependency locking, enabled per-configuration with dependencyLocking { lockAllConfigurations() } and written with ./gradlew dependencies --write-locks, freezes the exact resolved version set in a gradle.lockfile. Without it, a teammate adding an unrelated dependency next month can shift the resolution graph and quietly pull the vulnerable version back in through Gradle's "highest version wins" rule — with no diff in build.gradle to flag the change in review. Separately, Gradle 6.2 introduced dependency verification metadata (gradle/verification-metadata.xml), which checks artifact checksums and PGP signatures against a trusted list; that stops a compromised or typosquatted artifact from being substituted at download time, which is a supply-chain integrity control distinct from, but complementary to, CVE remediation.
Should Android Gradle projects handle remediation differently?
Yes — Android Gradle Plugin (AGP) projects resolve a separate dependency graph per build variant, so a fix applied to debugImplementation may not touch releaseImplementation, and a fix scoped to one product flavor won't propagate to others. An app with flavorDimensions for "free" and "paid," each built in debug and release, has up to four independent resolved classpaths; dependencyInsight needs to be run against each variant's configuration (debugRuntimeClasspath, paidReleaseRuntimeClasspath, and so on) to confirm the patched version actually lands in the build that ships to the Play Store. R8/ProGuard minification doesn't help here — it strips or renames unused code but doesn't remove a vulnerable class if it's reachable, and it can make post-build verification harder because class names in the shrunk APK no longer match the original jar. Constraints and forced versions should be declared once at the root build.gradle under a shared configuration where possible, rather than duplicated per module, to avoid the same CVE getting patched in three modules and missed in a fourth.
How Safeguard Helps
Safeguard ingests your resolved Gradle dependency graph — not just build.gradle source — and generates a full SBOM per module and per build variant, so the same transitive-jar visibility that dependencyInsight gives you one artifact at a time is available continuously across the whole repo. Griffin AI, Safeguard's reasoning engine, runs reachability analysis against each flagged CVE to determine whether the vulnerable class or method in a jar like log4j-core or jackson-databind is actually invoked by your code paths, cutting through the noise of transitive dependencies that are present but never called. For confirmed, reachable findings, Safeguard opens auto-fix pull requests that add the correct dependency constraint or BOM bump directly in build.gradle or build.gradle.kts, scoped to the right configuration and variant, so the fix lands where Gradle's resolver will actually honor it. Teams can also ingest existing SBOMs from prior scans to avoid re-analyzing unchanged modules on every run.