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-locationbefore you publish it - Attach the statement to the image with
docker scout attestation add, so every Scout scan of the image applies it
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
- Scan the image and look at the finding.
- Gather the evidence that the vulnerable code isn’t reachable.
- Write an OpenVEX statement with
vexctl. - Check it against the image locally with
--vex-location. - 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.logThe 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.0Two things are worth noticing:
Fixed version : not fixed. Every version ofipup 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-manifestThe 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 withpkg:docker/, and the repository and version are separated by@. The version here is the image digest. A tag, as inpkg: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 detectedThe 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 documentmeans Scout read the file, not that it applied it.--vex-authormust match the document’sauthor. 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.0matches 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.1above). - The status. Only
not_affectedsuppresses.
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 detectedThis 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
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
--subcomponentscovers the CVE anywhere in the image. - Tie it to a release. This statement names the
1.0image by its digest. When you ship1.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 callisPublic()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 adddoesn’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
- Turn expectations like “no unassessed high-severity CVEs” into checks that fail a build: Enforce your organization’s container standards.
- Fix the vulnerabilities that do have a fix: Find and fix vulnerabilities before you ship.
- Start from a base image that already carries SBOM, provenance, and VEX attestations: Build your application on a trusted foundation.
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.
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.
Learning path
Secure your software supply chain
Step 3 of 7
- Step 1: Build your application on a trusted foundation
- Step 2: Find and fix vulnerabilities before you ship
- Step 3: Document vulnerabilities that don't affect your application (this page)
- Step 4: Enforce your organization's container standards
- Step 5: Prove where your container image came from
- Step 6: Build a trusted container delivery pipeline
- Step 7: Only run trusted images in production
Table of contents