RBA Consulting
RBA Consulting
RBA Consulting

I am often surprised by how many teams treat Gradle as a “black box.” It is an incredibly powerful build automation tool, beloved for its flexibility and performance, yet in many corporate environments, developers simply copy-paste strict configurations, run ./gradlew build, and hope for green checkmarks.

While this approach works for getting a feature out the door, I find it leaves massive gaps in security and reproducibility. Unlike some other package managers that lock versions by default, Gradle’s default behavior favors “Conflict Resolution” ease over strict determinism. To secure our software supply chain, we need to peel back the layers of the build script and understand exactly how Gradle decides what ends up in your deployable artifact.

The Mechanics: How Gradle Resolves Conflict

The most common friction point I see for developers moving between tools (like switching from Maven) is understanding how conflicts are handled.

Newest Version Wins

When two dependencies request different versions of the same library (e.g., Library A needs log4j 2.15 and Library B needs log4j 2.17), Gradle must choose one. By default, Gradle uses an optimistic strategy: Newest Version Wins.

Gradle assumes that software adheres to Semantic Versioning and that newer versions are backward compatible. It will silently upgrade Library A to use 2.17.

  • The Risk: If 17 introduced a breaking change or deprecation, your app might crash at runtime despite compiling perfectly.
  • The Contrast: This differs significantly from Maven, which typically uses a “Nearest Definition” strategy (choosing the version “closest” to your project in the tree). In my experience, this difference explains why a build might be stable in one tool but break in another.

Configuring for the Enterprise: init.gradle

When I am spinning up a personal project, I usually define my repositories directly in build.gradle or settings.gradle.

repositories {
    mavenCentral()
}

In a corporate environment, this is a security risk. You do not want individual builds reaching out to the public internet arbitrarily. You need a traffic controller.

This is the job of the init.gradle script. This script lives in your GRADLE_USER_HOME (or is injected by your CI server) and runs before your project build. It allows an enterprise to force all traffic through a secure proxy.

By using an init script to inject repository configuration, you ensure every build—regardless of what the developer typed—pulls dependencies from your Internal Artifact Management System. This centralized control is essential for caching, auditing, and blocking malicious packages.

The Reproducibility Gap: Dependency Locking

Here lies the biggest “gotcha” in Gradle security: Gradle does not generate a lockfile by default.

If you build your app today, and library-x releases a new minor version tomorrow, your build next week might silently pick up that new version (due to dynamic resolution or ranges). This means your CI build artifact is not guaranteed to be the same code you tested locally.

Enabling Locks

To fix this, you must explicitly opt-in to Dependency Locking.

dependencyLocking {
    lockAllConfigurations()
}

When enabled, Gradle writes a file listing the exact resolution state of every configuration. This file (similar visually to a package-lock.json) must be committed. It acts as a contract: only these exact versions are allowed to exist in the build, guaranteeing that “Production” matches “Development” byte-for-byte.

Vulnerability Management: Constraints vs. Catalogs

Organizing versions is different from enforcing security.

From my experience, managing libraries and versions are usually strewn across gradle files in an unorganized manner which makes it hard to track what versions are in use and fix security vulnerabilities. For managing and organizing versions and libraries, I suggest teams move to Toml Catalogs and if necessary BOM files. I have a detailed breakdown of how to structure microservices using these tools in my blog on Java Microservices with BOM and Catalog.

Organization alone doesn’t fix a critical vulnerability in a transitive dependency. If a deep dependency has a CVE, you need to force an upgrade now, without waiting for the intermediate library to release a patch. Let’s dive into some strategies for dealing with this.

Strict Constraints

Gradle allows you to define constraints that are separate from dependencies. These are rules that say “If this dependency participates in the graph, it MUST meet this criteria.”

dependencies {
    constraints {
        implementation(“org.apache.logging.log4j:log4j-core:2.17.1”) {
            because(“CVE-2021-44228”)
        }
    }
}

This is a powerful security pattern. It does not add the library to your project; it simply acts as a guardrail. If any other library brings in log4j, Gradle intercepts it and forces the version to 2.17.1, resolving the vulnerability immediately across the entire graph.

The Spring Dependency Management Plugin

I often see the io.spring.dependency-management plugin associated strictly with Spring Boot, but it is actually a standalone tool that does not require the Spring ecosystem. It brings Maven-like dependency management features to Gradle, offering a powerful layer of resolution logic that is critical for security in any Java project.

Centralized Versioning (BOMs)

The plugin allows you to import Maven BOMs (Bill of Materials) to control versions. Spring Boot uses this to ensure that the hundreds of libraries it relies on (Jackson, Hibernate, Tomcat) are interoperable.

  • Security Benefit: You mostly avoid “Frankenstein dependencies”—incompatible library versions that open up security holes or runtime crashes. You trust the Spring team’s curated set.

Property Overrides

The plugin provides a simplified mechanism for overriding versions, which is crucial during “Zero Day” events. Instead of writing complex exclusion rules, I find I can often just override a version property for the already imported bom in the dependency management.

ext[“log4j2.version”] = “2.17.1”

Because the plugin enforces versions across the configuration, this single line can patch the vulnerability across your entire project effectively immediately. However, be aware that this mechanism works slightly differently than native Gradle constraints, and understanding the interaction between the two is vital for confirming a fix.

Vulnerability Mitigation Strategies: A Hierarchy of Force

When a critical vulnerability is discovered, the priority shifts from “clean architecture” to “stop the bleeding.” Gradle provides multiple mechanisms to override versions, varying in their aggression and scope. Choosing the right one depends on whether you need a surgical fix or a sledgehammer.

1. The dependencyManagement Block (Spring Plugin Based)

If you are using the Spring Dependency Management plugin, you have a high-level block to control versions explicitly. This is distinct from setting ext properties; it allows you to declare direct dependency overrides that take precedence over BOMs.

dependencyManagement {
    dependencies {
        // Overrides the version provided by any BOM or transitive dependency
        dependency(“org.apache.logging.log4j:log4j-core:2.17.1”)
    }
}

  • Best For: Projects already using the plugin that need to override a specific library without defining strict Gradle Constraints.

2. Resolution Strategy (force)

Before Gradle introduced proper Constraints, resolutionStrategy.force was the standard mechanism. It is a “brute force” approach that instructs Gradle to ignore conflict resolution rules entirely for a specific module.

configurations.all {
    resolutionStrategy {
        force(“com.google.guava:guava:30.0-jre”)
    }
}

  • Best For: Legacy builds or scenarios where the dependency graph is so messy that Constraints are being ignored. Note that force is considered deprecated in favor of strict constraints in newer Gradle versions, but it remains a common pattern for emergency patches.

3. Dependency Substitution

Sometimes a vulnerability isn’t fixed by a simple version bump. You might need to replace a library with a different fork, or replace a standard library with a “no-op” version to silence a vulnerability scanner for a test dependency.

configurations.all {
    resolutionStrategy.dependencySubstitution {
        substitute(module(“log4j:log4j”))
            .using(module(“org.slf4j:log4j-over-slf4j:1.7.30”))
    }
}

  • Best For: removing a dependency entirely or swapping it for a compatible alternative implementation (e.g., swapping a concrete logging framework for a bridge).

Common Pitfalls

Dynamic Versions (1.+)

I strongly recommend avoiding dynamic versions (e.g., implementation(“com.example:lib:1.2.+”)) unless you have Dependency Locking strictly enforced.

  • The Mitigation: If Dependency Locking is active, Gradle will freeze the resolved version in the lockfile, preventing unexpected updates. This effectively neutralizes the risk, making dynamic versions safe to use in controlled environments if you must support ranges.
  • The Risk (Without Locking): It makes builds non-reproducible. A malicious actor can essentially “push” code into your build pipeline just by releasing a higher version number.
  • My Preference: Even with locking, I prefer explicit versions. It makes code reviews easier—you can see the upgrade happening in the gradle file, not buried in a generated lockfile.

api vs implementation

In the java-library plugin, the choice of configuration matters for the “Blast Radius” of your dependencies.

  • api: The dependency is exposed to consumers. If Library A depends on B which uses api C, then A sees C.
  • implementation: The dependency is private. A depends on B, but C is hidden. Using implementation by default reduces compile times and shrinks the surface area an attacker can leverage if they compromise a sub-dependency.

Conclusion

Gradle is often viewed through the lens of complexity, but I find its verbosity allows for precise control over the software supply chain. By moving from implicit “Newest Wins” resolution to explicit Dependency Locking, and by using init.gradle to enforce secure boundaries, we turn the build tool from a liability into a gatekeeper.

Disclaimer

This article was developed with the assistance of artificial intelligence tools to support drafting, editing, and clarity. The core ideas, structural planning, and technical insights reflect the original thinking and professional experience of the RBA consultant who authored the piece. AI was used as a productivity aid, while all concepts, recommendations, and perspectives remain the author’s responsibility.

About the Author

Adam Utsch
Adam Utsch

Senior Principal Consultant

Adam is a seasoned software professional with deep experience in development, deployment, and application support. With a strong engineering foundation, they specialize in building scalable solutions and mentoring others in the technologies that drive real impact. Adam is passionate about continuous improvement, collaboration, and staying ahead of the tech curve.