Container Security Hardening

Harden Docker/container images and runtime deployments with secure base images, non-root users, CVE scanning, SBOM/signing, seccomp/AppArmor, and Kubernetes pod security controls. Use for Dockerfile security reviews, container CVEs, image scanning, distroless images, or production hardening.

Published by @sickn33 and contributors·0 agent reads / 30d·0 saves·

Container Security Hardening Skill

A production-focused guide for building, scanning, and running containers securely — from Dockerfile authoring through runtime enforcement and supply chain integrity.


When to Use This Skill

  • User mentions Docker security, container hardening, or Dockerfile security review
  • User asks about distroless images, non-root containers, or read-only filesystems
  • User wants to scan images for CVEs with Trivy, Grype, or Snyk
  • User mentions seccomp, AppArmor, Linux capabilities, or runtime security
  • User asks "is my Dockerfile secure?" or "how do I reduce my image attack surface?"
  • User wants to sign/verify images with Cosign or generate SBOMs
  • User asks about Kubernetes pod security, NetworkPolicy, or RBAC hardening
  • User says "fix container CVEs" or "harden my container for production"

When NOT to Use This Skill

  • The user is primarily asking about GitHub Actions CI/CD → recommend github-actions-advanced
  • The user needs general Docker usage help (not security) → recommend docker-expert
  • The user is working with Kubernetes orchestration beyond security → recommend kubernetes-architect
  • The user needs application-level security (SQL injection, XSS) → recommend api-security-best-practices

Step 1: Understand Context Before Responding

When invoked, first detect the current state:

# Find Dockerfiles in the project
find . -name "Dockerfile*" -not -path "*/node_modules/*" | head -10

# Check for existing security tooling
ls .trivyignore .hadolint.yaml .snyk docker-compose*.yml 2>/dev/null

# Inspect base images currently in use
grep -r "^FROM" $(find . -name "Dockerfile*") 2>/dev/null

# Check if Kubernetes manifests exist
find . -name "*.yaml" -path "*/k8s/*" -o -name "*.yaml" -path "*/manifests/*" | head -10

Then adapt recommendations to:

  • The tech stack (Node, Python, Go, Java — affects base image choice)
  • Whether this is Docker-only or Kubernetes-deployed
  • The CI platform in use (for scanner integration)
  • The existing base images and how far they are from best practice

The Five Layers of Container Security

1. Image Build        → Minimal base, no secrets, non-root, read-only FS
2. Image Scanning     → CVE scanning, SBOM, secret detection, Dockerfile lint
3. Runtime Security   → Capabilities, seccomp, AppArmor, resource limits
4. Supply Chain       → Signed images, pinned digests, trusted registries
5. Kubernetes Layer   → Pod Security Admission, NetworkPolicy, RBAC, Kyverno

Work through layers in order — hardening the image first gives the most leverage. See references/base-image-comparison.md for a full size/CVE trade-off table.


Layer 1: Dockerfile Hardening

1.1 Use a Minimal Base Image

# ❌ AVOID — massive attack surface (~100–200 CVEs typical)
FROM ubuntu:latest
FROM node:20

# ✅ BETTER — slim variants (glibc, smaller apt footprint)
FROM node:20-slim
FROM python:3.12-slim

# ✅ BEST — distroless (no shell, no package manager, built-in nonroot user)
FROM gcr.io/distroless/nodejs20-debian12
FROM gcr.io/distroless/python3-debian12
FROM gcr.io/distroless/static-debian12   # Go/Rust fully-static binaries

# ✅ ALSO GREAT — Alpine (musl libc; verify app compatibility first)
FROM alpine:3.20

# ✅ ZERO ATTACK SURFACE — for fully static binaries only
FROM scratch

See references/base-image-comparison.md for the full trade-off matrix.

1.2 Multi-Stage Build — Separate Build from Runtime

Never ship build tools, compilers, or dev dependencies in a production image.

# syntax=docker/dockerfile:1

# ── Stage 1: Install & Build ──────────────────────────────
FROM node:20-slim AS builder
WORKDIR /build
COPY package*.json ./
RUN npm ci                          # Install all deps (including devDeps)
COPY . .
RUN npm run build && npm prune --production

# ── Stage 2: Runtime — minimal, no build tools ────────────
FROM gcr.io/distroless/nodejs20-debian12@sha256:<digest>
LABEL org.opencontainers.image.source="https://github.com/org/repo"
LABEL org.opencontainers.image.revision="${BUILD_SHA}"
LABEL org.opencontainers.image.licenses="MIT"
WORKDIR /app
COPY --from=builder --chown=nonroot:nonroot /build/dist        ./dist
COPY --from=builder --chown=nonroot:nonroot /build/node_modules ./node_modules
USER nonroot:nonroot                # UID 65532 — built into distroless
EXPOSE 3000
CMD ["dist/server.js"]

Go / Rust static binary pattern:

FROM golang:1.22-alpine AS builder
WORKDIR /build
COPY go.* ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o app .

FROM scratch                        # Zero attack surface
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /build/app /app
USER 65532:65532
ENTRYPOINT ["/app"]

1.3 Run as Non-Root User

# For debian/ubuntu-based images — create dedicated user
RUN groupadd -r appgroup --gid 10001 && \
    useradd -r -g appgroup --uid 10001 --no-log-init appuser

COPY --chown=appuser:appgroup . /app

USER appuser    # Switch before CMD/ENTRYPOINT — never run as root

# ─────────────────────────────────────────────────────────
# For Alpine-based images
RUN addgroup -g 10001 -S appgroup && \
    adduser -u 10001 -S appuser -G appgroup

# For distroless — nonroot (UID 65532) is already built in
USER nonroot:nonroot

1.4 Pin Base Images to Digest

# ❌ UNSAFE — tags are mutable; image can be silently overwritten (supply chain attack)
FROM node:20-slim

# ✅ SAFE — SHA256 digest is cryptographically immutable
FROM node:20-slim@sha256:a1b2c3d4e5f6789abcdef0123456789abcdef0123456789abcdef0123456789ab

Get the current digest:

docker pull node:20-slim
docker inspect node:20-slim --format='{{index .RepoDigests 0}}'

Automate digest pinning with Renovate or Dependabot:

// .renovaterc.json
{
  "extends": ["config:base"],
  "dockerfile": { "enabled": true },
  "pinDigests": true
}

1.5 Never Bake Secrets into Images

# ❌ NEVER — secret in ENV or RUN; visible in `docker history` and layer cache
ENV AWS_SECRET_ACCESS_KEY=supersecret
RUN curl -H "Authorization: Bearer $TOKEN" https://api.example.com > config.json
ARG API_KEY                         # Also unsafe — visible in build args history

# ✅ CORRECT — BuildKit secret mount (never persisted in any layer)
# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=api_token \
    curl -H "Authorization: Bearer $(cat /run/secrets/api_token)" \
    https://api.example.com/config > config.json

Build with: docker build --secret id=api_token,src=./token.txt .

Check your image for leaked secrets:

docker history --no-trunc myapp:latest | grep -iE "secret|key|password|token"
trivy image --scanners secret myapp:latest

1.6 Read-Only Filesystem & No New Privileges

# In the Dockerfile — use exec form (no shell interpretation)
ENTRYPOINT ["node", "server.js"]    # ✅ exec form
# ENTRYPOINT /bin/sh -c "node..."  # ❌ shell form — spawns extra process

# Define a HEALTHCHECK
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
  CMD ["node", "-e", "require('http').get('http://localhost:3000/health', r => process.exit(r.statusCode === 200 ? 0 : 1))"]

Enforce read-only at runtime (see Layer 3).

1.7 Minimal .dockerignore

# Always exclude these from build context
.git
.github
.env
.env.*
*.pem
*.key
node_modules
__pycache__
.pytest_cache
coverage/
dist/
*.log
.DS_Store
Dockerfile*
docker-compose*
README.md
docs/
tests/

1.8 Full Hardened Dockerfile Example

# syntax=docker/dockerfile:1

# ── Build stage ───────────────────────────────────────────
FROM node:20-slim AS builder
WORKDIR /build
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci
COPY . .
RUN npm run build && npm prune --production

# ── Runtime stage ─────────────────────────────────────────
FROM gcr.io/distroless/nodejs20-debian12@sha256:<pin-digest-here>

LABEL org.opencontainers.image.source="https://github.com/org/repo"
LABEL org.opencontainers.image.revision="${BUILD_SHA}"
LABEL org.opencontainers.image.licenses="MIT"

WORKDIR /app
COPY --from=builder --chown=nonroot:nonroot /build/dist        ./dist
COPY --from=builder --chown=nonroot:nonroot /build/node_modules ./node_modules

USER nonroot:nonroot
EXPOSE 3000

HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
  CMD ["node", "-e", "require('http').get('http://localhost:3000/health', r => process.exit(r.statusCode===200?0:1))"]

CMD ["dist/server.js"]

Layer 2: Image Scanning

2.1 Trivy (Recommended — Fast, Comprehensive)

# Install
brew install trivy                              # macOS
apt install trivy                               # Debian/Ubuntu
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh \
  -o "$tmpdir/trivy-install.sh"
sed -n '1,160p' "$tmpdir/trivy-install.sh"
sh "$tmpdir/trivy-install.sh"

# Scan an image for CVEs
trivy image myapp:latest

# Fail CI on HIGH/CRITICAL severity
trivy image --exit-code 1 --severity HIGH,CRITICAL myapp:latest

# Scan Dockerfile for misconfigurations
trivy config ./Dockerfile

# Scan entire repo (vulnerabilities + secrets + misconfigs)
trivy fs --scanners vuln,secret,misconfig .

# Generate SBOM (CycloneDX or SPDX)
trivy image --format cyclonedx --output sbom.json myapp:latest
trivy image --format spdx-json  --output sbom.spdx.json myapp:latest

# Ignore specific CVEs (add justification comments)
trivy image --ignorefile .trivyignore myapp:latest

.trivyignore example:

# CVE-2023-1234 — only exploitable via X feature, not used in this app
CVE-2023-1234

# CVE-2023-5678 — fix not yet available; tracked in issue #42
CVE-2023-5678

2.2 Grype (Anchore Alternative)

# Install
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh \
  -o "$tmpdir/grype-install.sh"
sed -n '1,160p' "$tmpdir/grype-install.sh"
sh "$tmpdir/grype-install.sh"

# Scan image
grype myapp:latest

# Fail on critical
grype myapp:latest --fail-on critical

# Output SARIF for GitHub Security tab
grype myapp:latest -o sarif > results.sarif

# Pair with Syft for SBOM generation
syft myapp:latest -o cyclonedx-json > sbom.json
grype sbom:sbom.json                            # Scan the SBOM directly

2.3 Hadolint — Dockerfile Linting

# Run directly
docker run --rm -i hadolint/hadolint < Dockerfile

# With config file
hadolint --config .hadolint.yaml --failure-threshold warning Dockerfile

.hadolint.yaml:

failure-threshold: warning
ignore:
  - DL3008   # Pin versions in apt-get (allow floating for base layer)
trustedRegistries:
  - gcr.io
  - ghcr.io
  - public.ecr.aws

2.4 Secret Scanning in Images

# Trivy covers secrets too
trivy image --scanners secret myapp:latest

# Dedicated: TruffleHog
trufflehog docker --image myapp:latest

# git-secrets to prevent committing secrets
git secrets --scan

2.5 CI Integration (GitHub Actions — SHA-Pinned)

permissions:
  contents: read
  security-events: write      # Required for uploading SARIF

jobs:
  security-scan:
    runs-on: ubuntu-24.04
    timeout-minutes: 20
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2

      - name: Build image
        run: docker build -t myapp:${{ github.sha }} .

      - name: Lint Dockerfile
        uses: hadolint/hadolint-action@54c9adbab1582c2ef04b2016b760714a4bfde3cf  # v3.1.0
        with:
          dockerfile: Dockerfile
          failure-threshold: warning

      - name: Scan with Trivy
        uses: aquasecurity/trivy-action@6e7b7d1fd3e4fef0c5fa8cce1229c54b2c9bd0d8  # v0.28.0
        with:
          image-ref: myapp:${{ github.sha }}
          format: sarif
          output: trivy-results.sarif
          severity: HIGH,CRITICAL
          exit-code: '1'

      - name: Upload results to GitHub Security tab
        uses: github/codeql-action/upload-sarif@4f3212b61783c3c68e8309a0f18a699764811cda  # v3.27.1
        if: always()          # Upload even if scan found issues
        with:
          sarif_file: trivy-results.sarif

Layer 3: Runtime Security

3.1 docker run Hardening Flags

docker run \
  --read-only \                              # Read-only root filesystem
  --tmpfs /tmp:noexec,nosuid,size=100m \     # Writable tmpfs for /tmp only
  --tmpfs /var/run \                         # For PID files if needed
  --user 10001:10001 \                       # Non-root UID:GID
  --cap-drop ALL \                           # Drop ALL Linux capabilities
  --cap-add NET_BIND_SERVICE \               # Re-add only what's truly needed
  --security-opt no-new-privileges:true \    # Prevent privilege escalation via setuid
  --security-opt seccomp=seccomp.json \      # Custom seccomp profile
  --security-opt apparmor=docker-default \   # AppArmor profile
  --pids-limit 100 \                         # Prevent fork bombs
  --memory 512m \                            # OOM protection
  --memory-swap 512m \                       # Disable swap
  --cpus 1.0 \                               # CPU limit
  --network none \                           # No network (if not needed)
  --health-cmd "curl -f http://localhost:3000/health || exit 1" \
  --health-interval 30s \
  myapp:latest

3.2 Linux Capabilities — What to Drop and Keep

Drop ALL, then explicitly add only what your app requires:

CapabilityPurposeKeep?
NET_BIND_SERVICEBind ports < 1024Only if binding a privileged port
CHOWNChange file ownershipNo — set ownership at build time
SETUID / SETGIDSwitch user identityNo — drop always
SYS_ADMINBroad privileged operationsNo — most dangerous capability
NET_ADMINConfigure network interfacesNo (only network tools)
SYS_PTRACEDebug/trace processesNo (only debugger containers)
DAC_OVERRIDEOverride file permissionsNo — runs as correct user
NET_RAWRaw sockets (ping)No (blocked by default seccomp anyway)

Most web apps need zero capabilities. --cap-drop ALL alone is often sufficient.

3.3 Docker Compose Hardening

services:
  app:
    image: myapp:latest
    read_only: true
    user: "10001:10001"
    tmpfs:
      - /tmp:noexec,nosuid,size=100m
      - /var/run:noexec,nosuid,size=10m
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE    # Only if binding port < 1024
    security_opt:
      - no-new-privileges:true
      - seccomp:./references/seccomp-profile-template.json
    pids_limit: 100
    mem_limit: 512m
    memswap_limit: 512m
    cpus: 1.0
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s
    networks:
      - backend
    # Only expose externally if truly required
    # ports: ["8080:8080"]
    restart: unless-stopped
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

networks:
  backend:
    driver: bridge
    internal: true    # No external connectivity unless needed

3.4 Seccomp Profiles

The Docker default seccomp profile blocks ~44 dangerous syscalls. For stricter control:

# Step 1: Audit syscalls your app actually makes
docker run --security-opt seccomp=unconfined \
  --name audit-run myapp:latest &

# Capture with strace
strace -c -p $(docker inspect --format '{{.State.Pid}}' audit-run)

# Or with sysdig (more container-friendly)
sysdig -p "%syscall.type" container.name=audit-run | sort -u

# Step 2: Build a custom profile from references/seccomp-profile-template.json
# Step 3: Apply it
docker run --security-opt seccomp=references/seccomp-profile-template.json myapp:latest

See references/seccomp-profile-template.json for a minimal starting allowlist for typical web servers.

3.5 AppArmor Profile (Linux hosts)

# Load Docker's default AppArmor profile
sudo apparmor_parser -r /etc/apparmor.d/docker-default

# Apply at runtime
docker run --security-opt apparmor=docker-default myapp:latest

# Generate a custom profile
aa-genprof myapp   # Interactive — run app under aa-complain mode first

Layer 4: Supply Chain Security

4.1 Sign Images with Cosign (Sigstore — Keyless)

# Install cosign
brew install cosign    # macOS
# or: https://github.com/sigstore/cosign/releases

# Sign after push — keyless via OIDC (no long-lived keys)
cosign sign ghcr.io/org/myapp:latest

# Verify before deploy
cosign verify ghcr.io/org/myapp:latest \
  --certificate-identity-regexp="https://github.com/org/repo" \
  --certificate-oidc-issuer="https://token.actions.githubusercontent.com"

GitHub Actions — Sign & Verify Pipeline:

permissions:
  id-token: write     # Required for OIDC keyless signing
  packages: write

steps:
  - uses: sigstore/cosign-installer@dc72c7d5c4d10cd6bcb8cf6e3fd625a9e5e537da  # v3.7.0

  - name: Sign image (keyless via OIDC)
    run: |
      cosign sign --yes \
        ghcr.io/${{ github.repository }}:${{ github.sha }}
    env:
      COSIGN_EXPERIMENTAL: "true"

  - name: Attach SBOM attestation
    run: |
      cosign attest --yes \
        --predicate sbom.json \
        --type cyclonedx \
        ghcr.io/${{ github.repository }}:${{ github.sha }}

4.2 SBOM Generation & Attestation

# Generate SBOM with Syft
syft myapp:latest -o cyclonedx-json > sbom.json
syft myapp:latest -o spdx-json > sbom.spdx.json

# Attach to image as attestation
cosign attest --predicate sbom.json --type cyclonedx ghcr.io/org/myapp:latest

# Verify SBOM attestation before deployment
cosign verify-attestation \
  --type cyclonedx \
  --certificate-identity-regexp="https://github.com/org/repo" \
  --certificate-oidc-issuer="https://token.actions.githubusercontent.com" \
  ghcr.io/org/myapp:latest

4.3 Use Trusted Registries & Enable Registry Scanning

RegistryBuilt-in ScanningNotes
GHCR (GitHub Container Registry)No (use Trivy in CI)Best for OSS, OIDC auth
AWS ECRYes (enhanced scanning via Inspector)Enable per-repo
GCP Artifact RegistryYes (Container Analysis)Enabled by default
Azure ACRYes (Defender for Containers)Premium tier
Docker HubYes (limited on free tier)Avoid for private images
# Enable ECR enhanced scanning
aws ecr put-registry-scanning-configuration \
  --scan-type ENHANCED \
  --rules '[{"repositoryFilters":[{"filter":"*","filterType":"WILDCARD"}],"scanFrequency":"CONTINUOUS_SCAN"}]'

4.4 Admission Control — Block Unsigned/Unscanned Images

# Kyverno policy — require signed images before admission
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-signed-images
spec:
  validationFailureAction: Enforce
  rules:
    - name: verify-image-signature
      match:
        resources:
          kinds: [Pod]
      verifyImages:
        - imageReferences:
            - "ghcr.io/org/*"
          attestors:
            - entries:
                - keyless:
                    subject: "https://github.com/org/repo/.github/workflows/*"
                    issuer: "https://token.actions.githubusercontent.com"

Layer 5: Kubernetes Pod Security

Full reference: references/kubernetes-pod-security.md

5.1 Pod Security Context

apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
  namespace: production
spec:
  replicas: 3
  template:
    spec:
      # ── Pod-level security context ─────────────────────
      securityContext:
        runAsNonRoot: true
        runAsUser: 10001
        runAsGroup: 10001
        fsGroup: 10001
        fsGroupChangePolicy: OnRootMismatch
        seccompProfile:
          type: RuntimeDefault    # Use containerd/runc default seccomp
        supplementalGroups: []

      automountServiceAccountToken: false   # Disable unless needed

      # ── Container-level security context ──────────────
      containers:
        - name: app
          image: ghcr.io/org/myapp@sha256:<digest>   # Always use digest
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: ["ALL"]
              add: []              # Add nothing unless absolutely required
            runAsNonRoot: true
            runAsUser: 10001
            seccompProfile:
              type: RuntimeDefault

          # ── Resource limits (required for restricted PSA) ──
          resources:
            requests:
              memory: "128Mi"
              cpu: "100m"
            limits:
              memory: "512Mi"
              cpu: "500m"

          # ── Writable tmpfs mounts ──────────────────────
          volumeMounts:
            - name: tmp
              mountPath: /tmp
            - name: varrun
              mountPath: /var/run

      volumes:
        - name: tmp
          emptyDir:
            medium: Memory
            sizeLimit: 100Mi
        - name: varrun
          emptyDir:
            medium: Memory
            sizeLimit: 10Mi

5.2 Pod Security Admission (K8s 1.25+)

# Audit existing workloads before enforcing
kubectl label namespace production \
  pod-security.kubernetes.io/audit=restricted \
  pod-security.kubernetes.io/audit-version=latest

# Warn in staging, enforce in production
kubectl label namespace staging \
  pod-security.kubernetes.io/warn=restricted

kubectl label namespace production \
  pod-security.kubernetes.io/enforce=restricted \
  pod-security.kubernetes.io/enforce-version=latest
PSA LevelWhat It Blocks
privilegedNo restrictions
baselineBlocks hostNetwork, hostPID, privileged containers, hostPath
restrictedAlso requires non-root, read-only FS, drops capabilities, seccomp

5.3 NetworkPolicy — Zero-Trust Networking

# Step 1: Deny all ingress and egress by default in the namespace
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-all
  namespace: production
spec:
  podSelector: {}
  policyTypes: [Ingress, Egress]

---
# Step 2: Selectively allow only required traffic
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-app
  namespace: production
spec:
  podSelector:
    matchLabels:
      app: myapp
  policyTypes: [Ingress, Egress]
  ingress:
    - from:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: ingress-nginx
          podSelector:
            matchLabels:
              app.kubernetes.io/name: ingress-nginx
      ports:
        - port: 3000
  egress:
    - to:
        - podSelector:
            matchLabels:
              app: postgres
      ports:
        - port: 5432
    - to:                 # Allow only cluster DNS
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: kube-system
          podSelector:
            matchLabels:
              k8s-app: kube-dns
      ports:
        - port: 53
          protocol: UDP
        - port: 53
          protocol: TCP

5.4 RBAC — Least Privilege

# Create minimal role — never use wildcards
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: app-reader
  namespace: production
rules:
  - apiGroups: [""]
    resources: ["configmaps", "secrets"]
    resourceNames: ["myapp-config"]    # Lock to specific resource names
    verbs: ["get"]                     # Never ["*"]

---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: app-reader-binding
  namespace: production
subjects:
  - kind: ServiceAccount
    name: myapp-sa
    namespace: production
roleRef:
  kind: Role
  name: app-reader
  apiGroup: rbac.authorization.k8s.io
# Audit what permissions a service account has
kubectl auth can-i --list --as=system:serviceaccount:production:myapp-sa

# Find overly-permissive cluster roles
kubectl get clusterrolebindings -o json | \
  jq '.items[] | select(.roleRef.name == "cluster-admin") | .subjects'

5.5 Kyverno Policy Examples

# Require non-root containers
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-non-root
spec:
  validationFailureAction: Enforce
  rules:
    - name: check-run-as-non-root
      match:
        resources:
          kinds: [Pod]
      validate:
        message: "Containers must not run as root (runAsNonRoot: true required)"
        pattern:
          spec:
            containers:
              - securityContext:
                  runAsNonRoot: true

---
# Require image digest pinning
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-image-digest
spec:
  validationFailureAction: Enforce
  rules:
    - name: check-digest
      match:
        resources:
          kinds: [Pod]
      validate:
        message: "Images must be pinned to a SHA256 digest, not just a tag"
        pattern:
          spec:
            containers:
              - image: "*@sha256:*"

---
# Block privileged containers
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: disallow-privileged
spec:
  validationFailureAction: Enforce
  rules:
    - name: check-privileged
      match:
        resources:
          kinds: [Pod]
      validate:
        message: "Privileged containers are not allowed"
        pattern:
          spec:
            containers:
              - =(securityContext):
                  =(privileged): "false"

Common Pitfalls & Fixes

ProblemRoot CauseFix
Image runs as rootNo USER directiveAdd RUN useradd ... and USER appuser
Secret in docker historyENV or RUN curl -H "Bearer $TOKEN"Use RUN --mount=type=secret
Large image with many CVEsFull base image (node:20, ubuntu)Switch to node:20-slim or distroless
App crashes with --read-onlyWrites to /tmp or app directoryAdd --tmpfs /tmp for writable temp space
Trivy scan blocks CI on unfixable CVEsNo ignore fileAdd .trivyignore with justified entries
Container needs SYS_ADMINMissing --cap-drop contextInvestigate why — almost always avoidable
Tag-based images drift over timeMutable tagsPin to @sha256: digest; use Renovate to update
K8s pod rejected by PSAMissing security context fieldsAdd runAsNonRoot, readOnlyRootFilesystem, allowPrivilegeEscalation: false
App can't write to filesystemreadOnlyRootFilesystem: trueMount emptyDir volumes for writable paths

Security Checklist

Dockerfile

  • Minimal base image (distroless, slim, or alpine — not full debian/ubuntu)
  • Multi-stage build — no build tools, devDependencies, or compilers in runtime image
  • Non-root USER declared before CMD/ENTRYPOINT
  • Base image pinned to @sha256:... digest (not just tag)
  • No secrets in ENV, ARG, or RUN commands
  • HEALTHCHECK defined
  • OCI labels present (org.opencontainers.image.*)
  • .dockerignore excludes .git, .env, secrets, tests
  • ENTRYPOINT uses exec form, not shell form

Image Scanning

  • Trivy or Grype scan in CI (fails on HIGH/CRITICAL)
  • Hadolint passes with no warnings
  • Secret scan run on image (trivy --scanners secret)
  • SBOM generated and stored
  • .trivyignore has justified entries for accepted CVEs

Runtime

  • --read-only filesystem
  • --cap-drop ALL (add back only what's documented as required)
  • --security-opt no-new-privileges:true
  • --security-opt seccomp=<profile> applied
  • Resource limits set (--memory, --cpus, --pids-limit)
  • Image signed with Cosign; verified before deploy

Kubernetes

  • readOnlyRootFilesystem: true
  • allowPrivilegeEscalation: false
  • runAsNonRoot: true with explicit UID
  • capabilities.drop: ["ALL"]
  • Resource requests and limits defined
  • automountServiceAccountToken: false
  • Namespace PSA enforced at restricted level
  • NetworkPolicy default-deny applied
  • RBAC uses specific resource names and minimal verbs

Reference Files

  • references/base-image-comparison.md — Size, CVE count, shell/pkg-manager trade-offs: distroless vs alpine vs slim vs scratch
  • references/seccomp-profile-template.json — Minimal syscall allowlist for typical web servers; start here and extend
  • references/kubernetes-pod-security.md — NetworkPolicy, RBAC, OPA/Kyverno policies, service account hardening, PSA

Related Skills

  • docker-expert — General Docker usage, Compose orchestration, image optimization
  • gha-security-review — Security audit of GitHub Actions workflows
  • github-actions-advanced — CI pipeline patterns including scanner integration
  • kubernetes-architect — Full Kubernetes architecture, not just security
  • api-security-best-practices — Application-level security (injection, auth, OWASP)
  • k8s-security-policies — Extended Kubernetes security policies

Limitations

  • Use this skill only when the task clearly matches the scope described above.
  • Do not treat the output as a substitute for environment-specific penetration testing or a formal security audit.
  • Seccomp profiles and AppArmor are Linux-only; macOS/Windows Docker Desktop uses different mechanisms.
  • Stop and ask for clarification if required inputs, permissions, safety boundaries, or success criteria are missing.

Bundled with this artifact

5 files

Reference files that ship alongside this artifact. Agents pull these in only when the task needs them.

More on the bench

SKILL0

Zustand Store Ts

Create Zustand stores following established patterns with proper TypeScript types and middleware.

ai-prompt-engineering+3
0
SKILL0

Zoom Automation

Automate Zoom meeting creation, management, recordings, webinars, and participant tracking via Rube MCP (Composio). Always search tools first for current schemas.

ai-prompt-engineering+3
0
SKILL0

Zoho Crm Automation

Automate Zoho CRM tasks via Rube MCP (Composio): create/update records, search contacts, manage leads, and convert leads. Always search tools first for current schemas.

ai-prompt-engineering+3
0