Gradle's version catalogs feature went GA in Gradle 7.4 in 2022 and stabilised through 7.x and 8.x. The ergonomic case is obvious: one libs.versions.toml declares every dependency version your build uses, and every subproject references it. The security case is less commonly made, and it is bigger than it looks. A Gradle build with version catalogs is materially more auditable, more uniformly enforceable, and easier to defend to a compliance team than a build without them. This post is the security-centred case for adoption.
The drift problem version catalogs solve
Pre-catalogs, a large Gradle multi-project build accumulates implementation("com.fasterxml.jackson.core:jackson-databind:X") lines scattered across build.gradle.kts files. The same library can appear at different versions in different subprojects — 2.16.0 in one, 2.16.1 in another, 2.15.2 in a third nobody has touched in a year. Each version carries its own CVE exposure. Patching a known-bad version requires grep, manual edits, and hope.
Version catalogs collapse this. One file declares the version; subprojects reference by name. A patch is a one-line change in one file that propagates everywhere.
For supply chain posture, this is the same win that NuGet Central Package Management gave .NET and that npm overrides gave JavaScript. Single source of truth is the precondition for almost every other control.
The shape of a security-ready catalog
[versions]
kotlin = "2.0.20"
jackson = "2.17.2"
spring-boot = "3.3.3"
slf4j = "2.0.16"
[libraries]
jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson" }
jackson-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jackson" }
spring-boot-starter = { module = "org.springframework.boot:spring-boot-starter", version.ref = "spring-boot" }
[bundles]
jackson = ["jackson-databind", "jackson-kotlin"]
[plugins]
spring-boot = { id = "org.springframework.boot", version.ref = "spring-boot" }
Subprojects consume via:
dependencies {
implementation(libs.bundles.jackson)
implementation(libs.spring.boot.starter)
}
Everything supply-chain-relevant — versions, bundles, plugin coordinates — is in one file.
Enforcing a catalog as the only way to declare versions
The dependencyResolutionManagement block in settings.gradle.kts supports strictness:
dependencyResolutionManagement {
repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
versionCatalogs {
create("libs") {
from(files("gradle/libs.versions.toml"))
}
}
}
Combined with a custom Gradle plugin or a lint rule (e.g., via dependency-analysis-gradle-plugin) that forbids inline version strings in subproject build files, you can enforce that every version lives in the catalog. Without this enforcement, subprojects drift back to inline declarations over time and the posture benefit erodes.
PR review becomes targeted
Every supply-chain-relevant dependency change now shows up as a diff in one file. A reviewer looking at a PR to bump jackson from 2.17.2 to 2.17.3 sees the intent clearly, and the change either passes or fails CI gates based on that single edit.
This makes per-PR supply chain policy enforceable:
- "No CVE-listed versions may land in
libs.versions.tomlwithout an SLA exception." - "License changes require legal review."
- "Critical package version bumps require two reviewers."
Pre-catalogs, each of these policies had to run against every build file. Post-catalogs, they run against one file.
Catalog-level SCA is tractable
Scanning a Gradle project for vulnerabilities pre-catalogs meant expanding the dependency graph across every subproject and subprocess. Post-catalogs, a first-pass scan of the catalog file catches the majority of declared risk before full graph resolution runs. This makes CI PR-time scanning fast and targeted: parse TOML, query advisory databases, fail the PR if a CVE-listed version is introduced.
Full-graph scans still matter for transitives, but the catalog gate catches direct-dependency risk before it propagates.
Transitives still need dependency constraints
Version catalogs handle the direct dependency set. Transitive conflict resolution in Gradle still happens at resolution time. For security-critical transitive bumps, you need either implementation constraint(...) blocks or Gradle's strictly behavior in the catalog:
[libraries]
log4j-core-pinned = { module = "org.apache.logging.log4j:log4j-core", version = { strictly = "2.23.1" } }
A strictly version overrides transitive resolution and forces every consumer of log4j-core to use exactly 2.23.1. This is how you enforce a critical transitive patch (Log4Shell remediation, for instance) without modifying every direct-dependency declaration.
How catalogs interact with Dependabot and Renovate
Both tools support version catalogs. Dependabot has had catalog support since 2022; Renovate fully supports the TOML format with per-dependency configuration.
The update flow becomes: dependency bot opens a PR modifying one line in libs.versions.toml. CI runs the full build and tests. If everything passes and the CVE scan clean, the PR is mergeable. This is a dramatic improvement over the pre-catalog flow where dependency bot PRs touched many files and often conflicted with parallel PRs.
Multi-project supply chain, one file
For projects with many subprojects — mobile apps with shared feature modules, large backend monoliths with internal boundaries, multiplatform Kotlin projects — the catalog is the one file every subproject respects. This is the structural piece that makes "we have a uniform dependency posture across the organization" a truthful claim rather than an aspiration.
The migration to catalogs is typically a day for a mid-size project. The security ROI begins from the first PR that touches dependencies after migration.
How Safeguard Helps
Safeguard's Gradle support reads libs.versions.toml as the authoritative version source and correlates declared versions with the reachability graph. Policy gates can enforce that changes to the catalog require specific reviewer approval, block CVE-listed versions at PR time, and track strictly declarations as intentional transitive pins. Griffin AI identifies projects that haven't adopted catalogs and ranks them by adoption ROI (largest subproject count, most version drift, highest CVE exposure). For JVM organizations running many Gradle projects, Safeguard makes version catalog adoption a tracked supply chain property rather than a per-team decision.