DEV Community

Cover image for How to Secure Java Docker Images and Reduce CVE Risk
Mohammad-Ali A'RÂBI for Docker

Posted on Originally published at containersecurity.dev

How to Secure Java Docker Images and Reduce CVE Risk

The Black Forest Commandos' field guide to CVEs, SBOMs, hardened images, attestations, and software supply chain security.

Your Java application can inherit vulnerable dependencies and packages from your base image before you write a single line of application code. Also, scanning your Docker image for vulnerabilities has blind spots, as the SBOM generated after the fact does not give you the full picture. A successful Docker build alone does not tell you what is shipped, how it was built, or whether you can trust it.

Asgard is under attack. Shadow creatures called CVEs have infiltrated the realm, and Thor's hammer is proving surprisingly ineffective. Odin has only one option: summon the Black Forest Commandos.

Black Forest Commandos enter Asgard

Behind the mythology is a practical question: How do we build container images that we can inspect, trust, and deploy with confidence?

Let's secure a Spring Boot sample project together, and with the help of the Commandos (CLI commands).

TL;DR

Most of the CVEs come from base images. Just swapping the baseline base image for a Docker hardened base image reduces the runtime inventory—the packages an attacker could actually reach—by 75.5%, with no application dependency changes between the two images.

Baseline vs Hardened SBOM package count

Fewer number of packages, means less attack surface. Docker Hardened Images are also shipped with near-zero CVEs, non-root user, and no shell or package manager for the production images. So, even if an attacker gets inside your container, they won't have much to play with.

All these considered, swapping your base images won't make your application CVEs go away. That's why you should read the full article!

Start with the Ready-to-Run Sample

Before we start, let's check your toolset:

Checklist

  • Install Git to clone the sample repository.
  • Install and start Docker with Buildx. Check that docker info and docker buildx version work.
  • If not on Docker Desktop, install Docker Scout CLI separately. Check docker scout version.
  • Install Trivy and check trivy --version. The recorded run used 0.75.0.
  • Install Cosign and check cosign version. The recorded run used 3.1.3.
  • Have Bash, curl, and Python 3 available for the demo scripts.
  • Sign in to Docker Hub with docker login for Scout CVE queries.
  • Sign in to Docker Hardened Images with docker login dhi.io.

Docker builds, compiles, and tests the application, so you can follow this walkthrough without installing Java or Maven locally. Use a Bash-like terminal for the commands below.

Check Out the Tested Starting Point

The reference repository has two branches:

  • main, which is the state you will reach at the end of the article.
  • start, is the beginning of the journey.

We will start from there:

git clone --branch start --single-branch \
  https://github.com/ContainerSecurity-dev/dockerize-java-securely.git
cd dockerize-java-securely
Enter fullscreen mode Exit fullscreen mode

You can edit and commit your experiments on this local branch. Base-image tags and vulnerability databases can still change, so your scan results may differ.

The files you'll use are:

  • src/ and pom.xml: the working application and its HTTP integration test.
  • Dockerfile.baseline: the starting image, using the official Maven and Eclipse Temurin images.
  • Dockerfile: the hardened version you'll compare against it.
  • scripts/: helpers to check the HTTP response and save vulnerability reports.

So, let it rip.

1. Build a Baseline You Can Inspect

The Commandos survey the ground before they fight. For us, that ground is a baseline image, built and pushed so it can be inspected.

We use a temporary, loopback-only registry so the audience can practice pushes and attestations without publishing application images to a public registry. This unauthenticated HTTP registry is for local demos only. For a remote registry, use an authenticated namespace you own over HTTPS.

docker run -d --name dockerize-java-demo-registry \
  -p 127.0.0.1:15000:5000 registry:3
export PLATFORM=linux/amd64   # use linux/arm64 on Apple Silicon
export IMAGE_REPO=127.0.0.1:15000/dockerize-java-securely
mkdir -p results

docker buildx build --platform "$PLATFORM" \
  -f Dockerfile.baseline \
  --sbom=true --provenance=mode=max \
  --metadata-file results/baseline-build-metadata.json \
  --push -t "$IMAGE_REPO:baseline" .

scripts/smoke-test.sh "$IMAGE_REPO:baseline"
export IMAGE="$IMAGE_REPO:baseline"
Enter fullscreen mode Exit fullscreen mode

Let's unpack the commands:

  • The docker buildx build command is an extended command for building Docker images using BuildKit.
  • The flag --sbom=true instructs the builder to generate SBOM attestations: SBOM records "attached" to the image.
  • The flag --provenance=mode=max is used to generate provenance attestations. This attestation records more metadata about the build process.
  • As you can see, -f Dockerfile.baseline is used to specify the Dockerfile. So, the only non-optional argument, being the . at the end, is not actually the path to the Dockerfile, it's rather the build context, which is all the files that Docker can access and can potentionally end up in your Docker image.

Docker and Kubernetes Security

View book

Also, if you inspect the smoke-test.sh file, it's running the image with the following flags:

docker run --detach --rm --read-only --tmpfs /tmp --cap-drop ALL \
  --security-opt no-new-privileges:true -p 127.0.0.1::8080 "$image"
Enter fullscreen mode Exit fullscreen mode

The smoke test should return Hello, Docker Security! as user 10001:10001. The flags are used to run the container in a safer environment.

2. Know Your Enemy: CVEs and SBOMs

CVEs are shadow creatures

Log4Shell (CVE-2021-44228) demonstrated why knowing which dependencies we ship matters. An SBOM, or Software Bill of Materials, is a list of software components in your application. Having this list handy helps you identify your packages in case there is a known exploit in one of them. Having it does not make your application safer by itself.

Rothütle's first mission in Asgard is to discover who lives there. Using Docker Scout, you can check the list using docker scout sbom command:

docker scout sbom "$IMAGE" --format list
docker scout sbom "$IMAGE" --format spdx --output results/sbom.spdx.json
Enter fullscreen mode Exit fullscreen mode

How many packages are listed here?

Output of those commands will mention the SBOM attestation being used. If the attestation is absent from the build, Docker Scout will try to build it by scanning your files.

In the docker buildx build command we ran, we have created an SBOM attestation, and the Docker Scout command picked it up. But, the SBOM only contains packages from the final stage, which is not a full picture. A compromised build-time package can still write a backdoor into your application.

Further reading:

generating-an-sbom-with-docker-scout

3. Hunt the CVEs: Docker Scout and Trivy

Jack, our cybernetic scout, now dressing as Jack-o'-Lantern for Halloween, can detect CVEs hiding inside container images.

docker scout cves "$IMAGE"
docker scout cves --only-severity critical,high "$IMAGE"
Enter fullscreen mode Exit fullscreen mode

Docker Scout is an enterprise offering, free for personal use. Trivy is an open-source alternative and similarly an industry standard.

trivy image --scanners vuln "$IMAGE"
trivy image --scanners vuln --severity CRITICAL,HIGH --exit-code 1 "$IMAGE"
Enter fullscreen mode Exit fullscreen mode

Jack the scout, scanning for CVEs

The final command should return exit code 1, because there are high and/or critical CVEs in the image. That is a security gate you should add to your workflow.
Let's save machine-readable reports with the repository's helper:

# Saves JSON, summarizes findings, and exits 1 if the gate fails.
scripts/scan.sh "$IMAGE" results/baseline-trivy.json
Enter fullscreen mode Exit fullscreen mode

The scan.sh script runs Trivy under the hood, but also stores the reports and parses them. A similar script would be in your pipeline, e.g., to report the number of CVEs via a pull request comment.

Note that, severity is not the same as exploitability, and malicious software does not necessarily have a CVE. Scanner databases and package matching also differ: the same baseline yielded different totals in Scout and Trivy. Compare images within the same scanner and inspect the affected packages.

4. Include the Build Stage in Your SBOM

Rothütle suspects the inventory is incomplete: some components were present while the application was built, not only while it runs.

The baseline uses a multi-stage build, but --sbom=true alone inventories the final image. Packages used only while compiling the application can disappear from that inventory when the build stage is discarded.

First inspect the baseline's attached SBOM and provenance:

docker buildx imagetools inspect "$IMAGE_REPO:baseline" --format '{{json .SBOM}}' > results/baseline-sbom.json
docker buildx imagetools inspect "$IMAGE_REPO:baseline" --format '{{json .Provenance}}' > results/baseline-provenance.json
Enter fullscreen mode Exit fullscreen mode

Now edit Dockerfile.baseline. Add this argument immediately after the build stage's FROM line:

FROM maven:3.9-eclipse-temurin-21 AS build
ARG BUILDKIT_SBOM_SCAN_STAGE=true
Enter fullscreen mode Exit fullscreen mode

Rebuild under a separate tag so you can compare the two inventories:

docker buildx build --platform "$PLATFORM" \
  -f Dockerfile.baseline \
  --sbom=true --provenance=mode=max \
  --metadata-file results/baseline-build-sbom-metadata.json \
  --push -t "$IMAGE_REPO:baseline-build-sbom" .

docker buildx imagetools inspect "$IMAGE_REPO:baseline-build-sbom" --format '{{json .SBOM}}' > results/baseline-with-build-sbom.json
Enter fullscreen mode Exit fullscreen mode

The new output includes an additional sbom-build document alongside the runtime SPDX inventory. The argument adds metadata about the packages from earlier stages, and it doesn't mean those packages end up in the final image.

  • SBOM: What software was inventoried in the image and covered build stages?
  • Provenance: How was the image built, and from which materials?

Use BuildKit secret mounts for credentials. Maximum-level provenance can expose build arguments, so never put secrets in them. Furthermore, an attached attestation is not automatically a trusted cryptographic signature.

Now, how many packages are listed in the SBOM attestations?

Further reading:

the-poisoned-macchiato

5. Harden the Runtime and Measure the Difference

Armor time. Docker Hardened Images are a set of open-source and near-zero-CVE base images, designed to "armor" your Docker image against future attacks. You can check their catalog on dhi.io.

Let's swap the base image for the hardened ones:

# syntax=docker/dockerfile:1
FROM dhi.io/maven:3-jdk21-dev AS build
ARG BUILDKIT_SBOM_SCAN_STAGE=true
WORKDIR /app
COPY pom.xml .
COPY src ./src
RUN mvn -B verify

FROM dhi.io/eclipse-temurin:21-alpine3.23
WORKDIR /app
COPY --from=build /app/target/app.jar /app/app.jar
USER 10001:10001
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
Enter fullscreen mode Exit fullscreen mode

If you check the sample repo, there is a file .dockerignore there with the following content:

**
!pom.xml
!src/
!src/**
!Dockerfile
!Dockerfile.baseline
Enter fullscreen mode Exit fullscreen mode

This means, everything is ignored by the docker buildx build command (removed from the build context), except for the listed files. This is called an allowlist. So, instead of ignoring a bunch of files, we ignore everything, and allow a bunch of files.

Also, the POM sets finalName to app, avoiding an ambiguous JAR wildcard.

This follows Docker's Java DHI migration pattern, with explicit tests and a non-root runtime user.

docker buildx build --platform "$PLATFORM" \
  --sbom=true --provenance=mode=max \
  --metadata-file results/hardened-build-metadata.json \
  --push -t "$IMAGE_REPO:hardened" .

scripts/smoke-test.sh "$IMAGE_REPO:hardened"
# Also returns 1 for the recorded sample:
scripts/scan.sh "$IMAGE_REPO:hardened" results/hardened-trivy.json
python3 scripts/summarize-scans.py results/baseline-trivy.json results/hardened-trivy.json
Enter fullscreen mode Exit fullscreen mode

It's report time! You can run all of them yourself, or just read through mine!

Observed results: 13 October 2026

Both final application images use Spring Boot 4.0.8 and Java 21. The results are based on runs on Linux Ubuntu (linux/amd64) and Mac Silicon (linux/arm64), yielding identical results. These are Trivy 0.75.0 vulnerability records, grouped by scan target; totals are not counts of unique CVE IDs across all packages.

Scan target Baseline: eclipse-temurin:21-jre Hardened: dhi.io/eclipse-temurin:21-alpine3.23
OS packages 0 critical, 0 high, 40 medium, 4 low No findings
Java dependencies 3 critical, 5 high, 2 medium 3 critical, 5 high, 2 medium
Other detected binary (pebble) 3 high, 5 medium, 1 low, 4 unknown Not present as a scan target

Docker Scout 1.20.3 reported 5 critical / 13 high / 2 medium / 3 unspecified for the baseline and 3 critical / 5 high / 2 medium for the hardened image. Those are separate scanner observations, not interchangeable with the Trivy counts.

The original Spring Boot 4.0.5 sample had 9 critical / 18 high / 20 medium / 6 low Java findings in Trivy. Updating to 4.0.8 reduced those findings, but did not eliminate them. The remaining Java findings implicated Tomcat and Jackson dependencies. Review the report's fixed-version guidance, update the application dependencies, rerun tests, and rescan before promotion.

Inspect the Blast Radius in Your Dependency Tree

"Update Tomcat and Jackson" is easier said than done, because most of these libraries arrive transitively through the Spring Boot BOM. Inspect what your project actually resolves before you touch a version:

# Full resolved tree (run inside the builder image if you have no local JDK):
./mvnw -B dependency:tree

# Narrow it to the packages the scanner flagged:
./mvnw -B dependency:tree -Dincludes=org.apache.tomcat.embed:tomcat-embed-core
./mvnw -B dependency:tree -Dincludes=tools.jackson.core:jackson-core
Enter fullscreen mode Exit fullscreen mode

I can't believe it's 2026 and Maven flags still look like that. Dincludes. Anyway!

The tree shows each artifact's depth and the path that pulled it in. A dependency you declare directly in pom.xml can be overridden there or in a <dependencyManagement> entry; a transitive one usually needs a version override or an updated parent/BOM. After the change, confirm the fix reaches the runtime JAR, rerun mvn verify, and rescan the new digest. If an affected artifact is transitive and has no fix yet, record that in a VEX statement instead of silently suppressing it.

Check VEX Before Comparing Scanners

VEX (Vulnerability Exploitability eXchange) records whether a particular vulnerability affects a product. One can create VEX records for CVEs that are harmless in their case. One can ship them with their Docker image as "VEX attestations". Docker Hardened Image come with their own VEX attestations.

Docker Scout automatically applies DHI VEX statements and excludes them from the listed CVEs; Trivy needs VEX enabled:

docker scout cves dhi.io/eclipse-temurin:8-alpine3.23

# Configure and download the DHI VEX repository first (see linked instructions).
trivy image --scanners vuln --vex repo dhi.io/eclipse-temurin:8-alpine3.23
Enter fullscreen mode Exit fullscreen mode

For Trivy, enable the docker-hardened-images/advisories repository in its VEX configuration and download it before using --vex repo. Review applicable VEX statements; suppression is an assessment of affectedness, not a dependency fix.

This can sometimes explain differences in reported CVEs without any change to the image. See Docker’s VEX-aware scanning instructions.

The recorded Trivy scans above did not enable VEX. However, the observed difference between Scout and Trivy was in the non-DHI baseline, so it was not coming from VEX attestations.

Both images passed the HTTP smoke test. In the recorded comparison, with build-stage scanning enabled, the runtime SBOM contained 192 packages for the baseline and 47 for the hardened image; the separate build-stage inventories contained 396 and 377, respectively. Hence, just by swapping for a DHI base image, the number of dependencies in the production image is reduced by 75%. Hence the Artemisia bar chart image, as she's squashing the attack surface!

The results report records image digests, versions, and evidence.

docker-hardened-images-are-free

6. Sign Before You Trust: Cosign

Evie signs the SBOM attestations

Evie is the Commando responsible for cryptographic signatures. She always uses the immutable digest returned by the build, rather than a mutable tag:

export DIGEST_IMAGE="$IMAGE_REPO@$(python3 -c 'import json; print(json.load(open("results/hardened-build-metadata.json"))["containerimage.digest"])')"
mkdir -p .demo-keys
cosign generate-key-pair --output-key-prefix .demo-keys/cosign
cosign sign --key .demo-keys/cosign.key "$DIGEST_IMAGE"
cosign verify --key .demo-keys/cosign.pub "$DIGEST_IMAGE"

# Scan the same immutable artifact; this sample still fails the gate.
trivy image --scanners vuln --severity CRITICAL,HIGH --exit-code 1 "$DIGEST_IMAGE"
Enter fullscreen mode Exit fullscreen mode

Cosign 3.1.3 successfully verifies the signature, claims, and transparency-log inclusion. The vulnerability gate still returns 1. A signed image can contain known vulnerabilities.

Keep the private key out of Git; the sample ignores .demo-keys/ and *.key. Enter a key password interactively and use a disposable key for rehearsal. Default signing publishes signature metadata and the public key to a transparency log. Production verification needs a trusted signing policy, not an arbitrary public key supplied alongside an image.

We sign the image to make sure no one altered it on the way to production. But, the attackers are getting smarter. Read the Shai Hulud family account to learn about the incident that a signed image was altered by the attacker:

taxonomy-of-modern-supply-chain-worms

7. SIP It Before You Ship It

Shai-Hulud family of supply chain worms

The Shai-Hulud family illustrates why scanning a final image is not enough: malicious packages can target developers and CI before an image exists. AI coding agents are another environment with access to code, tools, and potentially credentials.

SIP: Security Immediate Plan ties the controls together:

  1. Sandbox agents. Isolate execution and restrict credentials, network access, and mounted directories.
  2. Lock down dependencies. Manage Maven dependency and plugin versions, inspect the resolved tree, and review updates. Gradle and npm projects need their own locking and install policies; this Maven sample does not exercise those tools.
  3. Harden builds. Keep compilers and build tools in the build stage, and run the final image without root privileges.
  4. Attest. Generate SBOM and provenance, including relevant build stages.
  5. Scan before promotion. Scan the exact digest, enforce your vulnerability policy, and verify trusted signatures before deployment.

Sandbox → Lock → Harden → Attest → Scan. No single scanner, image, or signature secures the entire supply chain. For agent isolation and ecosystem-specific dependency controls, see the full SIP implementation.

This walkthrough demonstrates three of those controls end to end on a real Java image: Harden (a DHI runtime running as a non-root user), Attest (SBOM and provenance, including the build stage), and Scan (gating the exact digest before promotion). It touches Lock only partially, through the Maven dependency tree and version discipline shown above; full dependency locking for Gradle and npm projects is outside this sample. Sandbox agents—isolating AI coding agents and restricting their credentials, network access, and mounts—is not exercised here at all, so treat it as an additional SIP control rather than something this experiment proved.

Read more about sbx and kits here:

docker-sandbox-kits-tutorial

When finished, remove the dedicated local registry and its temporary data:

docker rm -f dockerize-java-demo-registry
Enter fullscreen mode Exit fullscreen mode

Conclusion

Three findings came out of this run, and they map directly onto the Commando team:

Key takeaways

  • Hardened base images reduce OS-level exposure. The DHI runtime dropped from 192 inventoried packages to 47 (75.5%) and removed all 44 Ubuntu package findings and the pebble binary target, without changing any application dependency.
  • SBOM attestations improve visibility. --sbom=true --provenance=mode=max attaches what shipped and how it was built, and BUILDKIT_SBOM_SCAN_STAGE=true extends the inventory to the build stage.
  • Application dependency remediation remains essential. Tomcat and Jackson kept the same 3 critical and 5 high findings in both images, so hardening and signing never replaced updating libraries.

Hardening the base image and signing the result are necessary, not sufficient. The Commandos' armor kept out the creatures that came with the base image; the ones hiding inside Tomcat and Jackson are a different fight, and it is fought at the dependency level. Fix what you ship, prove how you built it, and scan the exact digest before it reaches production.

Black Forest Commandos at BaselOne

Keep Learning: Articles and Tutorials

Official Documentation

Meet the Black Forest Commandos in My Books

  • Docker and Kubernetes Security. To learn about Linux capabilities (the ones that we dropped here), or secure CI workflows, check this book.
  • Black Forest Shadow. The origin story of Gord, Rothütle, Evie, and the CVE monsters, set in the Black Forest in 1865.
  • Black Forest Commandos: Asgard Mission. The graphic novel behind the Commandos' adventure in Asgard. The story expands the mythology used in my talks and workshops. Do you want to know the ending? Spoiler alert!

SIP it before you ship it.

Mohammad-Ali A'râbi — Docker Captain, author, and creator of the Black Forest Commandos.

Results are a dated snapshot from the linked sample. Review versions, permissions, and security policies and rerun the checks before production use.

Top comments (0)