# Docker Compose Security Checklist

Reference checklist for `audit-docker-compose`. Each check maps to a concrete pattern the
auditor should detect. Fail examples are drawn from
`/home/lui/projects/internal/airflow3-demo/docker-compose.yaml`.

---

## Check: Port Bindings — Localhost Only

- **Priority**: Critical
- **What to look for**: Any `ports:` entry that omits a bind address or uses `0.0.0.0`.
  Docker bypasses UFW/iptables INPUT rules by inserting NAT rules directly, so a port
  published to `0.0.0.0` is reachable from the internet even when UFW denies it.
- **Pass condition**: All port bindings use an explicit `127.0.0.1:` prefix, e.g.
  `"127.0.0.1:8080:8080"`. The reverse proxy (Caddy/Nginx) is the only service that
  should bind to `0.0.0.0` on 80/443.
- **Fail example**:
  ```yaml
  ports:
    - "8080:8080"       # binds to 0.0.0.0 — exposed to internet
    - "5432:5432"       # database port exposed directly to internet
  ```
- **Remediation**:
  ```yaml
  # Before
  ports:
    - "8080:8080"

  # After
  ports:
    - "127.0.0.1:8080:8080"
  ```
- **Source**: Security report §05 ("Bind all Docker ports to 127.0.0.1"); §05 callout
  "Docker Bypasses UFW"

---

## Check: Internal Networks for Databases

- **Priority**: Critical
- **What to look for**: Database services (postgres, mysql, redis, etc.) that share a
  network with internet-facing services, or that are on the default bridge network without
  `internal: true`.
- **Pass condition**: Databases are on a dedicated network declared with `internal: true`
  so no container on that network can initiate outbound connections. Application services
  join both the backend and db networks; the reverse proxy joins only the frontend/backend
  network.
- **Fail example** (from `airflow3-demo/docker-compose.yaml`):
  ```yaml
  # postgres service has no networks: key — it lands on the default bridge
  # alongside the internet-facing airflow-apiserver on the same flat network
  services:
    postgres:
      image: postgres:13
      # no networks: key — shares default bridge with all other services
    airflow-apiserver:
      ports:
        - "8080:8080"
      # also on default bridge — can reach postgres directly
  ```
- **Remediation**:
  ```yaml
  services:
    postgres:
      networks:
        - db
    airflow-apiserver:
      networks:
        - backend
        - db        # needs DB access
      ports:
        - "127.0.0.1:8080:8080"

  networks:
    backend:
      driver: bridge
    db:
      driver: bridge
      internal: true   # no internet access from DB network
  ```
- **Source**: Security report §05 "Network Segmentation" + hardened template

---

## Check: Non-Root User Directive

- **Priority**: Important
- **What to look for**: Services that omit `user:` entirely (run as root inside the
  container), or that explicitly set `user: "0"` or `user: "0:0"` outside of init
  containers.
- **Pass condition**: Every long-running service sets `user:` to a non-root UID, e.g.
  `user: "1001"` or `user: "${AIRFLOW_UID:-50000}:0"`. Init-only containers (like
  `airflow-init`) may run as root transiently but should not be long-running services.
- **Fail example** (from `airflow3-demo/docker-compose.yaml`):
  ```yaml
  airflow-init:
    user: "0:0"    # root — acceptable only for a one-shot init container
  # but if a long-running service had this it would be a fail
  ```
- **Remediation**:
  ```yaml
  # Before (missing user key — runs as image default, often root)
  services:
    app:
      image: myapp:latest

  # After
  services:
    app:
      image: myapp:latest
      user: "1001"
  ```
- **Source**: Security report §05 hardened template; §05 lead ("Docker containers run as
  root by default")

---

## Check: Capability Drop — cap_drop ALL

- **Priority**: Important
- **What to look for**: Services that have no `cap_drop:` key, or that do not include
  `ALL` in the drop list. Linux capabilities allow privilege escalation even when not
  running as root.
- **Pass condition**: Every service either sets `cap_drop: [ALL]` (adding back only the
  capabilities it genuinely needs via `cap_add:`), **or** keeps default capabilities with
  a documented reason because its entrypoint requires them (see "gosu/su-exec images"
  below). A service that keeps default caps *without* a documented reason is a fail.
- **Fail example**:
  ```yaml
  services:
    app:
      image: myapp:latest
      # no cap_drop — container keeps all default capabilities including
      # CAP_NET_RAW, CAP_SYS_PTRACE, CAP_SETUID, CAP_SETGID etc.
  ```
- **Remediation**:
  ```yaml
  # After
  services:
    app:
      cap_drop:
        - ALL
      cap_add:
        - NET_BIND_SERVICE   # only if binding port < 1024
  ```

> **⚠️ `cap_drop: [ALL]` is not a safe drop-in for every service.** A blanket drop will
> break any container that relies on default capabilities at startup. Do not recommend it
> without identifying what each service actually needs, and **verify by starting the stack**
> (init containers must exit 0; gosu/su-exec images must reach `healthy`) — static
> inspection alone will not catch these failures. Common cases:
>
> | Service pattern | Why a blanket drop breaks it | Minimal `cap_add` to restore |
> |---|---|---|
> | Root (`user: "0:0"`) **init** container that `chown`s a mounted volume | `chown` needs `CAP_CHOWN`; traversing/writing foreign-owned paths needs `DAC_OVERRIDE`/`FOWNER` | `CHOWN`, `DAC_OVERRIDE`, `FOWNER` |
> | Root **sidecar** that **reads** mode-restricted mounted files (e.g. a VPN reading host-owned mode-600 keys) | root bypasses file perms only via a DAC capability; dropping it yields `Permission denied (errno=13)` | `DAC_READ_SEARCH` (read-only; narrower than `DAC_OVERRIDE`) plus any function cap such as `NET_ADMIN` |
> | Official image that **`gosu`/`su-exec`-drops** from root (postgres, redis, mariadb, …) | the entrypoint `chown`s its data dir and switches user, needing setuid/chown caps | keep default caps (documented), or restore `SETUID`, `SETGID`, `CHOWN`, `DAC_OVERRIDE`, `FOWNER` |
>
> Prefer the **narrowest** capability that works: `DAC_READ_SEARCH` (read + dir-search
> bypass only) over `DAC_OVERRIDE` (also bypasses write/exec) when the process only reads.

- **Source**: Security report §05 hardened template; field validation on
  `saxonia-data-orchestration` (PR #39 — blanket drop broke the VPN sidecar and would have
  broken the postgres entrypoint)

---

## Check: Secrets vs Environment Variables

- **Priority**: Important
- **What to look for**: Sensitive values (passwords, fernet keys, API tokens, connection
  strings) passed via `environment:` blocks or `.env` files. These values are visible
  in `docker inspect` output and in `/proc/<pid>/environ` on the host.
- **Pass condition**: Sensitive values are mounted via Docker Compose `secrets:` (which
  appear as files under `/run/secrets/` inside the container and do not show up in
  `docker inspect`). Non-sensitive config (feature flags, log levels, etc.) may remain
  as env vars.
- **Fail example** (from `airflow3-demo/docker-compose.yaml`):
  ```yaml
  environment:
    AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://airflow:airflow@postgres/airflow
    # ^^ full DB connection string with credentials in plain env var
  services:
    postgres:
      environment:
        POSTGRES_PASSWORD: airflow   # hardcoded password in env var
  ```
- **Remediation**:
  ```yaml
  services:
    postgres:
      secrets:
        - db_password
      environment:
        POSTGRES_PASSWORD_FILE: /run/secrets/db_password   # reads from file

  secrets:
    db_password:
      file: ./secrets/db_password.txt   # or use external: true for Docker Swarm
  ```
- **Source**: Security report §05 "Secrets: Env Vars vs Compose Secrets" table

---

## Check: Docker Socket Mount Exposure

- **Priority**: Important
- **What to look for**: Any volume mount of `/var/run/docker.sock` into a container.
  This grants the container full control over the Docker daemon — equivalent to root on
  the host.
- **Pass condition**: No service mounts the Docker socket unless it is an explicitly
  justified architectural requirement (e.g. a Docker-in-Docker CI runner). If Docker
  socket access is required for `DockerOperator`-style use cases, use a dedicated proxy
  like `alpine/socat` bound to localhost only, and restrict its exposure.
- **Fail example** (from `airflow3-demo/docker-compose.yaml`):
  ```yaml
  x-airflow-common:
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      # ^^ all Airflow containers (scheduler, triggerer, dag-processor, etc.)
      # get unrestricted Docker daemon access
  ```
- **Remediation**: If Docker socket access is unavoidable, isolate it behind a proxy:
  ```yaml
  # Use a socat proxy instead of direct socket mount
  docker-proxy:
    image: alpine/socat
    command: "TCP4-LISTEN:2375,fork,reuseaddr UNIX-CONNECT:/var/run/docker.sock"
    ports:
      - "127.0.0.1:2376:2375"   # bind to localhost only
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
  # Then configure DOCKER_HOST=tcp://docker-proxy:2375 in airflow services
  # (note: airflow3-demo already does this for the proxy itself, but the direct
  #  socket mount in x-airflow-common should still be removed)
  ```
- **Source**: Security report §05; real finding from `airflow3-demo/docker-compose.yaml`

---

## Check: Image SHA Pinning

- **Priority**: Recommended
- **What to look for**: Image references that use mutable tags (`latest`, a version tag
  like `:13`, `:3.0.3`) without a `@sha256:` digest. Mutable tags can be silently
  replaced with a compromised image.
- **Pass condition**: All images include a `@sha256:<digest>` pin, e.g.
  `postgres:16@sha256:abcdef...`. The version tag is kept for readability; the digest
  enforces the exact layer set.
- **Fail example** (from `airflow3-demo/docker-compose.yaml`):
  ```yaml
  postgres:
    image: postgres:13          # mutable — tag can be repointed

  x-airflow-common:
    image: ${AIRFLOW_IMAGE_NAME:-apache/airflow:3.0.3}   # no digest pin
  ```
- **Remediation**:
  ```yaml
  # Get digest: docker inspect --format='{{index .RepoDigests 0}}' postgres:13
  postgres:
    image: postgres:13@sha256:<digest>

  # For CI/CD, generate digests during build and write them to compose.override.yml
  ```
- **Source**: Security report §05 hardened template; §05 "Image Security Pipeline"

---

## Check: Resource Limits

- **Priority**: Recommended
- **What to look for**: Services without `deploy.resources.limits` set (or without the
  older `mem_limit`/`cpus` top-level keys). Without limits a single runaway container
  can exhaust host resources, taking down all other services.
- **Pass condition**: Every production service sets at least `memory` and `cpus` limits,
  and ideally `pids` to prevent fork-bomb style attacks.
- **Fail example** (from `airflow3-demo/docker-compose.yaml`):
  ```yaml
  airflow-scheduler:
    # no deploy.resources.limits — can consume all host CPU/memory/pids
  ```
- **Remediation**:
  ```yaml
  services:
    airflow-scheduler:
      deploy:
        resources:
          limits:
            cpus: "1.0"
            memory: 1G
            pids: 500
  ```
- **Source**: Security report §05 hardened template

---

## Check: Log Rotation Configuration

- **Priority**: Recommended
- **What to look for**: Services using the default `json-file` logging driver without
  size/file limits. Unbounded logs can fill the host disk and cause the Docker daemon to
  stop accepting new containers.
- **Pass condition**: Every service either sets `logging.options.max-size` and
  `logging.options.max-file`, or uses an external log driver (e.g. `loki`, `splunk`,
  `awslogs`) that handles rotation externally.
- **Fail example** (from `airflow3-demo/docker-compose.yaml`):
  ```yaml
  airflow-apiserver:
    # no logging: key — uses json-file driver with unlimited size
  ```
- **Remediation**:
  ```yaml
  services:
    airflow-apiserver:
      logging:
        driver: json-file
        options:
          max-size: "10m"
          max-file: "3"
  ```
- **Source**: Security report §05 hardened template

---

## Check: Read-Only Filesystem + tmpfs

- **Priority**: Recommended
- **What to look for**: Services without `read_only: true`. A writable container
  filesystem makes it easier for an attacker to install tools, modify binaries, or
  persist changes after a container escape.
- **Pass condition**: Services set `read_only: true` and use `tmpfs:` mounts for any
  paths that legitimately need write access (e.g. `/tmp`, `/run`).
- **Fail example**:
  ```yaml
  services:
    app:
      image: myapp:latest
      # no read_only — entire container filesystem is writable
  ```
- **Remediation**:
  ```yaml
  services:
    app:
      read_only: true
      tmpfs:
        - /tmp
        - /run
  ```
- **Source**: Security report §05 hardened template

---

## Check: no-new-privileges Security Option

- **Priority**: Recommended
- **What to look for**: Services that do not set `security_opt: [no-new-privileges:true]`.
  Without this, a process inside the container can use setuid/setgid binaries to gain
  capabilities beyond what was granted at container start.
- **Pass condition**: All services include `security_opt: [no-new-privileges:true]` (or
  equivalent). This is a low-cost, high-value hardening step.
- **Fail example**:
  ```yaml
  services:
    app:
      image: myapp:latest
      # no security_opt — setuid binaries inside can escalate privileges
  ```
- **Remediation**:
  ```yaml
  services:
    app:
      security_opt:
        - no-new-privileges:true
  ```
- **Source**: Security report §05 hardened template

---

## Check: Health Check Configuration

- **Priority**: Recommended
- **What to look for**: Services without a `healthcheck:` block. Without health checks,
  Docker treats a container as healthy as soon as it starts, which can cause dependent
  services to receive requests before the application is ready, and makes it impossible
  to detect silent failures automatically.
- **Pass condition**: Every service that accepts connections defines a meaningful
  `healthcheck:` with `test`, `interval`, `timeout`, `retries`, and `start_period`.
  The test should exercise the actual application endpoint, not just check if the
  process is running.
- **Fail example**:
  ```yaml
  services:
    postgres:
      image: postgres:13
      # no healthcheck — airflow-init may start before postgres is ready
      # (airflow3-demo actually has a healthcheck here, which is correct)
  ```
- **Remediation**:
  ```yaml
  services:
    app:
      healthcheck:
        test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
        interval: 30s
        timeout: 10s
        retries: 3
        start_period: 40s
  ```
  Use `depends_on: condition: service_healthy` in dependent services.
- **Source**: Security report §05 hardened template; `airflow3-demo` shows good healthcheck
  patterns on `airflow-apiserver`, `airflow-scheduler`, etc.

---

## Check: Database and broker have no published ports

- **Priority**: Critical
- **What to look for**: Check `postgres`, `mysql`, `redis`, and `rabbitmq` service definitions. These should have **no `ports:` entry at all** in production — they are only accessed over Docker internal networks. A published database port exposes the service to the internet regardless of firewall rules (see Docker-bypasses-UFW issue).
- **Pass condition**: No `ports:` entry on database or message broker services. They communicate via Docker network DNS only (e.g., `postgres:5432`).
- **Fail example**:
  ```yaml
  services:
    postgres:
      image: postgres:17
      ports:
        - "5432:5432"   # CRITICAL: database exposed to internet
    redis:
      image: redis:7
      ports:
        - "6379:6379"   # CRITICAL: Celery broker exposed
  ```
- **Remediation**:
  ```yaml
  services:
    postgres:
      image: postgres:17
      # No ports: entry — accessible only via Docker network as "postgres:5432"
      networks:
        - db
    redis:
      image: redis:7
      # No ports: entry
      networks:
        - backend
  networks:
    db:
      internal: true
  ```
- **Source**: Security report §05; research findings agent 2; Docker bypasses UFW principle

---

## Check: Database connection uses SSL (Airflow)

- **Priority**: Recommended
- **What to look for**: Check `AIRFLOW__DATABASE__SQL_ALCHEMY_CONN` (Airflow 3) or `AIRFLOW__CORE__SQL_ALCHEMY_CONN` (Airflow 2) for `?sslmode=` parameter. For Ansible deployments, check the `.env.j2` template. Without SSL, the Airflow metadata DB connection (including credentials) is transmitted in plaintext over the Docker network.
- **Pass condition**: Connection string includes `?sslmode=require` or `?sslmode=verify-full`.
- **Fail example**:
  ```yaml
  # No SSL — credentials transmitted in plaintext on Docker network
  AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://airflow:password@postgres:5432/airflow
  ```
- **Remediation**:
  ```yaml
  # After — with SSL
  AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://airflow:password@postgres:5432/airflow?sslmode=require
  ```
  Note: requires PostgreSQL to be configured with SSL certificates. For internal Docker networks this is Recommended rather than Critical, but Required if the DB is on a separate host or accessible outside the container network.
- **Source**: Airflow security hardening research; agent 2 findings

