Actually blocking CI builds on critical CVEs, not just warning
A scan report nobody reads protects no system. A real vulnerability policy gate actively stops the build process the moment a container image contains critical vulnerabilities, with clear thresholds and a defined way of handling false positives, instead of treating security warnings as an optional footnote in the CI log.
Table of Contents
- 1. The difference between reporting and blocking
- 2. Exit-code-based blocking with Trivy
- 3. Integration as its own pipeline step
- 4. Threshold strategies: not every severity should be treated equally
- 5. Handling false positives without gutting the gate
- 6. Prevention through base image choice instead of pure post-filtering
- 7. Dependency scanning versus image scanning: different layers
- 8. SBOM as the foundation for faster reaction to new CVEs
- 9. Reporting and tracking beyond the pipeline
- 10. Summary
- 11. FAQ
1. The difference between reporting and blocking
Many teams set up a container scan in their pipeline and mistakenly consider that sufficient security already. A scan that merely generates a report and signals the pipeline with exit code 0 regardless of the outcome has no actual protective effect, because nobody is forced to read the report before deploying. In practice, such reports vanish into build logs that get routinely ignored the moment the pipeline's big green checkmark appears.
A real policy gate is fundamentally different: the scan step itself actively decides success or failure of the build by returning a non-zero exit code once a defined threshold is exceeded, causing the entire pipeline to fail and preventing the subsequent deploy step from ever running. This difference between passive visibility and active enforcement is the core of every effective vulnerability policy in CI/CD.
2. Exit-code-based blocking with Trivy
Trivy, one of the most widely used open-source scanners for container images, offers exactly this mechanism built in via the --exit-code and --severity parameters. Setting --exit-code 1 together with --severity CRITICAL makes Trivy return exit code 1 as soon as at least one vulnerability with severity CRITICAL is found, which automatically fails the job in nearly every CI system. Without an explicit --exit-code, Trivy always returns exit code 0 by default, regardless of how many or how severe the vulnerabilities found are, a detail that gets overlooked in many pipeline configurations.
A common misconfiguration is running the scan with the correct exit code, but simultaneously setting continue-on-error: true or a similar flag in the CI system, usually out of the legitimate worry that a single false positive could bring down the entire pipeline. That very flag, however, undoes the gate's entire protective effect and should only be used in a separate, clearly labeled report-only stage, never in the actual blocking stage.
# Blocking scan: fails on critical vulnerabilities
trivy image --exit-code 1 --severity CRITICAL myapp:latest
# Combined threshold: HIGH and CRITICAL both block
trivy image --exit-code 1 --severity HIGH,CRITICAL myapp:latest
# Only consider fixable vulnerabilities (ignores CVEs without an available patch)
trivy image --exit-code 1 --severity CRITICAL --ignore-unfixed myapp:latest
3. Integration as its own pipeline step
A policy gate belongs as a standalone step between building the image and pushing it to the production registry, never afterward. If the scan only runs after the push, the vulnerable image is already available and could theoretically already have been deployed before the scan result is even in. The correct order is: build the image locally in the pipeline, scan the image and abort if the threshold is exceeded, only then push to the registry and proceed further.
In GitHub Actions this can be modeled directly as a sequential step after the build, requiring a separate build step without a push so the image is locally available for the scan before it actually reaches the registry. Alternatively, load: true in docker/build-push-action can be used to load the built image into the runner's local Docker daemon without pushing it yet.
jobs:
build-and-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build image locally (no push yet)
uses: docker/build-push-action@v6
with:
context: .
load: true
tags: myapp:scan-target
- name: Scan for critical vulnerabilities
uses: aquasecurity/trivy-action@0.28.0
with:
image-ref: myapp:scan-target
severity: CRITICAL
exit-code: '1'
ignore-unfixed: true
- name: Push only if scan passed
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/myorg/myapp:latest
4. Threshold strategies: not every severity should be treated equally
A policy that blocks on every vulnerability of every severity level, including LOW and MEDIUM, almost always leads teams to bypass or disable the gate in practice, because barely any realistic base image, especially one with many OS packages, is completely free of LOW or MEDIUM findings. The common and more workable strategy is a tiered policy: CRITICAL blocks the build immediately and without exception, HIGH also blocks but with a defined exception process for documented, accepted risks, while MEDIUM and LOW are merely reported without stopping the build.
This tiering should not be chosen arbitrarily, but should follow the application's actual risk profile. An image directly exposed to the internet, such as a publicly reachable web server, justifies a stricter policy than an internal batch processing system with no external network access. Some teams therefore run two separate policy profiles, a strict one for externally reachable services and a more moderate one for purely internal systems.
# Tiered policy as two separate scan steps
# Step 1: CRITICAL and HIGH hard-block the build
trivy image --exit-code 1 --severity CRITICAL,HIGH --ignore-unfixed myapp:latest
# Step 2: MEDIUM and LOW only reported, build continues anyway
trivy image --exit-code 0 --severity MEDIUM,LOW --format json --output report.json myapp:latest
5. Handling false positives without gutting the gate
Every policy gate sooner or later runs into a false positive: a CVE that formally applies to a package version present in the image, but is not actually exploitable for concrete reasons, for instance because the affected function is never called in the actual application flow, or an upstream patch has already been unofficially backported. The key difference from a poorly run gate is how this case is handled: instead of disabling the entire gate globally, every exception should be documented individually, traceably, and with an expiration date.
Trivy and comparable scanners support a .trivyignore file for this, in which individual CVE IDs can be listed with a justification as a comment. It is important to review this file regularly, for instance via a calendar reminder or automated ticket, because a CVE marked 'not exploitable' can suddenly become relevant again through a later code change, without the ignore list being updated automatically. A .trivyignore file without an expiration date almost always turns into a steadily growing list nobody questions anymore.
# .trivyignore
# CVE-2024-12345: Only affects the XML parser function, which is never
# called in our code path. Review date: 2026-11-01, Owner: Platform Team
CVE-2024-12345
# CVE-2023-98765: Upstream fix already included in the next base image update,
# planned update: Q4 2026, Owner: Platform Team
CVE-2023-98765
6. Prevention through base image choice instead of pure post-filtering
A policy gate alone does not solve a problem, it only prevents an already existing problem from reaching production. A prevention strategy that reduces the number of findings in the first place is far more effective, above all choosing a lean base image. Distroless images or Alpine-based variants contain drastically fewer OS packages than a full Debian or Ubuntu image, resulting in structurally less attack surface and fewer potential CVE carriers, regardless of how well the actual application image itself is maintained.
Equally important is regular, automated rebuilding of images purely because of an updated base image, even when nothing has changed in the application code itself. Many critical CVEs do not originate from custom code but from OS packages in the base image that were unremarkable at the original build time and were only later discovered to be vulnerable. A weekly, scheduled rebuild job that runs independently of code changes reliably catches exactly this category of vulnerability before it goes unnoticed through a regular deploy cycle.
name: Scheduled base image rebuild
on:
schedule:
- cron: '0 3 * * 1' # every Monday 03:00 UTC
workflow_dispatch:
jobs:
rebuild-and-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Rebuild with latest base image
run: docker build --pull --no-cache -t myapp:latest .
- name: Scan rebuilt image
uses: aquasecurity/trivy-action@0.28.0
with:
image-ref: myapp:latest
severity: CRITICAL,HIGH
exit-code: '1'
7. Dependency scanning versus image scanning: different layers
A common misunderstanding is treating dependency scanning of application libraries, for instance via npm audit or pip-audit, and image scanning of the finished container as redundant and using only one of the two. In reality they cover different layers: dependency scanning finds vulnerabilities in the application's direct and transitive dependencies, while image scanning additionally covers OS packages, system libraries, and everything the base image brings along, areas that pure dependency scanning fundamentally cannot see.
For full coverage, both scan types therefore belong in the pipeline, ideally as separate steps each with their own threshold, since the typical volume and severity of findings often differs between application dependencies and OS packages. Some teams apply a stricter policy for dependency scans, since they have direct control over the chosen libraries, while for OS packages in the base image often only waiting for an upstream patch remains an option.
8. SBOM as the foundation for faster reaction to new CVEs
A policy gate only ever checks the state at build time, it cannot say anything about which already-running production images are affected by a newly published CVE. This is exactly where a software bill of materials, SBOM for short, becomes essential: it precisely lists every package version contained in the image and makes it possible to answer, within minutes instead of days, which production images are actually affected by a new critical CVE, without having to rescan every single image.
Trivy can generate an SBOM directly during the image build in CycloneDX or SPDX format and store it as an artifact alongside the actual image, for instance as a signed attestation object in the registry. Combined with a central SBOM archive, a simple query can be run on every new CVE disclosure to check whether the affected package version appears in any production image, which drastically shortens reaction time to so-called zero-day situations compared to reactively waiting for the next scheduled scan.
# Generate an SBOM in CycloneDX format directly during the build
trivy image --format cyclonedx --output sbom.json myapp:latest
# Later: check specifically whether a given package version is present
trivy sbom sbom.json --severity CRITICAL,HIGH
9. Reporting and tracking beyond the pipeline
A blocking gate alone does not answer how many builds were blocked in total, which CVEs occur most frequently, or whether the overall security posture is improving or deteriorating over time. It is therefore worth exporting scan results, in addition to the plain pass/fail signal, into a central dashboard or system, for instance in SARIF format for the GitHub Security overview or into a dedicated vulnerability management tool, regardless of whether the build passes or fails.
This history is especially valuable for the documented exceptions mentioned earlier: without central tracking, a team quickly loses track of which exceptions are active, when they expire, and whether a vulnerability accepted as temporary should have been fixed long ago. A regular, for instance monthly, review of all active exceptions together with whoever owns security responsibility prevents the .trivyignore file from turning into a permanent blind spot.
| Severity | Recommended action | Exception process | Typical example |
|---|---|---|---|
| CRITICAL | Block build immediately, no exceptions | Only with a documented, time-limited exception | Remote code execution in a core library |
| HIGH | Block build | Documented exception with expiration date possible | Privilege escalation with no known exploit |
| MEDIUM | Report only, build continues | None needed, tracked in dashboard | Denial of service under specific conditions |
| LOW | Report only, build continues | None needed | Information leak with no direct harm |
Mironsoft
Container infrastructure, CI pipelines and deployment automation
Docker setups that hold up across the team and in production?
We review existing Dockerfiles and Compose stacks for security gaps, bloated images and fragile build pipelines, then build a container infrastructure that builds fast, runs securely and stays understandable across the team.
Dockerfile Review
Systematically optimizing multi-stage builds, layer caching and image size.
Security Audit
Hardening container isolation, secrets handling and image scanning against real attack surfaces.
CI/CD Integration
Building build pipelines, registries and deployment strategies for reproducible releases.
10. Summary
Vulnerability Policy Gates: Key Takeaways
Report vs. gate
Only a non-zero exit code on threshold breach actually stops the pipeline.
Tiered policy
CRITICAL and HIGH block the build, MEDIUM and LOW are only documented.
False positives
Individual, justified exceptions with an expiration date instead of disabling the whole gate.
Prevention
Lean base images and scheduled rebuilds reduce findings before the gate ever needs to trigger.