The Vulnerability Gate That Blocked Every Build¶
I chose Alpine for the runtime image because it is smaller. That decision made my pipeline permanently unbuildable, and fixing it exposed two more failures hiding behind the first. This is the full chain, the redesign that resolved it, and the principle I now apply to every scanning gate I write.
The Starting Configuration¶
The pipeline builds a Spring Boot application, scans the resulting image with Trivy, and refuses to publish if the scan finds anything critical. That last part is the whole point of having the gate:
- name: Trivy image scan
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}
severity: CRITICAL
exit-code: '1'
One scan, one threshold, hard failure. This is what most tutorials show and it is what I started with.
The Dockerfile used a two-stage build ending on Alpine:
FROM maven:3.9.9-eclipse-temurin-21-alpine AS builder
# ... mvn package ...
FROM eclipse-temurin:21-jre-alpine AS runtime
COPY --from=builder /app/target/*.jar app.jar
Alpine because the JRE variant is a fraction of the size of the Ubuntu equivalent, which is conventional advice and, in isolation, correct.
Failure One: A Gate With No Passing State¶
The pipeline went red and stayed red. Every run, Trivy reported between 5 and 15 critical CVEs, and every single one carried the same status:
Total: 11 (CRITICAL: 11)
┌──────────────┬────────────────┬──────────┬────────┬───────────────────┐
│ Library │ Vulnerability │ Severity │ Status │ Installed Version │
├──────────────┼────────────────┼──────────┼────────┼───────────────────┤
│ musl │ CVE-2025-XXXXX │ CRITICAL │ affected │ 1.2.5-r0 │
│ openssl │ CVE-2025-XXXXX │ CRITICAL │ affected │ 3.3.2-r1 │
└──────────────┴────────────────┴──────────┴────────┴───────────────────┘
affected, not fixed in. There was no version to upgrade to. The patches did not exist yet.
The reason is structural rather than a lapse by anyone. Alpine uses musl libc rather than glibc, and its package set is maintained by a much smaller group than Debian or Ubuntu. When an upstream vulnerability is disclosed, the window between disclosure and an Alpine package carrying the fix runs from days to weeks. Trivy's database is updated the moment the CVE is public.
So the gate was measuring the gap between disclosure and packaging, and failing the build on it. That gap is not something I can close from a Dockerfile.
The result is worse than a noisy gate. It is a gate with no reachable passing state. Nothing I could do to my own code would make it green.
The Wrong Fixes¶
Three options presented themselves and all three are variations on the same mistake.
Lower the threshold to HIGH. Now criticals pass silently. The gate reports nothing about the class of problem it exists to catch.
Add .trivyignore entries for the offending CVEs. This works until the next disclosure, at which point you add more. Within a few months the ignore file is the real configuration and nobody remembers why any given line is there.
Set exit-code: '0' and keep the scan for the logs. The most honest of the three, and it means the pipeline has a security step that cannot fail. A badge with no mechanism behind it.
What all three share is that they preserve the appearance of a gate while removing its function. If the only way to get a green build is to stop the gate from working, the gate is wrong, not the build.
The Redesign: Split by Who Owns the Fix¶
The insight that resolved this is that a container image contains two categories of software with different owners, and I had been scanning them as one thing.
OS packages come from the base image. I choose the base image, but I do not choose when its maintainers ship a patch. If musl has an unfixed critical, my options are to wait or to change distribution. Neither is a code change.
Application libraries come from pom.xml. If spring-security-web has a critical, I bump a version. That is a change I can make in minutes.
Failing the build on the first category punishes me for someone else's release schedule. Failing on the second is exactly what a gate is for.
So the single scan became three passes:
# Pass A: OS packages. Report, never block.
- name: Trivy image scan OS packages
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}
vuln-type: os
severity: CRITICAL,HIGH
exit-code: '0'
# Pass B: Application libraries. Block on critical.
- name: Trivy image scan JAR/library
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}
vuln-type: library
severity: CRITICAL
exit-code: '1'
# Pass C: Full JSON report, uploaded as an artifact.
- name: Trivy image scan full report
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}
format: json
output: trivy-image-report.json
exit-code: '0'
Pass C matters more than it looks. Without a durable artifact, the advisory findings from Pass A exist only in log output that ages out. With it, every build leaves a complete inventory attached to the run, which is what you actually want when someone asks what was in a given release.
The same split applies to the filesystem scan that runs before the image is built, so source dependency problems surface before you spend time producing an artifact you are going to discard.
Also: Change the Base Image¶
The split makes the gate correct. It does not make the OS findings disappear, and shipping eleven unfixed criticals is still shipping eleven unfixed criticals.
So the runtime stage moved to Ubuntu:
Canonical patches glibc within hours of disclosure for supported LTS releases. Critical OS CVEs went from 5 to 15 down to zero, and stayed there.
The image is larger. That is a real cost and it is the right trade. The comparison is not "small image versus large image", it is "small image with a permanently red pipeline versus larger image with a gate that works". The builder stage stayed on Alpine, because build-stage packages never reach the runtime image and therefore never appear in the scan.
Worth being explicit that this is not an argument against Alpine generally, because I ship an Alpine-based project myself. DebugBox is a Kubernetes debugging toolkit built on Alpine 3.21, and there Alpine is clearly the right call: the whole value of the image is being small enough to pull during an incident, and its OS surface is tiny.
The variable is not the distribution. It is what you have committed to failing on.
The actual rule
Your base image's patch cadence becomes a pipeline property the moment you hard-fail on OS CVEs. Alpine's is slower than Ubuntu's, which is fine until a gate depends on it.
A JRE runtime drags in a large OS surface and a slow-moving one. A debugging toolkit that is mostly statically linked binaries does not. Same distribution, opposite conclusion, because the gate is measuring something different in each case.
Failure Two: The Gate Immediately Found Something Real¶
With Pass B live and blocking, the build failed again. This time on findings I owned.
Seven critical CVEs, all in transitive dependencies pulled in by the Spring Boot BOM. Clearing them meant overriding managed versions explicitly rather than accepting what the parent POM resolved:
| Dependency | Reason |
|---|---|
tomcat-embed-core | Critical in the version the BOM selected |
spring-security-web | Pinned forward to 6.5.9 |
thymeleaf | Critical in the resolved transitive version |
This is the part where the redesign paid for itself. Under the original configuration, these seven findings were invisible, buried in a wall of unfixable OS noise that everyone had learned to ignore. Splitting the scan did not just unblock the pipeline; it surfaced seven real problems that had been hiding behind the eleven fake ones.
A gate that always fails and a gate that never fails convey the same amount of information. Which is none.
Failure Three: The Fix Broke the Quality Gate¶
Upgrading Spring Security to 6.5.9 turned the Trivy pass green and made the SonarQube quality gate fail.
on this line in SecurityConfig.java:
AntPathRequestMatcher is marked forRemoval = true in Spring Security 6.x. The quality gate is configured to allow zero new issues on new code, and the version bump had made this line new code as far as Sonar's diff was concerned.
The code was not broken. It compiled and it worked. But "works today and is scheduled for deletion" is exactly the category a maintainability gate should catch, so the gate was right and the fix was a real change:
which handles path matching internally and is the supported API.
Three failures in a chain. A base image choice produced an unfixable gate. Fixing the gate exposed seven real vulnerabilities. Fixing those exposed a deprecated API. Each one was invisible until the one before it was resolved.
The Divergence Across Three Applications¶
I run this pattern across a Java, a Python and a Node application, on two CI engines each, and the gates are not identical. The differences are informative, and one of them is a deliberate decision rather than a design.
Each cell below is Jenkins / GitHub Actions:
| Scan | Java | Python | Node |
|---|---|---|---|
| Filesystem, CRITICAL | fail / warn | fail / warn | fail / fail |
| Image, OS packages | fail / warn | warn / warn | warn / warn |
| Image, libraries, CRITICAL | fail / warn | fail / fail | fail / fail |
Two things in that table are worth stating plainly rather than smoothing over.
Java's image row is bold because the Java Jenkinsfile does not split OS from library at all. It runs a single blocking pass on any critical of any type. That is the pre-redesign posture described earlier in this post, still in the file. It passes today only because the Jammy migration took critical OS findings to zero, which means the gate is green for a reason outside my control. The three-pass split is implemented in the Python and Node pipelines and in the Java GitHub Actions workflow; the Java Jenkinsfile is the one that never got retrofitted.
Java's GitHub Actions column warns everywhere, including the library pass, and that is on purpose. BankApp is not my code. I took an existing application and put a DevSecOps pipeline around it. Hard-failing someone else's build on critical CVEs in their dependency tree makes a decision that belongs to whoever owns that code, so the GitHub Actions workflow reports rather than blocks and the line says so:
vuln-type: library
severity: CRITICAL
exit-code: '0' # Just to pass the scan step. Change to '1' to fail on CRITICALs.
This is a genuinely different situation from the one that opens this post. There, the gate could not pass because no fix existed anywhere. Here, fixes exist and someone else's roadmap decides when to take them. My job on inherited code is to make the finding visible and unambiguous, not to seize the merge button. The comment naming the exact flag to flip is the part that keeps this honest: it is a stated position with a one-character reversal, not a quietly softened threshold.
The filesystem gate diverges by engine rather than by language. All three Jenkinsfiles fail on critical at the source scan; on GitHub Actions only Node does. That asymmetry is not principled, it is drift between two implementations of the same pipeline written weeks apart, and it is exactly the kind of thing writing them up side by side surfaces.
This table is the pass/fail policy only; the full stage-by-stage picture, including why Node has 21 stages against Java's 14, is in One Pipeline Contract, Three Language Ecosystems.
Python and Node also add language-native tools the Java pipeline has no equivalent for: Bandit and pip-audit for Python, npm audit plus two ESLint configurations for Node. The gate design is portable. The tooling behind it is not.
The Principle¶
The Core Principle
A gate that fails on things you cannot fix is not a gate. It is a blocked pipeline, and somebody will eventually switch it off.
The practical form of that is one question per check: who owns the fix for what this can find?
If the answer is "the person who wrote the code", block the build. If the answer is "an upstream maintainer on their own schedule", report it, keep the artifact, and let a human decide whether to change base image or accept the risk. Blocking on the second kind guarantees that someone eventually deletes the check, and they will do it during an incident, in a hurry, without documenting why.
Source¶
- Java pipeline: both the Jenkinsfile and the GitHub Actions workflow
- Python pipeline
- Node pipeline
- Full runbook entry with the complete override analysis
Series: CI Pipeline Engineering Across Three Applications (Part 1 of 8)