• how-to
  • base images
  • remediation

How to tell base-image vulnerabilities from your own, and what to do with each

Split a scan into base-image findings, packages your Dockerfile added, and images you do not build, with Syft, Grype and a short script. Then fix each the right way.

StackRadar Team

12 min read

Scan an image you build and the scanner hands back one list. On 18 October 2026, Grype found 701 vulnerabilities in nginx:1.27-bookworm, the newest build of that tag, from April 2025. Thirty-nine of them were critical. Not one line of the list says which of those are fixed by rebuilding on a newer Debian, which need a change to the image's own Dockerfile, and which nobody on your team can fix at all.

Most of your container vulnerabilities are not yours to fix makes the case for splitting that list three ways: the base image's packages, the packages your build added, and whole images someone else builds. This post does the split by hand with Syft, Grype and thirty lines of Python, on a real image, and then says what to do with each bucket. If you run Docker Scout or StackRadar you get the split for free; the point of doing it once by hand is to know what the tools are claiming.

Step 1: find the base image

Everything depends on knowing where the base ends. The reliable answer is written on the image itself. The OCI image spec defines two annotations, org.opencontainers.image.base.name and org.opencontainers.image.base.digest, and Docker Official Images carry both on every per-architecture manifest. Syft fetches the manifest when it scans from a registry and keeps it in its JSON, so one scan gives you the SBOM you need later and the answer you need now:

find-base.sh
# Syft fetches the image's manifest anyway, so one scan gives you the SBOM
# and the annotations. --platform matters: the base is declared per architecture.
syft -q -o json --platform linux/amd64 registry:docker.io/library/nginx:1.27-bookworm > image.syft.json

jq -r '.source.metadata.manifest' image.syft.json | base64 -d \
  | jq '.annotations | with_entries(select(.key | startswith("org.opencontainers.image.base")))'
output
{
  "org.opencontainers.image.base.digest": "sha256:f957d7d19c4171aded89330899c1501c03707d879f200001aa47af87d98b3c70",
  "org.opencontainers.image.base.name": "debian:bookworm-slim"
}

The digest is the base as it was when the image was built, which is the version you have to compare against. debian:bookworm-slim today points at a different digest, and that difference is Step 3.

Two things to know about the annotation. It names the immediate base: nginx:1.27-alpine declares nginx:1.27.5-alpine-slim, which in turn declares Alpine, so walk the chain if the distribution is what you are after. And most images outside the official library do not have it, because docker build does not add one. Then the fallback is the build history in the image config, which Syft also keeps:

history-fallback.sh
# No annotation? The config's history is the Dockerfile, oldest first.
# The base image ends where its own instructions end; here, after line 1.
jq -r '.source.metadata.config' image.syft.json | base64 -d \
  | jq -r '.history[] | select(.empty_layer != true) | .created_by[0:72]'

# debian.sh --arch 'amd64' out/ 'bookworm' '@1749513600'
RUN /bin/sh -c set -x     && groupadd --system --gid 101 nginx     && useradd --
COPY docker-entrypoint.sh / # buildkit
COPY 10-listen-on-ipv6-by-default.sh /docker-entrypoint.d # buildkit
...

The base image's own instructions come first, and the boundary is the first instruction you recognise from your Dockerfile. For a local image, docker history prints the same list, newest first. The boundary is a guess when the history is squashed or when the base ends in a RUN that looks like yours. Which is why the better fix is to stop guessing:

annotate-build.sh
# Declare the base in your own builds and the question never comes up again.
BASE=docker.io/library/node:20-bookworm-slim
docker buildx build \
  --annotation "org.opencontainers.image.base.name=$BASE" \
  --annotation "org.opencontainers.image.base.digest=$(docker buildx imagetools inspect --format '{{.Manifest.Digest}}' $BASE)" \
  --push -t registry.example.com/app:1.4.0 .

--annotation writes to the image manifest by default, and the annotation travels with the image to every registry and every scanner that reads it.

Step 2: split the findings

With the base's digest in hand, the split is a subtraction. Scan the base at that digest, scan your image, and a package is the base image's if the base's own SBOM lists it at the same version. A package your Dockerfile upgraded shows up with a different version, so it lands on your side, which is right: you chose that version, and you can choose the next one.

split.sh
# 1. The base image's own SBOM, at the digest the annotation names.
syft -q -o json --platform linux/amd64 \
  registry:docker.io/library/debian:bookworm-slim@sha256:f957d7d19c4171aded89330899c1501c03707d879f200001aa47af87d98b3c70 \
  > base.syft.json

# 2. Findings for the image, from the SBOM you already have.
grype -q -o json sbom:image.syft.json > image.grype.json

# 3. Split them.
python3 split-findings.py image.syft.json base.syft.json image.grype.json
split-findings.py
#!/usr/bin/env python3
"""split-findings.py IMAGE.syft.json BASE.syft.json IMAGE.grype.json

A package is the base image's if the base image's own SBOM lists the same
package at the same version. Everything else your build added, upgraded or
vendored. Grype findings are then split the same way.
"""
import json, sys
from collections import Counter, defaultdict

image, base, report = (json.load(open(p)) for p in sys.argv[1:4])

key = lambda p: (p["type"], p["name"], p["version"])
in_base = {key(p) for p in base["artifacts"]}
bucket = {p["id"]: ("base" if key(p) in in_base else "yours") for p in image["artifacts"]}

severity = defaultdict(Counter)
packages = defaultdict(set)
for m in report["matches"]:
    b = bucket.get(m["artifact"]["id"], "yours")
    severity[b][m["vulnerability"]["severity"]] += 1
    packages[b].add(f'{m["artifact"]["name"]} {m["artifact"]["version"]}')

order = ["Critical", "High", "Medium", "Low", "Negligible", "Unknown"]
counts = Counter(bucket.values())
print(f'{len(image["artifacts"])} packages: {counts["base"]} from the base image, {counts["yours"]} yours')
for b in ("yours", "base"):
    total = sum(severity[b].values())
    by_sev = "  ".join(f"{s.lower()} {severity[b][s]}" for s in order if severity[b][s])
    print(f"\n{b}: {total} findings in {len(packages[b])} packages   {by_sev}")
    for p in sorted(packages[b])[:8]:
        print(f"  {p}")
    if len(packages[b]) > 8:
        print(f"  … {len(packages[b]) - 8} more")

On nginx:1.27-bookworm, against the debian:bookworm-slim digest its annotation names:

output
$ python3 split-findings.py image.syft.json base.syft.json image.grype.json
150 packages: 88 from the base image, 62 yours

yours: 400 findings in 30 packages   critical 30  high 140  medium 120  low 16  negligible 69  unknown 25
  curl 7.88.1-10+deb12u12
  libaom3 3.6.0-1+deb12u1
  libcurl4 7.88.1-10+deb12u12
  libdav1d6 1.0.0-2+deb12u1
  libde265-0 1.0.11-1+deb12u2
  libexpat1 2.5.0-1+deb12u1
  libfreetype6 2.12.1+dfsg-5+deb12u4
  libgd3 2.3.3-9
  … 22 more

base: 301 findings in 46 packages   critical 9  high 109  medium 103  low 15  negligible 65
  apt 2.6.1
  bsdutils 1:2.38.1-5+deb12u3
  coreutils 9.1-1
  dash 0.5.12-2
  diffutils 1:3.8-4
  dpkg 1.21.22
  gcc-12-base 12.2.0-14+deb12u1
  gpgv 2.2.40-1.1
  … 38 more
BUCKETFINDINGSAll findingsnginx:1.27-bookworm, one list701Base imagedebian:bookworm-slim, 46 packages301Yoursnginx's apt-get layer, 30 packages400
Measured on 18 October 2026 with Syft 1.42.1 and Grype 0.117.0 (database of 17 October).

Four hundred of the 701 findings are in packages the nginx Dockerfile installed itself: curl, libgd3 and the image codecs that the image-filter module pulls in. For the nginx maintainers those are "yours", fixed by changing the Dockerfile. For you, running the official image, the whole thing is a third-party image and the bucket that matters is Step 5. The script does not know who you are; it only knows where the packages came from.

Why not use the layer each package came from?

Syft and Grype record a layer digest on every file that is evidence for a package, and it is tempting to read the base boundary off that. It does not work the way you expect, and the figure shows why.

YOUR LAYER · RUN apt-get install nginx/var/lib/dpkg/status (rewritten)/var/lib/dpkg/info/nginx.md5sumsBASE LAYER · debian:bookworm-slim/var/lib/dpkg/status/var/lib/dpkg/info/libc6.md5sumsprimary evidencelast writer: your layerearliest fileinstalled in: base layerlibc6 2.36-9+deb12u10one package, two answerstake the earliest layer
dpkg's status file names the last layer that ran apt-get; a package's own files name the layer that installed it.

dpkg keeps one status file. Every apt-get rewrites it, so the scanner's primary evidence for every Debian package points at the last layer that ran apt-get: on this image, nginx's layer, for all 150 packages, libc6 included. Debian also keeps a file per package beside it, and the earliest layer among a package's files is the one that installed it, so a script that takes the minimum over locations[].layerID gets Debian right. Alpine and RPM-based images have no per-package file: on nginx:1.27-alpine, every package's only evidence is /lib/apk/db/installed in the top layer, and the layer method calls musl yours. Subtracting the base's SBOM works for all of them, which is why the script does that instead.

Trivy's --pkg-types os,library is a different split: operating-system packages versus language packages. An apt-get install curl in your Dockerfile is an OS package and yours; the npm bundled inside node:20 is a language package and the base image's. The two splits agree often enough to be confused and differ exactly where it matters. Docker Scout does the right split, and for images Docker Hub knows the base of it is one command:

docker-scout.sh
docker scout cves --only-base nginx:1.27-bookworm     # the base's findings
docker scout cves --ignore-base nginx:1.27-bookworm   # everything else
docker scout recommendations nginx:1.27-bookworm      # newer tags of the same base, with the difference

Step 3: base image findings are one rebuild

The 301 findings in the base bucket are not 301 tickets. They are one question: what does the newest build of this base look like? A tag like debian:bookworm-slim is rebuilt upstream as Debian publishes security updates, and an image built in April carries April's packages until someone builds it again. Measure before you rebuild:

rebuild-check.sh
# What a rebuild on today's debian:bookworm-slim would leave in the base layers.
syft -q -o json --platform linux/amd64 registry:docker.io/library/debian:bookworm-slim > base-now.syft.json
grype -q -o json sbom:base-now.syft.json | jq '.matches | length'
235

Rebuilding nginx on today's bookworm-slim would take the base bucket from 301 findings to 235 and from 9 criticals to 7; libc6 moves from deb12u10 to deb12u14 along the way. That is a real gain and an honest ceiling: Debian stable carries a long tail of findings it has decided not to patch or cannot, and a rebuild never clears those. CVE patching in Kubernetes covers what "no fix" and "won't fix" mean on a distribution.

In order of how much they change:

  1. Rebuild on the same tag. No Dockerfile change. Do it when the base moves, not when a scan happens to run: pin the FROM line by digest and let Renovate (which pins digests and updates them) or Dependabot (which updates tags) open the pull request, so the rebuild is a merge.
  2. Move to a newer tag of the same variant. node:20-bookworm-slim to node:22-bookworm-slim when the older line stops getting rebuilds. docker scout recommendations lists the candidates and what each one gains, fewer vulnerabilities or a smaller image.
  3. Change the variant. -slim drops packages; Alpine swaps the libc; distroless drops the shell and the package manager; Chainguard images are rebuilt from source frequently. Each removes findings by removing packages, and each is a migration, not a rebuild. One catch: a scanner can only report packages it can see. Distroless keeps dpkg metadata under /var/lib/dpkg/status.d (Syft finds five packages in distroless/static-debian12), but an image built FROM scratch with a static binary has no package database at all, and zero findings there is a different kind of zero.

Step 4: your own findings are the ordinary work

The "yours" bucket is the list a team can read to the end. For a language dependency the fix is a version bump and a rebuild. For an OS package your Dockerfile installed, it is the distribution's next build of that package, which you pick up with an apt-get upgrade in your layer or, better, by rebuilding on the newer base so the base brings it. Either way, read the fixed version carefully: 7.88.1-10+deb12u12 is not upstream curl 7.88.1 and the CVE list for it is Debian's, not curl.org's.

Two habits keep this bucket small. Install only what runs in production, in a final stage that does not inherit the build stage's compilers and headers. And let the base bring the libraries you need rather than installing them yourself, because a package you installed is a package you now track.

Step 5: images you do not build

For Prometheus, Grafana, an ingress controller, or the official nginx image itself, the base-versus-yours split is upstream's problem. You cannot change either. There is one action per image, and it is one of three:

  • Upgrade. A newer chart release or a newer image tag the chart lets you override. This clears most of what a scan reports on third-party images, because upstream rebuilds more often than you look.
  • Ask. If the newest release still has the finding, open the issue upstream or find the one that exists. More vendors now answer with a VEX statement: a signed, machine-readable claim about one CVE in one product.
  • Accept, with an expiry. When upstream has said why it is not exploitable, or the component is not reachable in your deployment, record the decision with an owner and a date, and let it expire.

A VEX statement is worth understanding because it is the only one of the three that removes the finding from your scanner's output without someone on your team suppressing it by hand. The OpenVEX spec defines four statuses: not_affected, affected, fixed and under_investigation. A not_affected statement must say why, from a fixed list of justifications such as vulnerable_code_not_present or vulnerable_code_not_in_execute_path:

vendor.openvex.json
{
  "@context": "https://openvex.dev/ns/v0.2.0",
  "@id": "https://vendor.example/vex/app-1.4.0",
  "author": "Vendor security team",
  "timestamp": "2026-10-12T09:00:00Z",
  "version": 1,
  "statements": [
    {
      "vulnerability": { "name": "CVE-2025-12345" },
      "products": [{ "@id": "pkg:oci/app@sha256:4d2c...?repository_url=registry.example.com/app" }],
      "status": "not_affected",
      "justification": "vulnerable_code_not_in_execute_path",
      "impact_statement": "The affected parser is compiled in but never reached; input is validated upstream."
    }
  ]
}
apply-vex.sh
grype registry.example.com/app:1.4.0 --vex vendor.openvex.json --show-suppressed
trivy image registry.example.com/app:1.4.0 --vex vendor.openvex.json --show-suppressed

Both scanners drop not_affected findings from the report and list them again with --show-suppressed, so the audit trail is the vendor's document rather than your ignore list. Trivy can also pull statements from a VEX repository with --vex repo; it marks the feature experimental. The statement is only as good as its author, so treat a vendor's VEX the way you treat a vendor's changelog: trusted, dated, and worth re-reading when the product changes.

Step 6: do not let the split hide an exploited CVE

The split sorts work by who does it. It says nothing about urgency, and the danger of filing 301 findings under "next rebuild" is that one of them is being exploited this week. The glibc "Looney Tunables" bug (CVE-2023-4911) is a base image finding on every Debian image built before October 2023 and it is on the CISA KEV list. So the last step is to run the exploited-CVE check across every bucket, and to move the rebuild forward when it hits. The KEV post has the kubectl and curl script; the free CLI does it in one line:

bash
# Exploited CVEs do not care which layer they are in. Gate on them across every bucket.
stackradar scan --fail-on kev

The same goes for a high EPSS score. How to combine the two into a working order for the whole cluster is the subject of prioritising container vulnerabilities with EPSS and KEV. And a split you ran once is a split you have to run again after every rebuild; scheduling the scan is its own how-to.

Order of work, for an image you build: exploited CVEs in any bucket today; your own packages this sprint; the base image at the next rebuild, which should be soon and automatic. For an image you do not build: upgrade, then ask, then accept with an expiry.

What StackRadar does

StackRadar applies the same split to every image running in your clusters, continuously, and the rules are the ones above. In the product, every findings table has an Application, Base image and All view with a count on each, and a second filter separates the images your organisation builds from third-party ones. The base boundary is found from the OCI annotations when the image has them, otherwise by matching the first layers against public images and against your own organisation's shared bases, with the build history as the last resort; when nothing answers, it falls back to the kind of package and never guesses a base image name. For images you build it scans the candidate base tags nightly and says what a rebuild would remove. A finding on the KEV list, or with an EPSS score of 0.1 or more, is listed under every view whichever bucket it is in. The rules and the overrides are on the Application vs base image docs page.

The free, Apache-2.0 StackRadar CLI does a smaller version of it from your laptop, with no account. Each image's table lists what the build added, and folds the base image's findings into one line:

stackradar scan
+ 35 in the base image nginx:1.27.5-alpine-slim (3 critical, 17 high, 12 medium, 3 low) — fixed by rebuilding on a newer base; --all to list

--all lists them, findings in CISA KEV are never folded, and the totals, the JSON output and --fail-on always count every finding. In -o json every finding carries a tier (os, runtime, application or unknown) and the base_image it was attributed to. The CLI reads the base from the OCI annotations and confirms it by fetching one manifest and comparing layers, so an apk add in your Dockerfile counts as yours. The continuous version, across clusters and with history, is on the Kubernetes vulnerability scanning page.

Frequently asked questions

How do I know which base image a container was built from?

Read the OCI annotations on its manifest: org.opencontainers.image.base.name and org.opencontainers.image.base.digest. Docker Official Images carry them, and docker buildx build --annotation adds them to your own images. If they are missing, the image config’s history lists every Dockerfile instruction oldest first, and the base image ends where its own instructions end, usually just before the first RUN or COPY you recognise as yours. docker history shows the same list for a local image.

How do I separate base image vulnerabilities from my application’s?

Scan the base image at the digest the annotation names, then scan your image, and treat every package that appears in both at the same version as the base image’s. Everything else your build added or upgraded. Grype and Syft give you both SBOMs as JSON, and a short script does the subtraction. Docker Scout does the same split with docker scout cves --only-base and --ignore-base.

Can I use the layer a package came from instead of scanning the base?

Only for Debian and Ubuntu images, and only with care. dpkg, apk and rpm keep one status file, so the scanner’s primary evidence for every package is the layer that last ran the package manager, not the one that installed the package. Debian keeps a file per package beside it (.md5sums, .list), and the earliest layer among a package’s files is the layer that installed it. Alpine and RPM-based images have no per-package file, so for them the layer method labels every package with the last apk or dnf run. Subtracting the base image’s SBOM works for all three.

How do I fix vulnerabilities in a base image?

Rebuild on a newer one. The same tag is often enough: distributions rebuild tags like debian:bookworm-slim and node:20-bookworm-slim for months, and an image built on last spring’s copy picks up fixes just by being built again. If the tag has stopped moving, move to the next tag of the same variant. Switching to a smaller base (slim, Alpine, distroless, Chainguard) removes packages rather than fixing them, which is a bigger change and worth doing separately. Pin the base by digest and let Renovate or Dependabot open the pull request when it changes.

What should I do about vulnerabilities in images I do not build?

Upgrade to a release where upstream fixed them: a newer Helm chart, or a newer image tag the chart lets you override. If no such release exists, ask upstream and record the answer. Some vendors publish VEX statements saying a CVE does not affect their image and why; Grype and Trivy both read OpenVEX with --vex and drop not_affected findings from the report, listing them again with --show-suppressed. Rebuilding someone else’s image yourself is rarely worth maintaining.

Is it safe to ignore base image vulnerabilities?

It is safe to work on them separately, not to ignore them. A CVE in glibc or OpenSSL that is on the CISA Known Exploited Vulnerabilities list is urgent whichever layer it sits in, and the fix is still a rebuild. Check exploited CVEs across every bucket before you file the base image’s findings under “next rebuild”.

All posts