Container Security: Image Scanning and Runtime Protection

Containers promised isolation, but that promise comes with caveats. Your containerized application inherits every vulnerability in its base image, every misconfiguration in its Dockerfile, and every...

Key Insights

  • Container security requires defense in depth: scanning images before deployment catches known vulnerabilities, but runtime protection detects zero-days and behavioral anomalies that static analysis misses.
  • The most impactful security improvement you can make today is switching to minimal base images and running as non-root—this eliminates entire categories of exploits with minimal effort.
  • Integrate scanning as a hard gate in CI/CD pipelines, not just an advisory report; if you’re not blocking deployments on critical CVEs, you’re just generating noise.

The Container Security Landscape

Containers promised isolation, but that promise comes with caveats. Your containerized application inherits every vulnerability in its base image, every misconfiguration in its Dockerfile, and every weakness in the runtime environment. Attackers know this. They scan public registries for exposed secrets, exploit known CVEs in outdated packages, and leverage container escapes to pivot from a compromised workload to the underlying host.

The attack surface breaks down into three categories: vulnerable images (outdated packages, known CVEs), misconfigurations (running as root, excessive capabilities), and runtime exploits (container escapes, malicious processes). Effective container security addresses all three through a combination of static analysis and dynamic monitoring.

The shift-left mindset—catching issues early in development—is necessary but insufficient. You need both: scanning images before they reach production and monitoring containers after they’re running.

Understanding Container Image Vulnerabilities

Container images are layered filesystems. When you build FROM ubuntu:22.04, you inherit every package in that base image, including the ones you don’t use. A vulnerability in libssl affects your image whether your application uses SSL directly or not.

This inheritance creates a dependency tree that’s often invisible to developers. Your Node.js application might have 50 direct dependencies, but the full image contains thousands of packages from the base image, each with its own CVE history.

Software Bill of Materials (SBOM) documents make this dependency tree explicit. An SBOM lists every component in your image—packages, libraries, and their versions—enabling vulnerability tracking and license compliance. Tools like Syft generate SBOMs in standard formats (SPDX, CycloneDX) that integrate with vulnerability databases.

The difference between a vulnerable and hardened base image is dramatic:

# Vulnerable: Full Ubuntu with unnecessary packages
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y python3 python3-pip
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY app.py .
CMD ["python3", "app.py"]

# Hardened: Minimal base with only required components
FROM python:3.11-slim-bookworm
RUN apt-get update && apt-get install -y --no-install-recommends \
    libpq5 \
    && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
CMD ["python3", "app.py"]

The slim variant reduces your attack surface by hundreds of packages. The --no-install-recommends flag prevents apt from pulling in suggested dependencies you don’t need.

Image Scanning Tools and Integration

The scanner landscape has matured significantly. Here’s how the major players compare:

Trivy (Aqua Security): Fast, comprehensive, and free. Scans images, filesystems, git repositories, and Kubernetes clusters. Excellent CI/CD integration.

Grype (Anchore): Lightweight scanner focused on speed. Pairs well with Syft for SBOM generation. Good for local development workflows.

Snyk: Commercial tool with generous free tier. Strong developer experience and IDE integration. Better at prioritizing vulnerabilities by reachability.

Clair: Originally from CoreOS, now maintained by Red Hat. Designed for registry-level scanning. More complex to deploy but scales well.

For most teams, Trivy offers the best balance of capability and simplicity. Here’s how to integrate it:

# Basic scan with severity filtering
trivy image --severity HIGH,CRITICAL myapp:latest

# JSON output for programmatic processing
trivy image --format json --output results.json myapp:latest

# Parse results to fail on critical vulnerabilities
trivy image --exit-code 1 --severity CRITICAL myapp:latest

The real value comes from CI/CD integration. This GitHub Actions workflow blocks merges when critical vulnerabilities are detected:

name: Container Security Scan

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Build image
        run: docker build -t ${{ github.repository }}:${{ github.sha }} .
      
      - name: Run Trivy vulnerability scanner
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: ${{ github.repository }}:${{ github.sha }}
          format: 'sarif'
          output: 'trivy-results.sarif'
          severity: 'CRITICAL,HIGH'
          exit-code: '1'
      
      - name: Upload scan results to GitHub Security
        uses: github/codeql-action/upload-sarif@v3
        if: always()
        with:
          sarif_file: 'trivy-results.sarif'

The exit-code: '1' parameter is crucial—it fails the workflow when vulnerabilities are found. The SARIF upload integrates findings with GitHub’s security dashboard for tracking over time.

Building Secure Images: Best Practices

Multi-stage builds separate build-time dependencies from runtime, dramatically reducing the final image size and attack surface:

# Build stage: includes compilers and dev dependencies
FROM golang:1.22-bookworm AS builder

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/server

# Runtime stage: minimal image with only the binary
FROM gcr.io/distroless/static-debian12:nonroot

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

# Run as non-root user (uid 65532 in distroless)
USER nonroot:nonroot

EXPOSE 8080
ENTRYPOINT ["/server"]

This Dockerfile demonstrates several hardening techniques:

  1. Distroless base: No shell, no package manager, no unnecessary binaries. Attackers can’t spawn a shell because there isn’t one.

  2. Non-root user: The nonroot user in distroless images has UID 65532. Even if an attacker exploits your application, they can’t escalate to root within the container.

  3. Static binary: CGO_ENABLED=0 produces a statically linked binary that doesn’t require libc, enabling the minimal static distroless variant.

  4. Stripped binary: The -ldflags="-s -w" flags remove debug symbols, reducing binary size and making reverse engineering harder.

Runtime Protection Fundamentals

Static scanning catches known vulnerabilities. Runtime protection catches everything else: zero-day exploits, behavioral anomalies, and misuse of legitimate functionality.

The key mechanisms are:

Seccomp profiles restrict which system calls a container can make. Most applications need fewer than 50 syscalls; the default Docker profile blocks about 44 dangerous ones, but you can go further:

{
  "defaultAction": "SCMP_ACT_ERRNO",
  "architectures": ["SCMP_ARCH_X86_64"],
  "syscalls": [
    {
      "names": [
        "read", "write", "close", "fstat", "mmap", "mprotect",
        "munmap", "brk", "rt_sigaction", "rt_sigprocmask",
        "ioctl", "access", "pipe", "select", "sched_yield",
        "mremap", "msync", "mincore", "madvise", "shmget",
        "shmat", "socket", "connect", "accept", "sendto",
        "recvfrom", "bind", "listen", "getsockname", "getpeername",
        "clone", "execve", "exit", "wait4", "kill", "uname",
        "fcntl", "flock", "fsync", "fdatasync", "getcwd",
        "chdir", "openat", "getdents64", "lseek", "readlinkat"
      ],
      "action": "SCMP_ACT_ALLOW"
    }
  ]
}

Kubernetes Pod Security Standards enforce security contexts at the namespace level:

apiVersion: v1
kind: Namespace
metadata:
  name: production
  labels:
    pod-security.kubernetes.io/enforce: restricted
    pod-security.kubernetes.io/audit: restricted
    pod-security.kubernetes.io/warn: restricted
---
apiVersion: v1
kind: Pod
metadata:
  name: secure-app
  namespace: production
spec:
  securityContext:
    runAsNonRoot: true
    runAsUser: 65532
    fsGroup: 65532
    seccompProfile:
      type: RuntimeDefault
  containers:
  - name: app
    image: myapp:latest
    securityContext:
      allowPrivilegeEscalation: false
      readOnlyRootFilesystem: true
      capabilities:
        drop:
          - ALL
    volumeMounts:
    - name: tmp
      mountPath: /tmp
  volumes:
  - name: tmp
    emptyDir: {}

The restricted policy enforces non-root execution, drops all capabilities, and requires read-only root filesystems. The emptyDir volume provides a writable /tmp when applications need temporary storage.

Implementing Runtime Monitoring with Falco

Falco monitors system calls in real-time and alerts on suspicious behavior. It’s the tripwire that catches attackers who’ve bypassed your other defenses.

Deploy Falco via Helm:

helm repo add falcosecurity https://falcosecurity.github.io/charts
helm install falco falcosecurity/falco \
  --namespace falco --create-namespace \
  --set driver.kind=ebpf \
  --set falcosidekick.enabled=true \
  --set falcosidekick.config.slack.webhookurl="https://hooks.slack.com/..."

Custom rules detect application-specific threats. This rule alerts when a shell is spawned in a container that shouldn’t have interactive access:

- rule: Shell Spawned in Container
  desc: Detect shell execution in production containers
  condition: >
    spawned_process and 
    container and
    shell_procs and
    not container.image.repository in (allowed_shell_images)    
  output: >
    Shell spawned in container 
    (user=%user.name container=%container.name 
    image=%container.image.repository shell=%proc.name 
    parent=%proc.pname cmdline=%proc.cmdline)    
  priority: WARNING
  tags: [container, shell, mitre_execution]

- list: allowed_shell_images
  items: [debug-container, maintenance-pod]

- rule: Sensitive File Access
  desc: Detect access to sensitive files
  condition: >
    open_read and
    container and
    (fd.name startswith /etc/shadow or
     fd.name startswith /etc/passwd or
     fd.name startswith /root/.ssh or
     fd.name startswith /proc/self/environ)    
  output: >
    Sensitive file accessed 
    (user=%user.name file=%fd.name container=%container.name 
    image=%container.image.repository)    
  priority: CRITICAL
  tags: [container, filesystem, mitre_credential_access]

Putting It Together: A Security Pipeline

The complete security pipeline combines admission control, scanning, and runtime monitoring. OPA Gatekeeper enforces policies at deployment time:

apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata:
  name: k8srequiredimagescan
spec:
  crd:
    spec:
      names:
        kind: K8sRequiredImageScan
  targets:
    - target: admission.k8s.gatekeeper.sh
      rego: |
        package k8srequiredimagescan
        
        violation[{"msg": msg}] {
          container := input.review.object.spec.containers[_]
          not has_scan_annotation(input.review.object)
          msg := sprintf("Container %v: image must be scanned before deployment", [container.name])
        }
        
        has_scan_annotation(obj) {
          obj.metadata.annotations["security.company.com/scanned"] == "true"
        }        
---
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sRequiredImageScan
metadata:
  name: require-image-scan
spec:
  match:
    kinds:
      - apiGroups: [""]
        kinds: ["Pod"]
    namespaces: ["production"]

This policy blocks pods without a scan annotation. Your CI/CD pipeline adds the annotation after successful scanning, creating an auditable chain of custody from build to deployment.

Container security isn’t a product you buy—it’s a practice you build. Start with minimal base images and non-root users. Add scanning to your CI/CD pipeline with hard failure gates. Deploy runtime monitoring to catch what scanning misses. Each layer reduces risk; together, they make container exploitation significantly harder than attacking traditional infrastructure.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.