Vulnerability Policy Gates: Actively Blocking CI Builds on Critical CVEs
AI generated
FROM
RUN
Docker · Security · CI/CD
Vulnerability Policy Gates
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.

17 min read Trivy exit code CVE thresholds False positive handling

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.

11. FAQ: Vulnerability Policy Gates: Key Takeaways

1Why isn't a plain vulnerability report without blocking enough?
A report that lets the pipeline pass with exit code 0 regardless of the outcome has no enforcing effect, since nobody is forced to read it before deploying. In practice such reports usually vanish unread into the CI log.
2How does Trivy block a build on critical vulnerabilities?
With the parameters --exit-code 1 and --severity CRITICAL, Trivy returns exit code 1 as soon as at least one critical vulnerability is found, causing the CI pipeline to automatically fail.
3Why shouldn't every severity level block the build?
A policy that also blocks on LOW and MEDIUM almost always leads teams to bypass the gate, because barely any realistic image is completely free of such findings. A tiered policy focused on CRITICAL and HIGH is more workable.
4How do you handle a false-positive CVE without weakening the gate?
Through a .trivyignore file with individual, justified exceptions carrying an expiration date, instead of globally disabling the entire gate or setting continue-on-error.
5Why should the scan happen before the push to the registry?
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 exists. The scan belongs between build and push.
6What is the difference between dependency scanning and image scanning?
Dependency scanning checks the application's direct and transitive dependencies, image scanning additionally covers OS packages and system libraries from the base image. Both cover different layers and should be combined.
7Why are scheduled rebuilds without code changes worthwhile?
Many critical CVEs originate from OS packages in the base image that are only discovered to be vulnerable after the original build. A regular rebuild job that runs independently of code changes reliably catches this category.
8What does --ignore-unfixed mean in Trivy?
This option hides vulnerabilities for which no patch is available yet, since the gate would not be actionable in that case anyway. It should be used deliberately, not as a blanket weakening of the gate.
9How often should the .trivyignore file be reviewed?
Regularly, for instance monthly, together with whoever owns security responsibility, to check whether documented exceptions are still valid or contain expired or now-relevant CVEs.
10Should the same vulnerability policy apply to every service?
Not necessarily. Externally reachable services often justify a stricter policy than purely internal systems with no external network access, which is why some teams maintain two distinct policy profiles.