# Airflow Security Checklist

Reference patterns drawn from:
- `gemma-airflow/Dockerfile` (BAD: fernet key + webserver secret baked into image via ARG→ENV in `prod_build` stage; hardcoded SMTP IAM key; hardcoded admin user details)
- `airflow3-demo/docker-compose.yaml` (BAD: default `airflow`/`airflow` credentials, port `8080:8080` not bound to localhost, Docker socket mounted on all containers)
- Airflow security documentation and deployment best practices

---

## Check: Fernet key not hardcoded in Dockerfile, compose, or config
- **Priority**: Critical
- **What to look for**: Search for `AIRFLOW__CORE__FERNET_KEY` in Dockerfiles, docker-compose files, `.env` files committed to Git, `airflow.cfg`, and CI/CD workflow files. Also search for `fernet_key` as a build ARG that gets promoted to ENV.
- **Pass condition**: `AIRFLOW__CORE__FERNET_KEY` is read from a secret manager (1Password, AWS Secrets Manager, Vault) at deploy time and written to a `.env` file with mode 0600. It never appears in source-controlled files, image layers, or CI logs.
- **Fail example**:
  ```dockerfile
  # Dockerfile (CRITICAL: fernet key baked into image layer)
  ARG fernet_key
  ENV AIRFLOW__CORE__FERNET_KEY=${fernet_key}
  ```
  ```yaml
  # docker-compose.yaml (CRITICAL: hardcoded fernet key)
  environment:
    AIRFLOW__CORE__FERNET_KEY: "wC3DN6MfXP1abcdefghijklmnopqrstu="
  ```
- **Remediation**:
  ```dockerfile
  # Before (insecure — baked into image via build arg)
  ARG fernet_key
  ENV AIRFLOW__CORE__FERNET_KEY=${fernet_key}

  # After (secure — inject at runtime via .env file, never in image)
  # Remove fernet_key ARG and ENV from Dockerfile entirely.
  # Fetch from 1Password at deploy time and write to .env (mode 0600):
  #   AIRFLOW__CORE__FERNET_KEY=<value from op read>
  ```
  ```yaml
  # docker-compose.yaml — reference .env file, never inline secrets
  env_file: .env  # Contains AIRFLOW__CORE__FERNET_KEY, mode 0600, not in Git
  ```
  Generate a new fernet key with: `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"`. Store in 1Password or equivalent vault.
- **Source**: `gemma-airflow/Dockerfile` lines 16–21; Airflow docs: Fernet key management

---

## Check: Webserver secret key not hardcoded
- **Priority**: Critical
- **What to look for**: Search for `AIRFLOW__WEBSERVER__SECRET_KEY` in Dockerfiles, compose files, `.env` committed to Git, and `airflow.cfg`. The webserver secret key signs session cookies — if compromised, an attacker can forge authenticated sessions.
- **Pass condition**: `AIRFLOW__WEBSERVER__SECRET_KEY` is fetched from a secret manager at deploy time and injected via a `.env` file with mode 0600. It is unique per environment and not the default value.
- **Fail example**:
  ```dockerfile
  # CRITICAL: webserver secret key in image layer
  ARG webserver_secret
  ENV AIRFLOW__WEBSERVER__SECRET_KEY=${webserver_secret}
  ```
- **Remediation**:
  ```dockerfile
  # Before (insecure — secret baked into Docker image)
  ARG webserver_secret
  ENV AIRFLOW__WEBSERVER__SECRET_KEY=${webserver_secret}

  # After (secure — remove from Dockerfile, inject via .env at runtime)
  # .env file (mode 0600, not in Git):
  # AIRFLOW__WEBSERVER__SECRET_KEY=<value from op read>
  ```
  Generate a new secret key with: `python -c "import secrets; print(secrets.token_hex(32))"`. Rotating this key invalidates all existing sessions.
- **Source**: `gemma-airflow/Dockerfile` lines 16, 22; Airflow security docs

---

## Check: Default credentials not in use (airflow/airflow)
- **Priority**: Critical
- **What to look for**: Search docker-compose files and Ansible playbooks for `_AIRFLOW_WWW_USER_PASSWORD`, `POSTGRES_PASSWORD`, and `_AIRFLOW_WWW_USER_USERNAME`. Check for the default values `airflow` / `airflow`. Also check if these are set via environment variable defaults like `${_AIRFLOW_WWW_USER_PASSWORD:-airflow}` — the `:-airflow` fallback is itself a critical issue in production.
- **Pass condition**: All passwords are non-default, randomly generated values. No `:-airflow` or `:-password` default fallbacks appear in production compose files. Credentials come from a secret manager or a `.env` file that is not committed to Git.
- **Fail example**:
  ```yaml
  # docker-compose.yaml (CRITICAL: default credentials with insecure fallback)
  environment:
    _AIRFLOW_WWW_USER_USERNAME: ${_AIRFLOW_WWW_USER_USERNAME:-airflow}
    _AIRFLOW_WWW_USER_PASSWORD: ${_AIRFLOW_WWW_USER_PASSWORD:-airflow}

  # postgres service (CRITICAL: hardcoded default password)
  environment:
    POSTGRES_USER: airflow
    POSTGRES_PASSWORD: airflow
  ```
- **Remediation**:
  ```yaml
  # Before (insecure — default credentials)
  POSTGRES_PASSWORD: airflow
  _AIRFLOW_WWW_USER_PASSWORD: ${_AIRFLOW_WWW_USER_PASSWORD:-airflow}

  # After (secure — strong passwords from secret manager, no fallback defaults)
  # postgres service:
  environment:
    POSTGRES_USER: airflow
    POSTGRES_PASSWORD: "${AIRFLOW_PG_PASSWORD}"  # From .env, fetched from 1Password

  # airflow-init:
  environment:
    _AIRFLOW_WWW_USER_USERNAME: "${AIRFLOW_WWW_USER}"       # From .env
    _AIRFLOW_WWW_USER_PASSWORD: "${AIRFLOW_WWW_PASSWORD}"   # From .env, no :- fallback
  ```
  Remove the `:-airflow` default fallback entirely — if the environment variable is missing, the deployment should fail loudly, not silently use a weak password.
- **Source**: `airflow3-demo/docker-compose.yaml` lines 253–254, 93–94

---

## Check: Webserver port not exposed to 0.0.0.0
- **Priority**: Critical
- **What to look for**: In docker-compose files, check the `ports:` mapping for the Airflow webserver or API server (typically port 8080). A mapping of `"8080:8080"` binds to all interfaces (`0.0.0.0`) by default, making Airflow reachable on the server's public IP. A mapping of `"127.0.0.1:8080:8080"` restricts to localhost only.
- **Pass condition**: Airflow port is bound to `127.0.0.1` (loopback only). A reverse proxy (Nginx, Caddy, Traefik) handles TLS termination and access control on the public interface.
- **Fail example**:
  ```yaml
  # CRITICAL: port exposed to all interfaces — Airflow reachable on public IP
  airflow-apiserver:
    ports:
      - "8080:8080"
  ```
- **Remediation**:
  ```yaml
  # Before (insecure — binds to 0.0.0.0:8080)
  ports:
    - "8080:8080"

  # After (secure — loopback only; reverse proxy handles public TLS)
  ports:
    - "127.0.0.1:8080:8080"

  # Or, expose only to a named Docker network and use a gateway network for the proxy:
  networks:
    gateway:
      aliases: [airflow-webserver]
    default:
  # (No ports: mapping at all — only accessible within Docker networks)
  ```
  Note: Docker bypasses UFW/iptables rules for published ports. Binding to `0.0.0.0` makes the port reachable from the internet even if a firewall rule blocks port 8080, because Docker inserts its own iptables rules. Always use `127.0.0.1:` prefix.
- **Source**: `airflow3-demo/docker-compose.yaml` line 108; `saxonia docker-compose.yml.j2` line 40 (good pattern: `127.0.0.1:{{ airflow_webserver_port }}:8080`)

---

## Check: Webserver authentication is enabled
- **Priority**: Critical
- **What to look for**: In `airflow.cfg` or environment variables, check `AIRFLOW__WEBSERVER__AUTHENTICATE` (Airflow 1.x/2.x) or `AIRFLOW__CORE__AUTH_MANAGER` (Airflow 3.x). For Airflow 2.x, check that FAB auth is configured and not set to `AUTH_TYPE = AUTH_NONE`. For Airflow 3.x, verify `FabAuthManager` or equivalent is the auth manager — not a no-op.
- **Pass condition**: Authentication is explicitly configured and uses a non-trivial backend (FAB with database, LDAP, OAuth, or SAML). The default admin account has a strong non-default password. `AUTH_ROLE_PUBLIC` is not set to `Admin` or `Viewer` unless intentional.
- **Fail example**:
  ```python
  # webserver_config.py (Airflow 2.x — disables auth entirely)
  AUTH_TYPE = AUTH_NONE
  AUTH_ROLE_PUBLIC = 'Admin'  # Anyone visiting the page gets Admin access
  ```
  ```yaml
  # airflow.cfg equivalent
  [webserver]
  authenticate = False
  ```
- **Remediation**:
  ```python
  # Before (insecure — no auth)
  AUTH_TYPE = AUTH_NONE
  AUTH_ROLE_PUBLIC = 'Admin'

  # After (secure — FAB auth with database backend)
  from flask_appbuilder.security.manager import AUTH_DB
  AUTH_TYPE = AUTH_DB
  # AUTH_ROLE_PUBLIC not set (defaults to no public access)
  ```
  ```yaml
  # Airflow 3.x docker-compose (verify auth manager is set)
  AIRFLOW__CORE__AUTH_MANAGER: airflow.providers.fab.auth_manager.fab_auth_manager.FabAuthManager
  ```
- **Source**: `airflow3-demo/docker-compose.yaml` line 57; Airflow security docs

---

## Check: Docker socket not mounted unless required for DockerOperator
- **Priority**: Important
- **What to look for**: Search docker-compose files for `/var/run/docker.sock` in `volumes:`. Mounting the Docker socket grants a container full root access to the host — any process inside the container can start privileged containers, read host filesystems, and escape the container entirely.
- **Pass condition**: Docker socket is not mounted on Airflow scheduler, webserver, or worker containers. If DockerOperator is used, the socket is mounted on a dedicated `docker-proxy` sidecar with minimal access, and `DOCKER_HOST` points to the proxy. The proxy is bound to localhost only.
- **Fail example**:
  ```yaml
  # IMPORTANT: Docker socket mounted on all Airflow containers
  x-airflow-common:
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock  # Root escape vector
  ```
- **Remediation**:
  ```yaml
  # Before (insecure — socket on all containers)
  x-airflow-common:
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock

  # After (secure — socket only on isolated proxy, bound to localhost)
  # Remove the socket mount from x-airflow-common volumes.

  # Add a dedicated docker-proxy sidecar:
  docker-proxy:
    image: alpine/socat
    command: "TCP4-LISTEN:2375,fork,reuseaddr UNIX-CONNECT:/var/run/docker.sock"
    ports:
      - "127.0.0.1:2376:2375"  # Localhost only
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock

  # In Airflow worker/scheduler environment:
  environment:
    DOCKER_HOST: "tcp://docker-proxy:2375"  # or use TCP via docker-proxy service name
  ```
  If DockerOperator is not used at all, remove the socket mount and the docker-proxy service entirely.
- **Source**: `airflow3-demo/docker-compose.yaml` line 81; Docker security best practices

---

## Check: API authentication is enabled
- **Priority**: Important
- **What to look for**: In Airflow 2.x, check `AIRFLOW__API__AUTH_BACKENDS` — it should not be set to `airflow.api.auth.backend.default` (which allows unauthenticated access). In Airflow 3.x, the REST API is auth-gated by the auth manager, but verify the API server is not accessible without credentials. Check for `deny_all` vs `basic_auth` or JWT.
- **Pass condition**: Airflow 2.x: `AIRFLOW__API__AUTH_BACKENDS` is set to `airflow.api.auth.backend.basic_auth` or a stronger backend. Airflow 3.x: API server requires authentication (controlled by auth manager). No anonymous access to DAG-triggering endpoints.
- **Fail example**:
  ```yaml
  # Airflow 2.x (insecure — unauthenticated API access)
  environment:
    AIRFLOW__API__AUTH_BACKENDS: 'airflow.api.auth.backend.default'
  ```
- **Remediation**:
  ```yaml
  # Before (insecure — default allows unauthenticated access)
  AIRFLOW__API__AUTH_BACKENDS: 'airflow.api.auth.backend.default'

  # After (secure — require basic auth or session auth)
  AIRFLOW__API__AUTH_BACKENDS: 'airflow.api.auth.backend.basic_auth,airflow.api.auth.backend.session'
  ```
  For production, prefer session-based auth (behind a reverse proxy with TLS) or JWT tokens over basic auth.
- **Source**: Airflow REST API authentication docs

---

## Check: Remote logging configured (not local filesystem only)
- **Priority**: Recommended
- **What to look for**: Check `AIRFLOW__LOGGING__REMOTE_LOGGING` and `AIRFLOW__LOGGING__REMOTE_BASE_LOG_FOLDER`. If remote logging is not configured, task logs accumulate on the local filesystem, consuming disk, and are lost when containers are rebuilt or the volume is wiped.
- **Pass condition**: `AIRFLOW__LOGGING__REMOTE_LOGGING` is set to `True`, and `AIRFLOW__LOGGING__REMOTE_BASE_LOG_FOLDER` points to an S3/GCS/Azure Blob path. The corresponding connection ID exists.
- **Fail example**:
  ```yaml
  # No remote logging — logs on local disk only, lost on rebuild
  # AIRFLOW__LOGGING__REMOTE_LOGGING not set (default: False)
  ```
- **Remediation**:
  ```yaml
  # Before (insecure — local logs only)
  # (not configured)

  # After (secure — remote logging to S3)
  environment:
    AIRFLOW__LOGGING__REMOTE_LOGGING: 'True'
    AIRFLOW__LOGGING__REMOTE_LOG_CONN_ID: 'aws_default'
    AIRFLOW__LOGGING__REMOTE_BASE_LOG_FOLDER: 's3://my-bucket/airflow/logs/production'
  ```
  The `aws_default` connection should use an IAM role or instance profile — not hardcoded access keys. Avoid hardcoding AWS access keys in the Dockerfile as ENV (see `gemma-airflow/Dockerfile` line 25: `ENV AIRFLOW__SMTP__SMTP_USER=AKIAIOSFODNN7EXAMPLE` — this is a hardcoded IAM key and is a separate critical finding).
- **Source**: `gemma-airflow/Dockerfile` lines 33–35 (good pattern with remote logging configured); Airflow logging docs

---

## Check: JWT secret set and consistent (Airflow 3)

- **Priority**: Critical
- **Applies to**: Airflow 3 only
- **What to look for**: Search for `AIRFLOW__API_AUTH__JWT_SECRET` across all docker-compose files, `.env` files, Ansible role templates (`.j2`), and Ansible role defaults/vars. In Airflow 3, this key signs JWT tokens for ALL API and internal worker-to-API-server communication. **If not set or inconsistent across components, workers cannot execute tasks** (symptom: `Invalid auth token: Signature verification failed` in worker logs).
- **Pass condition**: `AIRFLOW__API_AUTH__JWT_SECRET` is set to a cryptographically random value (≥32 bytes) and is **identical** across all containers (webserver/api-server, scheduler, workers, dag-processor). For Ansible deployments: fetched from 1Password/vault at deploy time, rendered into `.env` via Jinja2 template.
- **Fail example**:
  ```yaml
  # docker-compose.yaml — default fallback value = trivially forgeable
  AIRFLOW__API_AUTH__JWT_SECRET: ${AIRFLOW__API_AUTH__JWT_SECRET:-airflow_jwt_secret}
  ```
  ```yaml
  # Ansible defaults/main.yml — JWT secret hardcoded
  airflow_jwt_secret: "airflow_jwt_secret"
  ```
- **Remediation**:
  ```bash
  # Generate a secure JWT secret
  openssl rand -hex 32

  # Set consistently across all components
  # In Ansible (saxonia pattern):
  # tasks/main.yml — fetch from 1Password, render into .env.j2
  - name: Fetch JWT secret from 1Password
    command: op read "op://vault/Airflow/jwt_secret"
    register: airflow_jwt_secret
    no_log: true
    delegate_to: localhost
  ```
- **Source**: Airflow 3 release docs; GitHub issue #50538; Bitnami charts issue #34347

---

## Check: Auth manager configured for production (Airflow 3)

- **Priority**: Critical
- **Applies to**: Airflow 3 only
- **What to look for**: Check `AIRFLOW__CORE__AUTH_MANAGER` in docker-compose files, Ansible role templates, and airflow.cfg. The default in Airflow 3 is `SimpleAuthManager` which is **development-only** — it stores passwords in a plaintext JSON file (`$AIRFLOW_HOME/simple_auth_manager_passwords.json.generated`).
- **Pass condition**: `AIRFLOW__CORE__AUTH_MANAGER` is explicitly set to the FAB provider: `airflow.providers.fab.auth_manager.fab_auth_manager.FabAuthManager`, and `apache-airflow-providers-fab` is installed in the image.
- **Fail example**:
  ```yaml
  # Auth manager not set — defaults to SimpleAuthManager (dev-only)
  # AIRFLOW__CORE__AUTH_MANAGER not present in environment

  # Or explicitly set to Simple (never in production):
  AIRFLOW__CORE__AUTH_MANAGER: airflow.auth.managers.simple.simple_auth_manager.SimpleAuthManager
  ```
- **Remediation**:
  ```dockerfile
  # In Dockerfile
  RUN pip install apache-airflow-providers-fab
  ```
  ```yaml
  # In docker-compose or .env.j2
  AIRFLOW__CORE__AUTH_MANAGER: airflow.providers.fab.auth_manager.fab_auth_manager.FabAuthManager
  ```
- **Source**: Airflow 3 auth manager docs; GitHub issue #41683 (SimpleAuthManager is dev-only); agent 2/3 research

---

## Check: expose_config disabled

- **Priority**: Critical
- **What to look for**: Search for `AIRFLOW__WEBSERVER__EXPOSE_CONFIG` or `expose_config = True` in airflow.cfg, docker-compose environment blocks, and Ansible role templates. If enabled, any authenticated UI user can view the full Airflow configuration — including the Fernet key and database connection string.
- **Pass condition**: `expose_config` is absent (default False) or explicitly set to False.
- **Fail example**:
  ```ini
  # airflow.cfg (or .env) — exposes Fernet key to all UI users
  AIRFLOW__WEBSERVER__EXPOSE_CONFIG: 'True'
  ```
- **Remediation**:
  ```yaml
  # Ensure absent or explicitly disabled
  AIRFLOW__WEBSERVER__EXPOSE_CONFIG: 'False'
  ```
- **Source**: Airflow security docs; agent 4 research (intezer.com real-world breaches)

---

## Check: Airflow 2 EOL awareness

- **Priority**: Important
- **Applies to**: Airflow 2 deployments only
- **What to look for**: Detect the Airflow version from the Dockerfile base image (`FROM apache/airflow:2.x.x` or `FROM gemmaanalytics/ewah:*`), docker-compose image tag, or from Ansible role defaults.
- **Pass condition**: Running Airflow 3.x, or there is a documented and time-bound migration plan.
- **Fail example**:
  ```yaml
  # Airflow 2 is end-of-life as of April 22, 2026
  image: apache/airflow:2.10.3
  ```
- **Remediation**: Plan migration to Airflow 3. Airflow 2 receives no security patches after April 22, 2026. 9 CVEs were identified in 2025 (including CVSS 9.1 and 9.8). See migration guide: https://airflow.apache.org/docs/apache-airflow/stable/installation/upgrading_to_airflow3.html
- **Source**: Airflow supported versions page; EOL date April 22, 2026

---

## Check: Variables/Connections not storing sensitive values in metadata DB without secrets backend

- **Priority**: Important
- **What to look for**: Check whether a secrets backend (`AIRFLOW__SECRETS__BACKEND`) is configured. If not, sensitive Airflow Variables and Connection passwords are encrypted only by Fernet — if both the DB backup and Fernet key are compromised, all credentials are decryptable. Also check for `expose_config` (separate check above).
- **Pass condition**: `AIRFLOW__SECRETS__BACKEND` is set to AWS Secrets Manager, GCP Secret Manager, or HashiCorp Vault for production deployments with sensitive connections.
- **Fail example**:
  ```yaml
  # No secrets backend configured — all connections in metadata DB
  # AIRFLOW__SECRETS__BACKEND not present
  ```
- **Remediation**:
  ```yaml
  # AWS Secrets Manager example
  AIRFLOW__SECRETS__BACKEND: airflow.providers.amazon.aws.secrets.secrets_manager.SecretsManagerBackend
  AIRFLOW__SECRETS__BACKEND_KWARGS: '{"connections_prefix": "airflow/connections", "variables_prefix": "airflow/variables"}'
  ```
  For Docker Compose deployments: at minimum, inject sensitive connections as `AIRFLOW_CONN_*` environment variables scoped only to worker containers (not the webserver).
- **Source**: Airflow secrets backend docs; Intezer research on misconfigured Airflow deployments; agent 4 research

---

## Check: Ansible/IaC deployment — secrets fetched at deploy time, not hardcoded

- **Priority**: Critical
- **Applies to**: Deployments using Ansible (saxonia/OpenTofu+Ansible pattern)
- **What to look for**: In Ansible role files (`tasks/main.yml`, `defaults/main.yml`, `templates/*.j2`), check:
  1. Are sensitive values (fernet key, webserver secret, DB password, JWT secret) hardcoded in `defaults/main.yml` or `vars/main.yml`?
  2. Are they fetched from 1Password (`op read "op://..."`), Ansible Vault, or similar at deploy time?
  3. Do task steps use `no_log: true` for secret-fetching steps?
  4. Are rendered `.env` files created with `mode: "0600"`?
  5. Does the Jinja2 template (`.env.j2`) use `{{ variable_name }}` references (good — resolved at deploy time) rather than hardcoded literal values?
- **Pass condition**: All secrets are fetched via `op read`, `ansible-vault`, or similar at deploy time. `no_log: true` on all secret-fetching tasks. Rendered files have mode 0600. No literal secret values in defaults/vars/templates.
- **Fail example**:
  ```yaml
  # defaults/main.yml — CRITICAL: hardcoded secrets
  airflow_fernet_key: "kiQHALe31o7by-d9U-lzxhpDsmllTmu0DUagDuYQoWs="
  airflow_jwt_secret: "airflow_jwt_secret"
  ```
- **Remediation** (saxonia pattern):
  ```yaml
  # tasks/main.yml — correct pattern
  - name: Fetch fernet key from 1Password
    command: op read "op://Vault/Airflow/fernet_key"
    register: airflow_fernet_key
    no_log: true
    become: false
    delegate_to: localhost

  - name: Render .env
    template:
      src: .env.j2
      dest: "{{ airflow_dir }}/.env"
      mode: "0600"
  ```
  The `.env.j2` template uses `{{ airflow_fernet_key.stdout }}` — value is never stored in source control.
- **Source**: saxonia-data-infrastructure/server/ansible/roles/airflow/tasks/main.yml (reference implementation)

