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.
Installs into .claude/skills of the current project.
Are you the author of Container Security?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/shieldnet-360-container-security-secure-vibe)
---
name: container-security
description: "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."
---
<!-- Native skill bundle for Claude Code. Generated by `secure-vibe dev regenerate`. -->
<!-- Do not edit by hand; the source of truth is skills/container-security/SKILL.md. -->
# 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.
## 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.
- 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.
- 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.
- Add a `.dockerignore` excluding `.git`, `node_modules`, `.env`, `*.pem`, `*.key`, `target/`, `.terraform/`, `dist/`, `coverage/`.
- 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.
- 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/*`. 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.
- 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.
- 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.
- 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`.
- 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.
- 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.
- 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.
- 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.
- 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.
## Reference files
Read these only when the task calls for them.
- `references/eol-base-images.md`
- `references/verifying-findings.md`