# 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

Source: https://socket.dev/glossary/docker

Last updated: 2026-11-03

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](https://socket.dev/blog/trivy-docker-images-compromised), [the Checkmarx KICS image compromise](https://socket.dev/blog/checkmarx-supply-chain-compromise), and [the LiteLLM compromise](https://socket.dev/blog/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](https://github.com/opencontainers/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](https://docs.docker.com/engine/release-notes/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 demo/app:1.0 image we built and unpacked with docker save. Digests are truncated to 12 hex characters.
>
> Anatomy of the demo/app:1.0 Docker image: tag, index, manifests, config, and four layers, with the deleted .npmrc still present in layer 3

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](https://github.com/opencontainers/image-spec/blob/main/descriptor.md))

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](https://socket.dev/glossary/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
```

> One command silently repoints a tag. Select a step to compare. Registries behave the same way for anyone holding push access.
>
> A Docker tag moving to a new digest while a digest reference stays fixed

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](https://docs.docker.com/docker-hub/repos/manage/hub-images/immutable-tags/) feature is in beta, and the default setting is "All tags are mutable." Three 2026 incidents show what that default costs:

| Image | Date | What happened to the tags | What a tag-following consumer got |
| --- | --- | --- | --- |
| Trivy, official Docker Hub images | March 22, 2026 | New tags 0.69.5 and 0.69.6 pushed with no matching GitHub release; latest moved to 0.69.6 | The 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 Hub | April 22, 2026 | Existing tags v2.1.20, v2.1.20-debian, alpine, debian, and latest repointed to malicious digests; a fake v2.1.21 added | A modified KICS binary that could encrypt scan reports and send them to an external endpoint |
| ghcr.io/berriai/litellm | October 21, 2026 | v1.107.0, latest, main-latest, and main-stable all resolved to the same malicious image index | A 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](https://socket.dev/blog/teampcp-targeting-security-tools-across-oss-ecosystem), 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](https://www.docker.com/blog/retiring-docker-content-trust/) on July 29, 2025, and pointed publishers to [Sigstore](https://socket.dev/glossary/sigstore) and Notation. A valid signature from a compromised publisher still verifies. [Package provenance](https://socket.dev/glossary/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](https://github.com/opencontainers/image-spec/blob/main/layer.md)) 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](https://about.codecov.io/security-update/) 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](https://socket.dev/glossary/postinstall-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](https://socket.dev/blog/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](https://socket.dev/blog/pnpm-10-0-0-blocks-lifecycle-scripts-by-default) in January 2025, and [npm v12 shipped with install scripts off](https://socket.dev/blog/npm-12) 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](https://socket.dev/glossary/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](https://socket.dev/blog/socket-firewall-now-available-in-docker-hardened-images) 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](https://socket.dev/glossary/package-firewall), and it has the limits of one. Per the [Socket Firewall Free documentation](https://docs.socket.dev/docs/socket-firewall-free), "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](https://docs.docker.com/engine/security/) 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](https://docs.docker.com/engine/install/linux-postinstall/). 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](https://socket.dev/glossary/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:

| Stage | Tool examples | What it catches | What it misses |
| --- | --- | --- | --- |
| Dockerfile | docker build --check, Hadolint | Secrets in ARG and ENV, risky instruction patterns | The COPY then rm secret pattern, which passed --check in our test |
| Image contents | Docker Scout, Trivy, Grype | Known CVEs in OS and language packages | Malware with no CVE; the scanner is itself a supply chain dependency |
| Inventory and provenance | BuildKit --sbom and --provenance, Syft | What is in the image and which inputs built it | Whether those inputs were trustworthy, as LiteLLM's lookalike source showed |
| Signing | Sigstore cosign, Notation | Which identity published a digest | A compromised publisher signing malicious bytes |
| Dependency install | Socket Firewall in Docker Hardened Images | Known malware fetched during RUN installs | Artifacts served from cache; malware not yet confirmed |
| Runtime | Rootless mode, seccomp, AppArmor, Falco | Escapes, unexpected syscalls and process behavior | Exfiltration 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](https://socket.dev/blog/teampcp-targeting-security-tools-across-oss-ecosystem) 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](https://socket.dev/glossary/supply-chain-attack) surface held open by whoever last had the push credential.
