When snyk container test reports a vulnerability, it doesn't just name a package and a CVE — it also tells you which of the image's layers introduced that package, and in many cases which Dockerfile instruction produced that layer. For a FROM node:20-slim image with a dozen RUN and COPY steps stacked on top, that distinction is the difference between patching your own build script in five minutes and staring at a 40-package vulnerability list with no idea where to start. This mapping isn't guesswork or a heuristic scraped from log output — it's built on structural metadata that every OCI-compliant container image already carries: a manifest, a config blob with a history array, and a set of content-addressable layer digests. This post walks through, mechanically, how that metadata gets turned into a "found in layer 7, added by line 14 of your Dockerfile" answer, where the approach runs into its own limits, and what that means for how teams prioritize fixes.
What is a container layer, and why does a scanner need to know which one it's looking at?
A container image is a stack of filesystem diffs, not a single filesystem, and every one of those diffs has its own SHA256 digest. Per the OCI Image Format Specification (the spec Docker's own image format now defers to), an image is described by a manifest that points to a config JSON blob and an ordered list of layer blobs, each identified by a diff_id. Each layer is a tarball representing only the files added, changed, or removed relative to the layer beneath it — a RUN apt-get install -y curl step, for example, produces a layer containing just the new binaries, shared libraries, and package-manager metadata that command wrote to disk. When a scanner treats an image as one flattened filesystem, it can tell you that curl 7.81.0 is vulnerable to CVE-2023-38545, but not where in the build it came from. Because the layer boundaries already exist in the image's own metadata, a scanner that walks the manifest layer-by-layer, rather than the merged filesystem, can retain that provenance for free — it just has to preserve it instead of discarding it during the scan.
How does Snyk Container work out which layer a vulnerable package actually lives in?
It cross-references each package it finds against the layer whose filesystem diff introduced that package's files, using the config blob's rootfs.diff_ids array as the ordered map of layers to inspect. According to Snyk's documentation, snyk container test unpacks the image and, for OS-level packages, reads the package manager's own installed-package database — /var/lib/dpkg/status and .list files on Debian/Ubuntu, the RPM database on Red Hat/CentOS/UBI, and the /lib/apk/db/installed index on Alpine — to build the list of installed packages and versions. To attribute a given package to a specific layer, Snyk walks the ordered layer diffs and identifies the layer whose tarball first wrote (or last modified) the files backing that package entry. Because layers are applied in sequence and each is a discrete diff, a package that first appears in layer 4 of a 9-layer image is reported against layer 4, not against the flattened image as a whole. This is the same mechanical idea docker history and dive use to show per-layer size deltas — Snyk applies it to vulnerability findings instead of just disk usage.
How does a plain Dockerfile instruction like RUN apt-get install end up labeled in the scan results?
Snyk correlates layers back to Dockerfile lines using the history array inside the image config blob, which most builders (including Docker's classic builder and BuildKit) populate with a created_by string for every instruction, plus an empty_layer flag for instructions like ENV, LABEL, or WORKDIR that don't produce a filesystem diff. When you run snyk container test <image> --file=Dockerfile, Snyk has both the config blob's history and the actual Dockerfile text, so it can line them up: it skips the empty_layer entries, walks the remaining history items in order, and matches each one against the corresponding Dockerfile instruction, since well-formed images preserve build order in both places. That's what makes it possible for the CLI output to say a vulnerable libssl package was "introduced by your Dockerfile" via a specific RUN line rather than just "found in layer 6" — the layer number is a fact from the image bytes, and the Dockerfile line is inferred by aligning that same ordered history against the source you handed it. Without the --file flag, Snyk can still report the layer index and the created_by command string itself (e.g., /bin/sh -c apt-get update && apt-get install -y libssl1.1), just without tying it to a line number in your source file.
Why does Snyk sometimes only point to "base image" instead of a specific instruction?
Because the vulnerable layer was built by someone else's Dockerfile, not yours, and Snyk has no source to align it against. If a package first appears in a layer that traces back to FROM node:20-slim rather than to one of your own RUN/COPY/ADD lines, there is no local Dockerfile text describing that layer's history entry — the instruction that created it lives in the base image maintainer's build, which you don't have on disk. Snyk's base image detection handles this by matching the image's early layer digests against known public base images it indexes, which is also the mechanism behind Snyk's base image upgrade recommendations (e.g., suggesting node:20-slim → a newer patch tag that resolves several CVEs at once). In that case the practical fix isn't editing a Dockerfile line; it's bumping the FROM tag, and Snyk's output is deliberately framed that way — "upgrade base image" rather than "fix line 14" — because that reflects where the remediation actually has to happen.
Where does this layer-attribution model break down?
Three build patterns routinely erase the very metadata the mapping depends on: layer squashing, multi-stage builds, and BuildKit cache-mount steps. A docker build --squash (or an equivalent flattening step in some CI pipelines) merges every layer into one before the image is pushed, which destroys the per-instruction diff_ids and collapses the history array's usefulness — at that point a scanner can still find the vulnerable package, but it can only attribute it to "the image," not to a specific step. Multi-stage builds complicate things differently: only the layers from the final FROM stage that gets copied via COPY --from=builder end up in the shipped image, so a vulnerability that existed in an intermediate build stage but wasn't copied into the final artifact won't appear at all — and conversely, a vulnerable binary copied out of a builder stage will be attributed to the COPY --from instruction in the final stage, not to whichever RUN step in the builder stage originally installed it. And distroless or from-scratch final images, by design, often carry no package manager database at all, which is why Snyk Container's OS-package detection depends on that database being present — binaries installed by hand-copied files rather than a package manager typically show up (if at all) only through language-specific dependency scanning, not the OS-layer path.
Does knowing the layer actually change remediation priority?
Yes, because it separates "fix your build" from "wait on upstream" work, and that split is usually the first triage question a team asks. A vulnerability traced to a layer your own RUN npm install or apt-get install produced is something you can resolve today — pin a version, add an apt-get upgrade, or remove a package you don't need. A vulnerability traced to a base-image layer usually requires either an upstream patch release or a base image swap, which may be gated by compatibility testing you don't control on the same timeline. Teams that triage container findings without layer context tend to treat both categories the same way, which either causes needless base-image firefighting for something only a FROM bump will fix, or causes real self-inflicted issues to get deprioritized as "someone else's problem." Layer-level attribution is what turns a flat CVE list into an actionable split between the two.
How Safeguard Helps
Container images accumulate this kind of ambiguity fast — a base image maintained by one team, a builder stage maintained by another, and a final artifact that ships to production carrying vulnerabilities whose origin isn't obvious from a flattened scan. Safeguard's platform is built around keeping that provenance attached to a finding rather than losing it at scan time: SBOMs generated during the CI pipeline are tied back to the source commit and build step that produced each component, so when a new CVE lands, the resulting alert already carries the context of which layer, which repository, and which build stage introduced it — not just a package name and severity score. That context feeds directly into how Safeguard prioritizes remediation work across a fleet of images: findings tied to a team's own Dockerfile route to that team with the specific instruction flagged, while findings inherited from a shared base image get grouped and tracked against the base image owner instead of being redistributed to every downstream consumer. For organizations already running Snyk Container or a similar scanner, Safeguard's continuous monitoring layer sits alongside that data, correlating layer-level findings against exploitability signals and internal deployment topology so security and platform teams aren't re-deriving "is this our bug or upstream's" by hand for every new advisory.