The Version Override Maven Silently Ignored¶
I set <thymeleaf.version>3.1.4.RELEASE</thymeleaf.version>, rebuilt, and Trivy reported the same two critical CVEs in Thymeleaf 3.1.3. The property was not a typo and the build did not warn. Maven had read it, found nothing that consumed it, and moved on. This is what actually clears a transitive CVE in a Spring Boot project, and why the obvious mechanism works for some libraries and does nothing for others.
Once the image scan started blocking on library criticals, the pipeline stopped being a design exercise and started producing a list of seven things to fix. All seven were transitive: nothing I had written, nothing I had declared, everything pulled in by the Spring Boot BOM at a version it chose.
Two Override Mechanisms That Look Interchangeable¶
Maven gives you two ways to force a dependency version when a parent BOM already pins one. They are not equivalent, and the difference is the whole post.
| Mechanism | How it works | When it works |
|---|---|---|
<properties> key override | Maven resolves <properties> before the BOM. If the BOM reads a property key, your value wins. | Only when the BOM actually exposes a documented key for that library |
<dependencyManagement> block | Entries in your own POM always beat the parent BOM. Guaranteed by the Maven spec. | Always |
The property approach is the one every answer online reaches for first, because it is one line and it reads like configuration. It works by cooperation: the BOM has to have been written to look up that key. spring-boot-dependencies defines a long list of them, tomcat.version and spring-security.version among them, and for any library on that list a property override is clean and idiomatic.
For any library not on that list, setting the property does nothing at all. No error. No warning. The key sits in your POM, resolved and unread, while the BOM's own hardcoded version wins.
The failure mode
A property override for a key the BOM does not expose fails silently and successfully. The build passes, the POM looks correct in review, and the vulnerable version is still on the classpath. Nothing surfaces the mistake except a scanner reporting a CVE you believe you already fixed.
Thymeleaf was the one that caught me. Spring Boot 3.4.5's BOM does not expose a property key for the Thymeleaf artifacts, so <thymeleaf.version> was inert and 3.1.3.RELEASE shipped anyway. The fix was to stop asking and start declaring:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.thymeleaf</groupId>
<artifactId>thymeleaf</artifactId>
<version>3.1.4.RELEASE</version>
</dependency>
<dependency>
<groupId>org.thymeleaf</groupId>
<artifactId>thymeleaf-spring6</artifactId>
<version>3.1.4.RELEASE</version>
</dependency>
</dependencies>
</dependencyManagement>
Two artifacts, listed separately, because thymeleaf-spring6 is its own coordinate and inherits nothing from the fix applied to thymeleaf.
How To Know Which Mechanism You Need¶
The mistake above is avoidable, and the way to avoid it is to stop guessing at key names and ask Maven what it actually resolved.
Ask what the BOM exposes. The parent POM's property block is the authoritative list of overridable keys:
If thymeleaf.version is not in that output, setting it is a no-op. This takes ten seconds and replaces the entire guess-and-rescan loop.
Ask what a single property currently resolves to. Faster when you have a specific key in mind:
mvn help:evaluate -Dexpression=spring-security.version -q -DforceStdout
mvn help:evaluate -Dexpression=thymeleaf.version -q -DforceStdout
The first prints a version. The second prints null object or invalid expression, which is Maven telling you plainly that the key you were about to set does not exist. That message is the whole diagnosis, and it arrives before you have edited anything.
Then confirm the artifact actually moved. A resolved property is still not proof the dependency changed, because a <dependencyManagement> entry elsewhere or a nearer declaration can win:
Scoping the tree to one group is much easier to read than the full output and answers the only question that matters: which version is on the classpath.
The order that saves time
Check the key exists, set it, confirm the tree moved. Skipping the first step is what produces a POM that looks correct and a scanner that disagrees, and the scanner takes minutes to tell you what help:evaluate tells you instantly.
Why This Keeps Happening¶
Worth stating why transitive CVEs dominate this list rather than direct ones.
This application declares a handful of dependencies. The Spring Boot BOM pins several hundred versions behind them, and every one of those is a version I did not choose and mostly cannot name. When a scanner reports a critical, the odds strongly favour it being in something I never wrote down.
That inverts the intuition about dependency management. The versions you can see in your POM are the ones you already thought about. The risk concentrates in the ones you cannot see, which is exactly the set the BOM exists to manage on your behalf, and exactly the set where a property override may or may not reach.
What Seven Criticals Actually Looked Like¶
The first move was cheap and cleared most of it: bumping spring-boot-starter-parent from 3.4.4 to 3.4.5. A BOM bump is a single line that re-pins several dozen transitive versions at once, and it is always worth trying before hand-pinning anything.
What remained needed individual attention:
| Library | BOM version | Fixed version | Override used |
|---|---|---|---|
tomcat-embed-core | 10.1.39 | 10.1.54 | <tomcat.version> property |
spring-security-core | 6.4.4 | 6.5.9 | <spring-security.version> property |
spring-security-web | 6.4.4 | 6.5.9 | same key, both artifacts share it |
thymeleaf | 3.1.3.RELEASE | 3.1.4.RELEASE | <dependencyManagement> |
thymeleaf-spring6 | 3.1.3.RELEASE | 3.1.4.RELEASE | <dependencyManagement> |
spring-framework | 6.2.x | 6.2.17 | <spring-framework.version> property |
logback-core | 1.5.18 | 1.5.19 | <logback.version> property |
jackson | 2.18.x | 2.18.6 | <jackson-bom.version> property |
Two rows in that table carry a lesson beyond their own CVE.
spring-security-core and spring-security-web share one key. Setting <spring-security.version> moves both, which is convenient right up until you assume the same is true elsewhere and it is not. Thymeleaf's two artifacts needed two separate entries. There is no rule that tells you which libraries group and which do not; you read the BOM or you check the resolved tree.
jackson-bom.version, not jackson.version. The intuitive key name is wrong, and being wrong here produces exactly the silent no-op described above. This is the same class of mistake as Thymeleaf, just with a key that exists but is not the one being read.
The One That Could Not Be Fixed In Place¶
CVE-2026-22732 in spring-security-web was the interesting one. Trivy reported it critical and listed the fixed versions as 6.5.9 and 7.0.4.
The project was on the 6.4.x line. There is no 6.4.x release carrying that fix, and there was never going to be one: the patch was backported into 6.5.x and 7.0.x only. Staying on 6.4 meant staying vulnerable, regardless of how many patch releases came out.
This is where a version bump stops being a maintenance chore and becomes a compatibility question. Moving a minor version forward inside a Spring Boot project sounds like something the BOM should veto, and it turns out Spring Boot does not lock the Spring Security minor version. 6.5.x runs on Boot 3.4.x. So the override was safe:
Worth knowing before you need it, because the alternative reading of that CVE report is "we are blocked until Spring Boot ships a version that pulls in Security 6.5", which would have been weeks of waiting for something that was one property away.
That upgrade then broke the SonarQube quality gate on a deprecated API, which is its own chain of failures.
Verifying It Actually Took¶
The whole point of this post is that the POM lying to you is a normal outcome, so the POM cannot be the thing you check. The resolved tree is:
If the patched versions appear there, the override took. If the old version appears, the override was a no-op regardless of how correct it looks in <properties>.
Do this before re-running the pipeline, not after. A Trivy pass takes minutes and tells you the same thing a grep tells you in seconds.
What I Take Forward¶
A scanner finding is not a fix instruction. "Upgrade Thymeleaf to 3.1.4" describes an outcome. Whether that outcome is one property, two <dependencyManagement> entries, or a minor-version move the BOM did not anticipate is a separate question with a different answer per library.
Prefer the BOM bump first. One version change on the parent resolved more of this list than all the individual overrides combined, and it does not accumulate anything you have to maintain.
Treat every hand-pinned version as debt with an expiry. Each override in that table is a place where my POM has opinions the BOM does not know about. On the next Boot upgrade, every one of them needs re-checking, because a parent that has caught up now has its version silently overridden by my older pin. The mechanism that fixes a CVE today is the mechanism that holds a stale version tomorrow.
Verify against the resolved tree, never the declaration. This is the only durable habit out of the whole exercise. Maven's silence when a property goes unread is the default behaviour, not an edge case.
Source¶
- Java monolith repository, including the
pom.xmlcarrying every override in the table docs/trivy-troubleshooting.md, the working notes this post is drawn from, with the full CVE identifiers per row- Runbook: Phase 3b, GitHub Actions CI
Series: CI Pipeline Engineering Across Three Applications (Part 2 of 8)