# Dockerfile Security Checklist

Reference checklist for `audit-dockerfile`. Fail examples are drawn from
`gemma-airflow/Dockerfile (internal repo)` (real insecure production image).
Pass patterns reference the production-learnings branch approach:
`FROM apache/airflow:3.0.3` + `COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/`.

---

## Check: Secrets in ARG/ENV

- **Priority**: Critical
- **What to look for**: `ARG` declarations for sensitive values (passwords, keys, tokens,
  connection strings) that are then assigned to `ENV` variables. `ENV` values are baked
  permanently into every image layer and are visible via `docker inspect`, `docker
  history`, and any registry that stores the image.
- **Pass condition**: No `ARG` or `ENV` instruction holds a secret value. Secrets are
  injected at runtime via environment variables, Docker Compose secrets files, or a
  secrets manager (1Password, AWS Secrets Manager). The Dockerfile itself contains no
  sensitive data.
- **Fail example** (from `gemma-airflow/Dockerfile`):
  ```dockerfile
  ARG fernet_key
  ARG sql_alchemy_conn
  ARG webserver_secret
  ARG smtp_app_password

  ENV AIRFLOW__CORE__FERNET_KEY=${fernet_key}
  ENV AIRFLOW__CORE__SQL_ALCHEMY_CONN=${sql_alchemy_conn}
  ENV AIRFLOW__WEBSERVER__SECRET_KEY=${webserver_secret}
  ENV AIRFLOW__SMTP__SMTP_PASSWORD=${smtp_app_password}
  # All four secrets are now permanently baked into the image layers.
  # docker history myimage --no-trunc reveals every ENV instruction.
  ```
- **Remediation**:
  ```dockerfile
  # Remove all ARG/ENV for secrets from the Dockerfile entirely.
  # Pass them at runtime:

  # docker-compose.yaml
  services:
    airflow:
      env_file: .env          # or use Docker Compose secrets
      environment:
        # Only non-sensitive config here
        AIRFLOW__CORE__EXECUTOR: LocalExecutor
  ```
- **Source**: Real finding from `gemma-airflow/Dockerfile`; Security report §05 "Secrets:
  Env Vars vs Compose Secrets"

---

## Check: Hardcoded Credentials in Plaintext

- **Priority**: Critical
- **What to look for**: Literal credential values written directly into `ENV`, `RUN`, or
  `COPY` instructions — API keys, IAM access key IDs, passwords, tokens, S3 paths that
  encode account IDs, or user account details that should not be in version control.
- **Pass condition**: No instruction in the Dockerfile contains a recognizable credential
  pattern (AWS key IDs matching `AKIA[A-Z0-9]{16}`, passwords, email addresses used as
  usernames, etc.).
- **Fail example** (from `gemma-airflow/Dockerfile`):
  ```dockerfile
  # Hardcoded AWS SMTP IAM key ID — rotatable but should not be in source
  ENV AIRFLOW__SMTP__SMTP_USER=AKIAIOSFODNN7EXAMPLE

  # Hardcoded user identity baked into image
  ENV EWAH_AIRFLOW_USER_USER=admin
  ENV EWAH_AIRFLOW_USER_EMAIL=admin@example.com

  # Hardcoded S3 path encoding account/environment info
  ENV AIRFLOW__LOGGING__REMOTE_BASE_LOG_FOLDER=s3://example-airflow-logs/production

  # Hardcoded public hostname
  ENV AIRFLOW__WEBSERVER__BASE_URL="https://airflow.example.com"
  ```
- **Remediation**: Move all environment-specific and account-specific values out of the
  Dockerfile into runtime configuration:
  ```dockerfile
  # Dockerfile: only structural config
  FROM apache/airflow:3.0.3

  # docker-compose.yaml / .env (not committed):
  # AIRFLOW__SMTP__SMTP_USER=AKIAIOSFODNN7EXAMPLE
  # AIRFLOW__WEBSERVER__BASE_URL=https://...
  ```
- **Source**: Real finding from `gemma-airflow/Dockerfile`

---

## Check: Sensitive Files COPYed into Image

- **Priority**: Critical
- **What to look for**: `COPY` or `ADD` instructions that pull in files containing secrets
  or that use broad globs (e.g. `COPY . .`) without a `.dockerignore`. Even if the file is
  deleted in a later `RUN` step, it persists in the layer history and is recoverable.
- **Pass condition**: Only the minimal set of application code is COPYed. A `.dockerignore`
  file exists and excludes `.env`, `*.key`, `*.pem`, `secrets/`, `*.tfvars`, and other
  sensitive paths. Broad `COPY . .` is acceptable only when `.dockerignore` explicitly
  covers all sensitive paths.
- **Fail example**:
  ```dockerfile
  COPY . /app       # no .dockerignore — may include .env, .dlt/secrets/, etc.

  # Or: copying then deleting (secret still in layer history)
  COPY secrets/fernet.key /tmp/fernet.key
  RUN configure.sh /tmp/fernet.key
  RUN rm /tmp/fernet.key    # too late — key is in the previous layer
  ```
- **Remediation**:
  ```dockerfile
  # Use BuildKit secret mounts for build-time secrets (never touch layers)
  RUN --mount=type=secret,id=fernet_key configure.sh $(cat /run/secrets/fernet_key)

  # Ensure .dockerignore exists:
  # .env
  # .env.*
  # secrets/
  # *.key
  # *.pem
  # .dlt/
  ```
- **Source**: Real risk pattern; Security report §05 "Secrets" guidance

---

## Check: Base Image SHA Pinning

- **Priority**: Important
- **What to look for**: `FROM` instructions that use a mutable tag (`latest`, a version
  number) without a `@sha256:` digest. The base image can be updated transparently,
  introducing unreviewed changes or vulnerabilities.
- **Pass condition**: The `FROM` instruction includes a digest pin, e.g.
  `FROM apache/airflow:3.0.3@sha256:<digest>`. For multi-stage builds, every `FROM`
  line is pinned.
- **Fail example** (from `gemma-airflow/Dockerfile`):
  ```dockerfile
  FROM gemmaanalytics/ewah:v0.9.1 AS dev_build
  # No @sha256: pin — tag v0.9.1 could be force-pushed with different content
  ```
- **Remediation**:
  ```dockerfile
  # Get digest:
  # docker inspect --format='{{index .RepoDigests 0}}' apache/airflow:3.0.3

  FROM apache/airflow:3.0.3@sha256:<digest>
  COPY --from=ghcr.io/astral-sh/uv:latest@sha256:<digest> /uv /uvx /bin/
  ```
- **Source**: Security report §05 hardened template + "Image Security Pipeline"; clean
  example from production-learnings branch

---

## Check: USER Directive — Non-Root Runtime

- **Priority**: Important
- **What to look for**: Dockerfiles that never set a `USER` instruction. When `USER` is
  absent the container runs as root (UID 0), so any process escape immediately grants
  root on the host's user namespace.
- **Pass condition**: The Dockerfile sets `USER` to a non-root user before the final
  `CMD`/`ENTRYPOINT`. For images that need root during build (e.g. to install packages),
  add a `USER` instruction at the end of the build stage.
- **Fail example** (from `gemma-airflow/Dockerfile`):
  ```dockerfile
  FROM gemmaanalytics/ewah:v0.9.1 AS dev_build
  RUN pip install --upgrade scipy "numpy<2.0.0"
  # ... no USER instruction anywhere
  # Container runs as whatever user the base image defaults to (often root)
  ```
- **Remediation**:
  ```dockerfile
  FROM apache/airflow:3.0.3
  # ... build steps ...
  USER airflow    # or USER 50000 — use numeric UID for robustness
  ```
  The official `apache/airflow` image already sets `USER airflow`; verify this is not
  overridden in derived images.
- **Source**: Security report §05 lead ("Docker containers run as root by default");
  real finding from `gemma-airflow/Dockerfile`

---

## Check: Multi-Stage Build Secret Leakage

- **Priority**: Important
- **What to look for**: Multi-stage builds where secrets, credentials, or sensitive env
  vars set in an early stage are referenced in a later `FROM ... AS` stage via `--from=`,
  or where the final production stage copies artifacts from a stage that had secrets baked
  into it via `ENV`.
- **Pass condition**: Secrets exist only in intermediate stages and are consumed by `RUN`
  commands only. The final production stage (`FROM ... AS prod`) contains no `ENV`
  instructions for secrets and only copies compiled artifacts or application code — never
  a full layer tree from a stage that held secrets.
- **Fail example** (from `gemma-airflow/Dockerfile`):
  ```dockerfile
  FROM gemmaanalytics/ewah:v0.9.1 AS dev_build
  RUN pip install ...

  # Production stage copies FROM dev_build — which has all the secret ENV vars
  FROM dev_build AS prod_build
  ARG fernet_key
  ENV AIRFLOW__CORE__FERNET_KEY=${fernet_key}
  # The entire dev_build layer tree (including any cached secrets) is present
  # in prod_build because it was used as the base.
  ```
- **Remediation**:
  ```dockerfile
  # Build stage: install deps
  FROM python:3.12-slim AS builder
  COPY requirements.txt .
  RUN pip install --no-cache-dir -r requirements.txt

  # Runtime stage: start clean from a minimal base
  FROM apache/airflow:3.0.3
  COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
  COPY dags /opt/airflow/dags
  # No secrets here — inject at runtime
  ```
- **Source**: Real finding from `gemma-airflow/Dockerfile`

---

## Check: .dockerignore Existence and Coverage

- **Priority**: Important
- **What to look for**: Absence of a `.dockerignore` file, or a `.dockerignore` that does
  not exclude common sensitive paths. Without it, `COPY . .` pulls in `.env` files, local
  secret stores, git history, and development credentials.
- **Pass condition**: A `.dockerignore` file exists alongside the Dockerfile and excludes
  at minimum: `.env`, `.env.*`, `secrets/`, `.dlt/`, `*.key`, `*.pem`, `*.p12`,
  `.git/`, `__pycache__/`, and any test fixtures containing sample credentials.
- **Fail example**:
  ```
  # .dockerignore missing entirely, or present but only containing:
  __pycache__
  *.pyc
  # .env, secrets/, .dlt/ not excluded
  ```
- **Remediation**:
  ```
  # .dockerignore
  .git
  .env
  .env.*
  secrets/
  .dlt/
  *.key
  *.pem
  *.p12
  __pycache__
  *.pyc
  .pytest_cache
  .coverage
  node_modules
  ```
- **Source**: General Dockerfile security best practice; real risk from `gemma-airflow`
  pattern (no `.dockerignore` present in that repo)

---

## Check: pip vs Managed Package Installation

- **Priority**: Recommended
- **What to look for**: `RUN pip install` or `RUN pip3 install` in Dockerfiles when the
  project convention is to use `uv`. `pip` without a lockfile is non-deterministic across
  builds; `uv` with `uv.lock` produces reproducible, faster installs.
- **Pass condition**: Package installation uses `uv` via the official uv binary copied
  from its distroless image. The `uv.lock` file is committed and used during the build
  for reproducible installs.
- **Fail example** (from `gemma-airflow/Dockerfile`):
  ```dockerfile
  FROM gemmaanalytics/ewah:v0.9.1 AS dev_build
  RUN pip install --upgrade scipy "numpy<2.0.0"
  # Uses pip directly — no lockfile, non-deterministic resolution
  ```
- **Remediation**:
  ```dockerfile
  FROM apache/airflow:3.0.3
  COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/

  COPY uv.lock pyproject.toml ./
  RUN uv sync --frozen --no-dev
  ```
- **Source**: Real finding from `gemma-airflow/Dockerfile`; global CLAUDE.md convention
  ("Always use uv for running any Python code"); production-learnings branch clean example

---

## Check: Unnecessary Packages and Tools

- **Priority**: Recommended
- **What to look for**: `RUN apt-get install` (or similar) that installs debugging tools
  (`curl`, `wget`, `netcat`, `nmap`, `strace`, `gdb`) or package managers into the final
  production image. These expand the attack surface and can be used by an attacker who
  gains code execution.
- **Pass condition**: The production stage installs only packages required for the
  application to run. Build-time tools (compilers, header files, `git`) live only in a
  build stage and are not present in the final image. Use `--no-install-recommends` for
  apt installs.
- **Fail example**:
  ```dockerfile
  RUN apt-get install -y curl wget git vim procps \
      build-essential python3-dev   # build tools in production image
  ```
- **Remediation**:
  ```dockerfile
  # Build stage: install build deps
  FROM python:3.12-slim AS builder
  RUN apt-get install -y --no-install-recommends build-essential python3-dev
  RUN pip install ...

  # Runtime stage: copy only artifacts, no build tools
  FROM python:3.12-slim
  COPY --from=builder /app /app
  # curl/wget intentionally absent — use healthcheck binary or wget in minimal form
  ```
- **Source**: Security report §05 "Base image priority: Chainguard > Distroless > Alpine
  > Debian/Ubuntu"; general container security best practice
