Container Security: Code Review Guide
Table of Contents
1. Introduction to Container Security
Containers have revolutionized how we build and deploy software, but they introduce a new class of security risks that traditional code review doesn't address. A misconfigured Dockerfile, an overly permissive Kubernetes manifest, or an unscanned base image can give attackers a direct path from your application to the underlying host — or to every other container on the cluster.
Why This Matters
Over 75% of container images in production contain at least one high or critical vulnerability (Sysdig 2024 Cloud Security Report). Misconfigurations — not zero-days — account for the majority of container breaches. A single privileged: true flag in a Kubernetes pod spec gives an attacker full root access to the host node, potentially compromising every workload in the cluster.
In this guide, you'll learn how to review Dockerfiles for common security antipatterns, how to identify dangerous Kubernetes misconfigurations, why running containers as root is catastrophic, how to handle secrets without baking them into image layers, what "container escape" means and how to prevent it, and how to build a container image scanning pipeline.
Container Security Attack Surface
Container Lifecycle — Each Stage Has Risks
Common Vulnerabilities
Why is container security fundamentally different from traditional application security?
2. Dockerfile Security
The Dockerfile is where most container security issues originate. Every instruction in a Dockerfile creates a layer in the final image, and each layer can introduce vulnerabilities, leak secrets, or expand the attack surface.
Insecure Dockerfile — spot the problems
1# ❌ INSECURE: Multiple critical issues
2FROM ubuntu:latest
3
4# Installs as root (default) — container runs as root!
5RUN apt-get update && apt-get install -y \
6 curl wget git python3 python3-pip gcc make \
7 && rm -rf /var/lib/apt/lists/*
8
9# Secret baked into image layer — visible with docker history!
10ENV DATABASE_URL=postgres://admin:s3cr3t@db.prod.internal:5432/app
11ENV AWS_SECRET_ACCESS_KEY=AKIAIOSFODNN7EXAMPLE
12
13# Copies entire context including .env, .git, credentials
14COPY . /app
15WORKDIR /app
16
17RUN pip3 install -r requirements.txt
18
19# Runs as root with full privileges
20EXPOSE 8080
21CMD ["python3", "app.py"]Secure Dockerfile — hardened version
1# ✅ SECURE: Hardened multi-stage build
2# Stage 1: Build
3FROM python:3.12-slim AS builder
4
5WORKDIR /build
6COPY requirements.txt .
7RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
8
9# Stage 2: Production
10FROM python:3.12-slim
11
12# Pin the base image digest for reproducibility
13# FROM python:3.12-slim@sha256:a1b2c3d4e5f6...
14
15# Install security updates
16RUN apt-get update && apt-get upgrade -y \
17 && rm -rf /var/lib/apt/lists/*
18
19# Create non-root user
20RUN groupadd -r appuser && useradd -r -g appuser -d /app -s /sbin/nologin appuser
21
22WORKDIR /app
23
24# Copy only built dependencies from builder stage
25COPY /install /usr/local
26
27# Copy only application code (not .env, .git, etc.)
28COPY app.py .
29COPY src/ src/
30
31# Drop to non-root user
32USER appuser
33
34# No secrets in ENV — use runtime injection
35# No unnecessary packages — minimal attack surface
36
37EXPOSE 8080
38
39# Use exec form to handle signals properly
40HEALTHCHECK \
41 CMD ["python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')"]
42
43CMD ["python3", "app.py"]Dockerfile Security Rules
| Rule | Bad Practice | Secure Practice | Risk |
|---|---|---|---|
| Base image | FROM ubuntu:latest | FROM python:3.12-slim@sha256:... | Unpinned tags can change, slim reduces attack surface |
| User | Running as root (default) | USER appuser (non-root) | Root in container = root on host if escape occurs |
| Secrets | ENV DB_PASS=secret | Runtime injection via secrets manager | ENV persists in all layers, visible via docker history |
| COPY scope | COPY . /app | COPY specific files + .dockerignore | Leaks .git, .env, credentials, SSH keys |
| Build stages | Single stage with build tools | Multi-stage: build + production | Build tools (gcc, make) not needed at runtime |
| Package install | apt-get install curl wget git | Install only what is needed | Every extra package is additional CVE surface |
| ADD vs COPY | ADD https://url.com/archive.tar.gz | COPY (or RUN curl + verify checksum) | ADD auto-extracts and can fetch remote URLs |
A Dockerfile contains: ENV API_KEY=sk-live-abc123xyz. Even if this line is later removed with a subsequent RUN rm instruction, why is the secret still exposed?
3. Image Security & Scanning
Container images inherit every vulnerability from their base image. A FROM node:18 image includes the entire Debian OS with hundreds of packages — each a potential source of CVEs. Image scanning tools inspect every layer and package to identify known vulnerabilities.
Image scanning with popular tools
1# Trivy — comprehensive open-source scanner
2trivy image myapp:latest
3trivy image --severity HIGH,CRITICAL myapp:latest
4trivy image --exit-code 1 --severity CRITICAL myapp:latest # Fail CI on critical
5
6# Grype — fast vulnerability scanner from Anchore
7grype myapp:latest
8grype myapp:latest --fail-on high
9
10# Docker Scout — built into Docker Desktop
11docker scout cves myapp:latest
12docker scout quickview myapp:latest
13
14# Snyk Container
15snyk container test myapp:latest
16
17# Scan a Dockerfile (without building)
18trivy config Dockerfile
19hadolint Dockerfile # Dockerfile linterBase Image Comparison — Vulnerability Surface
| Base Image | Size | Typical CVEs | Use Case |
|---|---|---|---|
| ubuntu:22.04 | ~77 MB | 50-150 (varies) | Development, full toolchain needed |
| debian:bookworm-slim | ~80 MB | 30-100 | General purpose, slimmed down |
| python:3.12 | ~1 GB | 200-400+ | Full Python dev environment |
| python:3.12-slim | ~150 MB | 30-80 | Production Python apps |
| python:3.12-alpine | ~50 MB | 5-20 | Minimal, but musl libc quirks |
| node:20-alpine | ~130 MB | 5-20 | Production Node.js apps |
| gcr.io/distroless/base | ~20 MB | 0-5 | No shell, no package manager |
| scratch | 0 MB | 0 | Statically compiled binaries (Go, Rust) |
Distroless Images
Google's distroless images contain only your application and its runtime dependencies — no shell, no package manager, no ls or cat. This drastically reduces the attack surface. If an attacker gets code execution in a distroless container, they have no utilities to work with. Use FROM gcr.io/distroless/nodejs20-debian12 for Node.js or FROM gcr.io/distroless/python3-debian12 for Python.
GitHub Actions: Scan images in CI
1name: Container Security Scan
2on: [push, pull_request]
3
4jobs:
5 scan:
6 runs-on: ubuntu-latest
7 steps:
8 - uses: actions/checkout@v4
9
10 - name: Build image
11 run: docker build -t myapp:${{ github.sha }} .
12
13 - name: Run Trivy vulnerability scanner
14 uses: aquasecurity/trivy-action@master
15 with:
16 image-ref: myapp:${{ github.sha }}
17 format: table
18 exit-code: 1 # Fail the build
19 severity: CRITICAL,HIGH # On critical/high CVEs
20 ignore-unfixed: true # Skip CVEs with no fix available
21
22 - name: Lint Dockerfile
23 uses: hadolint/hadolint-action@v3.1.0
24 with:
25 dockerfile: DockerfileYour team uses FROM node:20 as their base image. A scan reveals 247 CVEs, but none are in your application code. Should you ignore them?
4. Secrets Management in Containers
Secrets (API keys, database passwords, TLS certificates) are one of the most commonly leaked artifacts in container environments. They can be exposed through Dockerfile layers, environment variables in orchestration configs, or logged in container output.
Common mistakes — secrets leaking in Docker
1# ❌ SECRET IN ENV — persists in image layer forever
2ENV DATABASE_URL=postgres://admin:password@db:5432/prod
3
4# ❌ SECRET IN ARG — visible in docker history
5ARG GITHUB_TOKEN=ghp_abc123
6RUN git clone https://$GITHUB_TOKEN@github.com/org/private-repo.git
7
8# ❌ COPY credentials file — even if deleted later, it's in a layer
9COPY .env /app/.env
10COPY credentials.json /app/credentials.json
11RUN rm /app/.env # Still in the previous layer!
12
13# ❌ SECRET IN RUN — visible in image metadata
14RUN echo "machine github.com login token password ghp_abc123" > ~/.netrcSecure alternatives for build-time secrets
1# ✅ Docker BuildKit secrets (never written to image layers)
2# syntax=docker/dockerfile:1
3FROM python:3.12-slim
4
5# Mount secret at build time — only available during this RUN
6RUN \
7 GITHUB_TOKEN=$(cat /run/secrets/github_token) && \
8 pip install git+https://$GITHUB_TOKEN@github.com/org/private-lib.git
9
10# Build command:
11# DOCKER_BUILDKIT=1 docker build --secret id=github_token,src=./token.txt .
12
13# ✅ Multi-stage build to exclude secrets from final image
14FROM node:20-alpine AS builder
15ARG NPM_TOKEN
16RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc
17RUN npm ci
18RUN rm .npmrc # Remove from builder stage
19
20FROM node:20-alpine
21COPY /app/node_modules ./node_modules
22# Final image has NO .npmrc or NPM_TOKENKubernetes Secrets — mounting at runtime
1# ✅ Inject secrets at runtime via Kubernetes Secrets
2apiVersion: v1
3kind: Secret
4metadata:
5 name: app-secrets
6type: Opaque
7data:
8 database-url: cG9zdGdyZXM6Ly9hZG1pbjpzM2NyM3RAZGIucHJvZC5pbnRlcm5hbDo1NDMyL2FwcA==
9---
10apiVersion: apps/v1
11kind: Deployment
12metadata:
13 name: myapp
14spec:
15 template:
16 spec:
17 containers:
18 - name: myapp
19 image: myapp:1.0.0
20 env:
21 - name: DATABASE_URL
22 valueFrom:
23 secretKeyRef:
24 name: app-secrets
25 key: database-url
26 # Or mount as a file:
27 volumeMounts:
28 - name: secrets
29 mountPath: /etc/secrets
30 readOnly: true
31 volumes:
32 - name: secrets
33 secret:
34 secretName: app-secretsKubernetes Secrets Are Not Encrypted by Default
Kubernetes Secrets are stored as base64-encoded (not encrypted!) values in etcd. Anyone with API access can read them. Enable encryption at rest for etcd, use an external secrets manager (HashiCorp Vault, AWS Secrets Manager, or external-secrets operator), and apply strict RBAC to limit who can read Secret resources.
A developer uses docker build --build-arg DB_PASSWORD=secret123 . and accesses it with ARG DB_PASSWORD in the Dockerfile. Is this secure?
5. Container Runtime Security
Even with a perfectly hardened Dockerfile, a container can be undermined by its runtime configuration. Docker and Kubernetes provide many options that weaken isolation when misused — privileged mode, host networking, writable root filesystems, and excessive Linux capabilities.
Docker Compose — insecure vs secure
1# ❌ INSECURE docker-compose.yml
2services:
3 app:
4 image: myapp:latest
5 privileged: true # Full host access!
6 network_mode: host # Shares host network namespace
7 pid: host # Can see/signal host processes
8 volumes:
9 - /:/host # Mounts entire host filesystem!
10 - /var/run/docker.sock:/var/run/docker.sock # Can control Docker daemon
11 cap_add:
12 - ALL # All Linux capabilities
13
14---
15# ✅ SECURE docker-compose.yml
16services:
17 app:
18 image: myapp:1.2.3 # Pinned version, not :latest
19 read_only: true # Read-only root filesystem
20 tmpfs:
21 - /tmp # Writable /tmp only
22 security_opt:
23 - no-new-privileges:true # Prevent privilege escalation
24 cap_drop:
25 - ALL # Drop all capabilities
26 cap_add:
27 - NET_BIND_SERVICE # Only add what's needed
28 deploy:
29 resources:
30 limits:
31 cpus: '0.5'
32 memory: 256M # Prevent resource exhaustion
33 user: "1000:1000" # Run as non-rootDangerous Runtime Flags
| Flag | What It Does | Risk | When Needed |
|---|---|---|---|
| --privileged | Gives container all capabilities + access to all devices | Full container escape possible | Almost never — use specific capabilities instead |
| --network=host | Container shares host network namespace | Can access host services on 127.0.0.1, sniff traffic | Rare — high-performance networking only |
| --pid=host | Container sees all host processes | Can signal/trace host processes | Monitoring/debugging tools only |
| -v /var/run/docker.sock | Container can control Docker daemon | Equivalent to root on the host | CI runners, Docker-in-Docker (use alternatives) |
| --cap-add=SYS_ADMIN | Broad capability: mount filesystems, trace processes | Many escape paths | Avoid — use more specific capabilities |
| --cap-add=SYS_PTRACE | Can trace/debug other processes | Read memory of other containers on same host | Debugging only, never in production |
The Docker Socket Is Root Access
Mounting /var/run/docker.sock inside a container gives it the ability to create new privileged containers, mount the host filesystem, and effectively gain full root access to the host. This is a well-known container escape vector. Never mount the Docker socket unless absolutely necessary, and if you must (e.g., for CI/CD tooling), use alternatives like Kaniko for building images or rootless Docker.
A docker-compose.yml sets privileged: true because 'the app needs to bind to port 80'. What is the correct fix?
6. Kubernetes Security Basics
Kubernetes adds a powerful orchestration layer on top of containers, but its extensive configuration surface creates many opportunities for misconfigurations. Pod security, RBAC, network policies, and service account management are all critical areas to review.
Pod Security Context — hardened configuration
1apiVersion: apps/v1
2kind: Deployment
3metadata:
4 name: secure-app
5spec:
6 replicas: 2
7 selector:
8 matchLabels:
9 app: secure-app
10 template:
11 metadata:
12 labels:
13 app: secure-app
14 spec:
15 # ✅ Don't automount the service account token unless needed
16 automountServiceAccountToken: false
17
18 securityContext:
19 # ✅ Pod-level: run as non-root
20 runAsNonRoot: true
21 runAsUser: 1000
22 runAsGroup: 1000
23 fsGroup: 1000
24 # ✅ Apply a restrictive seccomp profile
25 seccompProfile:
26 type: RuntimeDefault
27
28 containers:
29 - name: app
30 image: myapp:1.0.0@sha256:abc123... # ✅ Pinned by digest
31 ports:
32 - containerPort: 8080
33
34 securityContext:
35 # ✅ Container-level hardening
36 allowPrivilegeEscalation: false
37 readOnlyRootFilesystem: true
38 runAsNonRoot: true
39 capabilities:
40 drop: ["ALL"]
41
42 resources:
43 # ✅ Always set resource limits
44 requests:
45 cpu: 100m
46 memory: 128Mi
47 limits:
48 cpu: 500m
49 memory: 256Mi
50
51 volumeMounts:
52 - name: tmp
53 mountPath: /tmp
54
55 volumes:
56 - name: tmp
57 emptyDir:
58 sizeLimit: 100Mi # ✅ Limit writable tmp volumeNetworkPolicy — restrict pod-to-pod communication
1# ✅ Default deny all ingress and egress
2apiVersion: networking.k8s.io/v1
3kind: NetworkPolicy
4metadata:
5 name: default-deny-all
6 namespace: production
7spec:
8 podSelector: {} # Applies to ALL pods in namespace
9 policyTypes:
10 - Ingress
11 - Egress
12
13---
14# ✅ Allow only specific traffic
15apiVersion: networking.k8s.io/v1
16kind: NetworkPolicy
17metadata:
18 name: allow-app-traffic
19 namespace: production
20spec:
21 podSelector:
22 matchLabels:
23 app: web-frontend
24 policyTypes:
25 - Ingress
26 - Egress
27 ingress:
28 - from:
29 - podSelector:
30 matchLabels:
31 app: api-gateway
32 ports:
33 - port: 8080
34 egress:
35 - to:
36 - podSelector:
37 matchLabels:
38 app: backend-api
39 ports:
40 - port: 3000
41 - to: # Allow DNS resolution
42 - namespaceSelector: {}
43 podSelector:
44 matchLabels:
45 k8s-app: kube-dns
46 ports:
47 - port: 53
48 protocol: UDP- Always set
runAsNonRoot: true— Prevents the container from running as UID 0, even if the image defaults to root. - Set
readOnlyRootFilesystem: true— Prevents attackers from writing to the filesystem (e.g., dropping webshells). UseemptyDirvolumes for temp files. - Drop all capabilities, add only what is needed —
capabilities: { drop: ["ALL"] }then selectively add back (rarely needed). - Disable
automountServiceAccountToken— Unless the pod needs to talk to the Kubernetes API, disable this to prevent token theft. - Set resource limits — Without limits, a compromised container can DoS the entire node by consuming all CPU/memory.
- Apply NetworkPolicies — By default, all pods can talk to all other pods. NetworkPolicies implement zero-trust networking within the cluster.
A Kubernetes deployment has automountServiceAccountToken: true (the default). Why is this a security risk?
7. Detection During Code Review
When reviewing pull requests that include container configuration, use a systematic approach to identify security issues. Container misconfigurations are often subtle — a single line in a YAML file can compromise an entire cluster.
Code Review Detection Patterns for Containers
| File Type | What to Review | Red Flags |
|---|---|---|
| Dockerfile | Base image, USER instruction, COPY scope, ENV/ARG | FROM :latest, no USER directive, COPY . (entire context), secrets in ENV/ARG |
| .dockerignore | Missing or incomplete file | Missing .env, .git, *.pem, credentials from ignore list |
| K8s Deployment | securityContext, resources, service account | Missing runAsNonRoot, no resource limits, automountServiceAccountToken not disabled |
| K8s Pod spec | privileged, capabilities, hostPath volumes | privileged: true, hostPath mounts, SYS_ADMIN capability |
| docker-compose.yml | privileged, volumes, network_mode | Docker socket mount, host network, privileged mode, :latest tags |
| Helm values | Security settings overridden, image tags | Security contexts disabled, :latest or mutable tags used |
| NetworkPolicy | Missing or overly permissive | No NetworkPolicy in namespace = all pods can communicate |
| RBAC (Role/ClusterRole) | Excessive permissions | Wildcards (*) in verbs/resources, cluster-admin bindings |
Automated scanning tools for code review
1# Dockerfile linting
2hadolint Dockerfile
3# Checks: pinned versions, no root, no secrets, etc.
4
5# Kubernetes manifest scanning
6kubesec scan deployment.yaml
7# Scores security posture of K8s manifests
8
9# Open Policy Agent / Conftest — policy-as-code
10conftest test deployment.yaml --policy security-policy/
11# Custom rules: "deny pods with privileged: true"
12
13# Trivy config scanning (Dockerfile + K8s + Terraform)
14trivy config .
15# Scans all IaC files in the directory
16
17# Checkov — comprehensive IaC scanner
18checkov -d .
19# Checks Dockerfile, K8s, Terraform, CloudFormation
20
21# kube-bench — CIS Kubernetes Benchmark
22kube-bench run --targets node
23# Verifies cluster configuration against CIS standards.dockerignore Is a Security Control
A missing or incomplete .dockerignore means COPY . /app sends everything to the Docker daemon: .env, .git (with full commit history), SSH keys, credentials files, and anything else in the build context. Always include: .env*, .git, *.pem, *.key, node_modules, credentials*, and docker-compose*.yml in your .dockerignore.
A PR adds a Kubernetes Deployment with no securityContext defined at all. Is this safe?