Find and fix vulnerabilities before you ship
- Intermediate
- 25 min
- Published
- Verified
What you'll learn
- Get a vulnerability overview of an image with
docker scout quickview, and tell what your base image contributes from what you added - Narrow
docker scout cvesto actionable findings with--only-fixed,--only-severity, and--only-package-type - Find a newer build of your base image with
docker scout recommendations - Fix vulnerable npm dependencies and refresh the base image, then prove the result with
docker scout compare
Version 1.0 of your team’s API is tagged and ready to go out. Before it does, someone asks the reasonable question: what known vulnerabilities are in it? You run a scanner, and it reports twenty-five of them, across packages you never chose directly. Some are high severity. Some have no fix. About half are in the base image, and the rest are buried in your dependency tree.
Twenty-five findings is not twenty-five tasks. The skill is sorting them: which ones you can fix today, which ones you fix by changing something other than your own code, and which ones you can’t do anything about yet. In this guide, you use the Docker Scout CLI to do that sorting on a real-shaped image, fix what’s actionable, and then prove the new image is better than the old one rather than assuming it.
What you’ll do
Starting from a Node.js API built on a Docker Hardened Image, you:
- Get an overview with
docker scout quickview. - Narrow the list with
docker scout cves, see how much of the risk comes from the base image versus what you added, and filter until only the findings you can act on remain. - Check the base image with
docker scout recommendations. - Fix the dependencies and refresh the base, then rebuild.
- Prove it with
docker scout compare.
The terminals on this page are simulations. Click through them to see what each command prints, then run the same commands against your own project.
Prerequisites
To follow along on your own machine, you need:
- Docker Desktop, which includes the Docker Scout CLI plugin and Buildx. On Docker Engine without Desktop, install the Scout plugin separately.
- A Docker account, signed in with
docker login, and a Docker Hub repository you can push to. The examples usemyorg/my-app; substitute your own namespace. - Access to Docker Hardened Images, signed in with
docker login dhi.io. The community images are free with a Docker ID. - An image store that keeps build attestations. Use the
containerd image store
in Docker Desktop, or a builder that uses the
docker-containerdriver. - Node.js and npm, to update the lockfile.
Your own scan results will differ from the ones shown here. Vulnerability data changes daily, and so do your dependencies.
The starting point
The project is ~/my-app, a small Express API that listens on port 3000:
my-app/
├── .dockerignore
├── Dockerfile
├── index.js
├── package.json
└── package-lock.jsonconst express = require("express");
const app = express();
app.get("/health", (req, res) => res.json({ status: "ok" }));
app.listen(3000, () => console.log("Listening on port 3000"));The package.json allows any 4.x release of Express from 4.19.2 up:
{
"name": "my-app",
"version": "1.0.0",
"private": true,
"main": "index.js",
"dependencies": {
"express": "^4.19.2"
}
}The package-lock.json, though, was generated when 4.19.2 was current, and it
still pins Express and everything beneath it to the versions resolved that
day. npm ci installs exactly what the lockfile says, so the image gets those
versions no matter what the range in package.json allows. To recreate this
state in an empty project, run npm install express@4.19.2.
.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 Dockerfile is a multi-stage build on a Docker Hardened Image (DHI). The
-dev variant installs dependencies, and the minimal runtime variant, with no
shell or package manager, is what ships:
# 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"]To learn why the image is built this way, see Build your application on a trusted foundation.
Build and push version 1.0 with an SBOM and maximum-mode provenance:
~/my-app
docker buildx build --sbom=true --provenance=mode=max --push -t myorg/my-app:1.0 .
[+] Building 13.1s (22/22) FINISHED docker:desktop-linux => [internal] load build definition from Dockerfile 0.0s => [builder 3/5] COPY package*.json ./ 0.0s => [builder 4/5] RUN npm ci --omit=dev 0.8s => [stage-1 2/3] COPY --from=builder --chown=node:node /usr/src/app /app 0.0s => exporting to image 0.4s => => pushing manifest for docker.io/myorg/my-app:1.0@sha256:7759a66533f 0.0s
The attestation flags matter for scanning, not only for provenance. Scout reads the provenance attestation to identify the DHI base image and apply the VEX statements Docker publishes for it. Without max-mode provenance, Scout can’t trace the base and reports CVEs that Docker has already assessed as not affecting the image. That’s noise you’d then have to sort through by hand.
Get the overview
Start with docker scout quickview. It summarizes the image’s
vulnerabilities by severity, and checks the image against Docker Scout’s
default policies:
~/my-app
docker scout quickview myorg/my-app:1.0
✓ SBOM obtained from attestation, 77 packages found
✓ Provenance obtained from attestation
✓ SBOM obtained from attestation, 12 packages found
✓ Provenance obtained from attestation
✓ VEX statements obtained from attestation
✓ VEX statements obtained from attestation
✓ Policy evaluation completed
Target │ myorg/my-app:1.0 │ 0C 7H 9M 9L
digest │ 338d1dc055ba │
Base image │ dhi.io/node:22-alpine3.23 │
Policy status FAILED (5/7 policies met)
Health score B (72%)
Status │ Policy │ Results
────────┼────────────────────────────────────────────────┼─────────────────────────────
✓ │ Default non-root user │
! │ Copyleft licensed packages found │ 7 packages
! │ Fixable critical or high vulnerabilities found │ 0C 7H 0M 0L
✓ │ No high-profile vulnerabilities │ 0C 0H 0M 0L
✓ │ No outdated base images │
✓ │ No unapproved base images │ 0 deviations
✓ │ Supply chain attestations │ 0 deviations
What's next:
View policy violations → docker scout policy myorg/my-app:1.0
View vulnerabilities → docker scout cves myorg/my-app:1.0
Compare with the latest in the registry → docker scout compare --to-latest myorg/my-app:1.0The Target row counts the whole image’s findings by severity: critical,
high, medium, and low. Here that’s 7 high, 9 medium, and 9 low. The
Base image row names dhi.io/node:22-alpine3.23, which Scout read from the
provenance attestation, but it doesn’t count the base image’s findings
separately. The next command shows where they come from.
Below the counts, Scout evaluates the image against its default policies. One of them asks the question this guide is about: are there fixable critical or high vulnerabilities? There are 7.
Narrow the list
docker scout cves lists every finding. On a real image, the full output
runs to hundreds of lines, so start with a package-level view:
~/my-app
docker scout cves --format only-packages --only-vuln-packages myorg/my-app:1.0
✓ SBOM obtained from attestation, 77 packages found
✓ Provenance obtained from attestation
✓ SBOM obtained from attestation, 12 packages found
✓ Provenance obtained from attestation
✓ VEX statements obtained from attestation
✗ Detected 9 vulnerable packages with a total of 25 vulnerabilities
Name │ Version │ Type │ Vulnerabilities
────────────────┼────────────┼──────┼─────────────────────────────
body-parser │ 1.20.2 │ npm │ 0C 1H 0M 1L
cookie │ 0.6.0 │ npm │ 0C 0H 0M 1L
coreutils-env │ 9.8-r1 │ apk │ 0C 0H 2M 1L
express │ 4.19.2 │ npm │ 0C 0H 0M 1L
nodejs-22 │ 22.23.0-r0 │ apk │ 0C 3H 5M 2L
path-to-regexp │ 0.1.7 │ npm │ 0C 3H 0M 0L
qs │ 6.11.0 │ npm │ 0C 0H 2M 1L
send │ 0.18.0 │ npm │ 0C 0H 0M 1L
serve-static │ 1.15.0 │ npm │ 0C 0H 0M 1L
What's next:
View base image update recommendations → docker scout recommendations myorg/my-app:1.0Seven of the nine packages are npm packages, and you only declared one of
them. Express pulls in the rest. The other two, nodejs-22 and
coreutils-env, are Alpine (apk) packages from the base image. Add up the
rows by type, and 4 high, 2 medium, and 6 low findings come from what you
added. The other 3 high, 7 medium, and 3 low come from the base image.
So the findings split into two groups with two different fixes. The ones you added are fixed in your project. The ones in the base are fixed by building on a newer base image.
The -dev builder stage doesn’t appear anywhere in this picture. It has a
shell, a package manager, and build tooling, but none of it ships, so Scout
doesn’t scan it by default. If you want to see it anyway, --multi-stage
includes it.
Now decide what’s actionable. Two questions do most of the work:
- Is there a fix? A finding with no fixed version has nothing to upgrade
to.
--only-fixedhides those. - How severe is it? Critical and high findings come first.
--only-severitytakes a comma-separated list.
Docker Scout’s own default policy uses the same definition: fixable critical
and high vulnerabilities. Add --only-package-type npm to keep the base
image’s findings out of this view, because you’ll fix those differently:
~/my-app
docker scout cves --only-fixed --only-severity critical,high --only-package-type npm myorg/my-app:1.0
✓ SBOM obtained from attestation, 77 packages found
✓ Provenance obtained from attestation
✓ SBOM obtained from attestation, 12 packages found
✓ Provenance obtained from attestation
✓ VEX statements obtained from attestation
✗ Detected 2 vulnerable packages with a total of 4 vulnerabilities
## Overview
│ Analyzed Image
───────────────────┼─────────────────────────────
Target │ myorg/my-app:1.0
digest │ 338d1dc055ba
platform │ linux/arm64
vulnerabilities │ 0C 4H 0M 0L
size │ 38 MB
packages │ 69
│
Base image │ dhi.io/node:22-alpine3.23
│ f9ba6116e056
## Packages and Vulnerabilities
0C 3H 0M 0L path-to-regexp 0.1.7
pkg:npm/path-to-regexp@0.1.7
Dockerfile (11:11)
COPY --from=builder --chown=node:node /usr/src/app /app
✗ HIGH CVE-2024-52798 [Inefficient Regular Expression Complexity]
https://scout.docker.com/v/CVE-2024-52798?s=github&n=path-to-regexp&t=npm&vr=%3C0.1.12
Affected range : <0.1.12
Fixed version : 0.1.12
CVSS Score : 7.7
CVSS Vector : CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N/E:P
✗ HIGH CVE-2024-45296 [Inefficient Regular Expression Complexity]
https://scout.docker.com/v/CVE-2024-45296?s=github&n=path-to-regexp&t=npm&vr=%3C0.1.10
Affected range : <0.1.10
Fixed version : 0.1.10
CVSS Score : 7.7
CVSS Vector : CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N/E:P
✗ HIGH CVE-2026-4867 [Inefficient Regular Expression Complexity]
https://scout.docker.com/v/CVE-2026-4867?s=github&n=path-to-regexp&t=npm&vr=%3C0.1.13
Affected range : <0.1.13
Fixed version : 0.1.13
CVSS Score : 7.5
CVSS Vector : CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
0C 1H 0M 0L body-parser 1.20.2
pkg:npm/body-parser@1.20.2
Dockerfile (11:11)
COPY --from=builder --chown=node:node /usr/src/app /app
✗ HIGH CVE-2024-45590 [Asymmetric Resource Consumption (Amplification)]
https://scout.docker.com/v/CVE-2024-45590?s=github&n=body-parser&t=npm&vr=%3C1.20.3
Affected range : <1.20.3
Fixed version : 1.20.3
CVSS Score : 8.7
CVSS Vector : CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N
4 vulnerabilities found in 2 packages
CRITICAL 0
HIGH 4
MEDIUM 0
LOW 0
What's next:
View base image update recommendations → docker scout recommendations myorg/my-app:1.0Four findings, each with a version that fixes it. That’s the work list.
Everything you filtered out still exists, so be clear about why each kind was set aside:
- The low and medium npm findings are all fixable too, and they’re in the same dependency tree. The upgrade that clears the four highs will most likely clear them as well. They don’t need triage of their own.
- The base image’s findings aren’t yours to patch one package at a time.
You don’t install
nodejs-22orcoreutils-env; the base image does. You’ll handle them in the next section. - Findings with no fix, which you’ll meet in the base image, have nothing
to upgrade to. Use
--only-unfixedto list them. In this image, that’s the three findings incoreutils-env. If one doesn’t affect your application, you can record that with a VEX statement. See Document vulnerabilities that don’t affect your application.
Severity isn’t the only signal. The severity rating comes from the advisory
source, and it says how bad exploitation would be, not how likely it is. For
a probability, --epss adds Exploit Prediction Scoring System scores, and
--only-cisa-kev keeps only CVEs that CISA lists as known to be exploited.
Check the base image
Fixing the base image findings means building on a newer base image.
docker scout recommendations looks for one. With --only-refresh, it only
looks for newer builds of the tag you already use:
~/my-app
docker scout recommendations --only-refresh myorg/my-app:1.0
✓ SBOM obtained from attestation, 77 packages found
✓ Provenance obtained from attestation
✓ SBOM obtained from attestation, 12 packages found
✓ Provenance obtained from attestation
Target │ myorg/my-app:1.0
digest │ 338d1dc055ba
Base image │ dhi.io/node:22-alpine3.23
No recommendationsFor a Docker Hardened Images base, Scout has no recommendations to make, so
this check comes back empty. Without --only-refresh, the command also looks
for other tags, and --only-update looks only for those. For this base, both
come back empty too.
The package list is the better evidence here. The three high findings in
nodejs-22 have a fixed version, and Node.js is part of the base image, so
only a newer build of the base can ship it. A tag is a pointer, and Docker
moves 22-alpine3.23 to a new build whenever a patched package lands. Your
image was built from whatever the tag pointed at then, and rebuilding picks
up whatever it points at now.
A refresh keeps the same Node.js major version and the same Alpine release, so it’s the lowest-risk base change you can make. Changing to a different tag, for example a newer Node.js major, is a code change with its own testing.
Fix the dependencies
All four highs come from Express’s dependencies, and you don’t depend on any
of them directly. Because the range in package.json (^4.19.2) already
allows newer releases of Express, npm update can move to the latest 4.x
release and resolve its dependencies again:
~/my-app
npm update express
changed 13 packages, and audited 69 packages in 2s 16 packages are looking for funding run `npm fund` for details found 0 vulnerabilities
npm ls path-to-regexp body-parser
my-app@1.0.0 /home/moby/my-app └─┬ express@4.22.3 ├── body-parser@1.20.8 └── path-to-regexp@0.1.13
Both packages are now past every fixed version Scout listed. npm update
rewrote package-lock.json, and that’s what the image build uses.
package.json didn’t change, because the new versions were already inside
its range.
Whether an update like this is safe is still your call. Express 4.22 is a
minor release on the same major line, but read the changelog of what you
upgrade and run your tests. When the fixed version is outside your range, for
example on a new major, npm install <package>@<version> changes the range.
When a vulnerable package is a transitive dependency that its parent hasn’t
updated yet, npm’s overrides field in package.json can force a version.
Rebuild with a fresh base
Build version 1.1. --pull makes the builder fetch the current build of
every base image the Dockerfile names, instead of reusing a cached one. That
picks up the refreshed dhi.io/node:22-alpine3.23 for both stages:
~/my-app
docker buildx build --pull --sbom=true --provenance=mode=max --push -t myorg/my-app:1.1 .
[+] Building 13.6s (19/19) FINISHED docker:desktop-linux => [internal] load build definition from Dockerfile 0.0s => [internal] load metadata for dhi.io/node:22-alpine3.23-dev 2.2s => [internal] load metadata for dhi.io/node:22-alpine3.23 1.4s => [builder 4/5] RUN npm ci --omit=dev 0.8s => [stage-1 2/3] COPY --from=builder --chown=node:node /usr/src/app /app 0.0s => exporting to image 0.4s => => pushing manifest for docker.io/myorg/my-app:1.1@sha256:c49e670e27f 0.0s
If your Dockerfile pins the base image by digest rather than by tag, --pull
won’t change anything: a digest always names the same build. Refreshing then
means updating the digest in the Dockerfile, which tools like Dependabot and
Renovate can do for you through pull requests. Pinning by digest is how you
make a build reproducible, and it’s covered with build policies in
Enforce your organization’s container standards.
Prove the fix
A clean-looking scan of the new image tells you where it ended up. It
doesn’t show what changed. docker scout compare analyzes both images and
shows the difference. --ignore-unchanged leaves out the packages that are
identical in both:
~/my-app
docker scout compare myorg/my-app:1.1 --to myorg/my-app:1.0 --ignore-unchanged
! 'docker scout compare' is experimental and its behaviour might change in the future
✓ SBOM obtained from attestation, 76 packages found
✓ Provenance obtained from attestation
✓ SBOM obtained from attestation, 77 packages found
✓ Provenance obtained from attestation
✓ SBOM obtained from attestation, 12 packages found
✓ Provenance obtained from attestation
✓ Policy evaluation completed
✓ VEX statements obtained from attestation
✓ Policy evaluation completed
✓ Pulled
✓ Pulled
✓ VEX statements obtained from attestation
## Overview
│ Analyzed Image │ Comparison Image
───────────────────┼─────────────────────────────┼─────────────────────────────
Target │ myorg/my-app:1.1 │ myorg/my-app:1.0
digest │ 3d140f858a77 │ 338d1dc055ba
tag │ 1.1 │ 1.0
platform │ linux/arm64 │ linux/arm64
vulnerabilities │ 0C 0H 0M 0L │ 0C 7H 9M 9L
│ -7 -9 -9 │
size │ 38 MB (+11 kB) │ 38 MB
packages │ 76 (-1) │ 77
│ │
Base image │ node:22-alpine3.23 │ node:22-alpine3.23
tags │ also known as │ also known as
│ │
vulnerabilities │ │The full output continues with environment variable, label, config, policy,
and package-by-package sections, left out here. The ## Overview section at
the top is the summary.
This is the evidence a reviewer wants. Version 1.1 has 7 fewer high, 9 fewer
medium, and 9 fewer low findings than 1.0, which leaves none. The package
section accounts for every change: thirteen npm packages moved with the
lockfile, and nodejs-22, coreutils-env, and ca-certificates-bundle
moved with the base image. The one package removed is the old base image’s
entry for itself, which the new build’s SBOM no longer lists. No packages
were added, so the fix didn’t quietly change what’s in the image.
Even the three coreutils-env findings that --only-unfixed listed are
gone: the refreshed base ships a newer build of the package, and Scout
reports nothing against it. Scout keeps evaluating images in repositories
where you’ve enabled it, so findings update as advisories change. When a fix
ships for a future finding, the next refresh of the base picks it up.
What a scan can and can’t tell you
A scan matches package versions against published advisories. That’s useful, and it has limits worth stating plainly:
- It doesn’t know how you use a package. A finding means a vulnerable version is present, not that your code reaches the vulnerable function. That’s the gap VEX fills.
- It’s a snapshot. New CVEs are published every day against packages that were clean when you shipped. The image you just fixed will have findings again eventually.
- A clean scan isn’t a security review. It says nothing about your own code, your configuration, or where the image came from.
Running these commands by hand works for one release. To stop a vulnerable
image from shipping at all, turn the same criteria into a check that fails
the build. docker scout cves accepts --exit-code, and Docker Scout
policies express the same rules as code. See
Enforce your organization’s container standards
and
Build a trusted container delivery pipeline.
Where to go next
Not every finding has a fix, and plenty of real findings are in code your application never calls. Recording that, in a form scanners can read, is the next step: Document vulnerabilities that don’t affect your application.
For the full set of filters and output formats, see the
docker scout cves reference.
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 2 of 7
- Step 1: Build your application on a trusted foundation
- Step 2: Find and fix vulnerabilities before you ship (this page)
- Step 3: Document vulnerabilities that don't affect your application
- 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