Common Mistake #

Most security incidents involving Ansible automation aren’t the result of sophisticated zero-day vulnerability exploitation. Those incidents generally stem from configuration negligence, shortcut practices left lingering in production environments, or a lack of understanding about the security lifecycle. Storing secrets in plaintext in Git, disabling host key verification to speed up connections, executing playbooks directly against production servers without dry-run testing, and giving the Ansible user unlimited root access are examples of anti-patterns we still encounter very often.

This article comprehensively discusses the 8 most common security mistakes made in Ansible development and operations. We’ll analyze why these mistakes are dangerous, how they impact our system’s security posture, and the best solutions for fixing them to build a robust and secure automation infrastructure.

Mistake Classification #

To make risk mapping easier, we can divide these security mistakes into four main interrelated categories:

CATEGORY 1: SECRET & CREDENTIAL MANAGEMENT
  ✗ Storing Vault passwords or API keys in a Git repository in plaintext
  ✗ Forgetting to set the no_log: true attribute on tasks processing sensitive data
  ✗ Storing secrets in permanent environment variables on managed nodes
  ✗ Not periodically auditing old encrypted files

CATEGORY 2: ACCESS RIGHTS & PRIVILEGE
  ✗ Allowing direct SSH login as root
  ✗ Enabling become: true globally at the play level for all tasks
  ✗ Giving NOPASSWD: ALL sudo access without per-command restrictions on target hosts
  ✗ Using a single shared SSH key without a passphrase for all administrators

CATEGORY 3: VERIFICATION & VALIDATION
  ✗ Disabling host key checking (host_key_checking = False)
  ✗ Ignoring check mode (--check --diff) before production deployments
  ✗ Making direct modifications on target servers without updating the playbook (drift)

CATEGORY 4: SUPPLY CHAIN & DEPENDENCIES
  ✗ Installing third-party roles from Ansible Galaxy without code review
  ✗ Not pinning the versions of role requirements and external collections

By classifying these mistakes, we can perform a structured security assessment of our Ansible repository and prioritize fixes based on the most critical risk levels.


1. Hardcoded Secrets and Plaintext Vault Files #

The first and most damaging mistake often made is storing Ansible Vault password files or other credentials in plaintext inside a Git repository. Once a password is committed to Git, it stays in the commit history forever, even after we delete the file in a later commit.

# ANTI-PATTERN: Writing the vault password to a text file and adding it to Git
echo "our_secret_password_123" > .vault_pass.txt
ansible-playbook site.yml --vault-password-file .vault_pass.txt
git add .vault_pass.txt
git commit -m "Adding the vault configuration file" # ✗ CREDENTIALS LEAKED TO GIT!

Even if we later remove it with git rm, an attacker with read access to our Git repository can extract the password value again by reading the old commit history log:

# Attacker reads the commit history to find deleted files
git log --all --full-history -p -- .vault_pass.txt

Best Solution #

We must configure our CI/CD pipeline to inject the password dynamically using the standard input from a memory stream, without ever writing the password to physical storage media:

# CORRECT: Passing the vault password through CI/CD standard input directly
export ANSIBLE_VAULT_PASSWORD_FILE=/dev/stdin
echo "$CI_ENV_VAULT_PASSWORD" | ansible-playbook site.yml --vault-password-file /dev/stdin

If working in a local environment, we can use our operating system’s built-in keychain module or call an external secret manager (like AWS Secrets Manager) to fetch the vault password securely at runtime.


2. SSH Login as Root #

Many administrators take the shortcut of configuring Ansible to log into target servers directly as the root user. This practice is very dangerous because it eliminates operational accountability (audit trails). If a configuration error or security incident occurs, the logging system only reports that the activity was done by the root user, without us being able to detect which developer actually initiated the connection.

# ANTI-PATTERN: Connecting to the target host directly as root
# inventory.ini
[production]
web-server-01 ansible_host=10.0.1.10 ansible_user=root # ✗ DON'T DO THIS!

Best Solution #

We must create a dedicated system user with restricted rights for Ansible execution (for example the ansible-deploy user), then use controlled, specific sudo privilege escalation for the tasks that need it.

We can restrict which commands the Ansible user is allowed to run with root rights in the /etc/sudoers.d/ansible-deploy configuration file:

# CORRECT: Allowing sudo only for the commands Ansible needs
# File: /etc/sudoers.d/ansible-deploy (Validate with visudo)
ansible-deploy ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart nginx
ansible-deploy ALL=(ALL) NOPASSWD: /usr/bin/apt-get update
ansible-deploy ALL=(ALL) NOPASSWD: /usr/bin/apt-get install *

The configuration above ensures that if the ansible-deploy user is compromised, attackers can’t use that user to perform destructive actions beyond the explicitly allowed commands.


3. Disabling Host Key Checking #

To avoid SSH fingerprint confirmation interactions when first connecting to new servers, developers often disable the host key checking feature (host_key_checking = False) in the ansible.cfg file.

# ANTI-PATTERN: Disabling host key checking protection in ansible.cfg
[defaults]
host_key_checking = False # ✗ OPENS THE DOOR TO MAN-IN-THE-MIDDLE ATTACKS

This action is very dangerous because it makes Ansible willing to accept SSH connections from any server that answers that IP address, without verifying the server’s public key identity. Attackers can use DNS spoofing, ARP poisoning, or router hijacking techniques to divert Ansible connections to their fake servers (Man-in-the-Middle), then instantly steal our privilege escalation credentials.

Best Solution #

We must always enable host key checking by default. For newly provisioned servers, we should fetch their SSH key fingerprints first using a safe scanning script (side-channel) before running the main playbook:

# CORRECT: Scanning and explicitly adding legitimate host keys
ssh-keyscan -H 10.0.1.10 >> ~/.ssh/known_hosts

# Manually verifying the fingerprint against trusted inventory data
ssh-keygen -lf <(ssh-keyscan -t ed25519 10.0.1.10 2>/dev/null)

By ensuring host_key_checking = True in our ansible.cfg configuration, we completely close the network interception attack gap.


4. Playbooks Straight to Production Without Testing #

Running infrastructure changes directly against production servers without going through adequate testing is the fastest recipe for triggering system downtime. Small mistakes like variable typos or untested loop logic can corrupt critical configuration files and stop main services instantly.

Best Solution #

We must build a continuous integration and deployment (CI/CD) pipeline that applies multi-level validation before a playbook is allowed to touch production servers. The ideal testing flow should include the following stages:

  1. Linting: Validating code writing standards using ansible-lint.
  2. Syntax Check: Ensuring there are no YAML file parsing errors.
  3. Dry Run: Running the playbook with the --check --diff flags in a staging environment to see planned changes.
  4. Integration Testing: Applying changes to an isolated testing infrastructure.
  5. Canary Rollout: Releasing changes to a small subset of production servers (e.g. 1 host first) before releasing to all servers.

Here’s an example CI/CD pipeline configuration using GitHub Actions:

# File: .github/workflows/ansible-pipeline.yml
name: Ansible Deployment Gate
on:
  push:
    branches: [ main ]

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout Code
        uses: actions/checkout@v4

      - name: Install Ansible and Lint
        run: |
          sudo apt-get update
          sudo apt-get install -y ansible ansible-lint
                    
      - name: Run Syntax Check
        run: |
          ansible-playbook playbooks/site.yml --syntax-check          

      - name: Run Linting Guard
        run: |
          ansible-lint playbooks/          

Applying these automatic check gates will prevent basic syntax errors from reaching our production servers.


5. Ignoring or Avoiding Check Mode #

Check mode (--check) is often ignored by engineers because it’s considered time-consuming or produces too-noisy output. However, running a playbook without reviewing changes first is the same as deploying blindfolded.

Best Solution #

We should get into the habit of always running playbooks with the --check and --diff flag combination. The --diff option is very useful because it shows line-by-line differences (file diff) that would be applied to the target host if the playbook were actually run:

# CORRECT: Reviewing changes before real execution
ansible-playbook site.yml -i inventory/production --check --diff

The terminal output will show the change plan like this:

TASK [Update Nginx configuration] ----------------------------------------------------
--- before: /etc/nginx/nginx.conf
+++ after: /tmp/ansible-template-diff-xyz
@@ -12,4 +12,4 @@
     keepalive_timeout  65;
-    gzip  off;
+    gzip  on;

By reading this visual diff, we can immediately confirm whether the upcoming changes match our expectations before we release the actual execution.


6. Using become: true Globally #

Setting the become: true parameter at the top play level (global level) so all tasks run as root is a design mistake that violates the principle of least privilege.

# ANTI-PATTERN: Global root escalation for the entire playbook
- name: Deploy the web application
  hosts: servers
  become: true # ✗ All tasks below will run as root
  tasks:
    - name: Fetch the package zip from the local build server
      get_url:
        url: "https://builds.internal/app.zip"
        dest: "/tmp/app.zip" # This task doesn't need root

    - name: Extract the user configuration files
      unarchive:
        src: "/tmp/app.zip"
        dest: "/home/appuser/src" # This task doesn't need root

Running file download or archive extraction tasks as root dramatically increases the blast radius if the downloaded file turns out to be malicious or has script defects.

Best Solution #

We must disable privilege escalation at the global level, and declare it explicitly only on specific tasks that genuinely need administrative rights (like system package installation or modifying OS configuration files):

# CORRECT: Privilege escalation done selectively per task
- name: Deploy the web application
  hosts: servers
  become: false  # Default: Run without root privilege
  tasks:
    - name: Download the source code package
      get_url:
        url: "https://builds.internal/app.zip"
        dest: "/tmp/app.zip"

    - name: Install system dependencies (Needs root)
      package:
        name: nginx
        state: present
      become: true  # Only this task gets root escalation

    - name: Extract the package to the user directory
      unarchive:
        src: "/tmp/app.zip"
        dest: "/home/appuser/src"

7. Hardcoded Hostnames in Inventory #

Filling inventory files statically with hardcoded IP addresses or hostnames makes long-term system maintenance difficult. When servers are replaced, added to the cluster, or decommissioned, we must update the inventory file manually. If we forget, Ansible keeps trying to contact the old server, causing playbook failures due to connection timeouts.

Best Solution #

We’re advised to use a Dynamic Inventory connected directly to our cloud provider (like the aws_ec2 or gcp_compute plugins). With dynamic inventory, Ansible requests the server list in real-time from the cloud API before executing the playbook, ensuring deleted servers automatically disappear from the target list.

Additionally, we can add a health check mechanism at the start of the playbook to filter out unresponsive servers without stopping the entire deployment execution:

- name: Initial connectivity test
  hosts: all
  gather_facts: false
  tasks:
    - name: Ping the target server
      ping:
      register: ping_status
      ignore_unreachable: true

    - name: Mark hosts that don't respond
      set_fact:
        host_is_unreachable: true
      when: ping_status.unreachable is defined and ping_status.unreachable

8. Information Leakage Through Plaintext Logs #

Many operations teams don’t realize that the device facts (gather facts) collected by Ansible at the start of execution contain complete target system configuration information, like disk partition details, private IP addresses, MAC addresses, and even the list of installed packages with their detailed versions. If this log output is left exposed to the public or stored on CI/CD storage media without protection, attackers can use it to map our internal architecture and find vulnerable package versions to exploit.

Best Solution #

We must limit fact gathering to only the specific parameters we need for our playbook logic, using the filtered setup module:

# CORRECT: Limiting system fact recording to the minimum level
- name: Initialize the web server deployment
  hosts: webservers
  gather_facts: false  # Disable the built-in global fact gathering
  tasks:
    - name: Collect only the default IP address information
      setup:
        filter: ansible_default_ipv4
      register: server_ip_facts

    - name: Display the limited information
      debug:
        msg: "Starting deployment on host {{ inventory_hostname }} with IP {{ server_ip_facts.ansible_facts.ansible_default_ipv4.address }}"

This step speeds up the playbook initialization process (because there’s no need to gather thousands of other system facts) while minimizing sensitive data leakage into our log files.


Decision Tree — Auditing Playbooks for Anti-Patterns #

To make it easier to detect these configuration mistakes in our repository, we can refer to the following audit flow before launching changes to production environments:

flowchart TD
    A["Audit the playbook"] --> B{"Vault password<br/>from where?"}
    B -- "Text file" --> C["FAIL: move to<br/>secret manager"]
    B -- "Secret manager" --> D["OK"]
    A --> E{"become: true?"}
    E -- "Global" --> F["FAIL: explicit per task"]
    E -- "Per task" --> G["OK"]
    A --> H{"host_key_checking?"}
    H -- "False" --> I["FAIL: enable<br/>known_hosts"]
    H -- "True" --> J["OK"]
    A --> K{"--check in CI/CD?"}
    K -- "No" --> L["FAIL: add to pipeline"]
    K -- "Yes" --> M["OK"]
    A --> N{"Ansible user<br/>sudo permissions?"}
    N -- "NOPASSWD ALL" --> O["FAIL: limit to specific commands"]
    N -- "Limited" --> P["OK"]

    style A stroke:#4a90e2,stroke-width:2px
    style B stroke:#7b68ee,stroke-width:2px
    style C stroke:#d0021b,stroke-width:2px
    style F stroke:#d0021b,stroke-width:2px
    style I stroke:#d0021b,stroke-width:2px
    style L stroke:#d0021b,stroke-width:2px
    style O stroke:#d0021b,stroke-width:2px
    style D stroke:#50c878,stroke-width:2px
    style G stroke:#50c878,stroke-width:2px
    style J stroke:#50c878,stroke-width:2px
    style M stroke:#50c878,stroke-width:2px
    style P stroke:#50c878,stroke-width:2px

Ansible Security Review Checklist #

As a practical guide, make sure our Ansible repository has passed the following checklist before we do an official release to production systems:

VARIABLE & SECRET MANAGEMENT:
  □ No encrypted passwords, API keys, or private keys stored as plaintext.
  □ All sensitive data has been encrypted using Ansible Vault strings or looked up from a centralized secret manager.
  □ The 'no_log: true' attribute has been added to all tasks that read, write, or process credentials.

SSH ACCESS & PRIVILEGE RIGHTS:
  □ The 'ansible.cfg' file has 'host_key_checking = True' configured.
  □ SSH connections to managed nodes use a non-root user and leverage sudo escalation.
  □ 'sudo' execution permissions for the Ansible user on managed nodes are restricted to only relevant commands.
  □ The 'become: true' privilege escalation is installed selectively on specific tasks, not globally at the play level.

DEPLOYMENT & TESTING PIPELINE:
  □ Every playbook change must pass 'ansible-lint' and '--syntax-check' checks in the CI pipeline.
  □ Production deployments must be preceded by '--check --diff' testing in the staging environment.

Summary #

  • No Plaintext Credentials — Never commit vault password files or sensitive strings in plaintext into a Git repository. Use pipeline memory injection integration or an external secret manager.
  • Non-Root Login — Restrict SSH access to reject direct root login, create a dedicated user with limited sudo access rights on managed nodes.
  • Enable Host Key Checking — Make sure host_key_checking = True stays active to protect Ansible connections from Man-in-the-Middle network interception attack threats.
  • Apply a Validation Pipeline — Build an automatic testing pipeline (Linting, Syntax Check, Dry Run) to filter out typos and bugs before playbooks touch production systems.
  • Use Dry-Run Mode — Always use the --check --diff command combination to visually analyze file configuration change details before applying real execution.
  • Selective Escalation — Avoid using global become: true escalation. Declare the become parameter specifically at the task level only.
  • Use Dynamic Inventory — Implement dynamic inventory to automate target host list updates in real-time and avoid errors from inactive hosts.
  • Limit Fact Gathering — Disable global system fact gathering when unnecessary, and use the setup module filter to fetch only the needed variables minimally and safely.

← Previous: SSH Security Next: Provision Host →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact