Guide

Document vulnerabilities that don't affect your application

  • Intermediate
  • 25 min
  • Published
  • Verified

What you'll learn

  • Decide whether a CVE is not_affected, and choose the justification that records why
  • Write an OpenVEX statement for your image with vexctl create
  • Check the statement against your image with docker scout cves --vex-location before you publish it
  • Attach the statement to the image with docker scout attestation add, so every Scout scan of the image applies it

The terminals below are simulated. Nothing runs on your machine, and each one starts from the state the guide has reached at that point.

Docker Scout reports one high-severity vulnerability in your API image: CVE-2024-29415, in the npm package ip. Every release, someone sees it in the scan, opens the advisory, and reaches the same conclusion as last time. The flaw is in ip.isPublic(), which can misclassify some addresses. Your application never calls it. There’s also no fixed version to upgrade to.

That conclusion lives in a chat thread and in a few people’s heads. The scanner doesn’t know about it, so the CVE keeps showing up, and neither does anyone who pulls your image and scans it themselves.

VEX (Vulnerability Exploitability eXchange) is a standard way to write that conclusion down. A VEX statement says, in a machine-readable form, “this product is not affected by this CVE, and here is why.” Docker Scout reads VEX statements and leaves the CVEs they cover out of its results. In this guide, you write a statement for the ip finding, check it locally, and attach it to the image so it goes wherever the image goes.

What you’ll do

  1. Scan the image and look at the finding.
  2. Gather the evidence that the vulnerable code isn’t reachable.
  3. Write an OpenVEX statement with vexctl.
  4. Check it against the image locally with --vex-location.
  5. Attach it to the image in the registry, and confirm Scout applies it without being told where to look.

This guide doesn’t cover fixing vulnerabilities. If a fixed version exists, upgrading is almost always the better answer. See Find and fix vulnerabilities before you ship. VEX is for the findings that remain after that.

Prerequisites

To follow along on your own machine, you need:

  • Docker Desktop, or Docker Engine with the Docker Scout CLI plugin. See Install Docker Scout.
  • The containerd image store enabled. Attaching a VEX document as an attestation requires it.
  • vexctl, the OpenVEX command-line tool.
  • Node.js and npm, to inspect the dependency tree.
  • A Docker Hub repository you can push to. The examples use myorg/my-app. Attaching an attestation needs write access to the repository.
  • A Docker ID signed in to dhi.io (docker login dhi.io) to pull the Docker Hardened Images base image. Docker Hardened Images Community is free with a Docker ID.

Starting state

The example is ~/my-app, a small Express API. It uses the ip package for one thing: printing the container’s address when it starts.

my-app/
├── .dockerignore
├── Dockerfile
├── index.js
├── package.json
└── package-lock.json
{
  "name": "my-app",
  "version": "1.0.0",
  "main": "index.js",
  "dependencies": {
    "express": "^4.22.3",
    "ip": "2.0.1"
  }
}
const express = require("express");
const ip = require("ip");

const app = express();

app.get("/", (req, res) => {
  res.json({ status: "ok" });
});

app.listen(3000, () => {
  console.log(`my-app listening on http://${ip.address()}:3000`);
});

package-lock.json is the lockfile npm install writes. The build installs from it with npm ci, which fails without one. To recreate this state in an empty project, write package.json and run npm install.

.dockerignore keeps your local node_modules out of the build context, so COPY . . can’t overwrite the dependencies the build installed:

node_modules
npm-debug.log

The image builds on a Docker Hardened Image (DHI). The build stage uses the -dev variant, which has npm, and the final stage uses the runtime variant, which doesn’t:

# syntax=docker/dockerfile:1

FROM dhi.io/node:22-alpine3.23-dev AS builder
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .

FROM dhi.io/node:22-alpine3.23
ENV PATH=/app/node_modules/.bin:$PATH
COPY --from=builder --chown=node:node /usr/src/app /app
WORKDIR /app
CMD ["node", "index.js"]

The runtime image has no entrypoint, so CMD names both node and the script.

Build it with an SBOM and maximum-mode provenance, and push it. Both attestations matter here, for a reason the last section explains:

Host machine · in ~/my-app

docker buildx build --sbom=true --provenance=mode=max --push -t myorg/my-app:1.0 .
[+] Building 28.9s (19/19) FINISHED                        docker:desktop-linux
 => [internal] load build definition from Dockerfile                       0.0s
 => [builder 1/5] FROM dhi.io/node:22-alpine3.23-dev@sha256:6721841fb09d  18.5s
 => [builder 4/5] RUN npm ci --omit=dev                                    1.3s
 => [stage-1 2/3] COPY --from=builder --chown=node:node /usr/src/app /app  0.0s
 => exporting to image                                                     0.2s
 => => pushing layers                                                      0.0s
 => => pushing manifest for docker.io/myorg/my-app:1.0@sha256:50537fb69e9  0.0s

See the finding

Scan the image:

Host machine · in ~/my-app

docker scout cves myorg/my-app:1.0
    ✓ Image stored for indexing
    ✓ SBOM obtained from attestation, 77 packages found
    ✓ Provenance obtained from attestation
    ✓ Pulled
    ✗ Detected 1 vulnerable package with 1 vulnerability


## Overview

                   │       Analyzed Image
───────────────────┼─────────────────────────────
 Target            │  myorg/my-app:1.0
   digest          │  0d5930abcc33
   platform        │ linux/arm64
   vulnerabilities │    0C     1H     0M     0L
   size            │ 38 MB
   packages        │ 77
                   │
 Base image        │  dhi.io/node:22-alpine3.23
                   │  9e2168660d60


## Packages and Vulnerabilities

   0C     1H     0M     0L  ip 2.0.1
pkg:npm/ip@2.0.1

Dockerfile (11:11)
COPY --from=builder --chown=node:node /usr/src/app /app

    ✗ HIGH CVE-2024-29415 [Server-Side Request Forgery (SSRF)]
      https://scout.docker.com/v/CVE-2024-29415?s=github&n=ip&t=npm&vr=%3C%3D2.0.1
      Affected range : <=2.0.1
      Fixed version  : not fixed
      CVSS Score     : 8.1
      CVSS Vector    : CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H



1 vulnerability found in 1 package
  CRITICAL  0
  HIGH      1
  MEDIUM    0
  LOW       0


What's next:
    View base image update recommendations → docker scout recommendations myorg/my-app:1.0

Two things are worth noticing:

  • Fixed version : not fixed. Every version of ip up to and including 2.0.1, the latest, is affected. There’s nothing to upgrade to. You could replace the package, but that’s a code change you schedule, not a fix you ship today.
  • The base image contributes no findings. Scout traced the base image, dhi.io/node:22-alpine3.23, through the provenance attestation, and none of its packages has a known vulnerability. The one finding is in a package you added. You’ll come back to the base image at the end.

Decide whether the CVE affects you

A VEX statement is only as good as the reasoning behind it, so do the reasoning first. The advisory for CVE-2024-29415 says that ip.isPublic() treats some addresses, such as 127.1 and ::fFFf:127.0.0.1, as publicly routable. An application that uses isPublic() to decide whether a user-supplied URL is safe to fetch could be tricked into requesting an internal address.

Check where ip comes from and what calls it:

Host machine · in ~/my-app

npm ls ip
my-app@1.0.0 /home/moby/my-app
└── ip@2.0.1
grep -rnE "isPublic|isPrivate" --include=*.js --exclude-dir=node_modules .
grep -n "ip\." index.js
11:  console.log(`my-app listening on http://${ip.address()}:3000`);

ip is a direct dependency, and nothing else in the tree pulls it in. The application makes one call, ip.address(), with no arguments. In ip 2.0.1, that path lists the machine’s own network interfaces and skips loopback addresses. It only reaches isPublic() or isPrivate() when you pass "public" or "private", and it never sees data from a request.

That is the assessment: the vulnerable function exists in the image, but the application never calls it.

Pick a status and a justification

OpenVEX gives each statement one of four statuses:

  • not_affected: the product contains the component, and the vulnerability can’t be exploited in it. This is the only status that makes Docker Scout suppress the CVE.
  • affected: it can be exploited, and the statement should say what to do about it.
  • fixed: this version contains a fix.
  • under_investigation: you’re aware of it and still assessing. Scout keeps showing the CVE.

A not_affected statement must also say why, using one of five justifications defined by the OpenVEX specification. They range from “the vulnerable component isn’t present at all” (component_not_present) to “a mitigation already exists” (inline_mitigations_already_exist). The case here, where the code is present but never run, is vulnerable_code_not_in_execute_path. See Status justifications in the specification for all five.

You can add a free-text impact statement alongside the justification. Use it. The justification code tells a scanner which category applies, and the impact statement tells the next person reviewing it what you actually checked.

Write the statement

A statement names the image it’s about. Name the image by its digest, so the statement covers exactly the build you assessed. Look up the digest of the image you pushed:

Host machine · in ~/my-app

docker buildx imagetools inspect myorg/my-app:1.0
Name:      docker.io/myorg/my-app:1.0
MediaType: application/vnd.oci.image.index.v1+json
Digest:    sha256:50537fb69e9222b67c5cbfc87c930b118408345403d6941021b844219091a87b

Manifests:
  Name:        docker.io/myorg/my-app:1.0@sha256:0d5930abcc33f33e6a61f740abcfb13f778f8483c5c01b3c3ee601ca62bfe09d
  MediaType:   application/vnd.oci.image.manifest.v1+json
  Platform:    linux/arm64

  Name:        docker.io/myorg/my-app:1.0@sha256:0e77a3a5654cd086e57d3a9113ff30ff1456602dc1f4dac6bbc275ff6c603106
  MediaType:   application/vnd.oci.image.manifest.v1+json
  Platform:    unknown/unknown
  Annotations:
    vnd.docker.reference.digest: sha256:0d5930abcc33f33e6a61f740abcfb13f778f8483c5c01b3c3ee601ca62bfe09d
    vnd.docker.reference.type:   attestation-manifest

The linux/arm64 entry is the image itself, and the unknown/unknown entry holds its SBOM and provenance attestations. The image’s digest starts with 0d5930abcc33, the digest Scout printed in its Overview.

Keep VEX documents in the repository, next to the code they describe, so a change to them goes through review like any other change. Create a .vex directory and write the statement into it:

Host machine · in ~/my-app

mkdir -p .vex
vexctl create --author="security@myorg.example" --product="pkg:docker/myorg/my-app@sha256:0d5930abcc33f33e6a61f740abcfb13f778f8483c5c01b3c3ee601ca62bfe09d" --subcomponents="pkg:npm/ip@2.0.1" --vuln="CVE-2024-29415" --status="not_affected" --justification="vulnerable_code_not_in_execute_path" --impact-statement="my-app calls only ip.address() with no arguments, which never reaches isPublic() or isPrivate(). No request data is passed to the ip package." --file=".vex/CVE-2024-29415.vex.json"
 > VEX document written to .vex/CVE-2024-29415.vex.json

On your own machine, you can split that command across lines with \:

vexctl create \
  --author="security@myorg.example" \
  --product="pkg:docker/myorg/my-app@sha256:0d5930abcc33f33e6a61f740abcfb13f778f8483c5c01b3c3ee601ca62bfe09d" \
  --subcomponents="pkg:npm/ip@2.0.1" \
  --vuln="CVE-2024-29415" \
  --status="not_affected" \
  --justification="vulnerable_code_not_in_execute_path" \
  --impact-statement="my-app calls only ip.address() with no arguments, which never reaches isPublic() or isPrivate(). No request data is passed to the ip package." \
  --file=".vex/CVE-2024-29415.vex.json"

Each flag answers one question:

  • --author: who is making the claim. OpenVEX requires it. Use an address that reaches whoever owns the assessment, not a personal one that leaves when they do.
  • --product: what the claim is about, as a package URL (PURL). An image PURL starts with pkg:docker/, and the repository and version are separated by @. The version here is the image digest. A tag, as in pkg:docker/myorg/my-app@1.0, is valid OpenVEX, but Docker Scout 1.26.0 doesn’t match it to an image whose SBOM comes from an attestation, like this one.
  • --subcomponents: which package inside the image carries the CVE. Leave it out and the statement covers the CVE wherever it appears in the image, which is broader than you usually mean.
  • --vuln, --status, --justification, and --impact-statement: the assessment itself.

vexctl prints only where it wrote the document. Look at what it wrote:

Host machine · in ~/my-app

cat .vex/CVE-2024-29415.vex.json
{
  "@context": "https://openvex.dev/ns/v0.2.0",
  "@id": "https://openvex.dev/docs/public/vex-4ef1b086072f727a77197680ab5d684512e606be64098ea50635f6b5a1e86505",
  "author": "security@myorg.example",
  "version": 1,
  "statements": [
    {
      "vulnerability": {
        "name": "CVE-2024-29415"
      },
      "products": [
        {
          "@id": "pkg:docker/myorg/my-app@sha256:0d5930abcc33f33e6a61f740abcfb13f778f8483c5c01b3c3ee601ca62bfe09d",
          "subcomponents": [
            {
              "@id": "pkg:npm/ip@2.0.1"
            }
          ]
        }
      ],
      "status": "not_affected",
      "justification": "vulnerable_code_not_in_execute_path",
      "impact_statement": "my-app calls only ip.address() with no arguments, which never reaches isPublic() or isPrivate(). No request data is passed to the ip package.",
      "timestamp": "2026-10-16T18:38:45.154152846Z"
    }
  ],
  "timestamp": "2026-10-16T18:38:45Z"
}

A document holds a list of statements. This one has a single statement, and vexctl generated the document @id from a hash of its contents. When you assess another CVE, write another file. Keeping one CVE per file keeps the review history readable.

Check it against the image

Before you publish a statement, confirm Scout reads it the way you meant. --vex-location points a local analysis at a file or directory of VEX documents. --vex-author says whose statements to accept. Without it, Scout accepts only statements written by Docker, and loads yours without applying it:

Host machine · in ~/my-app

docker scout cves myorg/my-app:1.0 --vex-location .vex --vex-author security@myorg.example
    ✓ Image stored for indexing
    ✓ SBOM obtained from attestation, 77 packages found
    ✓ Provenance obtained from attestation
    ✓ Pulled
    ✓ Loaded 1 VEX document
    ✓ No vulnerable package detected


## Overview

                   │       Analyzed Image
───────────────────┼─────────────────────────────
 Target            │  myorg/my-app:1.0
   digest          │  0d5930abcc33
   platform        │ linux/arm64
   vulnerabilities │    0C     0H     0M     0L
   size            │ 38 MB
   packages        │ 77
                   │
 Base image        │  dhi.io/node:22-alpine3.23
                   │  9e2168660d60


## Packages and Vulnerabilities

  No vulnerable packages detected

The finding is gone. Nothing about the image changed. Scout matched your statement to the image (by its digest), the package (pkg:npm/ip@2.0.1), and the CVE, and filtered the CVE out because the status is not_affected. A suppressed CVE isn’t listed or counted at all: the Overview now shows 0H.

This check is how you validate a VEX document with Scout. A statement that doesn’t match anything fails silently. It doesn’t cause an error, and the CVE stays in the results. If the CVE is still there, check:

  • The author. Loaded 1 VEX document means Scout read the file, not that it applied it. --vex-author must match the document’s author. It defaults to <.*@docker.com>, so without the flag your statement is ignored.
  • The image PURL. It must start with pkg:docker/, and the version must follow an @. pkg:docker/myorg/my-app:1.0 matches nothing, and neither does the tag form, pkg:docker/myorg/my-app@1.0. Use the digest.
  • The package PURL and version. It must name the version that’s in the image, as Scout prints it under the package (pkg:npm/ip@2.0.1 above).
  • The status. Only not_affected suppresses.

Attach the statement to the image

The .vex directory works for you, in this checkout. Anyone else who scans myorg/my-app:1.0 still sees the CVE, because the statement isn’t anywhere they can find it. Attach it to the image as an attestation. It’s stored in the registry with the image, so anyone who can pull the image can read it, and Scout applies it without being told where to look.

You don’t need to rebuild or push the image again. Attach the document to the image that’s already in the registry:

Host machine · in ~/my-app

docker scout attestation add --file .vex/CVE-2024-29415.vex.json --predicate-type https://openvex.dev/ns/v0.2.0 myorg/my-app:1.0
    ✓ Successfully attached 1 attestation

--predicate-type tells consumers how to read the attached document. https://openvex.dev/ns/v0.2.0 is the in-toto predicate type for OpenVEX.

docker scout attestation is marked experimental in the CLI reference. You can’t remove an attestation once it’s added. Attaching a new VEX document replaces the earlier one for Scout’s purposes, but the old attestation stays.

Confirm the CVE is suppressed for everyone

Scan again, this time without --vex-location, so the only place Scout can find your statement is the registry. Use the registry:// prefix. You built this image on this machine, so without the prefix Scout analyzes your local copy, which doesn’t have the new attestation:

Host machine · in ~/my-app

docker scout cves registry://myorg/my-app:1.0
    ✓ SBOM obtained from attestation, 77 packages found
    ✓ Provenance obtained from attestation
    ✓ VEX statements obtained from attestation
    ✓ No vulnerable package detected

This is the result a teammate, a CI job, or a customer sees when they scan the image. Their scan reads your statement from the image’s attestations, next to its SBOM and provenance.

To see everything attached to the image, run docker scout attest list registry://myorg/my-app:1.0.

Understand what happened

Where Docker Scout finds VEX statements Three sources of VEX statements feed into a Docker Scout analysis. The first is the Docker Hardened Image's own VEX attestation on dhi.io/node, which Scout reaches through your image's provenance. The second is your VEX attestation, attached to myorg/my-app:1.0 in the registry. The third is local VEX files passed with --vex-location. Scout matches each statement to the image, package, and CVE, drops the CVEs marked not_affected, and reports only the findings that still apply. VEX SOURCES Hardened base image VEX on dhi.io/node, found through provenance Your VEX attestation attached to myorg/my-app:1.0 Local VEX files --vex-location .vex Docker Scout matches image, package, and CVE, drops not_affected Scan result only the findings that still apply

A scanner matches package versions against advisories. It can tell you that ip 2.0.1 is in the image and that CVE-2024-29415 affects ip 2.0.1. It can’t tell you whether your code calls the vulnerable function. VEX is how the people who know the code supply that missing fact, in a format any VEX-aware scanner can read.

Docker Hardened Images can ship VEX for their own packages

Docker publishes VEX statements for Docker Hardened Images, covering CVEs reported against packages in an image that Docker has assessed as not exploitable as shipped. Scout applies them automatically. Not every image has them: dhi.io/node:22-alpine3.23 ships no VEX attestation today, which is why your first scan had no VEX statements obtained from attestation line. Its packages had no findings to suppress.

For an image built on a DHI, Scout has to know which base image you used before it can apply them. It reads that from the provenance attestation, which is why the build used --provenance=mode=max --sbom=true. Without them, Scout can’t trace the base image, and your scan reports CVEs that Docker has already assessed.

Your statement and Docker’s don’t need to be merged. Scout reads the VEX attestations along the provenance chain and applies each one: Docker’s to the base image’s packages, and yours to what you added. To export Docker’s statements for another scanner, use docker scout vex get. See Scan Docker Hardened Images.

Why an attestation, and not a file in the image

You can also COPY a *.vex.json file into the image, and Scout reads it from the filesystem. It’s harder to keep current. Changing the assessment means rebuilding the image, and Scout ignores filesystem VEX entirely when the image has any attestations. An image built with SBOM and provenance always does. An attestation can be added after the image is built and pushed, and replaced without touching the image.

Docker Scout also has exceptions you create in the Scout Dashboard or Docker Desktop, which are easier to manage across many images. Only members of your Docker organization can see them. A VEX attestation is visible to anyone who can pull the image, which is what you want when people outside your organization consume it.

A VEX statement is a claim you have to stand behind

Suppressing a CVE removes it from everyone’s view, so treat a VEX statement the way you’d treat a security sign-off:

  • Scope it tightly. Name the image, the package, and the version. A statement without --subcomponents covers the CVE anywhere in the image.
  • Tie it to a release. This statement names the 1.0 image by its digest. When you ship 1.1, that’s a new image with a new digest, so the statement no longer applies, and that’s deliberate. Reassess, because the code might call isPublic() by then, and issue a new statement.
  • Keep the reasoning with it. The impact statement and the pull request that added the file are your audit trail.
  • Know who can add one. docker scout attestation add doesn’t sign the attestation, so anyone with push access to the repository can attach a statement. Signing attestations, so consumers can check who made a claim, is covered in Prove where your container image came from.

Where to go next

For every vexctl flag, see the vexctl repository. For the document format, see the OpenVEX specification. For Scout’s side, see Create an exception using VEX in the Docker Docs.

Learning paths

Part of a learning path

This works on its own and is also included in the paths below. Open a path to see the complete route.