Guide

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 cves to 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

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

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:

  1. Get an overview with docker scout quickview.
  2. 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.
  3. Check the base image with docker scout recommendations.
  4. Fix the dependencies and refresh the base, then rebuild.
  5. 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 use myorg/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-container driver.
  • 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.json
const 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.log

The 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.0

The 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.0

Seven 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.

Where the findings in myorg/my-app:1.0 come from The image myorg/my-app:1.0 has two parts. The application layers hold node_modules, 68 npm packages installed from package-lock.json, which carry 4 high, 2 medium, and 6 low findings; you fix those by updating the lockfile. The base image layers come from dhi.io/node:22-alpine3.23, whose nodejs-22 and coreutils-env packages carry 3 high, 7 medium, and 3 low findings; you fix those by rebuilding on a newer build of the base. The -dev builder stage isn't part of the shipped image, so Scout doesn't scan it by default. MYORG/MY-APP:1.0 Your layers node_modules · 68 npm packages 0C 4H 2M 6L Base image layers dhi.io/node:22-alpine3.23 0C 3H 7M 3L Update the lockfile change package-lock.json, then rebuild Refresh the base image rebuild on the tag's newest digest with --pull fix fix

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-fixed hides those.
  • How severe is it? Critical and high findings come first. --only-severity takes 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.0

Four 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-22 or coreutils-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-unfixed to list them. In this image, that’s the three findings in coreutils-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 recommendations

For 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.

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.