Playing with Grype and Docker
Nothing induces more panic than compliance, specifically being told you need a SBOM for SOC2 while several beers and a scotch deep. There I was, in a drunken trance, scouring Google AI summaries and implementing the rawest of chaos. It took me a week of sifting through eldritch git commits to figure out what I ended up implementing. Somewhere in there I landed on Syft, which at the time I thought was only for Docker containers, but turns out it is a lot more than that. It’s built by Anchore inc., who do software compliance, SBOM management, and vulnerability detection. Their paid service is detailed, but the more interesting bit is that a couple of their tools are free and open source. Syft worked incredibly well, though since these were private work projects I did have to do updates and alterations to reflect full accuracy.
Anchore also ship Grype for vulnerability scanning, and given Hacker News has been a parade of “your everything is on fire” posts lately, I figured I’d find a reason to use it. And while I was poking around, the “free” Docker Hardened Images (DHI) looked worth a look too.
Docker does have a tool called Docker Scout which basically does the exact same thing but very specialised and built for Docker. You can use this for free for local usage and even will give some very cool recommendation for how to help reduce some vulnerabilities. Though more detailed functionality and features are all locked behind docker enterprise licenses, which if you are building docker images as your deployments as your entire business then it probably would be worth doing.
Now that I’ve played with this stuff for a couple of weeks, I wanted to just write about it and what I found. I’ll focus on using Grype, mainly because it’s open source and I think it’s cool. So what am I gonna do to show my findings? Also, CVE count is a shit-house metric.
Building a Docker Image
I don’t really like writing things in Python, but I do think it is important to keep up to date with tech and people won’t shut up about uv so I’ll write a simple FastAPI server using it:
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
async def root() -> dict[str, str]:
return { "message": "Hello, World!" }
That’s all the code, adding the FastAPI dependency was as simple as:
uv add fastapi --extra standard
Running it locally with uv run fastapi dev does as expected and I get a
“Hello, World!” response when doing a get request to the local endpoint. Well
that’s that, let’s Dockerize the way astral-sh would like from their example.
You may also note that I am pre-hardening these images by the deployed image
to run as a nonroot user so people who are podman pilled can just chill the
fuck out and not use the root as an excuse.
# Stage 1: Build the application to the `/app` directory
FROM ghcr.io/astral-sh/uv:python3.14-trixie AS builder
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
# Omit dev deps
ENV UV_NO_DEV=1
# Disable python downloads, use image system python version
ENV UV_PYTHON_DOWNLOADS=0
WORKDIR /app
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --locked --no-install-project
COPY . /app
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked
# Stage 2: final image without UV
FROM python:3.14-trixie
# Setup a non-root user
RUN groupadd --system --gid 1000 nonroot \
&& useradd --system --gid 1000 --uid 1000 --create-home nonroot
# Copy the application from the builder
COPY --from=builder --chown=nonroot:nonroot /app /app
# Place executables in the environment at the front of the path
ENV PATH="/app/.venv/bin:$PATH"
# Use the non-root user to run our application
USER nonroot
# Use `/app` as the working directory
WORKDIR /app
EXPOSE 8080
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]
It’s a bit of a large Dockerfile, but most of it is copy and paste from the
astral-sh multistage example. Essentially, the first stage uses uv to get
the dependencies and build the python virtual environment to run; then the
second stage is a python only image which setups a nonroot user, pulls the
virtual environment and then runs it with uvicorn (a binary in the virtual
environment bin path).
I built the image with docker build -t terry:base -f Dockerfile.base . and
tested that it worked with docker run --rm -p 8080:8080 terry:base. This
Image uses Debian Trixie, so the final image is quite large, 1.2GB large which
is insane. It’s an image that contains a full Debian environment with python
installed, this means package manager, shell and other binaries and tooling
that is expected in a functioning Debian environment.
You can do a vulnerability scan on this image using grype by doing a simple
~/dev/terry
❯ grype terry:base | head
✔ Loaded image terry:base
✔ Parsed image sha256:2337af5544a75e8a120f3a0201593d9c0bce
✔ Cataloged contents c6db7d994075212fe994cdc8278fc5e0a1f393ea1fe
├── ✔ Packages [520 packages]
├── ✔ Executables [1,413 executables]
├── ✔ File metadata [21,761 locations]
└── ✔ File digests [21,761 files]
✔ Scanned for vulnerabilities [1431 vulnerability matches]
├── by severity: 38 critical, 193 high, 346 medium, 29 low, 890 negligible (304 unknown)
└── by status: 295 fixed, 1505 not-fixed, 369 ignored
NAME INSTALLED FIXED IN TYPE VULNERABILITY SEVERITY EPSS RISK
libwmf-0.2-7 0.2.13-1.1+b3 deb CVE-2007-3996 Medium 15.1% (94th) 7.6
libwmf-dev 0.2.13-1.1+b3 deb CVE-2007-3996 Medium 15.1% (94th) 7.6
libwmflite-0.2-7 0.2.13-1.1+b3 deb CVE-2007-3996 Medium 15.1% (94th) 7.6
imagemagick 8:7.1.1.43+dfsg1-1+deb13u8 deb CVE-2023-34152 Negligible 64.9% (98th) 3.2
imagemagick-7-common 8:7.1.1.43+dfsg1-1+deb13u8 deb CVE-2023-34152 Negligible 64.9% (98th) 3.2
imagemagick-7.q16 8:7.1.1.43+dfsg1-1+deb13u8 deb CVE-2023-34152 Negligible 64.9% (98th) 3.2
libmagickcore-7-arch-config 8:7.1.1.43+dfsg1-1+deb13u8 deb CVE-2023-34152 Negligible 64.9% (98th) 3.2
libmagickcore-7-headers 8:7.1.1.43+dfsg1-1+deb13u8 deb CVE-2023-34152 Negligible 64.9% (98th) 3.2
libmagickcore-7.q16-10 8:7.1.1.43+dfsg1-1+deb13u8 deb CVE-2023-34152 Negligible 64.9% (98th) 3.2
...shitload more entries...
I had to pipe this to head because there were so many vulnerabilities that
it blew through my terminal scroll buffer. To help, here is how to read this.
There are 1431 vulnerabilities that were found/matched. The table will give the name of them and the vulnerability as well as the severity. All of these that are visible are just Debian packages that have nothing to do with the python. The Exploit Prediction Scoring System (EPSS) is an interesting value, it’s a value that is calculated using actual real machine learning across a shitload of data to estimate the probability of this vulnerability will be exploited in the wild within the next 30 days. This system has been maintained by Forum of Incident Response and Security Teams (FIRST) organisation with community and volunteer support.
Grype generates a “RISK” score which is calculated by combining the CVSS and
EPSS scores and normalising it between 0.0-10.0. Grype sorts the
vulnerabilities in the table by this risk value instead of severity as you
could have a HIGH severity vulnerability but if the chance of it actually
happen in the wild is near zero then it isn’t as important as a lower severity
that will definitely happen.
I am not going to triage these vulnerabilities. I would never just run a Debian environment Docker image as I care about the size of images. I don’t often do Docker containers, but the ones I do make will either be an Alpine image or a Scratch image. Because this is python, I can’t do a scratch image without some extreme fuckery, so let’s just make this an Alpine image.
Road to Alpine
To convert the Dockerfile above to deploy on Alpine is quite simple, you just
need to change the final image to python:3.14-alpine and change the user
commands to the busybox alternative.
# Setup a non-root user
RUN addgroup -S -g 1000 nonroot \
&& adduser -S -G nonroot -u 1000 -h /home/nonroot nonroot
The rest can stay the same. Though technically, you probably should build with an Alpine image as well due to Debian venv probably including artifacts that probably won’t even run on the Alpine machine. But for this example, it works fine ™️. I did end up changing this for completeness.
So now running Grype on this, I don’t need to pipe to head
~/dev/terry
❯ grype terry:alpine
✔ Loaded image terry:alpine
✔ Parsed image sha256:5f97d29db20ff0ae80d1c0ec06685507dd0a
✔ Cataloged contents 6e53d761b5997cb82004597d0d43cfca0045ef18a0c
├── ✔ Packages [81 packages]
├── ✔ File metadata [733 locations]
├── ✔ Executables [126 executables]
└── ✔ File digests [733 files]
✔ Scanned for vulnerabilities [12 vulnerability matches]
├── by severity: 2 critical, 2 high, 8 medium, 0 low, 0 negligible
└── by status: 2 fixed, 10 not-fixed, 0 ignored
NAME INSTALLED FIXED IN TYPE VULNERABILITY SEVERITY EPSS RISK
python 3.14.5 binary CVE-2026-6100 Critical 0.2% (37th) 0.1
python 3.14.5 binary CVE-2026-3298 High < 0.1% (22nd) < 0.1
python 3.14.5 3.15.0a6 binary CVE-2025-15366 Medium 0.1% (28th) < 0.1
python 3.14.5 3.15.0a6 binary CVE-2025-15367 Medium 0.1% (28th) < 0.1
python 3.14.5 binary CVE-2026-7210 Critical < 0.1% (19th) < 0.1
busybox 1.37.0-r30 apk CVE-2025-60876 Medium < 0.1% (16th) < 0.1
busybox-binsh 1.37.0-r30 apk CVE-2025-60876 Medium < 0.1% (16th) < 0.1
ssl_client 1.37.0-r30 apk CVE-2025-60876 Medium < 0.1% (16th) < 0.1
python 3.14.5 binary CVE-2025-12781 Medium < 0.1% (14th) < 0.1
python 3.14.5 binary CVE-2026-4786 High < 0.1% (6th) < 0.1
python 3.14.5 binary CVE-2026-6019 Medium < 0.1% (9th) < 0.1
python 3.14.5 binary CVE-2026-1502 Medium < 0.1% (6th) < 0.1
Oh yeah, 1431 down to 12; as well as all with a risk with 0.1 or less. This is pretty neat in itself, it also is only 122MB in size. Most of these issues unlike the Debian one don’t have fixes, which can be a problem but because there are only a handful of CVEs we can go through them manually and triage them.
Ok, that took longer than my ADHD accounted for. But TLDR, she ain’t a problem. I’ll just go over the Critical and High for this post:
CVE-2026-6100: Use-After-Free for lzma, bz2 and gzip, which I’m not using.CVE-2026-3298: Is Windows Only, so not applicableCVE-2026-7210: XML things, not being used.CVE-2026-4786: Command Injection with thewebbrowser, which I’m not using.
The other vulnerabilities are along the same lines, the busybox ones are about
wget vulnerabilities, but we aren’t using it so it’s not a problem. How do we
tell Grype and everyone else that my image is actually fine and these issues
have been triaged.
Vexing an Image
Reading the Grype Filter scan results doc, there is something called Vulnerability Exploitability eXchange (VEX) which is a JSON formatted file which can filter specific vulnerabilities with a status and justification. These vex files can be attached to the final image for downstream use.
This seems to be what Docker Hardened Images seems to do. So how do I do this to my image? I have no idea and I don’t really know if I am doing this properly.
What I did was install the OpenVEX CLI vexctl and created a VEX statement per
vulnerability with:
mkdir -p parts
vexctl create \
--author="Lachlan Cox <lcox74@outlook.com>" \
--product="pkg:oci/terry?tag=alpine" \
--vuln="CVE-2026-6100" \
--status="not_affected" \
--justification="vulnerable_code_not_in_execute_path" \
--impact-statement="The application does not import or instantiate lzma.LZMADecompressor, bz2.BZ2Decompressor, or gzip.GzipFile. The hello-world FastAPI endpoint performs no decompression. The vulnerable code path requires explicit decompressor instance reuse after a MemoryError, which does not occur in this application." \
--file="parts/cve-2026-6100.json"
I did this for all of them, there’s only 12 and it was a good excuse to write the whole thing as a bash script and then use Claude to verify that I wasn’t talking absolute bullshit.
Once I built all the parts, I used the vexctl merge to merge all of the
parts/cve-*.json files into a single vex.json. Then run the grype scan
again with the vex filter flag:
~/dev/terry
❯ grype terry:alpine --vex vex.json
✔ Loaded image terry:alpine
✔ Parsed image sha256:0705e626f60cb77ae47cde8d6bdd8d241bd5
✔ Cataloged contents 03d2a633a1ddb58da673cbe8b078ccb36240980d268
├── ✔ Packages [81 packages]
├── ✔ Executables [126 executables]
├── ✔ File metadata [733 locations]
└── ✔ File digests [733 files]
✔ Scanned for vulnerabilities [0 vulnerability matches]
├── by severity: 2 critical, 2 high, 8 medium, 0 low, 0 negligible
└── by status: 2 fixed, 10 not-fixed, 12 ignored
No vulnerabilities found
I feel like a real security engineer now. Actually, for completeness sake, I will also pin the images in the Dockerfile to a sha256 digest so this can be repeatable (with the exception of new vulnerabilities).
Now, how do I publish the attestation file so I can attach it to the image so you don’t need to use the vex flag… Turns out this is annoying and I’m not doing it. Essentially, I need to publish this image to an actual registry and create some cosign keys to sign the attestation file and push it to the registry. I’m not signing up for a public registry and then dealing with the auth and other chaos for a simple hello world example.
Attaching the Attestation File
I changed my mind. James reminded me that you can run a local Docker registry. So let’s delve into this and waste more time on something that I probably would never need to do ™️.
# Step 1: Start a local Registry
docker run -d -p 5000:5000 --restart=always --name registry registry:2
# This immediately broke because I'm on a mac and AirPlay seems to already
# use this port.
# Step 2: Different port
docker run -d -p 5050:5000 --restart=always --name registry registry:2
# Step 3: Generate cosign keypair. I generated with no password because in this
# day and age seems to think vulnerable is the defacto standard.
cosign generate-key-pair
# Step 4: Update and rebuild my Vex file to use the the `--product` of
# `pkg:oci/terry@sha256:be31bc5c26a720218d1914cc2b70d06a7ebe00a00cc3c39fe21e107596701583?repository_url=localhost:5050`
# Step 5: Tag and Push the image to the registry. I didn't expect this to work
# the first time.
docker tag terry:alpine localhost:5050/terry:alpine
docker push localhost:5050/terry:alpine
# Step 6: Sign and attach the attestation with cosign. This also worked first
# time, but it did complain that I didn't add the digest of the image
# which is fair.
cosign attest \
--yes \
--type openvex \
--predicate vex.json \
--key cosign.key \
--allow-insecure-registry \
localhost:5050/terry:alpine
# Step 7: Verify that it worked. I think it worked, it spat out a massive base64
# payload, but it did exit with 0 so I assume it is fine.
cosign verify-attestation \
--type openvex \
--key cosign.pub \
--allow-insecure-registry \
localhost:5050/terry:alpine
That’s it, so that means I should be able to just do
grype localhost:5050/terry:alpine and see 0 vulnerabilities. I see 12, it
didn’t work. The reason it didn’t work was because I read the documentation
wrong for how attestation files work. Turns out that Trivy does do an OCI
auto detect and shows me that the attestation file attach works. Yes trivy
exists and is cool, no I’m not getting into it.
So how do I extract the attestation file so I can use it with grype? Turns out the cosign verify-attestation base64 payload can be used to extract the vex file.
cosign verify-attestation \
--type openvex \
--key cosign.pub \
--allow-insecure-registry \
--insecure-ignore-tlog \
localhost:5050/terry:alpine 2>/dev/null \
| jq -s '[.[] | .payload | @base64d | fromjson | .predicate]
| map(select(.statements[0].products[0]."@id" |
contains("@sha256:")))
| .[0]' \
> vex-from-attestation.json
Then you can use that file as the vex and re-run the grype scan:
~/dev/terry
❯ grype localhost:5050/terry:alpine --vex vex-from-attestation.json
✔ Loaded image localhost:5050/terry:alpine
✔ Parsed image sha256:0705e626f60cb77ae47cde8d6bdd8d241bd5
✔ Cataloged contents 03d2a633a1ddb58da673cbe8b078ccb36240980d268
├── ✔ Packages [81 packages]
├── ✔ File metadata [733 locations]
├── ✔ Executables [126 executables]
└── ✔ File digests [733 files]
✔ Scanned for vulnerabilities [12 vulnerability matches]
├── by severity: 2 critical, 2 high, 8 medium, 0 low, 0 negligible
└── by status: 2 fixed, 10 not-fixed, 12 ignored
No vulnerabilities found
Trying Docker Hardened Images
We are secure, why do we need to do more. Well an issue with the current Alpine image is that it still has a shell and still has a package manager. We may not use it, but for whatever reason someone was able to get into the image they could cause some chaos to the system.
So we have 2 options, do distroless or a Docker Hardened Image (DHI). I’m going to pick DHI for the sole reason of the current distroless image for python is running python 3.13 and our project needs python 3.14. The other reason to use a DHI is the Docker Hardening service, they have teams of staff which actively go through and patch these images, meet compliance standards, active support and depending on if you are paying for enterprise you can even get SLA-backed updates.
So let’s create a new Docker image that uses the DHI image. But there are a couple things to note first:
- We need to pick the image to use from the dhi python catalog.
- Login to Docker Hub
To match the other Dockerfiles, I’m going to copy the Docker.base debian
image and use the dhi.io/python:3 as the final image. If you try and pull this
image it will fail with an unauthorised response. So you need to do a:
docker login dhi.io and then docker pull dhi.io/python:3 to get the image.
Then you’ll find it will fail because you haven’t logged into Docker Desktop
because for whatever reason, they just don’t respect logging in with just the
cli even though that’s what their docs say.
Note: I had to also remove the non-root user because there is no shell so those commands just don’t exist. The DHI image also already runs as non-root.
Now that we have this, let’s do a grype on my new image terry:dhi:
~/dev/terry
❯ grype terry:dhi
✔ Loaded image terry:dhi
✔ Parsed image sha256:0c2b00978fbc36e20cd91dfb361b1b3f4561
✔ Cataloged contents 9b5ef7804d9f6dd3e67bd27735abe5180b16bc71dd1
├── ✔ Packages [109 packages]
├── ✔ Executables [417 executables]
├── ✔ File metadata [1,666 locations]
└── ✔ File digests [1,666 files]
✔ Scanned for vulnerabilities [65 vulnerability matches]
├── by severity: 4 critical, 14 high, 25 medium, 0 low, 22 negligible
└── by status: 2 fixed, 63 not-fixed, 0 ignored
NAME INSTALLED FIXED IN TYPE VULNERABILITY SEVERITY EPSS RISK
python 3.14.5 binary CVE-2026-6100 Critical 0.2% (37th) 0.1
libc6 2.41-12+deb13u3 deb CVE-2019-9192 Negligible 2.3% (85th) 0.1
libc6 2.41-12+deb13u3 (won't fix) deb CVE-2026-5450 Critical < 0.1% (22nd) < 0.1
libc6 2.41-12+deb13u3 deb CVE-2018-20796 Negligible 1.3% (80th) < 0.1
libc6 2.41-12+deb13u3 deb CVE-2019-1010025 Negligible 1.2% (79th) < 0.1
python 3.14.5 binary CVE-2026-3298 High < 0.1% (22nd) < 0.1
python 3.14.5 3.15.0a6 binary CVE-2025-15366 Medium 0.1% (28th) < 0.1
python 3.14.5 3.15.0a6 binary CVE-2025-15367 Medium 0.1% (28th) < 0.1
python 3.14.5 binary CVE-2026-7210 Critical < 0.1% (19th) < 0.1
libc6 2.41-12+deb13u3 (won't fix) deb CVE-2026-5928 High < 0.1% (21st) < 0.1
libuuid1 2.41-5 (won't fix) deb CVE-2026-3184 Medium < 0.1% (24th) < 0.1
libc6 2.41-12+deb13u3 (won't fix) deb CVE-2026-5435 High < 0.1% (15th) < 0.1
libncursesw6 6.5+20250216-2 (won't fix) deb CVE-2025-6141 Medium < 0.1% (21st) < 0.1
libtinfo6 6.5+20250216-2 (won't fix) deb CVE-2025-6141 Medium < 0.1% (21st) < 0.1
ncurses-base 6.5+20250216-2 (won't fix) deb CVE-2025-6141 Medium < 0.1% (21st) < 0.1
ncurses-bin 6.5+20250216-2 (won't fix) deb CVE-2025-6141 Medium < 0.1% (21st) < 0.1
liblzma5 5.8.1-1 (won't fix) deb CVE-2026-34743 Medium < 0.1% (18th) < 0.1
libc6 2.41-12+deb13u3 deb CVE-2019-1010024 Negligible 0.5% (66th) < 0.1
python 3.14.5 binary CVE-2025-12781 Medium < 0.1% (14th) < 0.1
libc6 2.41-12+deb13u3 deb CVE-2010-4756 Negligible 0.4% (57th) < 0.1
python 3.14.5 binary CVE-2026-4786 High < 0.1% (6th) < 0.1
libc6 2.41-12+deb13u3 deb CVE-2019-1010023 Negligible 0.3% (52nd) < 0.1
python 3.14.5 binary CVE-2026-6019 Medium < 0.1% (9th) < 0.1
libsqlite3-0 3.46.1-7+deb13u1 deb CVE-2021-45346 Negligible 0.3% (50th) < 0.1
python 3.14.5 binary CVE-2026-1502 Medium < 0.1% (6th) < 0.1
libncursesw6 6.5+20250216-2 (won't fix) deb CVE-2025-69720 High < 0.1% (2nd) < 0.1
libtinfo6 6.5+20250216-2 (won't fix) deb CVE-2025-69720 High < 0.1% (2nd) < 0.1
ncurses-base 6.5+20250216-2 (won't fix) deb CVE-2025-69720 High < 0.1% (2nd) < 0.1
ncurses-bin 6.5+20250216-2 (won't fix) deb CVE-2025-69720 High < 0.1% (2nd) < 0.1
libc6 2.41-12+deb13u3 (won't fix) deb CVE-2026-6238 Medium < 0.1% (3rd) < 0.1
libbz2-1.0 1.0.8-6 (won't fix) deb CVE-2026-42250 Medium < 0.1% (3rd) < 0.1
libc6 2.41-12+deb13u3 deb CVE-2019-1010022 Negligible 0.1% (35th) < 0.1
libuuid1 2.41-5 (won't fix) deb CVE-2026-27456 Medium < 0.1% (2nd) < 0.1
zlib1g 1:1.3.dfsg+really1.3.1-1+b1 (won't fix) deb CVE-2026-27171 Medium < 0.1% (0th) < 0.1
libsqlite3-0 3.46.1-7+deb13u1 deb CVE-2025-70873 Negligible < 0.1% (16th) < 0.1
libuuid1 2.41-5 deb CVE-2022-0563 Negligible < 0.1% (7th) < 0.1
libuuid1 2.41-5 deb CVE-2025-14104 Negligible < 0.1% (0th) < 0.1
Oh yeah, it didn’t pull the DHI attestation file that Docker Hardened Images
build and maintain. I’m going to use Docker Scout to pull the attestation vex
file from the dhi.io/python:3 which is what the “Attestations” tab on the
image’s page says to do:
docker scout attestation get --predicate \
--predicate-type https://openvex.dev/ns/v0.2.0 \
--platform linux/arm64 dhi.io/python:3 \
-o vex-dhi-predicate.json
Reading the outputted vex file I got a bit confused as it didn’t cover all the vulnerabilities, and when running grype with this vex file I get:
~/dev/terry
❯ grype terry:dhi --vex vex-dhi-predicate.json
✔ Loaded image terry:dhi
✔ Parsed image sha256:0c2b00978fbc36e20cd91dfb361b1b3f4561
✔ Cataloged contents 9b5ef7804d9f6dd3e67bd27735abe5180b16bc71dd1
├── ✔ Packages [109 packages]
├── ✔ Executables [417 executables]
├── ✔ File metadata [1,666 locations]
└── ✔ File digests [1,666 files]
✔ Scanned for vulnerabilities [9 vulnerability matches]
├── by severity: 4 critical, 14 high, 25 medium, 0 low, 22 negligible
└── by status: 2 fixed, 63 not-fixed, 56 ignored
NAME INSTALLED FIXED IN TYPE VULNERABILITY SEVERITY EPSS RISK
python 3.14.5 binary CVE-2026-6100 Critical 0.2% (37th) 0.1
python 3.14.5 binary CVE-2026-3298 High < 0.1% (22nd) < 0.1
python 3.14.5 3.15.0a6 binary CVE-2025-15366 Medium 0.1% (28th) < 0.1
python 3.14.5 3.15.0a6 binary CVE-2025-15367 Medium 0.1% (28th) < 0.1
python 3.14.5 binary CVE-2026-7210 Critical < 0.1% (19th) < 0.1
python 3.14.5 binary CVE-2025-12781 Medium < 0.1% (14th) < 0.1
python 3.14.5 binary CVE-2026-4786 High < 0.1% (6th) < 0.1
python 3.14.5 binary CVE-2026-6019 Medium < 0.1% (9th) < 0.1
python 3.14.5 binary CVE-2026-1502 Medium < 0.1% (6th) < 0.1
I’m not too sure what is going on, the only thing I can think of is that the vulnerability database for Grype includes the CVEs that are “Awaiting Enrichment” while the Docker Scout one filters them out. The site does say that there are 2 active vulnerabilities for this image which are CVE-2026-7210 and CVE-2026-8328 which confuses me more because CVE-2026-8328 is awaiting enrichment and isn’t included in grype. Even weirder, CVE-2026-7210 is marked as Critical 9.8 in Grype (and on NIST) but Medium 6.3 in Scout. The only reason I can think of is Docker does additional triage to their database. If anything, this is just a good reason to use multiple of these tools like Docker Scout, Grype and Trivy to get the best coverage and triage yourself.
Looking at the 9 survivors again though, they actually kind of make sense.
Every single one is a binary match on the Python interpreter itself and that’s
the one thing Docker can’t VEX away for me. They can sign off on libc6 and
ncurses because they ship those layers and know exactly what their image does
with them. But whether CVE-2026-6100 matters depends on whether my application
ever touches lzma, and Docker has no idea what my application does. They’d be
lying if they signed not_affected on my behalf. So the vendor’s VEX clears the
vendor’s half of the image, and the interpreter CVEs are application-dependent,
meaning the only person who can clear them is me, with exactly the kind of
hand-rolled vex file I built earlier. The homework splits cleanly in two: their
layers, their VEX; my code paths, my VEX.
On size, the DHI image is also 156MB, which is slightly bigger than the Alpine one. Which tracks as it is Debian at its base.
So What?
So what do you pick? The Alpine image or the DHI image? The Alpine is slightly smaller and has a lot less packages and less areas to attack; but it has a shell and a package manager in the final image. DHI is slightly bigger but has a wider attack vector; but it doesn’t have a shell or package manager to exploit and has active vulnerability scanning and patching support and is free at the moment but requires logging in to Docker Hub to even work.
Eh. It depends I guess. In most cases the alpine image is probably the “best” option for convenience sake. But if you are building a very public important application or are required to meet compliance standards then the DHI is probably the best option.
Something that is important to note is that, sure the CVE count is higher on the DHI image, or even in the regular Debian image, but these are all not triaged and most probably don’t even apply to the final image.
On a side note, you can harden the Alpine image even more by adding a removal
of the apk package manager (+ database) and remove everything in the /bin
directory so you just have no shell as well by adding the following to the
Dockerfile:
RUN rm -rf /sbin/apk /etc/apk /lib/apk /var/cache/apk /usr/share/apk \
&& find /bin -mindepth 1 -delete
Grype will acknowledge that busybox no longer exists, so we end up with the same 9 other python vulnerabilities that are still not applicable to this. A bonus is that this shrinks the image to 119MB as well, love me some storage savings.
I wanted to add a note that I did do a distroless version and test on my galivanting trip. It was around 140MB, but being Debian based there was a lot of vulnerabilities matching, and no attestation; on top of running python 3.13. It just wasn’t worth it so I didn’t want to write about it in this post.
This ended up being a bit of fun. I’ll rewrite up the Dockerfiles and script I used to generate the vex file and make that available somewhere on my github.