| name | docker-safety |
| description | Block container runtime escape paths, root-by-default images, build-time secrets baked into layers, and supply-chain risks from floating base tags before they reach a registry or a host. Require non-root `USER`, digest-pinned base images, BuildKit secret mounts (never `ENV`/`ARG` for secrets), `--no-install-recommends` + cache cleanup, and refuse `--privileged`, `--net=host`, `--pid=host`, host root bind-mounts, and `chmod 777` inside containers. |
| license | MIT |
| metadata | {"catpilot":{"id":"docker-safety","version":"1.0.0","severity":"critical","category":"container","applies_to":{"languages":["any"],"frameworks":["docker","docker-compose","kubernetes","any"],"runtimes":["claude-code","cursor","openclaw","cline","aider","copilot","codex-cli"]},"control_mappings":{"soc2":["CC6.1","CC6.6","CC6.8","CC7.2"],"pci_dss":["2.2.1","6.2","6.4.5","7.2.1"],"iso_27001":["A.12.1.2","A.12.5.1","A.12.6.1","A.14.2.5"],"nist_csf":["PR.AC-4","PR.IP-1","PR.IP-3","PR.PT-3"],"owasp_top_10":["A05:2021","A06:2021","A08:2021"]},"provenance":{"origin":"catpilot","incident_derived":false},"maintainers":[{"team":"catpilot-security"}],"references":["https://docs.docker.com/develop/dev-best-practices/","https://docs.docker.com/build/building/secrets/","https://docs.docker.com/engine/reference/run/#runtime-privilege-and-linux-capabilities","https://www.cisecurity.org/benchmark/docker"]}} |
Why
A Docker image and its run command together define the security posture
of every workload they produce. The same image can ship as a hardened,
non-root, read-only filesystem service — or as a root-owned container
with the host's /var/run/docker.sock mounted in, one docker exec
away from a host takeover.
The failures this skill blocks share a common shape: a single line in
a Dockerfile, a docker-compose.yml, or a docker run flag that
breaks the isolation boundary the container is supposed to provide.
FROM node:latest ships a moving target. The same Dockerfile builds
a different image week to week, and a CVE fixed upstream silently
re-enters when the tag floats backward.
- A container running as
root has UID 0 on the host's kernel
namespace. With certain capabilities or a misconfigured runtime, a
container compromise becomes a host compromise.
ENV API_KEY=sk_live_... writes the secret into a layer that lives
in the image's history forever — pushed to every registry the image
is pushed to, pulled to every host that runs it.
--privileged disables every capability filter, seccomp profile,
and AppArmor profile the runtime would otherwise apply. The
container is effectively a process running on the host with
containment-as-suggestion.
--net=host shares the host's network namespace, so the container
can bind to host ports, reach host services on 127.0.0.1, and
receive traffic intended for the host. Combined with a vulnerable
service in the container, this is a direct path to LAN exposure.
- A bind-mount of
/ or /var/run/docker.sock from the host into
the container hands the container read/write access to the host
filesystem or the host's Docker daemon. Either is escape-class.
This skill applies the same query-then-modify discipline used
elsewhere in the bundle: non-root by default, immutable by default,
no secrets at build time, no host-namespace sharing.
When to apply
Apply this skill before the agent writes, recommends, or commits
any of the following:
Dockerfile, Containerfile, or any image-build manifest.
docker-compose.yml, docker-compose.*.yml, or any v2/v3 compose file.
docker build, docker buildx, nerdctl build, podman build,
buildah invocations.
docker run, docker exec, nerdctl run, podman run, or any
invocation that adds runtime flags.
- Kubernetes
PodSpec, Deployment, StatefulSet, or DaemonSet
manifests that set securityContext, hostNetwork, hostPID,
hostIPC, privileged, or volumes with hostPath.
- CI workflows that build, tag, or push container images
(GitHub Actions
docker/build-push-action, GitLab CI Docker
templates, BuildKit drivers, OCI build pipelines).
This skill is the container-runtime counterpart to cloud-cli-safety
(infrastructure), local-cli-safety (host shell), and
secret-blocking (credential material). All four apply together — a
Dockerfile that runs as root and also has ENV API_KEY=... matches
this skill and secret-blocking and must satisfy both.
Rules
Rule 1 — Base images are pinned by digest, never by floating tag
FROM <image>:latest, FROM <image>:<major>, FROM <image>:<major>.<minor>,
and FROM <image> (implicit latest) are prohibited for production
images. The base must be pinned to an immutable digest:
FROM node:20.11.1-alpine3.19@sha256:f1fe1d...
Tag-only pinning (FROM node:20.11.1-alpine3.19) is acceptable for
internal tooling, throwaway scripts, and CI test rigs where image
reproducibility is not a requirement. It is not acceptable for
images that will be deployed, pushed to a public registry, or
referenced by a production manifest.
The base image must come from a verifiable source: an official Docker
Hub library image, a vendor-published registry (gcr.io/distroless,
mcr.microsoft.com, public.ecr.aws), or an organization-owned
registry. Random user namespaces are prohibited unless explicitly
approved.
Rule 2 — Non-root USER is mandatory; no images run as root
Every image must declare a non-root USER before CMD/ENTRYPOINT.
The default Docker behavior of running as root (UID 0) is prohibited.
A non-root user is created and switched to inside the Dockerfile:
RUN addgroup -S app && adduser -S app -G app
USER app
Files the runtime needs to read must be COPY --chown=app:app-ed in.
The container's filesystem must not contain files the running user
cannot read because the image was built as root and never adjusted.
For images that genuinely need to start as root (binding to port < 1024
without CAP_NET_BIND_SERVICE, package install on first boot), use
gosu, su-exec, or tini to drop to a non-root user before
executing the application. The application itself never runs as root.
Rule 3 — Build-time secrets use BuildKit mounts, never ENV/ARG
ENV API_KEY=..., ARG SECRET=..., and COPY .env . are prohibited
in any image-build manifest. Each of these writes the secret into a
layer that is recoverable from the image's history by anyone who has
the image, including read-only registry consumers.
Build-time secrets use BuildKit's --mount=type=secret:
# syntax=docker/dockerfile:1.7
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN=$(cat /run/secrets/npm_token) npm ci
Invoke the build with the secret passed in at build time:
docker build --secret id=npm_token,src=$HOME/.npmrc-token .
Runtime secrets (database passwords, API keys the running container
needs) are passed at docker run time via --env-file, an
orchestrator's secret store, or a sidecar like vault-agent. They are
not baked into the image.
Rule 4 — Runtime flags do not break the isolation boundary
The following docker run / docker-compose.yml settings are
prohibited and must be flagged whenever the agent encounters them:
| Flag / setting | Why it is prohibited |
|---|
--privileged / privileged: true | Disables seccomp, AppArmor, capability dropping. Container ≈ host process. |
--net=host / network_mode: host | Shares host network namespace. Container binds host ports, reaches host loopback. |
--pid=host / pid: host | Sees and can signal host processes. |
--ipc=host / ipc: host | Shares host IPC namespace. Container can read host SHM. |
--uts=host | Shares host UTS namespace. Container can rename the host. |
-v /:/host (or any host-root bind) | Container has full host filesystem. |
-v /var/run/docker.sock:/var/run/docker.sock | Container controls the host's Docker daemon. Trivial host takeover. |
--cap-add=SYS_ADMIN (or ALL) | Broad capability that subsumes others. Restores most root powers. |
--security-opt apparmor=unconfined | Disables the AppArmor profile. |
--security-opt seccomp=unconfined | Disables the default seccomp filter. |
--userns=host | Disables user-namespace remapping. |
If the user requests any of these, the agent surfaces the trade-off
explicitly ("--privileged removes the seccomp/AppArmor/capability
isolation that normally separates this container from the host") and
requires explicit, named confirmation. Diagnostic-only and
sandboxed-host exceptions exist; production deployment with these
flags does not.
Rule 5 — Read-only root filesystem, no-new-privileges, dropped caps
Production containers run with:
read_only: true (compose) / --read-only (run) — the container's
root filesystem is mounted read-only. Writable paths the application
needs are explicit tmpfs mounts.
security_opt: [no-new-privileges:true] (compose) /
--security-opt=no-new-privileges (run) — prevents setuid binaries
from gaining privileges mid-execution.
cap_drop: [ALL] followed by an explicit cap_add for only the
capabilities the workload requires (commonly an empty list; rarely
NET_BIND_SERVICE).
When the agent generates a docker-compose.yml or Kubernetes
securityContext, these settings are present unless the user has
explicitly opted out for a documented reason.
Rule 6 — Package install is pinned, minimal, and cache-cleaned
OS and language-runtime package installs must:
- Pin versions where the package manager supports it
(
apt-get install foo=1.2.3-1, apk add foo=1.2.3-r0,
pip install -r requirements.txt --require-hashes,
npm ci rather than npm install).
- Use the minimal-install flag where one exists
(
apt-get install --no-install-recommends).
- Clean the package-manager cache in the same
RUN layer
(rm -rf /var/lib/apt/lists/* for apt, apk --no-cache for apk,
pip install --no-cache-dir for pip).
- Verify checksums or signatures for binaries downloaded inside the
build (cross-references
supply-chain).
apt-get update without an install in the same RUN layer is
prohibited because cache invalidation makes the resulting image
non-reproducible.
Negative examples
# ❌ Floating tag — image is a moving target
FROM node:latest
FROM python:3
FROM ubuntu
# ❌ Untrusted registry namespace
FROM random-user/node:14
# ❌ Runs as root (default)
FROM node:20-alpine
WORKDIR /app
COPY . .
CMD ["node", "index.js"]
# ❌ Build-time secret in ENV/ARG — persists in image history
ENV API_KEY="sk_live_12345"
ARG DB_PASSWORD="hunter2"
COPY .env .
# ❌ Unpinned package install, no cache cleanup
RUN apt-get update && apt-get install -y python3 curl
RUN pip install requests
RUN npm install
# ❌ chmod 777 inside the container
RUN chmod -R 777 /app
services:
app:
image: myapp:latest
privileged: true
network_mode: host
pid: host
volumes:
- /:/host
- /var/run/docker.sock:/var/run/docker.sock
docker run --privileged --net=host -v /:/host myapp:latest
docker run -v /var/run/docker.sock:/var/run/docker.sock myapp:latest
spec:
hostNetwork: true
hostPID: true
containers:
- name: app
image: myapp:latest
securityContext:
privileged: true
runAsUser: 0
Remediation
Production-grade Dockerfile
# syntax=docker/dockerfile:1.7
# ✅ Pinned by digest
FROM node:20.11.1-alpine3.19@sha256:f1fe1d05cce5b5d7caf24bc15ef79e7e58a31e7c8a7a7e0d3a5c6e7d8f9a0b1c AS deps
# ✅ Non-root user created before WORKDIR
RUN addgroup -S app && adduser -S app -G app
WORKDIR /app
# ✅ BuildKit secret mount for private-registry token
RUN --mount=type=secret,id=npm_token,uid=1000 \
--mount=type=bind,source=package.json,target=package.json \
--mount=type=bind,source=package-lock.json,target=package-lock.json \
NPM_TOKEN=$(cat /run/secrets/npm_token) \
npm ci --omit=dev
# ✅ COPY with --chown so non-root user can read app files
COPY --chown=app:app . .
USER app
CMD ["node", "index.js"]
Production-grade docker-compose.yml
services:
app:
image: myorg/app@sha256:f1fe1d...
read_only: true
tmpfs:
- /tmp
- /var/run
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
user: "1000:1000"
networks:
- app-net
environment:
LOG_LEVEL: info
env_file:
- secrets/runtime.env
Production-grade Kubernetes securityContext
spec:
automountServiceAccountToken: false
containers:
- name: app
image: registry.example.com/app@sha256:f1fe1d...
securityContext:
allowPrivilegeEscalation: false
privileged: false
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
capabilities:
drop: [ALL]
seccompProfile:
type: RuntimeDefault
Safe apt-get pattern
# ✅ Update + install + clean in one layer, no recommends, pinned
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates=20230311 \
curl=7.88.1-10+deb12u5 \
&& rm -rf /var/lib/apt/lists/*
Safe BuildKit secret invocation
docker buildx build \
--secret id=npm_token,src=$HOME/.npmrc-token \
--tag myorg/app:$(git rev-parse --short HEAD) \
--push \
.
docker run with a tight runtime profile
docker run \
--user 1000:1000 \
--read-only \
--tmpfs /tmp \
--security-opt no-new-privileges \
--cap-drop ALL \
--network app-net \
--env-file secrets/runtime.env \
myorg/app@sha256:f1fe1d...
Production detection heuristics
Treat the Docker workload as production-class (escalating the protocol)
when any of these match:
- The image is tagged or pushed to a registry path containing
prod,
production, release, or customer.
- The compose file or PodSpec is in a directory containing
prod,
production, release, or customer.
- The deployment target is a managed orchestrator hostname pattern
(
*.eks.amazonaws.com, *.gke.io, *.aks.azure.com,
*.fly.io, *.railway.app, *.run.app, *.azurecontainer.io).
- The image references a base from an organization-owned registry
pinned by digest.
- CI workflow names or branch protection identify the build as a
production release pipeline.
If any signal matches, all six rules apply without exception, and the
runtime-flag check (Rule 4) escalates to require named confirmation
in addition to surfacing the trade-off.
References