Container Image Security


Introduction

Container images are the building blocks of modern application deployment. An insecure base image or dependency can compromise every environment where the container runs. Securing the container supply chain requires attention to every layer — from the base image choice to runtime enforcement.

Minimal Base Images

Smaller base images reduce attack surface and vulnerability count.




# BAD: Large base image with unnecessary tools


FROM ubuntu:22.04


RUN apt-get update && apt-get install -y python3 curl wget git build-essential




# GOOD: Minimal Python image


FROM python:3.12-slim




# BETTER: Distroless — no package manager, no shell


FROM gcr.io/distroless/python3-debian12




# BEST: Scratch — completely empty, only your binary


FROM scratch


COPY my-compiled-binary /app/








# Compare image sizes


docker images --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}"




# ubuntu:22.04 → 77MB


# python:3.12 → 1.0GB


# python:3.12-slim → 130MB


# gcr.io/distroless/python3 → 90MB





Alpine Considerations




FROM alpine:3.19




# Install only what's needed


RUN apk add --no-cache \


python3=~3.12 \


ca-certificates




# Remove apk cache


RUN rm -rf /var/cache/apk/*





Note: Alpine uses musl libc instead of glibc, which can cause compatibility issues with Python wheels and compiled binaries.

Multi-Stage Builds

Multi-stage builds separate the build environment from the runtime environment, ensuring build tools and source code are not included in the final image.




# Build stage


FROM golang:1.22 AS builder


WORKDIR /app


COPY go.mod go.sum ./


RUN go mod download


COPY . .


RUN CGO_ENABLED=0 go build -o /app/server -ldflags="-s -w"




# Runtime stage


FROM gcr.io/distroless/static-debian12:nonroot


COPY --from=builder /app/server /server


EXPOSE 8080


USER nonroot:nonroot


ENTRYPOINT ["/server"]








# Python multi-stage build


FROM python:3.12-slim AS builder


WORKDIR /app


COPY requirements.txt .


RUN pip install --user --no-cache-deps -r requirements.txt




FROM python:3.12-slim


COPY --from=builder /root/.local /root/.local


COPY app/ ./app/


ENV PATH=/root/.local/bin:$PATH


USER nobody


CMD ["python", "-m", "app"]





Image Scanning

Scan images for known vulnerabilities before deployment.




# Trivy scan


trivy image myapp:latest --severity CRITICAL,HIGH




# Trivy with output formats


trivy image --format sarif --output trivy-report.sarif myapp:latest


trivy image --format cyclonedx --output sbom.json myapp:latest




# Grype scan


grype myapp:latest --only-fixed --fail-on high




# Scan within CI/CD


grype myapp:latest --fail-on high --add-cpes-if-none








# .trivyignore — exclude accepted risks


# CVE-2023-1234 — accepted, no exploit available, will fix in next sprint


CVE-2023-1234


# CVE-2023-5678 — false positive in this context


CVE-2023-5678








# CI/CD gate: block deployment if critical vulnerabilities found


import subprocess


import json




def scan_and_validate(image_tag):


result = subprocess.run([


'trivy', 'image', '--format', 'json',


'--severity', 'CRITICAL,HIGH',


image_tag


], capture_output=True, text=True)




report = json.loads(result.stdout)


vulnerabilities = report.get('Results', [])




critical_count = sum(


len(vuln.get('Vulnerabilities', []))


for vuln in vulnerabilities


)




if critical_count > 0:


print(f"BLOCKED: {critical_count} critical/high vulnerabilities found")


exit(1)




print(f"PASSED: No critical vulnerabilities")





Image Signing with Cosign

Signing container images ensures integrity and provenance.




# Generate a key pair


cosign generate-key-pair




# Sign an image


cosign sign --key cosign.key registry.example.com/myapp:latest




# Verify a signed image


cosign verify --key cosign.pub registry.example.com/myapp:latest




# Keyless signing with OIDC


cosign sign registry.example.com/myapp:latest




# Verify keyless signature


cosign verify registry.example.com/myapp:latest








# Kubernetes admission controller to verify signatures


apiVersion: cosign.sigstore.dev/v1alpha1


kind: ClusterImagePolicy


metadata:


name: image-signature-policy


spec:


images:


- glob: "registry.example.com/*"


authorities:


- key:


data: |


-----BEGIN PUBLIC KEY-----


...


-----END PUBLIC KEY-----


- keyless:


identities:


- issuer: https://accounts.google.com


subject: "builder@example.com"








# Enforce in CI/CD pipeline


cosign verify \


--key k8s://my-namespace/cosign-public-key \


$IMAGE_TAG || exit 1





Dockerfile Best Practices




# 1. Use specific tags, never :latest


FROM python:3.12-slim@sha256:abc123...




# 2. Run as non-root


RUN addgroup -S appgroup && adduser -S appuser -G appgroup


USER appuser




# 3. Set filesystem read-only


COPY --chown=appuser:appgroup --chmod=0444 app/ ./app/




# 4. Drop capabilities in compose/kubernetes


# In Kubernetes:


securityContext:


runAsNonRoot: true


readOnlyRootFilesystem: true


capabilities:


drop: ["ALL"]


allowPrivilegeEscalation: false




# 5. HEALTHCHECK instruction


HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost:8080/health || exit 1





Conclusion

Container image security requires a layered approach: use minimal base images and distroless for production, implement multi-stage builds to exclude build tools, scan images for vulnerabilities before every deployment, and sign images with cosign to ensure integrity. Enforce these practices in CI/CD pipelines and at admission control time in Kubernetes.