Lachlan Cox

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:

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:

  1. We need to pick the image to use from the dhi python catalog.
  2. 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.

<< Previous Post

|

Next Post >>

#Docker #Security #Containers #Python