Share feedback
Answers are generated based on the documentation.

Explore VEX statements in Docker Hardened Images

Standard vulnerability scanners report CVEs against packages present in an image. With Docker Hardened Images, those packages are there by design, but each reported CVE has a VEX statement explaining whether it is exploitable in this specific product configuration. This guide walks through scanning a Docker Hardened Image with and without VEX and auditing the justification behind every suppression.

Prerequisites

  • Docker Desktop, authenticated to dhi.io. Sign in with docker login dhi.io. Docker Desktop includes Docker Scout, which fetches the VEX attestation.
  • A vulnerability scanner. This guide shows examples for Docker Scout, Trivy, and Grype. Trivy and Grype can also run as Docker containers with no installation needed.
  • jq (optional), for filtering the VEX file.

Expose daemon for Windows users

The -v /var/run/docker.sock:/var/run/docker.sock socket mount used in the containerized scanner commands throughout this guide does not work on Docker Desktop for Windows. To use containerized scanners on Windows, go to Settings > General in Docker Desktop and turn on Expose daemon on tcp://localhost:2375 without TLS. Then replace -v /var/run/docker.sock:/var/run/docker.sock with -e DOCKER_HOST=tcp://host.docker.internal:2375 in every containerized scanner command.

Warning

Exposing the daemon on TCP without TLS makes your system vulnerable to remote code execution attacks. Turn off the setting when you are done testing.

Step 1: Scan without VEX

Sign in to the Docker Hardened Images registry:

$ docker login dhi.io

Then pull the image:

$ docker pull dhi.io/python:3.13

Then scan without VEX to see the raw CVE count. Docker Scout automatically applies VEX on Docker Hardened Images. To see the unfiltered CVE baseline, use Trivy or Grype.

$ trivy image --scanners vuln dhi.io/python:3.13

If Trivy isn't installed, run it in a container:

$ docker run --rm \
  -v /var/run/docker.sock:/var/run/docker.sock \
  aquasec/trivy:latest image --scanners vuln dhi.io/python:3.13

Example output:

Total: 30 (UNKNOWN: 0, LOW: 15, MEDIUM: 11, HIGH: 4, CRITICAL: 0)
$ grype dhi.io/python:3.13

If Grype isn't installed, run it in a container:

$ docker run --rm \
  -v /var/run/docker.sock:/var/run/docker.sock \
  anchore/grype:latest docker:dhi.io/python:3.13

Example output:

NAME          INSTALLED              FIXED IN     TYPE  VULNERABILITY       SEVERITY
libc6         2.41-12+deb13u2                     deb   CVE-2018-20796      Negligible
libc6         2.41-12+deb13u2        (won't fix)  deb   CVE-2026-4437       High
libc6         2.41-12+deb13u2        (won't fix)  deb   CVE-2026-5450       Critical
...

The output lists CVEs across libc6, libncursesw6, libsqlite3-0, libuuid1, zlib1g, and others, all runtime dependencies that Python needs to function. These packages are present by design.

A scan result like this doesn't mean every reported CVE requires patching. It means these CVEs have been reported against packages present in the image. Whether any of those CVEs are actually exploitable in this configuration is a separate question, and that's exactly what VEX answers.

Step 2: Fetch the VEX attestation

Export the VEX attestation to a local file:

$ docker scout vex get registry://dhi.io/python:3.13 --output python-vex.json

The registry:// prefix tells Scout to fetch the attestation from the registry rather than the local image store. Because you pulled the image in Step 1, it already exists locally, and without this prefix Scout would find no attestation there.

This fetches a signed OpenVEX document from registry.scout.docker.com, Docker's supply chain metadata registry for all Docker Hardened Images. The document records Docker's exploitability assessment for every CVE found in the image's SBOM.

Note

Docker Scout fetches this file automatically when scanning. You only need to download it explicitly for scanners that don't natively integrate it, or to run the jq queries in Steps 5 and 6.

Step 3: Scan with VEX applied

Docker Scout automatically fetches and applies the VEX attestation with no local file needed:

$ docker scout cves dhi.io/python:3.13

Example output:

    ✓ SBOM obtained from attestation, 47 packages indexed
    ✓ Provenance obtained from attestation
    ✓ VEX statements obtained from attestation
    ✓ No vulnerable package detected

Pass the VEX file with the --vex flag:

$ trivy image --scanners vuln --vex python-vex.json dhi.io/python:3.13

If Trivy isn't installed, run it in a container:

$ docker run --rm \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -v "$(pwd)/python-vex.json:/tmp/vex.json" \
  aquasec/trivy:latest image --scanners vuln --vex /tmp/vex.json dhi.io/python:3.13

Example output:

Total: 0 (UNKNOWN: 0, LOW: 0, MEDIUM: 0, HIGH: 0, CRITICAL: 0)

Some vulnerabilities have been ignored/suppressed. Use the '--show-suppressed' flag to display them.

Pass the VEX file with the --vex flag:

$ grype dhi.io/python:3.13 --vex python-vex.json

If Grype isn't installed, run it in a container:

$ docker run --rm \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -v "$(pwd)/python-vex.json:/tmp/vex.json" \
  anchore/grype:latest docker:dhi.io/python:3.13 --vex /tmp/vex.json

Example output:

No vulnerabilities found

Same image, same packages, same CVE database. The only difference is context. The scanner matched each CVE against the VEX file and suppressed every one that Docker assessed as not exploitable.

The packages are still there. Check the SBOM and you will see libc6, libsqlite3-0, and every other package from Step 1. Zero CVEs does not mean the packages were removed. It means each reported CVE has a documented reason why it does not apply to this product configuration.

VEX is an open standard: the attestation travels with the image and any compliant scanner reads the same reasoning.

Step 4: Inspect every suppression and its justification

Docker Scout and Grype suppress VEX-matched CVEs but do not surface the justification code in their output. Use Trivy's --show-suppressed flag to see every suppressed CVE alongside its per-CVE justification code.

$ trivy image --scanners vuln --vex python-vex.json --show-suppressed dhi.io/python:3.13

If Trivy isn't installed, run it in a container:

$ docker run --rm \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -v "$(pwd)/python-vex.json:/tmp/vex.json" \
  aquasec/trivy:latest image --scanners vuln --vex /tmp/vex.json --show-suppressed dhi.io/python:3.13

Example output:

Suppressed Vulnerabilities (Total: 28)
======================================
┌──────────────┬──────────────────┬──────────┬──────────────┬───────────────────────────────────────────────────┐
│   Library    │  Vulnerability   │ Severity │    Status    │                     Statement                     │
├──────────────┼──────────────────┼──────────┼──────────────┼───────────────────────────────────────────────────┤
│ libc6        │ CVE-2010-4756    │ LOW      │ not_affected │ vulnerable_code_cannot_be_controlled_by_adversary │
│ libsqlite3-0 │ CVE-2025-70873   │ LOW      │ not_affected │ vulnerable_code_not_present                       │
│ ...          │ ...              │ ...      │ ...          │ ...                                               │
└──────────────┴──────────────────┴──────────┴──────────────┴───────────────────────────────────────────────────┘

The Statement column shows the machine-readable justification code from the VEX file.

The justification codes have precise meanings:

  • vulnerable_code_cannot_be_controlled_by_adversary: the vulnerable code path exists in the package, but an attacker cannot trigger it in this configuration.
  • vulnerable_code_not_present: the vulnerable code was not compiled into this build or is otherwise absent.
  • inline_mitigations_already_exist: Docker has applied a backport or patch that addresses the CVE in this image.

For the full list of justification codes, see VEX status reference.

Every suppression is documented, auditable, and verifiable with any VEX-enabled scanner.

Step 5: Read Docker's reasoning for a specific CVE

The justification codes are machine-readable; the status_notes field in the VEX file contains Docker's human-readable reasoning. Use jq to look up a specific CVE:

$ jq '.statements[] | select(.vulnerability.name == "CVE-2010-4756") | {status, justification, status_notes}' python-vex.json

Example output:

{
  "status": "not_affected",
  "justification": "vulnerable_code_cannot_be_controlled_by_adversary",
  "status_notes": "Standard POSIX behavior in glibc. Applications using glob need to impose limits themselves. Requires authenticated access and is considered unimportant by Debian."
}

The status_notes field explains Docker's reasoning in plain language. For CVE-2010-4756, the glob behavior described by the CVE is standard POSIX behavior, requires authenticated access, and is classified as unimportant by the Debian security team.

Each statement also lists the affected products as Package URLs (PURLs), for example pkg:deb/debian/glibc@2.41-12%2Bdeb13u2?os_distro=trixie&os_name=debian&os_version=13. Trivy matched this statement to libc6 in the image's SBOM by comparing that PURL against the packages recorded in the SBOM.

Important

PURL matching is strict. Scanners must match VEX statements to packages using the full PURL string, including the os_name, os_version, and os_distro qualifiers. Matching on package name alone risks applying a suppression from one OS version to a different version where the CVE is exploitable.

Step 6: Filter VEX statements by status

Once you have python-vex.json, use jq to query it directly.

Count statements by status:

$ jq '[.statements[].status] | group_by(.) | map({status: .[0], count: length})' python-vex.json

List all CVEs under active investigation:

$ jq '[.statements[] | select(.status == "under_investigation") | {cve: .vulnerability.name, products: [.products[]."@id"]}]' python-vex.json

List any CVEs with affected status:

$ jq '[.statements[] | select(.status == "affected") | {cve: .vulnerability.name, action: .action_statement}]' python-vex.json

The affected query returns an empty array for the current dhi.io/python:3.13 image, which is the expected result for an actively maintained tag. To see affected entries across all DHI Python versions, query the full VEX feed:

$ curl -s https://raw.githubusercontent.com/docker-hardened-images/advisories/main/vex/python/dhi-python.vex.json \
  | jq '[.statements[] | select(.status == "affected") | {cve: .vulnerability.name, action: .action_statement}]'

For status definitions and justification codes, see the VEX status reference.

What's next