Container Security
Rules (for AI agents)
ALWAYS
- Use multi-stage builds: separate builder/test stages from the final runtime
image so build toolchains and source aren't shipped. The last stage should be a
minimal base —
gcr.io/distroless/<variant>,scratch, or a versioned-slim/-alpinevariant — pinned by SHA256 digest, not just tag. Two bases have nothing to pin:scratch, and a reference to an earlier stage in the same file (FROM base), which is pinned on that stage's ownFROMline. - Run as a non-root user: set
USERexplicitly on the final stage, as a number not a name. Omitting it leaves the container running as root. K8srunAsNonRootrejects UID 0 and cannot resolve a username, soUSER appuserfails at startup with "image has non-numeric user"; any non-zero UID passes, and 10000+ by convention also avoids colliding with host accounts. - Use
npm ci(and equivalentspnpm install --frozen-lockfile,yarn install --frozen-lockfile) in container builds, notnpm install.npm installmutates the lockfile and resolves versions per-build, producing non-deterministic images that drift from the lockfile. - Add a
.dockerignoreexcluding.git,node_modules,.env,*.pem,*.key,target/,.terraform/,dist/,coverage/. - Build with BuildKit (default since Docker Engine 23;
DOCKER_BUILDKIT=1on older engines) soRUN --mount=type=secret,id=<name>is available for build-time credentials.# syntax=docker/dockerfile:1selects the frontend and does not enable BuildKit by itself. - Emit an SBOM (
docker buildx --sbom=true/syft) and attach it to the image so downstream scanners can audit the dependency set. - Pin apt packages and clean lists in the same layer:
apt-get update && apt-get install -y --no-install-recommends pkg=1.2.3 && rm -rf /var/lib/apt/lists/*. Theupdatemust be in that sameRUN— without it there are no package lists; in an earlier layer it goes stale behind the build cache. - Set explicit
HEALTHCHECKfor long-running services, and separately setlivenessProbe/readinessProbe/startupProbein K8s. Kubernetes never runs the image'sHEALTHCHECK— probes are the only mechanism there, so neither one covers the other. - Set resource
requestsandlimitson every container (CPU and memory). Without a memory limit, one container can exhaust the node and take down every pod scheduled beside it — the limit is what bounds a runaway's blast radius to its own container. - Harden at the container-level
securityContext(spec.containers[].securityContext):capabilities.drop: [ALL]then add back only what's needed,readOnlyRootFilesystem: true,allowPrivilegeEscalation: false. These three exist only at container level — under the pod'sspec.securityContextthey are silently ignored while the manifest still applies, so the control vanishes with nothing to show for it. UseemptyDirfor paths that must stay writable. - Apply a seccomp profile (
seccompProfile.type: RuntimeDefaultat minimum) and AppArmor / SELinux where available. This one — likerunAsNonRootandrunAsUser— may be set on the pod and inherited by its containers. - Scan every image in CI (Trivy, Grype, Snyk, or your registry's scanner) and triage findings on severity, reachability and fix availability together. Fail the build on fixable CRITICAL / HIGH findings; a material vulnerability with no published fix needs mitigation or a recorded risk acceptance, not an automatic pass — "unfixable" is remediation information, not a reason it stopped mattering.
- Set
automountServiceAccountToken: falseon every workload that does not call the Kubernetes API. The default mounts a real ServiceAccount token into the container, so an RCE in an app that never needed cluster access still hands the attacker one. - For multi-tenant workloads (per-user/per-customer sessions on shared
infra), isolate tenants at the kernel boundary: a separate VM — or
gVisor / Kata — per tenant, never just separate containers on one shared
daemon. Drop
privileged, enable user namespaces, and give each tenant its own network. A privileged container on a shared host escapes to the host trivially, so on shared infra that is full compromise of every co-tenant. - Expose container orchestration to clients only through a scoped, authenticated broker API that performs the few operations a client may request (start/stop my session). The client must never hold direct daemon or cluster access.
- Consult
iam-best-practicesfor cluster RBAC and the identity a workload runs as, andsupply-chain-securityfor base-image provenance. This skill owns the container's own configuration, not what it inherits.
NEVER
- Run containers as root or with
privileged: true/allowPrivilegeEscalation: trueoutside of explicit, audited system pods (e.g., CNI plugins). - Use a base image that is past its vendor's end-of-life date, or any
non-LTS release of a runtime that ships an LTS line. EOL images stop
receiving security patches, so a maintained image with open CVEs is
still safer than an EOL one with none. Resolve the date against
endoflife.date/<runtime>rather than from memory — a version that was supported when this rule was written may not be today. A dated snapshot of the common runtimes is inreferences/eol-base-images.md. - Mount the host docker socket (
/var/run/docker.sock) inside an application container. It's effectively root on the host. - Expose the container daemon API over the network (
tcp://…:2375, or:2376even with TLS) to clients or apps. The daemon API is root-on-host: whoever reaches it runs arbitrary privileged containers and mounts the host filesystem. (A desktop/CLI app talking straight to a remote daemon is the same anti-pattern as a mounteddocker.sock, just over TCP.) - Ship a single shared client credential (one mTLS cert/key, token, or kubeconfig bundled into every copy of a distributed app) to reach that daemon or cluster. Every install holds the same key — trivially extracted from the app bundle — so it grants every user identical access and cannot be revoked per-user. Issue per-user / per-session, short-lived, scoped credentials.
- Run a tenant's container
privilegedon a host shared with other tenants, or attach tenant containers to a shared external bridge network — the first gives container-escape → co-tenant takeover, the second gives cross-tenant L3 reachability. - Embed secrets in image layers via
ENV,ARG,COPY, or byecho-ing them to a file. Even if--squash'd, BuildKit cache and registry layers leak. - Run
curl … | shorwget -O- … | shin aRUN— piping an unverified remote script to a shell is arbitrary remote code at build time. Download, verify a pinned SHA-256, then execute. - Use an unversioned tag as the final image base —
latest,stable, or a bare variant likepython:slim/node:alpine. Builds become non-reproducible and quietly pick up CVEs. The versioned variants (python:3.12-slim,node:20-alpine) are the recommended minimal bases: it is the missing version that makes a tag mutable, not the variant name. - Use
ADD <url>to fetch a remote resource unverified. EitherADD --checksum=sha256:<hash> <url>(Dockerfile frontend 1.6+), orRUN curl --failplus an explicit checksum step, or vendor the artifact. - Use
hostNetwork: true,hostPID: true, orhostIPC: truefor application pods. - Run pods in the
kube-systemnamespace, or any namespace without aNetworkPolicyand PodSecurity admission policy.
KNOWN FALSE POSITIVES
- A builder stage legitimately runs as root, installs a toolchain, and skips apt version pins — it is discarded and never shipped. These rules target the final stage.
FROM scratchandFROM <earlier-stage>are not unpinned bases: neither can carry a tag, and the stage reference is pinned on its ownFROMline.- A base-image CVE with no published upstream fix should not hold the build red indefinitely — but route it to mitigation or a recorded exception rather than dropping it, and re-check when the base image next moves.
latestin adocker-compose.dev.ymlor a throwaway local build is not a production reproducibility problem.- Operators that legitimately need cluster-admin access (kubelet, CSI drivers,
CNI plugins) require elevated privileges; they belong in
kube-systemor a dedicated namespace with auditing, not in application namespaces. - Bare-metal Kubernetes nodes sometimes legitimately disable
seccompfor drivers that aren't compatible; document the exception. - One-shot debugging pods (kubectl debug, ephemeral containers) intentionally bypass many of these controls; they should not be persisted as YAML in the repo.
- A remote Docker / K8s endpoint over mTLS (
:2376) is acceptable for an operator's own CI / build farm where each operator holds a personal, revocable cert — the anti-pattern is shipping one shared cert inside a distributed end-user app. privilegedor a shared bridge network within a single trust domain (one team's own microservices, or a sim stack on the developer's own machine) is lower-risk than the multi-tenant case; these rules target the shared-host, cross-tenant blast radius specifically.
Context (for humans)
Containers leak two ways: image-layer leaks (secrets in ENV, build artifacts
left in the final image, vulnerable base CVEs) and runtime escapes (privileged
mode, docker.sock, host namespaces). NIST SP 800-190 frames these as image
risks, registry risks, orchestrator risks, and runtime risks.
AI assistants almost always generate Dockerfiles that work and ship — fast — but
they default to a single-stage FROM node / FROM python and USER root. This
skill is the counterweight; pair it with iam-best-practices for cluster
RBAC and supply-chain-security for image provenance beyond the pod.
A distinct, often-missed class is remote-daemon and multi-tenancy exposure.
Handing a client app direct daemon access (tcp://host:2375 + a cert shipped in
the app bundle) makes every user root on the host; running multiple tenants'
privileged containers on one shared daemon with a shared network means one
tenant's escape compromises all of them. The container hardening flags
(privileged, host namespaces, capabilities) matter most precisely where the
blast radius is multi-tenant — isolate at the VM/kernel boundary, and never let
a client touch the daemon directly.
References
references/verifying-findings.md— confirm or refute a finding, then lock itreferences/eol-base-images.md— dated snapshot of EOL runtimes; check endoflife.date for the current answerchecklists/k8s_pod_security.yaml- The nine deterministic
dkr-*checks live in the scanner (internal/tools/library_scanners.go), not in a checklist file — run them withsecure-vibe scanor thescan_dockerfiletool. - CIS Docker Benchmark.
- CIS Kubernetes Benchmark.
- NIST SP 800-190.