Back to glossary

Docker

Docker is a platform that builds, ships, and runs software as containers: isolated Linux processes started from layered, content-addressed images.

Also known as Docker Engine, Docker container, Docker image, Dockerfile, Containerization

Docker is a containerization platform for building, distributing, and running software in containers: ordinary Linux processes isolated by kernel namespaces and cgroups, started from layered, content-addressed images. Because an image bundles an operating system, a language runtime, and every dependency, each Docker image is a software supply chain artifact that inherits all of their risk.

The most important security fact about Docker is that an image tag is a mutable pointer. node:24, latest, and v2.1.20 mean whatever the last account with push access uploaded. In 2026, attackers pushed malicious builds under trusted tags in the Trivy Docker image compromise, the Checkmarx KICS image compromise, and the LiteLLM compromise. Our position follows from that: treat every image as a dependency tree you did not write, pin it by digest, and check what is in its layers rather than what its tag, labels, or attached metadata claim.

How does Docker work?

#

Docker works by asking the Linux kernel to run an ordinary process inside its own namespaces and control groups, on a root filesystem assembled from image layers. Nothing is emulated. The docker CLI calls the dockerd daemon's API, dockerd hands the container to containerd, and containerd invokes runc, the OCI runtime that creates the namespaces and executes the process.

We started a busybox container on a Docker Engine 29.9.2 host and looked at it from both sides:

Bash
$ docker run --rm demo/base:1 sh -c 'echo "inside: my PID is $$"'
inside: my PID is 1

$ C=$(docker run -d demo/base:1 sh -c 'sleep 300')
$ P=$(docker inspect --format '{{.State.Pid}}' $C); ps -o pid,user,args -p $P
  PID USER     COMMAND
 1496 root     sh -c sleep 300

$ for n in pid mnt net uts ipc; do echo "$n host=$(readlink /proc/self/ns/$n) container=$(readlink /proc/$P/ns/$n)"; done
pid host=pid:[4026531836] container=pid:[4026532267]
mnt host=mnt:[4026531832] container=mnt:[4026532264]
net host=net:[4026531833] container=net:[4026532268]
uts host=uts:[4026531838] container=uts:[4026532265]
ipc host=ipc:[4026531839] container=ipc:[4026532266]

$ uname -r; docker exec $C busybox uname -r
6.18.44-fc-v230
6.18.44-fc-v230

Inside the container, the shell believes it is PID 1. On the host, the same shell is PID 1496, running as root, in five namespaces the host does not share. Both sides report the same kernel version because only one kernel exists.

That shared kernel is the line between containerization and virtualization. A virtual machine boots its own kernel; a container borrows the host's kernel and trusts it to enforce the boundary. Containers start fast and pack densely for exactly that reason. The cost is that a kernel vulnerability can become a container escape, which is why a container is a packaging and resource boundary first and a security boundary second.

What is inside a Docker image?

#

A Docker image is a small graph of JSON documents and tar archives, each named by the SHA-256 digest of its bytes. Since Docker Engine 25.0, docker save writes that graph as a standard OCI image layout, so you can unpack any image with tar and jq. We built a four-instruction image to see every piece:

Dockerfile
FROM demo/base:1
WORKDIR /app
COPY .npmrc package.json ./
# install dependencies with the token, then "remove" it
RUN rm /app/.npmrc

The tag demo/app:1.0 resolved to an image index. The index listed a manifest for linux/amd64 and a second, attestation manifest that BuildKit attached on its own, carrying SLSA provenance. The platform manifest points to one config blob and four layers:

JSON
{
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.manifest.v1+json",
  "config": {
    "mediaType": "application/vnd.oci.image.config.v1+json",
    "digest": "sha256:abfea640842e7f86b63cbe706cfbfbec72d3b3e0f33e5c62cd0f2698352cb086",
    "size": 985
  },
  "layers": [
    { "digest": "sha256:dcdd13d8a8ba30a8d3b95712e226c6936af16c07816b8767c8d55cd076dc979c", "size": 1145379 },
    { "digest": "sha256:82405ea72b5f01eeef6ef885c6969ec205fa3cc2fdaad5c38b1b14488fcafb02", "size": 93 },
    { "digest": "sha256:db8f622916e27621e58696f6e95b3534f4014fe63cefdade55971a8775ad7ac5", "size": 252 },
    { "digest": "sha256:24b6ac6627793376a2108e2d5c6d6779d02c250bfb81e221435f4f3a6440f8c5", "size": 162 }
  ]
}

The figure traces the image from top to bottom. The tag demo/app:1.0 resolves to image index sha256:c6dc1fa37235…. The index lists two manifests: the linux/amd64 manifest sha256:3cd41af82fe2… and an attestation manifest sha256:5e061071a53c… whose predicate type is https://slsa.dev/provenance/v1. The platform manifest points to config sha256:abfea640842e…, which records one history entry per Dockerfile instruction, and to four gzipped layers. Layer 1 is the busybox root filesystem (1.1 MB). Layer 2 creates app/. Layer 3, from the COPY, contains app/.npmrc and app/package.json. Layer 4, from the RUN rm, contains only a zero-byte whiteout file named app/.wh..npmrc. At container start, the overlay merge hides .npmrc, so the running container sees only package.json in /app.

Every layer is a tar archive of filesystem changes, and every object is addressed by its hash. The OCI image specification states the contract plainly: "Before consuming content targeted by a descriptor from untrusted sources, the byte content SHOULD be verified against the digest string." (OCI descriptor spec)

A digest is the only name in this graph that cannot be changed without changing the content.

The same structure explains why images carry so much third-party code. Everything under FROM comes along: a base image for Node.js or Python ships a Linux distribution's packages, a language runtime, and that runtime's package manager before your first COPY. Your application dependencies land on top. A software bill of materials for an image therefore has to cover at least three package ecosystems at once: the OS packages, the runtime, and your own dependency tree.

Docker tags are pointers, not versions

#

A Docker tag is a mutable name that a registry maps to a digest, and anyone who can push can remap it. Retagging our own image took one command and printed no warning:

Bash
$ docker image inspect --format '{{.Id}}' demo/app:1.0
sha256:c6dc1fa372359c26501ee6eba74b1772a78d2dede507971f0c7c17a2f54edad5
$ docker tag demo/app:1.1 demo/app:1.0
$ docker image inspect --format '{{.Id}}' demo/app:1.0
sha256:7ffe3e317db02a545ec23a770fd7ec5b5928bdef8945e40c1d92ce4c893cda8b
$ docker build -t demo/app:1.0 .
Tag referencedemo/app:1.0
→
Contentsha256:c6dc1fa37235…
Digest referencedemo/app@sha256:c6dc1fa37235…
→
Contentsha256:c6dc1fa37235…
$ docker tag demo/app:1.1 demo/app:1.0
Tag referencedemo/app:1.0
→
Moved tosha256:7ffe3e317db0…
Digest referencedemo/app@sha256:c6dc1fa37235…
→
Unchangedsha256:c6dc1fa37235…
One command silently repoints a tag. Select a step to compare. Registries behave the same way for anyone holding push access.

Before the retag, both the tag reference demo/app:1.0 and the digest reference demo/app@sha256:c6dc1fa37235… resolve to content sha256:c6dc1fa37235…. After docker tag demo/app:1.1 demo/app:1.0, the tag reference resolves to sha256:7ffe3e317db0…, while the digest reference still resolves to sha256:c6dc1fa37235….

Registries behave the same way. As of November 2026, Docker Hub's immutable tags feature is in beta, and the default setting is "All tags are mutable." Three 2026 incidents show what that default costs:

ImageDateWhat happened to the tagsWhat a tag-following consumer got
Trivy, official Docker Hub imagesMarch 22, 2026New tags 0.69.5 and 0.69.6 pushed with no matching GitHub release; latest moved to 0.69.6The TeamPCP infostealer. mirror.gcr.io kept serving 0.69.4 to 0.69.6 after removal. 0.69.3 was the last known clean release.
checkmarx/kics on Docker HubApril 22, 2026Existing tags v2.1.20, v2.1.20-debian, alpine, debian, and latest repointed to malicious digests; a fake v2.1.21 addedA modified KICS binary that could encrypt scan reports and send them to an external endpoint
ghcr.io/berriai/litellmOctober 21, 2026v1.107.0, latest, main-latest, and main-stable all resolved to the same malicious image indexA Python credential stealer that runs at interpreter startup

Sources for the table are our reports linked above. The Trivy images carried the same infostealer as earlier stages of the TeamPCP campaign, and TeamPCP appears to have claimed the KICS compromise as well.

Pinning by digest closes the moved-tag hole. A digest reference fetches the same bytes no matter what anyone does to the tag later:

Dockerfile
# Resolved at build time: whoever controls the tag controls this line
FROM node:24-slim

# Content-addressed: a repointed tag cannot change what this pulls
FROM node:24-slim@sha256:<the 64-hex digest you reviewed>

A digest proves immutability, not safety. If you resolve a digest during an attack window, you pin the malware exactly, and pinned images stop receiving fixes until something bumps them. Pair digest pins with an update bot that proposes new digests as reviewable changes, and with a delay before adopting a freshly pushed one.

Signatures answer a different question: who published these bytes. Docker's original signing system, Docker Content Trust, is being retired; Docker reported that "fewer than 0.05% of Docker Hub image pulls use DCT" when it announced the retirement on July 29, 2025, and pointed publishers to Sigstore and Notation. A valid signature from a compromised publisher still verifies. Package provenance has the same limit, and LiteLLM is the cleanest example: the SLSA provenance attached to the malicious image accurately recorded a source repository named Berri-AI/litellm, one hyphen away from the real BerriAI/litellm. Provenance told the truth about a build nobody should have trusted.

Deleting a file in a Dockerfile does not remove it

#

Deleting a file in a later Dockerfile instruction does not remove it from a Docker image. The deletion becomes a whiteout marker in a new layer. The OCI specification defines it: "A whiteout filename consists of the prefix .wh. plus the basename of the path to be deleted." (OCI layer spec) The running container never sees the file. Anyone who can pull the image can read it.

Here is the npm token from our demo Dockerfile, recovered from the image after rm:

Bash
$ docker run --rm demo/app:1.0 ls -la /app
total 12
drwxr-xr-x    1 0        0             4096 Nov  3 23:46 .
drwxr-xr-x    1 0        0             4096 Nov  3 23:53 ..
-rw-r--r--    1 0        0               34 Nov  3 23:46 package.json

$ docker save demo/app:1.0 -o app.tar && tar -xf app.tar
$ tar -tzvf blobs/sha256/24b6ac6627793376a2108e2d5c6d6779d02c250bfb81e221435f4f3a6440f8c5 | grep npmrc
---------- 0/0               0 1970-01-01 00:00 app/.wh..npmrc
$ tar -tzvf blobs/sha256/db8f622916e27621e58696f6e95b3534f4014fe63cefdade55971a8775ad7ac5 | grep app/
drwxr-xr-x 0/0               0 2026-11-03 23:46 app/
-rw-r--r-- 0/0              73 2026-11-03 23:46 app/.npmrc
-rw-r--r-- 0/0              34 2026-11-03 23:46 app/package.json
$ tar -xzOf blobs/sha256/db8f622916e27621e58696f6e95b3534f4014fe63cefdade55971a8775ad7ac5 app/.npmrc
//registry.npmjs.org/:_authToken=npm_FAKEdemoTOKEN0000000000000000000000

Docker's own linter does not flag this pattern. docker build --check on the Dockerfile above returned "Check complete, no warnings found." The linter does catch secrets passed as build arguments, through its SecretsUsedInArgOrEnv rule, and for good reason: a build argument consumed by RUN is written verbatim into the image config.

Bash
$ cat Dockerfile.arg
FROM demo/base:1
ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > /.npmrc

$ docker build --check -f Dockerfile.arg .
WARNING: SecretsUsedInArgOrEnv - https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/
Do not use ARG or ENV instructions for sensitive data (ARG "NPM_TOKEN")

$ docker build --build-arg NPM_TOKEN=npm_FAKEdemoTOKEN0000 -f Dockerfile.arg -t demo/arg:1 .
$ docker history --no-trunc --format '{{.CreatedBy}}' demo/arg:1 | head -1
RUN |1 NPM_TOKEN=npm_FAKEdemoTOKEN0000 /bin/sh -c echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > /.npmrc # buildkit

This failure mode has a well-known production precedent. Codecov's incident report says the attacker "gained access because of an error in Codecov's Docker image creation process," which exposed a credential used to modify its Bash Uploader. The modified script ran from January 31, 2021 until a customer noticed a checksum mismatch on April 1.

BuildKit secret mounts are the fix. The secret is mounted as a file for the duration of one RUN step and is never written to a layer:

Dockerfile
FROM demo/base:1
WORKDIR /app
COPY package.json ./
RUN --mount=type=secret,id=npmrc,target=/app/.npmrc \
    cat /app/.npmrc > /dev/null && echo "install would run here"

We built it with docker build --secret id=npmrc,src=.npmrc, saved the image, and decompressed all 10 blobs: none contained the token. The limit is the step itself. Every process in that RUN can read the mounted file while it runs, including every install script of every dependency it installs. That is where the next problem starts.

Where Docker builds run untrusted code

#

Docker builds run untrusted code in every RUN instruction that installs dependencies. RUN npm install executes the install scripts of every package in the tree. pip install of a source distribution runs its build backend. A Python wheel can drop .pth and sitecustomize.py files that execute on every interpreter start. The build container holds whatever you gave the build: mounted registry tokens, cloud credentials, a forwarded SSH agent.

LiteLLM 1.107.0 showed what that means in a container. Its wheels shipped cook.pth and sitecustomize.py, which our analysis of the LiteLLM compromise found would collect cloud credentials, private keys, and AI tool tokens "without the application importing LiteLLM." Researchers also found the payload in the published LiteLLM container image, where any container start that launches Python can run the collector again. LiteLLM receives about 23M weekly downloads. Socket's scanner flagged the wheels seven minutes after publication, but a flag protects only the builds that check it before installing, and any deployment following latest kept receiving the malicious index until the tag moved again.

Package managers are finally changing the default that makes RUN npm install dangerous. pnpm 10 blocked lifecycle scripts by default in January 2025, and npm v12 shipped with install scripts off on July 8, 2026. Default-on install scripts were a design mistake that both projects have now corrected. The catch for Docker is that the npm in your build is the npm in your base image. Check npm --version inside the image you build on, and until it is v12 or later, use npm ci --ignore-scripts with an explicit allowlist for the few packages that need a build step. npm ci installs exactly what the lockfile records and fails if package.json disagrees, so the build cannot adopt a version published minutes earlier.

Since December 2025, Docker Hardened Images bundle Socket Firewall Free in their Node.js, Python, and Rust images. In those images, npm, yarn, pnpm, pip, uv, and cargo run through the sfw binary, which checks each package artifact request against Socket's security API and blocks confirmed malware before the artifact reaches the build. It works as a package firewall, and it has the limits of one. Per the Socket Firewall Free documentation, "Only confirmed malware is blocked," and an artifact served from a local cache makes no network request, so sfw has nothing to inspect. In Docker terms, a package already sitting in a RUN --mount=type=cache directory or in a cached layer from an earlier build bypasses the check.

What is Docker container security?

#

Docker container security is the practice of controlling what goes into an image, who can change it, and what a running container can do to its host. The first two are supply chain problems and are covered above. The third is the runtime boundary, and it rests on a few hard facts from Docker's own documentation:

  • The Docker daemon requires root unless you opt into rootless mode, and Docker's security guide says "only trusted users should be allowed to control your Docker daemon."
  • "The docker group grants root-level privileges to the user," per Docker's Linux post-install guide. Adding a CI user to that group is a root grant.
  • Mounting /var/run/docker.sock into a container hands that container the same daemon control, which is root on the host.
  • Docker starts containers with a restricted set of Linux capabilities by default. --privileged removes those restrictions.
  • All containers share the host kernel, so kernel patching is part of container security.

Running containers as a non-root user, dropping capabilities the workload does not need, keeping the daemon socket out of containers, and using rootless mode where it fits all shrink the blast radius. Container security covers the runtime side, including orchestrators, in more depth.

Our view is that runtime hardening is necessary and not where the 2026 incidents happened. The Trivy, KICS, and LiteLLM attacks needed no container escape. The malicious code was the workload, it ran with exactly the access the workload was given, and the credentials it stole were the ones the container was supposed to have.

What are the best Docker security tools?

#

The best Docker security tools are the ones that cover each stage where an image can go wrong: the Dockerfile, the build, the registry, and the running container. No single tool covers all four, and each category has a blind spot worth knowing before you rely on it:

StageTool examplesWhat it catchesWhat it misses
Dockerfiledocker build --check, HadolintSecrets in ARG and ENV, risky instruction patternsThe COPY then rm secret pattern, which passed --check in our test
Image contentsDocker Scout, Trivy, GrypeKnown CVEs in OS and language packagesMalware with no CVE; the scanner is itself a supply chain dependency
Inventory and provenanceBuildKit --sbom and --provenance, SyftWhat is in the image and which inputs built itWhether those inputs were trustworthy, as LiteLLM's lookalike source showed
SigningSigstore cosign, NotationWhich identity published a digestA compromised publisher signing malicious bytes
Dependency installSocket Firewall in Docker Hardened ImagesKnown malware fetched during RUN installsArtifacts served from cache; malware not yet confirmed
RuntimeRootless mode, seccomp, AppArmor, FalcoEscapes, unexpected syscalls and process behaviorExfiltration through credentials and network paths the workload legitimately has

Our verdict: start with digest pinning and install-time controls, because the 2026 attacks entered through moved tags and dependency installs, and neither CVE scanners nor runtime sensors were designed to stop that. Malware does not get a CVE. Add CVE scanning for the OS layer next, and triage it by what the image actually executes rather than by severity score, because a minimal base image removes many findings outright. Use signatures and provenance as evidence for incident response, not as approval gates on their own.

The scanner row deserves its own warning. Two of the three compromised images in the table above were security scanners. A CI pipeline that pulls a scanner at latest to check infrastructure code hands that image the repository and the pipeline's secrets, which is exactly the access our analysis of TeamPCP found the group going after. Pin your security tools by digest with the same discipline as your base images.

The open problem is that the registry, not the consumer, decides whether a tag means anything. Digests make bytes immutable, and signatures and provenance say who produced them, but none of the three tells you whether latest today is the image you reviewed yesterday. Until registries make immutable tags the default, every FROM line that names a tag is a supply chain attack surface held open by whoever last had the push credential.