Container Security skill for Claude Code
Minimal images, non-root pods, scanned on every build. Claude fixes container risk at the source: it swaps bloated base images for pinned distroless or slim ones, moves build secrets to BuildKit mounts, runs as a numeric non-root user, and applies a restricted securityContext with dropped capabilities and a read-only root filesystem. Trivy and hadolint gate CI, images get signed, and every change is measured against a CVE baseline.
- name
container-security - size209 lines · ≈2,050 tokens
- files1
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Hardens container images and their runtime — Trivy scanning of images, filesystems, and IaC with CI gates, minimal and distroless base images, non-root users, no secrets in layers, image signing, Docker run flags, and Kubernetes pod hardening with securityContext, Pod Security Admission, NetworkPolicies, and service account scoping. Use when scanning or fixing image CVEs, writing or reviewing a Dockerfile or Kubernetes manifest for security, choosing a base image, or responding to a container finding. Try asking
- Trivy found 212 vulnerabilities in our API image — get that number down
- Harden this Kubernetes deployment so it passes the restricted Pod Security profile
- Review our Dockerfile for security problems and rewrite it
Use it when
- Image scans report many OS or language package CVEs
- Writing or reviewing a Dockerfile or Kubernetes manifest for production
- Enforcing Pod Security Admission or NetworkPolicies in a namespace
- Adding image scanning and signing to CI
Skip it when
- The goal is build speed and image size rather than security — use dockerfile-optimization instead
- You need general Deployment, Service, and probe setup — use kubernetes-deploy instead
How to check the result
Trivy passes the high and critical gate or every remaining finding is documented with an expiry, the container runs with a non-zero UID, and a privileged test pod is rejected by the namespace.
The common mistake it prevents
Passing secrets through ARG or ENV during the build, which bakes them into image layers where anyone who can pull the image can read them with docker history.
Done well means: images are small, built from a pinned minimal base, run as non-root with no shell when possible, contain no secrets, are scanned in CI with a gate on fixable high/critical findings, are signed, and run under a restricted pod profile with dropped capabilities, a read-only root filesystem, and default-deny networking.
Workflow
- [ ] 1. Scan the current image and Dockerfile; record the baseline
- [ ] 2. Fix the base image first (most CVEs come from it)
- [ ] 3. Harden the Dockerfile (multi-stage, non-root, no secrets, pinned)
- [ ] 4. Harden runtime (docker flags or Kubernetes securityContext)
- [ ] 5. Add CI gates: scan, lint, sign
- [ ] 6. Rescan and compare against the baseline
1. Scan
trivy image --severity HIGH,CRITICAL myapp:1.4.2 # OS + language packages
trivy image --ignore-unfixed --exit-code 1 --severity HIGH,CRITICAL myapp:1.4.2 # CI gate
trivy image --scanners vuln,secret myapp:1.4.2 # also look for baked-in secrets
trivy fs --scanners vuln,secret,misconfig . # repo: lockfiles, secrets, IaC
trivy config . # Dockerfile, k8s YAML, Terraform, Helm
hadolint Dockerfile # Dockerfile lint
docker history --no-trunc myapp:1.4.2 # see what each layer did
Triage like dependency findings: is the package used at runtime, is there a fixed version,
does the base image already have a patched tag? Record accepted findings in
.trivyignore with a comment and expiry (CVE-2025-XXXX exp:2026-06-30).
2. Base image
| Need | Base |
|---|---|
| Static Go/Rust binary | gcr.io/distroless/static-debian12:nonroot or scratch (add CA certs and /etc/passwd user yourself) |
| JVM, Node, Python runtime | Distroless language images (gcr.io/distroless/nodejs22-debian12, .../python3-debian12, .../java21-debian12) or Chainguard images |
| Need a package manager / shell at runtime | debian:12-slim, python:3.13-slim, node:22-slim; or Alpine if musl is fine for your deps |
| Compliance requires a vendor OS | UBI minimal / vendor-hardened images |
- Pin by digest for release builds:
FROM node:22-slim@sha256:<digest>; let Renovate or Dependabot bump digests so you still get patches. - Rebuild regularly (weekly) even without code changes; base images get patched.
- Distroless/Chainguard images have no shell. Debug with
kubectl debugephemeral containers or the:debugimage variants, not by adding a shell to prod.
3. Dockerfile hardening
# syntax=docker/dockerfile:1
FROM node:22-slim@sha256:<digest> AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
--mount=type=secret,id=npmrc,target=/root/.npmrc \
npm ci --omit=dev
COPY . .
RUN npm run build
FROM gcr.io/distroless/nodejs22-debian12:nonroot
WORKDIR /app
COPY --from=build --chown=nonroot:nonroot /app/node_modules ./node_modules
COPY --from=build --chown=nonroot:nonroot /app/dist ./dist
USER nonroot
EXPOSE 8080
CMD ["dist/server.js"]
Rules:
- Multi-stage: compilers, package managers, and source stay in the build stage.
- Non-root
USERwith a numeric UID ideally (KubernetesrunAsNonRootcan only verify numeric UIDs):USER 10001:10001on non-distroless images afteruseradd. - No secrets in
ARG,ENV, or copied files: they persist in layers and image history. Use BuildKit--mount=type=secret(docker build --secret id=npmrc,src=$HOME/.npmrc .). .dockerignoreexcluding.git,.env*,node_modules, keys, test data.COPYspecific paths, notCOPY . .into the final stage.- No
curl | sh; verify checksums for downloaded binaries. - Prefer
COPY --chmod/--chownover extraRUN chmodlayers. - No
sudo, no SSH server, nolatesttags.
4. Runtime hardening
Docker / Compose:
docker run --read-only --tmpfs /tmp \
--cap-drop=ALL --security-opt=no-new-privileges:true \
--user 10001:10001 --pids-limit 256 --memory 512m \
myapp:1.4.2
Never: --privileged, mounting /var/run/docker.sock into app containers (root on the
host), --net=host or --pid=host without a specific need.
Kubernetes pod spec:
spec:
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
seccompProfile: { type: RuntimeDefault }
containers:
- name: app
image: registry.example.com/myapp@sha256:<digest>
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: ["ALL"] }
resources:
requests: { cpu: 100m, memory: 128Mi }
limits: { memory: 512Mi }
volumeMounts:
- { name: tmp, mountPath: /tmp }
volumes:
- name: tmp
emptyDir: {}
Namespace-level enforcement with Pod Security Admission:
apiVersion: v1
kind: Namespace
metadata:
name: payments
labels:
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/enforce-version: latest
pod-security.kubernetes.io/warn: restricted
Roll out with warn and audit labels first; kubectl label --dry-run=server against a
namespace reports which existing pods would violate restricted.
Network: default-deny per namespace, then allow specific flows (requires a CNI that enforces NetworkPolicy, e.g. Calico or Cilium):
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: default-deny, namespace: payments }
spec:
podSelector: {}
policyTypes: ["Ingress", "Egress"]
Remember to allow DNS egress (UDP/TCP 53 to kube-dns) after default-deny.
RBAC and secrets:
- One service account per workload; bind only the verbs and resources it needs. No
cluster-adminfor apps; no wildcard*verbs. - Mount a token only where the app calls the Kubernetes API.
- Enable etcd encryption at rest; prefer External Secrets / CSI secret store drivers over long-lived static secrets.
- Block metadata endpoint access from pods (cloud-specific: IMDSv2 hop limit 1 on AWS, workload identity on GKE/AKS).
5. CI gates
# GitHub Actions excerpt: pin third-party actions by full commit SHA in real workflows
- name: Build
run: docker build -t myapp:${{ github.sha }} .
- name: Scan
run: trivy image --ignore-unfixed --exit-code 1 --severity HIGH,CRITICAL myapp:${{ github.sha }}
- name: Lint Dockerfile
run: hadolint Dockerfile
Sign and verify images with Sigstore cosign (keyless in CI via OIDC):
cosign sign registry.example.com/myapp@sha256:<digest> and verify in admission
(Kyverno or Sigstore policy-controller) so only signed images from your CI identity run.
Generate an SBOM per image (trivy image --format cyclonedx --output sbom.json <ref> or
syft <ref>), and attach it as an attestation if your registry supports it.
Gotchas
--ignore-unfixedhides CVEs with no patch; still review criticals among them for mitigations.readOnlyRootFilesystembreaks apps writing to/tmp, caches, or PID files; mountemptyDirvolumes for those paths instead of dropping the setting.- Alpine’s musl can change DNS and performance behavior for some runtimes (Python wheels, JVM); test before switching.
- Image scanners read package databases. Binaries copied in without a package manager (curl-downloaded tools) may be invisible; scan those artifacts separately.
- A non-root user with
CAP_NET_BIND_SERVICEdropped cannot bind ports below 1024; listen on 8080 and map it.
Verification
trivy image --ignore-unfixed --severity HIGH,CRITICAL --exit-code 1passes, or every remaining finding is in.trivyignorewith a reason and expiry.docker run --rm --entrypoint id myapp:tag(non-distroless) shows a non-zero UID;kubectl get pod -o jsonpath='{.spec.securityContext}'matches the hardened spec.- The namespace enforces
restricted; a test pod withprivileged: trueis rejected. - Report: base image before/after, image size before/after, CVE counts before/after.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.