Skip to main content

Vulnerability Gates: Block Exploited CVEs in CI/CD

Build a CVE gate for CI/CD that blocks exploited and fixable vulnerabilities using CISA KEV, EPSS and fix data, with expiring exceptions, SBOMs and VEX.

A vulnerability gate fails a build when the code or image contains a known vulnerability that you decided must not ship. The scanner part is easy: Trivy, Grype or OSV-Scanner finds known CVEs in a lockfile or image in seconds. The hard part is the rule. A gate that blocks on every critical CVE blocks almost every build of a real service, and a gate that blocks nothing is just a report.

This part builds a gate that a team can live with. It blocks findings that are being exploited, findings with a high EPSS score that have a fix, and critical findings that have a fix. It leaves the rest to the backlog, warns about every accepted exception, and makes each exception expire.

Prerequisites

  • The orders-api repository from part 1, or any project with a lockfile.
  • OSV-Scanner, jq, curl and Conftest 0.71 or later.
  • Optional: Trivy or Grype if you want to scan container images.

Choose a Scanner

All three popular open source scanners read lockfiles, SBOMs and container images and report known vulnerabilities. They differ in where they get their data:

Trivy 0.76 Grype 0.121 OSV-Scanner 2.7
Scans Images, file systems, repos, SBOMs, Kubernetes; also IaC and secrets Images, file systems, SBOMs Lockfiles, SBOMs, images
Vulnerability data Local database: about 130 MB to download, 1.6 GB unpacked Local database: about 190 MB to download, 2.5 GB unpacked The OSV.dev API, no local database by default
Exploit data None EPSS score, KEV flag and a risk score per finding None
Fail the build --exit-code 1 --severity CRITICAL --fail-on critical Exits 1 on any finding
Exceptions .trivyignore.yaml with expired_at, VEX .grype.yaml ignore rules, VEX osv-scanner.toml with ignoreUntil

The database sizes were measured on 2026-10-23 and grow over time. In CI, cache the Trivy or Grype database between runs (part 4 shows how), or every job downloads it again.

The real runs in this part use OSV-Scanner, because it needs no local database: it sends package names and versions to the OSV.dev API. The gate logic itself works with any scanner, since it only needs a list of findings with an ID, a package, a severity and a fixed version.

Scan the Example Service

orders-api locks its Java dependencies in a Gradle lockfile. The versions are from early 2022, when the service was written, and nobody has touched them since:

# This is a Gradle generated file for dependency locking.
# Manual edits can break the build and are not advised.
# This file is expected to be part of source control.
ch.qos.logback:logback-classic:1.2.11=compileClasspath,runtimeClasspath
ch.qos.logback:logback-core:1.2.11=compileClasspath,runtimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.13.2=compileClasspath,runtimeClasspath
com.fasterxml.jackson.core:jackson-core:2.13.2=compileClasspath,runtimeClasspath
com.fasterxml.jackson.core:jackson-databind:2.13.2=compileClasspath,runtimeClasspath
com.google.guava:guava:31.0.1-jre=compileClasspath,runtimeClasspath
org.postgresql:postgresql:42.3.3=runtimeClasspath
org.slf4j:slf4j-api:1.7.36=compileClasspath,runtimeClasspath
org.springframework:spring-beans:5.3.17=compileClasspath,runtimeClasspath
org.springframework:spring-core:5.3.17=compileClasspath,runtimeClasspath
org.springframework:spring-web:5.3.17=compileClasspath,runtimeClasspath
org.springframework:spring-webmvc:5.3.17=compileClasspath,runtimeClasspath
org.yaml:snakeyaml:1.30=runtimeClasspath
empty=annotationProcessor

After these summary lines, OSV-Scanner prints a table with one row per finding. Run on 2026-10-23, the scan found 66 known vulnerabilities, 6 of them critical. A gate of "no critical vulnerabilities" fails this build, and it will keep failing every build until someone spends days upgrading Spring. The gate needs a better rule than a severity threshold.

Severity Is Not Risk

A CVSS score describes how bad a vulnerability is if someone exploits it. It does not say whether anyone is exploiting it, or whether a fix exists. Two free data sources add that:

  • CISA KEV (Known Exploited Vulnerabilities) is a catalog of CVEs with evidence of real-world exploitation. It had 1,755 entries on 2026-10-23. In June 2026, CISA's Binding Operational Directive 26-04 replaced BOD 22-01: US federal agencies now get deadlines from a table that combines KEV status, internet exposure, whether exploitation can be automated, and technical impact.
  • EPSS (Exploit Prediction Scoring System) from FIRST gives each CVE a daily probability, from 0 to 1, of exploitation activity in the next 30 days. The current model (v2026.06.15) was released in June 2026. EPSS says nothing about whether the vulnerable code is reachable in your application.

A gate built on these blocks a finding when any of these is true:

  1. It is in KEV. It is being exploited, so it blocks even without a fix.
  2. Its EPSS score is 0.1 or higher and a fixed version exists.
  3. It is critical and a fixed version exists.

Everything else goes to the backlog and to your code scanning dashboard (part 4), not to the build log.

Add KEV and EPSS to the Findings

This script turns the OSV-Scanner JSON into a flat list of findings and adds the KEV flag and the EPSS score to each one. It downloads the KEV catalog and asks the EPSS API about the CVEs in batches of 100, because the API accepts at most 2,000 characters of CVE IDs per request:

#!/usr/bin/env bash
# Turn osv-scanner JSON into one finding per package and vulnerability,
# with the CISA KEV flag and the EPSS score added.
set -euo pipefail
osv_json=${1:-osv.json}

curl -fsSL -o kev.json \
  https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json

jq '[.results[].packages[] | .package as $p | .vulnerabilities[] | {
      id,
      cve: ([.aliases[]? | select(startswith("CVE-"))][0] // .id),
      package: $p.name,
      version: $p.version,
      severity: (.database_specific.severity // "UNKNOWN"),
      fixed: [.affected[] | select(.package.name == $p.name)
              | .ranges[]?.events[] | .fixed // empty]
    }]' "$osv_json" > findings.raw.json

# The EPSS API accepts at most 2,000 characters of CVE IDs per request,
# so ask for 100 CVEs at a time and merge the answers.
shopt -s nullglob
jq -r '[.[].cve | select(startswith("CVE-"))] | unique | .[]' findings.raw.json > cves.txt
split -l 100 cves.txt cves.batch.
for batch in cves.batch.*; do
  curl -fsSL "https://api.first.org/data/v1/epss?cve=$(paste -sd, "$batch")&limit=100"
done | jq -s '{data: (map(.data) | add // [])}' > epss.json
rm -f cves.txt cves.batch.*

jq --slurpfile kev kev.json --slurpfile epss epss.json '
  ($kev[0].vulnerabilities | map({key: .cveID, value: true}) | from_entries) as $k
  | ($epss[0].data | map({key: .cve, value: (.epss | tonumber)}) | from_entries) as $e
  | {findings: map(. + {kev: ($k[.cve] // false), epss: ($e[.cve] // 0)})}
' findings.raw.json > findings.json

jq -r '"\(.findings | length) findings, \([.findings[] | select(.kev)] | length) in KEV, \([.findings[] | select(.epss >= 0.1)] | length) with EPSS >= 0.1"' findings.json

The severity value is the GitHub Security Advisory label that OSV includes for language ecosystems such as Maven, npm and PyPI. Advisories from other sources, such as Linux distributions, store severity differently. Extend that line before you run the script on OS packages; otherwise those findings come out as UNKNOWN, and only the KEV and EPSS rules apply to them.

The top of the list already tells a different story from the severity counts. CVE-2022-22965 (Spring4Shell) is in KEV and almost certain to be probed. CVE-2022-1471 in SnakeYAML is rated only "high", so a critical-only gate would let it through, yet its EPSS score is 0.996. Meanwhile CVE-2024-1597 in the PostgreSQL driver has a CVSS score of 10.0 but an EPSS score below 0.05: it only matters if the application sets preferQueryMode=simple.

Write the Gate as a Policy

The gate rules are Rego, tested and reviewed like the policies in part 1. Exceptions live in a separate data file, and each one has a reason, a ticket and an expiry date:

package vulns

epss_threshold := 0.1

fixable(f) if count(f.fixed) > 0

fix_text(f) := concat(" or ", f.fixed)

deny contains msg if {
	some f in input.findings
	f.kev
	not excepted(f)
	msg := sprintf("%s in %s %s is in the CISA KEV catalog (fix: %s)", [f.cve, f.package, f.version, fix_text(f)])
}

deny contains msg if {
	some f in input.findings
	not f.kev
	f.epss >= epss_threshold
	fixable(f)
	not excepted(f)
	msg := sprintf("%s in %s %s has EPSS %.3f (fix: %s)", [f.cve, f.package, f.version, f.epss, fix_text(f)])
}

deny contains msg if {
	some f in input.findings
	not f.kev
	f.epss < epss_threshold
	f.severity == "CRITICAL"
	fixable(f)
	not excepted(f)
	msg := sprintf("%s in %s %s is critical (fix: %s)", [f.cve, f.package, f.version, fix_text(f)])
}

# Exceptions live in exceptions.yaml and are valid through their expiry date (UTC).
# valid_until is undefined for a missing or malformed date, so such an entry never counts.
valid_until(e) := time.add_date(time.parse_ns("2006-01-02", e.expires), 0, 0, 1)

active(e) if valid_until(e) > time.now_ns()

excepted(f) if {
	some e in data.exceptions
	e.id == f.cve
	e.package == f.package
	active(e)
}

warn contains msg if {
	some f in input.findings
	some e in data.exceptions
	e.id == f.cve
	e.package == f.package
	active(e)
	msg := sprintf("%s in %s is accepted until %s: %s (%s)", [f.cve, f.package, e.expires, e.reason, e.ticket])
}

deny contains msg if {
	some e in data.exceptions
	valid_until(e) <= time.now_ns()
	msg := sprintf("the exception for %s in %s expired on %s: fix it or renew it", [e.id, e.package, e.expires])
}

deny contains msg if {
	some e in data.exceptions
	not valid_until(e)
	msg := sprintf("the exception for %s in %s needs an expires date in YYYY-MM-DD format", [e.id, e.package])
}
exceptions: []

Nine findings block the build instead of 66, and each message says why and which version fixes it. A developer can act on this list.

Fix What the Gate Blocks

Most of the blocking findings are in Spring Framework 5.3, which reached the end of open source support in 2024. The fix is the upgrade the team has postponed: Spring Framework 6.2 (with Spring Boot 3), plus current versions of the other libraries:

# This is a Gradle generated file for dependency locking.
# Manual edits can break the build and are not advised.
# This file is expected to be part of source control.
ch.qos.logback:logback-classic:1.5.38=compileClasspath,runtimeClasspath
ch.qos.logback:logback-core:1.5.38=compileClasspath,runtimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.22=compileClasspath,runtimeClasspath
com.fasterxml.jackson.core:jackson-core:2.22.4=compileClasspath,runtimeClasspath
com.fasterxml.jackson.core:jackson-databind:2.22.4=compileClasspath,runtimeClasspath
com.google.guava:guava:33.7.2-jre=compileClasspath,runtimeClasspath
org.postgresql:postgresql:42.7.14=runtimeClasspath
org.slf4j:slf4j-api:2.0.20=compileClasspath,runtimeClasspath
org.springframework:spring-beans:6.2.19=compileClasspath,runtimeClasspath
org.springframework:spring-core:6.2.19=compileClasspath,runtimeClasspath
org.springframework:spring-web:6.2.19=compileClasspath,runtimeClasspath
org.springframework:spring-webmvc:6.2.19=compileClasspath,runtimeClasspath
org.yaml:snakeyaml:2.7=runtimeClasspath
empty=annotationProcessor

Two critical findings remain. Both were published in August 2026 and affect Spring Framework 6.2.19, the newest open source 6.2 release. The open source fix is Spring Framework 7.0.9; a fixed 6.2.20 exists only for customers of Spring's commercial Enterprise Support. Spring itself rates CVE-2026-47884 as medium, while the GitHub advisory that OSV reports rates it critical: severity labels depend on who assigns them. CVE-2026-47884 needs XsltView, and CVE-2026-47890 needs server-sent events that render view fragments. orders-api uses neither, and the move to Spring Framework 7 is a planned project, not a one-line change. This is what exceptions are for.

Exceptions That Expire

Add both findings to exceptions.yaml with the reason, the ticket that tracks the real fix, and a date:

exceptions:
  - id: CVE-2026-47884
    package: org.springframework:spring-webmvc
    reason: orders-api does not use XsltView; the fix needs Spring Framework 7
    ticket: ORD-412
    expires: '2026-12-31'
  - id: CVE-2026-47890
    package: org.springframework:spring-webmvc
    reason: orders-api does not render view fragments over SSE; the fix needs Spring Framework 7
    ticket: ORD-412
    expires: '2026-12-31'

The build passes, and the accepted risk is still printed on every run. On 1 January 2027 the exception stops working and the gate fails with "the exception for CVE-2026-47884 ... expired". Someone has to fix the finding or renew the exception in a reviewed pull request. Test that behaviour once, so a refactor of the policy cannot break it. OPA's with keyword can replace the clock in a test:

package vulns_test

import data.vulns

finding := {
	"cve": "CVE-2026-47884",
	"package": "org.springframework:spring-webmvc",
	"version": "6.2.19",
	"severity": "CRITICAL",
	"fixed": ["7.0.9"],
	"kev": false,
	"epss": 0.006,
}

exception := {
	"id": "CVE-2026-47884",
	"package": "org.springframework:spring-webmvc",
	"reason": "XsltView is not used",
	"ticket": "ORD-412",
	"expires": "2026-12-31",
}

dec_1_2026 := time.parse_rfc3339_ns("2026-12-01T00:00:00Z")

dec_31_2026_noon := time.parse_rfc3339_ns("2026-12-31T12:00:00Z")

jan_1_2027 := time.parse_rfc3339_ns("2027-01-01T00:00:00Z")

test_critical_with_fix_is_denied if {
	count(vulns.deny) == 1 with input as {"findings": [finding]}
		with data.exceptions as []
}

test_valid_exception_turns_deny_into_warn if {
	count(vulns.deny) == 0 with input as {"findings": [finding]}
		with data.exceptions as [exception]
		with time.now_ns as dec_1_2026
	count(vulns.warn) == 1 with input as {"findings": [finding]}
		with data.exceptions as [exception]
		with time.now_ns as dec_1_2026
}

test_exception_is_valid_on_its_last_day if {
	count(vulns.deny) == 0 with input as {"findings": [finding]}
		with data.exceptions as [exception]
		with time.now_ns as dec_31_2026_noon
}

test_expired_exception_fails_the_gate if {
	msgs := vulns.deny with input as {"findings": [finding]}
		with data.exceptions as [exception]
		with time.now_ns as jan_1_2027
	"the exception for CVE-2026-47884 in org.springframework:spring-webmvc expired on 2026-12-31: fix it or renew it" in msgs
}

test_exception_without_a_valid_date_does_not_count if {
	undated := object.remove(exception, ["expires"])
	msgs := vulns.deny with input as {"findings": [finding]}
		with data.exceptions as [undated]
	count(msgs) == 2
}

test_kev_blocks_even_without_a_fix if {
	kev := object.union(finding, {"kev": true, "fixed": []})
	count(vulns.deny) == 1 with input as {"findings": [kev]}
		with data.exceptions as []
}

All 12 tests pass: the six new ones and the six Kubernetes policy tests from part 1.

If you prefer to keep exceptions in the scanner's own format, each tool has one. Trivy and OSV-Scanner support an expiry date; Grype does not:

Warning

Trivy's --ignore-policy flag also accepts a Rego file, but Trivy 0.76 still compiles that file as Rego v0. A policy written in the v1 syntax from part 1 fails to load there unless it starts with import rego.v1. Prefer .trivyignore.yaml, or gate on Trivy's JSON output with Conftest as shown above.

Not Affected: VEX

An exception says "this affects us and we accept it for now". Sometimes the honest answer is "this does not affect us at all": the vulnerable function is never called, or the vulnerable feature is turned off. VEX (Vulnerability Exploitability eXchange) is the standard way to write that down so that scanners and your customers can read it. Trivy (--vex, still marked experimental) and Grype (--vex) both read OpenVEX documents:

{
  "@context": "https://openvex.dev/ns/v0.2.0",
  "@id": "https://example.com/vex/orders-api-2026-10-23",
  "author": "orders-api team",
  "timestamp": "2026-10-23T09:00:00Z",
  "version": 1,
  "statements": [
    {
      "vulnerability": { "name": "CVE-2026-47884" },
      "products": [
        {
          "@id": "pkg:oci/orders-api?repository_url=ghcr.io/example/orders-api",
          "subcomponents": [{ "@id": "pkg:maven/org.springframework/spring-webmvc@6.2.19" }]
        }
      ],
      "status": "not_affected",
      "justification": "vulnerable_code_not_in_execute_path",
      "impact_statement": "orders-api does not register or render XsltView."
    }
  ]
}

Use VEX only for statements you can defend in an audit, and review them when the code changes. A "not affected" statement that was true for version 1.4 can be false for 1.5.

Gate on SBOMs, Not Just Builds

A gate at build time only knows about CVEs published before the build. Spring4Shell was published years after most of the affected applications were deployed. To catch new CVEs in software that is already running, keep a software bill of materials (SBOM) for every release and scan the SBOMs on a schedule.

Trivy can generate a CycloneDX SBOM without downloading its vulnerability database, and OSV-Scanner reads CycloneDX and SPDX files directly:

The SBOM lists 14 components: the 13 libraries and the lockfile itself. Scanning it finds the same two Spring findings as the lockfile scan. Store the SBOM with each release (as a build artifact, or attached to the image as a signed attestation, see part 4), and run the same enrich-and-gate steps against it every night. A new KEV entry for something you shipped last month then opens a ticket the next morning instead of waiting for the next build. In a cluster, the Trivy Operator does the same for running workloads and writes VulnerabilityReport resources.

Gate New Dependencies in Pull Requests

On GitHub, the dependency review action compares the dependency graph of a pull request with the base branch, and fails only on vulnerabilities and licenses that the pull request adds. That makes it a good first gate for an old repository: existing debt does not block anyone, but nobody can add new debt.

name: Dependency review
on: pull_request

permissions:
  contents: read

jobs:
  dependency-review:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
        with:
          persist-credentials: false
      - uses: actions/dependency-review-action@v5
        with:
          fail-on-severity: high
          allow-licenses: Apache-2.0, MIT, BSD-2-Clause, BSD-3-Clause, ISC

It works on public repositories, and on private repositories with GitHub Code Security or GitHub Advanced Security. Part 4 pins these actions to commit SHAs.

Roll the Gate Out

  1. Report only. Run the scan and the policy, but do not fail the job (continue-on-error: true in GitHub Actions). Publish the results.
  2. Block KEV. Turn on the KEV rule first. It rarely fires, and when it fires nobody argues.
  3. Block high EPSS and fixable criticals. Add the other two rules once the KEV findings are fixed.
  4. Tune the threshold. Look at what the EPSS rule blocked in the last month. Lower the threshold if the team keeps up, raise it if the gate is ignored.

Track a few numbers: how many builds the gate blocks, the median age of open findings, and how many exceptions are active. If exceptions keep growing, the rules are too strict or the team needs time on the backlog.

Common Mistakes

  • Blocking on every critical CVE in an old codebase. The build stays red until the whole backlog is cleared. Start with KEV and fixable findings, and use dependency review to stop new debt.
  • Using --ignore-unfixed everywhere. It hides unfixed vulnerabilities that are in KEV, which are the ones that need a mitigation today.
  • Exceptions without an expiry date. They turn into permanent holes. Every exception needs an owner, a reason and a date.
  • Scanning only at build time. New CVEs appear every day for code you already shipped. Rescan SBOMs on a schedule.
  • Treating EPSS as proof. A high EPSS score predicts exploitation activity against the CVE in general. It does not prove exploitation (KEV is the evidence for that), and it says nothing about whether your code calls the vulnerable function.
  • Downloading the scanner database in every job. Cache it, or use a scanner that queries an API.

Summary

  • Scan lockfiles, images and SBOMs with Trivy, Grype or OSV-Scanner, and pass the JSON output to a gate policy.
  • Block what is in CISA KEV, has a high EPSS score with a fix, or is critical with a fix. Send everything else to the backlog.
  • Keep exceptions in Git with a reason, a ticket and an expiry date, and test that expired exceptions fail the gate.
  • Use VEX for "not affected" statements, and rescan stored SBOMs every day.

Next, Compliance Gates checks infrastructure code against CIS, PCI DSS and SOC 2 controls before it is applied.

Keep Learning

Updated:

Tags

SecurityDevSecOpsCI/CDQuality GatesPolicy as CodeVulnerability ManagementComplianceKubernetes

Found an issue?