# Container Security

> Hardening for Dockerfiles, OCI images, Kubernetes manifests, and Helm charts. Use when generating a Dockerfile or image build, writing Kubernetes, Helm, or Kustomize manifests, or reviewing container changes in a pull request.

- Skill: `shieldnet-360/container-security` (Agent Skill, multi-file: 5 files)
- Install (CLI): `npx skillmds@latest add shieldnet-360/container-security`
- Raw SKILL.md: https://api.skillmd.com/api/skills/shieldnet-360/container-security/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: ShieldNet-360 (https://skillmd.com/u/shieldnet-360)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/shieldnet-360/container-security

---


# 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` /
  `-alpine` variant — 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 own `FROM` line.
  <!-- pattern: { id: dkr-multi-stage, severity: high, check: llm } -->
  <!-- pattern: { id: dkr-pinned-base-digest, severity: high, cwe: 1357, check: deterministic } -->
- Run as a non-root user: set `USER` explicitly on the **final stage**, as a **number**
  not a name. Omitting it leaves the container running as root. K8s `runAsNonRoot`
  rejects UID 0 and cannot resolve a username, so `USER appuser` fails at startup with
  "image has non-numeric user"; any non-zero UID passes, and 10000+ by convention also
  avoids colliding with host accounts.
  <!-- pattern: { id: dkr-missing-user-directive, severity: critical, cwe: 250, check: deterministic } -->
  <!-- pattern: { id: dkr-non-root-user, severity: critical, cwe: 250, check: deterministic } -->
- Use **`npm ci`** (and equivalents `pnpm install --frozen-lockfile`,
  `yarn install --frozen-lockfile`) in container builds, not `npm install`.
  `npm install` mutates the lockfile and resolves versions per-build,
  producing non-deterministic images that drift from the lockfile.
  <!-- pattern: { id: dkr-npm-install-not-ci, severity: medium, check: deterministic } -->
- Add a `.dockerignore` excluding `.git`, `node_modules`, `.env`, `*.pem`, `*.key`,
  `target/`, `.terraform/`, `dist/`, `coverage/`.
  <!-- pattern: { id: dkr-dockerignore-exists, severity: high, check: llm } -->
- Build with **BuildKit** (default since Docker Engine 23; `DOCKER_BUILDKIT=1` on
  older engines) so `RUN --mount=type=secret,id=<name>` is available for build-time
  credentials. `# syntax=docker/dockerfile:1` selects the frontend and does **not**
  enable BuildKit by itself.
  <!-- pattern: { id: dkr-build-with-buildkit, severity: low, check: llm } -->
- Emit an **SBOM** (`docker buildx --sbom=true` / `syft`) and attach it to the
  image so downstream scanners can audit the dependency set.
  <!-- pattern: { id: dkr-sbom-emitted, severity: medium, check: llm } -->
- 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/*`. The `update` must be in that same `RUN` — without it there
  are no package lists; in an earlier layer it goes stale behind the build cache.
  <!-- pattern: { id: dkr-apt-version-pin, severity: medium, cwe: 1357, check: deterministic } -->
- Set explicit `HEALTHCHECK` for long-running services, and separately set
  `livenessProbe` / `readinessProbe` / `startupProbe` in K8s. Kubernetes never runs
  the image's `HEALTHCHECK` — probes are the only mechanism there, so neither one
  covers the other.
  <!-- pattern: { id: dkr-healthcheck-defined, severity: medium, check: llm } -->
- Set resource `requests` and `limits` on 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's `spec.securityContext`
  they are silently ignored while the manifest still applies, so the control vanishes
  with nothing to show for it. Use `emptyDir` for paths that must stay writable.
- Apply a seccomp profile (`seccompProfile.type: RuntimeDefault` at minimum) and
  AppArmor / SELinux where available. This one — like `runAsNonRoot` and
  `runAsUser` — 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.
  <!-- pattern: { id: dkr-image-scan-required, severity: high, check: llm } -->
- Set `automountServiceAccountToken: false` on 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-practices` for cluster RBAC and the identity a workload runs as,
  and `supply-chain-security` for 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:
  true` outside 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 in `references/eol-base-images.md`.
  <!-- pattern: { id: dkr-eol-base-image, severity: critical, cwe: 1104, check: llm } -->
- 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
  `:2376` even 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 mounted `docker.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 `privileged` on 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 by `echo`-ing them
  to a file. Even if `--squash`'d, BuildKit cache and registry layers leak.
  <!-- pattern: { id: dkr-no-secrets-in-env, severity: critical, cwe: 798, check: deterministic } -->
  <!-- pattern: { id: dkr-no-secrets-in-build-args, severity: critical, cwe: 798, check: llm } -->
  <!-- pattern: { id: dkr-no-secret-leak-in-layers, severity: critical, check: llm } -->
- Run `curl … | sh` or `wget -O- … | sh` in a `RUN` — piping an unverified
  remote script to a shell is arbitrary remote code at build time. Download,
  verify a pinned SHA-256, then execute.
  <!-- pattern: { id: dkr-no-curl-pipe-sh, severity: critical, cwe: 829, check: deterministic } -->
- Use an **unversioned** tag as the final image base — `latest`, `stable`, or a bare
  variant like `python: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.
  <!-- pattern: { id: dkr-explicit-latest-tag, severity: high, check: deterministic } -->
- Use `ADD <url>` to fetch a remote resource **unverified**. Either
  `ADD --checksum=sha256:<hash> <url>` (Dockerfile frontend 1.6+), or `RUN curl
  --fail` plus an explicit checksum step, or vendor the artifact.
  <!-- pattern: { id: dkr-no-add-remote, severity: medium, check: deterministic } -->
- Use `hostNetwork: true`, `hostPID: true`, or `hostIPC: true` for application
  pods.
- Run pods in the `kube-system` namespace, or any namespace without a
  `NetworkPolicy` and 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 scratch` and `FROM <earlier-stage>` are not unpinned bases: neither can carry
  a tag, and the stage reference is pinned on its own `FROM` line.
- 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.
- `latest` in a `docker-compose.dev.yml` or 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-system` or a
  dedicated namespace with auditing, not in application namespaces.
- Bare-metal Kubernetes nodes sometimes legitimately disable `seccomp` for
  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.
- `privileged` or 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 it
- `references/eol-base-images.md` — dated snapshot of EOL runtimes; check endoflife.date for the current answer
- `checklists/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 with
  `secure-vibe scan` or the `scan_dockerfile` tool.
- [CIS Docker Benchmark](https://www.cisecurity.org/benchmark/docker).
- [CIS Kubernetes Benchmark](https://www.cisecurity.org/benchmark/kubernetes).
- [NIST SP 800-190](https://csrc.nist.gov/publications/detail/sp/800-190/final).

