Open Source Security

Maven Enforcer Plugin Security Rules

Maven Enforcer is a blunt instrument most teams underuse. Here is how to turn it into a supply chain guardrail that blocks bad versions, bad repositories, and bad dependency graphs before they ship.

Shadab Khan
Security Engineer
7 min read

The Maven Enforcer plugin has been around since 2009, and for most of that time it has been treated as a style checker that nags about JDK versions and nothing more. That framing is a missed opportunity. The same plugin that fails your build when someone builds on JDK 8 can fail your build when someone pulls log4j-core 2.14.1 through a transitive, or when a pom quietly adds a repository outside your mirror. If you configure it seriously, maven-enforcer-plugin becomes one of the cheapest and most reliable supply chain guardrails you can put on a Java project.

I have been writing enforcer rulesets for clients since 2019, and the pattern that works is the same everywhere: a small, shared ruleset in a parent POM, strict defaults, documented escape hatches, and wired into CI so nobody can skip it with -Denforcer.skip=true. This post walks through the rules that matter, the versions that matter, and the traps that ate a whole afternoon the first time I hit them.

Why Enforcer deserves a second look in 2024

The 3.x line of maven-enforcer-plugin, current at 3.4.1 as of this writing, shipped a cleaner rule API and fixed several performance problems that made earlier versions painful on large multi-module builds. Version 3.0.0-M3 introduced the new AbstractEnforcerRule base class and the rule element syntax in March 2022, and 3.3.0 in April 2023 added proper Bean-style configuration and async rule evaluation. If you are still on the 1.x or 3.0.0-M1 line, most of the rules I reference below will not work as written. Upgrade first, then configure.

The cost of enforcer is low. The plugin runs during the validate phase by default, which means it fires before compile and before any plugin that might download something surprising. Rules evaluate against the resolved model, so they see the fully-expanded dependency tree after imports and management, which is exactly what you want for supply chain checks.

The banned-dependencies rule, used seriously

The rule most teams know about is bannedDependencies. The rule most teams use is a two-line snippet that bans commons-logging:commons-logging and nothing else. That is not a useful ruleset. The version that earns its keep looks like this, conceptually: ban every artifact that has appeared in a critical advisory for the past three years, pin allowed versions with range operators, and mark exceptions with inline XML comments that reference the ticket that approved them.

A partial list of entries worth encoding for a 2024 Java codebase: org.apache.logging.log4j:log4j-core:[,2.17.1) for Log4Shell fallout (CVE-2021-44228, CVE-2021-45046, CVE-2021-45105), com.fasterxml.jackson.core:jackson-databind:(,2.12.7.1) for the long tail of gadget-chain CVEs through 2022, org.springframework:spring-core:(,5.3.20) for Spring4Shell (CVE-2022-22965), com.h2database:h2:[,2.1.210) for CVE-2021-42392, and org.yaml:snakeyaml:(,1.33) for the constructor deserialization issues fixed in October 2022 (CVE-2022-1471). Add org.apache.commons:commons-text:(,1.10.0) for Text4Shell (CVE-2022-42889) and org.springframework.cloud:spring-cloud-function-context:(,3.2.3) for CVE-2022-22963.

The trick is that bannedDependencies evaluates transitives, so you cannot lie your way past it by not naming the artifact in your direct pom. The rule reports the path, which is what makes it actionable: the error message tells the developer which of their direct dependencies dragged the banned version in, and that is usually enough to know what to upgrade.

requireSameVersions and dependency convergence

The dependencyConvergence rule is the one I argue for hardest and the one teams push back on hardest. It fails the build when two paths through the dependency tree resolve the same artifact to different versions. The default Maven behavior is nearest-wins, which produces a single version in the classpath but often not the version either path expected. Convergence conflicts are how you ship with a mocked-up jackson-databind that has a 2.13 API and a 2.9 runtime, and that is how you reintroduce deserialization CVEs that you thought you patched.

Turn dependencyConvergence on, then spend a week fixing the conflicts with explicit <dependencyManagement> entries. The fixes are almost always trivial, and the resulting build is reproducible in a way the pre-convergence build was not. If you use Spring Boot, the spring-boot-dependencies BOM handles most of this for you; the convergence rule exists to catch the cases the BOM does not cover, which are exactly the cases you want surfaced.

Restricting repositories

The requireExternalUrls rule and the less-known bannedRepositories rule together let you say: this build may only pull from our internal Artifactory mirror, period. No jcenter, no random https://maven.someone.dev/ that a developer added three years ago and forgot. The rule fails if any <repository> or <pluginRepository> declaration outside the allowlist exists in the reactor or in any dependency's POM.

The practical value is high. In 2022, researchers found hundreds of typo-squatted artifacts on abandoned mirrors, and the attack path was always the same: a project declared a non-Central repository, the repository resolution happened before the malicious lookup was blocked, and the compromised artifact was cached. Mirroring through Artifactory or Nexus eliminates the path. The enforcer rule enforces that the mirroring is not being bypassed by a well-meaning <repositories> block in a child POM.

requireMavenVersion, requireJavaVersion, and the CI lie

The classic rules still matter, but for a non-obvious reason. requireMavenVersion[3.8.1,) and requireJavaVersion[17,) are not about catching developers on old laptops; they are about catching CI runners with stale base images. Maven 3.8.1 was the cutoff for the HTTP-to-HTTPS repository redirect (CVE-2021-26291), and versions before that would happily resolve plugins over plain HTTP if the POM said so. I have audited CI pipelines where the GitHub Actions runner pinned to actions/setup-java@v1 was still shipping Maven 3.6.3 in 2023. The enforcer rule made that visible in 20 seconds.

Wiring it into CI without the skip

The single most important configuration change: in your parent POM, set fail-fast true, and in CI, run mvn verify -Denforcer.skip=false -Denforcer.fail=true explicitly. The default behavior of the Maven command is to honor any -Denforcer.skip=true passed through, and I have seen teams commit build scripts that include the skip because the rule was initially too noisy. Lock that down at the pipeline level. Also set <failFast>true</failFast> so that one violation stops the build rather than letting the developer see only the first of forty cascading errors.

For local developer velocity, add a dev Maven profile that relaxes a subset of rules (typically bannedDependencies is kept, dependencyConvergence is relaxed) so that in-progress branches do not break every time someone adds a snapshot. The CI profile, -Pci, uses the full strict ruleset. This two-profile pattern is how you avoid the political fight where developers disable enforcer because it blocks their morning.

The rules most teams miss

Three rules that quietly earn their keep: requirePluginVersions forces every plugin in the build to have an explicit version, which closes a supply chain hole where a missing version falls back to "latest." reactorModuleConvergence catches a class of multi-module bugs where internal modules reference each other at inconsistent versions, which becomes a supply chain problem when you publish the modules. And requireUpperBoundDeps, from the Maven Enforcer extra rules bundle, fails the build when a transitive dependency would silently pull a version higher than the one the user declared, which is a classic confused-deputy footgun.

How Safeguard Helps

Safeguard ingests your Maven reactor and produces the same transitive graph the enforcer plugin sees, but with live vulnerability data attached. When you set a policy that rejects any build containing a known-exploited CVE, Safeguard generates the exact bannedDependencies rule entries to drop into your parent POM so that the policy is enforced at build time, not just at scan time. We also watch for repository declarations outside your approved mirror and surface them as supply chain findings, which pairs with the enforcer requireExternalUrls rule to catch the same problem at two layers. Teams that wire Safeguard output into their enforcer config close the window between a CVE being published and it being blocked in builds to under a day.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.