Skip to content

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:

FROM eclipse-temurin:21-jre-jammy AS runtime

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.

java:S5738: "@Deprecated code marked for removal should never be used"

on this line in SecurityConfig.java:

.logoutRequestMatcher(new AntPathRequestMatcher("/logout"))

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:

.logoutUrl("/logout")

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


Series: CI Pipeline Engineering Across Three Applications (Part 1 of 8)

Next: The Version Override Maven Silently Ignored