Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Container Security

Container security encompasses the practices, tools, and Linux kernel features used to isolate containers, limit their capabilities, and prevent breakout attacks. This page covers the defense-in-depth approach: seccomp profiles, AppArmor, Linux capabilities, read-only filesystems, and image scanning.

Introduction

Containers share the host kernel, making security fundamentally different from virtual machines. A container breakout gives the attacker access to the host kernel — the same kernel running all other containers. Defense-in-depth means layering multiple security mechanisms so that a single vulnerability doesn’t compromise the entire system.

Security layers:

flowchart TB
    subgraph "Container Security Layers"
        L1["Image Scanning<br>(pre-deployment)"]
        L2["Read-Only Rootfs<br>(runtime)"]
        L3["Linux Capabilities<br>(privilege restriction)"]
        L4["Seccomp Profiles<br>(syscall filtering)"]
        L5["AppArmor / SELinux<br>(mandatory access control)"]
        L6["User Namespaces<br>(UID isolation)"]
        L7["Resource Limits<br>(cgroups)"]
    end

    L1 --> L2 --> L3 --> L4 --> L5 --> L6 --> L7

Seccomp Profiles

Seccomp (Secure Computing Mode) restricts the system calls a container can make. Since containers share the host kernel, limiting syscalls dramatically reduces the attack surface.

Default Docker/Podman Profile

The default profile blocks approximately 44 of ~330 syscalls on x86-64:

{
  "defaultAction": "SCMP_ACT_ERRNO",
  "defaultErrnoRet": 1,
  "archMap": [
    { "architecture": "SCMP_ARCH_X86_64", "subArchitectures": ["SCMP_ARCH_X86"] }
  ],
  "syscalls": [
    {
      "names": [
        "accept", "accept4", "access", "arch_prctl", "bind", "brk",
        "chdir", "chmod", "chown", "clock_getres", "clock_gettime",
        "close", "connect", "dup", "dup2", "dup3", "epoll_create",
        "epoll_create1", "epoll_ctl", "epoll_wait", "execve", "exit",
        "exit_group", "fcntl", "fstat", "futex", "getcwd", "getdents64",
        "getpid", "getppid", "getsockname", "getsockopt", "getuid",
        "ioctl", "listen", "lseek", "madvise", "mmap", "mprotect",
        "munmap", "nanosleep", "newfstatat", "open", "openat", "pipe",
        "pipe2", "poll", "prctl", "pread64", "pwrite64", "read",
        "readlink", "recvfrom", "recvmsg", "rename", "rt_sigaction",
        "rt_sigprocmask", "rt_sigreturn", "sendmsg", "sendto", "set_robust_list",
        "set_tid_address", "setsockopt", "shutdown", "sigaltstack",
        "socket", "stat", "statfs", "sysinfo", "tgkill", "umask",
        "uname", "unlink", "wait4", "write", "writev"
      ],
      "action": "SCMP_ACT_ALLOW"
    }
  ]
}

Custom Seccomp Profile

{
  "defaultAction": "SCMP_ACT_ERRNO",
  "defaultErrnoRet": 1,
  "comment": "Strict profile for a web server",
  "syscalls": [
    {
      "names": [
        "read", "write", "open", "close", "fstat", "lseek",
        "mmap", "mprotect", "munmap", "brk", "rt_sigaction",
        "rt_sigprocmask", "ioctl", "access", "pipe", "select",
        "sched_yield", "dup", "dup2", "clone", "fork", "execve",
        "exit", "wait4", "kill", "getpid", "getuid", "getgid",
        "geteuid", "getegid", "fcntl", "flock", "fsync",
        "epoll_create", "epoll_ctl", "epoll_wait",
        "socket", "connect", "accept", "sendto", "recvfrom",
        "bind", "listen", "getsockname", "getpeername",
        "setsockopt", "getsockopt", "clone", "uname",
        "readlink", "gettimeofday", "getpid", "sysinfo"
      ],
      "action": "SCMP_ACT_ALLOW"
    },
    {
      "names": ["ptrace", "kexec_load", "init_module", "finit_module", "delete_module"],
      "action": "SCMP_ACT_KILL",
      "comment": "Block dangerous kernel operations"
    }
  ]
}

Applying Seccomp Profiles

# Docker
docker run --security-opt seccomp=custom.json nginx

# Podman
podman run --security-opt seccomp=custom.json nginx

# Kubernetes
apiVersion: v1
kind: Pod
metadata:
  name: secured-pod
spec:
  securityContext:
    seccompProfile:
      type: Localhost
      localhostProfile: profiles/custom.json
  containers:
  - name: app
    image: myapp:latest

Generating Seccomp Profiles with strace

# Trace syscalls used by a container
strace -f -c -p $(pgrep -x nginx) 2>&1 | head -30

# Or use OCI seccomp tools
docker run --rm \
    --security-opt seccomp=unconfined \
    -v /etc/seccomp:/output \
    oci-seccomp-bpf-hook \
    myapp:latest

# Output: /etc/seccomp/myapp.json

AppArmor

AppArmor is a Linux Security Module (LSM) that restricts programs’ capabilities with per-program profiles. It limits file access, network access, and capability use.

Default AppArmor Profile

# Check current AppArmor status
sudo aa-status

# Docker default profile: docker-default
# Podman: unconfined by default (SELinux preferred)

Custom AppArmor Profile

# /etc/apparmor.d/containers/myapp
#include <tunables/global>

profile myapp flags=(attach_disconnected) {
  #include <abstractions/base>

  # Deny all file writes by default
  deny /** w,

  # Allow read access to specific paths
  /app/** r,
  /etc/ssl/certs/** r,
  /etc/resolv.conf r,
  /etc/hosts r,

  # Allow write only to specific paths
  /tmp/** rw,
  /var/log/myapp/** rw,

  # Network access
  network inet stream,
  network inet dgram,

  # Deny raw sockets
  deny network raw,
  deny network packet,

  # Deny mount operations
  deny mount,

  # Deny ptrace
  deny ptrace,

  # Allow specific capabilities
  capability net_bind_service,
  deny capability sys_admin,
  deny capability sys_module,
  deny capability sys_rawio,
}

Loading and Applying

# Load the profile
sudo apparmor_parser -r /etc/apparmor.d/containers/myapp

# Docker
docker run --security-opt apparmor=myapp myapp:latest

# Verify
docker inspect --format '{{.AppArmorProfile}}' <container>

AppArmor vs SELinux

AspectAppArmorSELinux
Default onUbuntu, Debian, SUSERHEL, Fedora, CentOS, Android
ModelPath-basedLabel-based
ComplexitySimpler profilesMore complex, more powerful
Container defaultdocker-defaultcontainer_t (Podman default)
Profile formatCustom DSLPolicy modules (M4 macros)

Linux Capabilities

Capabilities break root’s monolithic privilege into ~40 distinct units. Containers should drop all capabilities and add back only what’s needed.

Common Capabilities

CapabilityPurposeRisk
CAP_NET_BIND_SERVICEBind to ports < 1024Low
CAP_NET_RAWUse raw sockets, packet injectionMedium
CAP_SYS_ADMINBroad admin operations (mount, etc.)High
CAP_SYS_PTRACETrace/debug processesHigh
CAP_SYS_MODULELoad kernel modulesCritical
CAP_SYS_RAWIODirect I/O port accessCritical
CAP_DAC_OVERRIDEBypass file permission checksMedium
CAP_FOWNERBypass ownership checksMedium
CAP_SETUIDChange UIDMedium
CAP_CHOWNChange file ownershipLow
CAP_KILLSend signals to any processMedium
CAP_MKNODCreate device filesMedium

Dropping and Adding Capabilities

# Drop all, add only what's needed
docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE nginx

# Podman
podman run --cap-drop=ALL --cap-add=NET_BIND_SERVICE nginx

# Kubernetes
apiVersion: v1
kind: Pod
spec:
  containers:
  - name: app
    securityContext:
      capabilities:
        drop:
          - ALL
        add:
          - NET_BIND_SERVICE
      allowPrivilegeEscalation: false

Default Capabilities

Docker/Podman grant these by default (should be dropped when not needed):

CAP_CHOWN
CAP_DAC_OVERRIDE
CAP_FSETID
CAP_FOWNER
CAP_MKNOD
CAP_NET_RAW
CAP_SETGID
CAP_SETUID
CAP_SETFCAP
CAP_SETPCAP
CAP_NET_BIND_SERVICE
CAP_SYS_CHROOT
CAP_KILL
CAP_AUDIT_WRITE

Checking Container Capabilities

# Show capabilities of a running container
docker inspect --format '{{.HostConfig.CapAdd}}' <container>
docker inspect --format '{{.HostConfig.CapDrop}}' <container>

# Inside the container
cat /proc/1/status | grep Cap
# CapEff: 00000000a80425fb
# CapPrm: 00000000a80425fb

# Decode capabilities
capsh --decode=00000000a80425fb
# 0x00000000a80425fb=cap_chown,cap_dac_override,cap_fowner,cap_fsetid,
# cap_kill,cap_setgid,cap_setuid,cap_setpcap,cap_net_bind_service,
# cap_net_raw,cap_sys_chroot,cap_mknod,cap_audit_write,cap_setfcap

Read-Only Root Filesystem

A read-only rootfs prevents attackers from modifying binaries, installing tools, or establishing persistence inside the container.

# Docker / Podman
docker run --read-only --tmpfs /tmp:rw,noexec,nosuid \
    --tmpfs /var/run:rw,noexec,nosuid \
    nginx

# With specific writable volumes
docker run --read-only \
    --tmpfs /tmp:rw,size=100m \
    -v app-data:/var/lib/app:rw \
    myapp:latest

# Kubernetes
apiVersion: v1
kind: Pod
spec:
  containers:
  - name: app
    securityContext:
      readOnlyRootFilesystem: true
    volumeMounts:
    - name: tmp
      mountPath: /tmp
    - name: app-data
      mountPath: /var/lib/app
  volumes:
  - name: tmp
    emptyDir:
      medium: Memory
      sizeLimit: 100Mi
  - name: app-data
    persistentVolumeClaim:
      claimName: app-data-pvc

No New Privileges

Prevents setuid binaries from gaining elevated privileges:

# Docker / Podman
docker run --security-opt no-new-privileges:true myapp

# Kubernetes
spec:
  containers:
  - name: app
    securityContext:
      allowPrivilegeEscalation: false

Image Scanning

Scanning container images for known vulnerabilities (CVEs) is a critical pre-deployment security measure.

Trivy

# Install Trivy
sudo apt install trivy   # or
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin

# Scan an image
trivy image nginx:latest
# nginx:latest (debian 12.4)
# ===========================
# Total: 142 (UNKNOWN: 0, LOW: 85, MEDIUM: 42, HIGH: 13, CRITICAL: 2)
#
# ┌──────────────┬────────────────┬──────────┬─────────────────┐
# │   Library    │ Vulnerability  │ Severity │  Fixed Version  │
# ├──────────────┼────────────────┼──────────┼─────────────────┤
# │ libssl3      │ CVE-2024-0727  │ MEDIUM   │ 3.0.13-1~deb12u │
# └──────────────┴────────────────┴──────────┴─────────────────┘

# Scan with severity filter
trivy image --severity HIGH,CRITICAL nginx:latest

# Scan and output JSON
trivy image --format json --output results.json nginx:latest

# Scan in CI (fail on critical)
trivy image --exit-code 1 --severity CRITICAL myapp:latest

# Scan filesystem / Containerfile
trivy fs .
trivy config .

Grype

# Install
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin

# Scan image
grype nginx:latest

# Output in table format
grype nginx:latest -o table

# JSON output
grype nginx:latest -o json > results.json

Docker Scout

# Built into Docker Desktop / Docker CLI
docker scout cves nginx:latest
docker scout recommendations nginx:latest

Snyk

# Install
npm install -g snyk

# Authenticate
snyk auth

# Scan image
snyk container test nginx:latest

SBOM (Software Bill of Materials)

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

# Scan SBOM with Grype
grype sbom:sbom.spdx.json

# Generate SBOM with Docker
docker sbom nginx:latest

Comprehensive Security Example

A production-hardened container configuration:

# Kubernetes Pod with full security hardening
apiVersion: v1
kind: Pod
metadata:
  name: hardened-app
  labels:
    app: hardened
spec:
  automountServiceAccountToken: false
  securityContext:
    runAsNonRoot: true
    runAsUser: 65534        # nobody
    runAsGroup: 65534
    fsGroup: 65534
    seccompProfile:
      type: Localhost
      localhostProfile: profiles/strict.json
  containers:
  - name: app
    image: myregistry.io/app@sha256:abc123  # Pin by digest
    securityContext:
      allowPrivilegeEscalation: false
      readOnlyRootFilesystem: true
      capabilities:
        drop:
          - ALL
        add:
          - NET_BIND_SERVICE
      seLinuxOptions:
        type: spc_t
    resources:
      limits:
        memory: "256Mi"
        cpu: "500m"
      requests:
        memory: "128Mi"
        cpu: "250m"
    volumeMounts:
    - name: tmp
      mountPath: /tmp
    - name: app-data
      mountPath: /var/lib/app
    livenessProbe:
      httpGet:
        path: /healthz
        port: 8080
      initialDelaySeconds: 5
      periodSeconds: 10
    readinessProbe:
      httpGet:
        path: /ready
        port: 8080
  volumes:
  - name: tmp
    emptyDir:
      medium: Memory
      sizeLimit: 50Mi
  - name: app-data
    persistentVolumeClaim:
      claimName: app-data

Security Checklist

CategoryCheckStatus
ImageScan for CVEs before deployment
ImageUse minimal base images (distroless, alpine)
ImagePin by digest, not tag
RuntimeRun as non-root user
RuntimeRead-only rootfs
RuntimeDrop all capabilities
RuntimeCustom seccomp profile
RuntimeAppArmor/SELinux enforced
RuntimeNo privilege escalation
RuntimeResource limits set (CPU, memory)
NetworkNetwork policies applied
SecretsNo secrets in environment variables
SecretsUse Kubernetes secrets or vault
Supply chainSBOM generated and signed

References