Security Anti Pattern #
Infrastructure security is the main foundation of the entire system development and operations (DevOps) lifecycle. When we automate server management with Ansible, every configuration decision we make can have a major impact on the overall system security posture. Unlike syntax errors or ordinary execution errors that immediately stop a playbook, security anti-patterns often run smoothly without triggering any errors. However, these hidden gaps open the door wide for potential data breaches, privilege escalation, and even total compromise of our infrastructure.
A single password or private key accidentally committed to a public Git repository can be exploited by automated scanning bots within seconds. Disabling SSH host key verification globally in production removes our protection from active eavesdropping attacks. Being too lazy to restrict sudo command access makes our automation account a highly vulnerable entry point. Through this in-depth discussion, we’ll thoroughly examine various critical security anti-patterns in Ansible, understand the risks behind them, and learn concrete steps to implement safe, reliable solutions.
1. Storing Secrets Openly in Git Repositories #
Storing sensitive information like database passwords, API keys, private SSL certificates, or cloud provider credentials in plaintext format inside Ansible configuration files that go into Git version control is one of the most fatal mistakes. Git repositories are designed to record change history forever, so deleting a secret file or line in the latest commit won’t remove it from the Git history.
Why Is This Dangerous? #
Every developer or CI/CD system with repository access can see those credentials. If the repository is public or accidentally exposed, outsiders can immediately take control of our infrastructure. Even on private repositories, internal credential leaks still violate compliance principles and make lateral movement easier for attackers who manage to get into our internal network.
# ANTI-PATTERN: storing secrets directly in configuration files committed to Git
# File: group_vars/all.yml
db_password: "super_secret_password_123" # ✗ DON'T: Plaintext password directly readable
api_key: "«redacted:sk-…»" # ✗ DON'T: Sensitive API key exposed
aws_secret_key: "wJalrXUtnFEMI/K7MDENG" # ✗ DON'T: Cloud provider credentials leaked
# File: ansible.cfg
[defaults]
vault_password_file = ~/.vault_pass # ✗ DON'T: Risk of being committed if not excluded in .gitignore
# CORRECT: Use Ansible Vault to encrypt sensitive information
# File: group_vars/all.yml (Safe to commit to Git)
db_user: "appuser"
db_host: "{{ vault_db_host }}" # ✓ References the encrypted variable
db_password: "{{ vault_db_password }}" # ✓ References the encrypted variable
# File: group_vars/vault.yml (Created and edited with 'ansible-vault encrypt')
# All data below has been encrypted with the AES-256 algorithm
vault_db_host: !vault |
$ANSIBLE_VAULT;1.1;AES256
65383561333765333737656662366164626135663737383062326532653262336336626535646132
3964353434653838613134373461623861643431613133330a616263303863643765343536396465
6661646364356464613361626364313233343536373839300a396263646566313233343536373839
vault_db_password: !vault |
$ANSIBLE_VAULT;1.1;AES256
31353930613765333737656662366164626135663737383062326532653262336336626535646132
3964353434653838613134373461623861643431613133330a616263303863643765343536396465
6661646364356464613361626364313233343536373839300a396263646566313233343536373839
# File: .gitignore
# Explicitly exclude all files storing local vault passwords
.vault_pass
*.vault_pass
secrets/*.vault_pass
Checking and Cleaning Up Git Leaks #
If we suspect a secret has leaked into Git history, we must act quickly by scanning the commit history using tools like git log or specialized utilities like gitleaks.
# Searching for sensitive strings across all Git commit history
git log --all --full-history -- "**/*secret*" "**/*password*" "**/*key*"
# Using regular expressions to track potential token or password leaks
git grep -i "password\|secret\|api_key\|aws_" $(git log --format="%H") 2>/dev/null | head -20
[!CAUTION] If a secret has already entered a Git repository pushed to a remote, we must immediately rotate all those credentials. Editing commits or deleting them with force push doesn’t guarantee the data hasn’t been copied by others. Use tools like
git-filter-repoor BFG Repo-Cleaner to permanently clean local Git history before force pushing.
2. Disabling Host Key Checking (host_key_checking = False) in Production
#
For convenience during initial provisioning of many new servers, many administrators disable SSH host key checking (host_key_checking = False) in the ansible.cfg configuration or via environment variables.
Why Is This Dangerous? #
SSH Host Key Checking validates the target server’s identity before exchanging encryption credentials and session data. When this option is disabled, Ansible blindly accepts any host key without matching it against the known_hosts database. This opens a security gap for Man-in-the-Middle (MITM) attacks. An attacker on the same network path (e.g. through DNS spoofing or ARP poisoning) can redirect SSH traffic to their own server, capture login credentials, and manipulate the tasks Ansible runs.
# ANTI-PATTERN: disabling host key checking globally in production
# File: ansible.cfg
[defaults]
host_key_checking = False # ✗ DON'T: Opens a Man-in-the-Middle (MITM) attack gap
# ANTI-PATTERN: disabling the check inline in a task
- name: Execute a command directly on the remote server
command: ssh [email protected] "systemctl restart app"
environment:
ANSIBLE_HOST_KEY_CHECKING: "False" # ✗ DON'T: Bypasses SSH host key security
To address this, we must manage server host keys properly. During initial provisioning, we can automatically fetch the server host key and save it to the local known_hosts file, or use a safer host key acceptance mode.
# CORRECT: Verify the host key and add it to known_hosts before the main play runs
- name: Initialize SSH Host Keys Safely
hosts: localhost
gather_facts: false
tasks:
- name: Fetch the latest host key from target servers and register it in known_hosts
known_hosts:
name: "{{ item }}"
key: "{{ lookup('pipe', 'ssh-keyscan -t rsa,ecdsa,ed25519 ' + item) }}"
state: present
loop: "{{ groups['production'] }}"
run_once: true
# Alternative for dynamic environments (e.g. CI/CD pipelines with ephemeral runners)
# File: ansible.cfg (Use accept-new, not fully disabling verification)
[ssh_connection]
ssh_args = -o StrictHostKeyChecking=accept-new
# ✓ CORRECT: The "accept-new" option automatically saves new keys never seen before,
# but still rejects and disconnects if an existing server key suddenly changes.
Here’s a visualization of the SSH Client decision-making flow when verifying the target server’s Host Key:
flowchart TD
Start["Control Node initiates an SSH connection"] --> Check["Is the Host Key in known_hosts?"]
Check -- "Yes, matches" --> Success["Secure connection established"]
Check -- "Yes, but doesn't match (changed)" --> Warning["SSH Warning: Possible MITM Attack!"]
Warning --> Fail["Connection terminated by the SSH client"]
Check -- "No" --> Policy{"What is the host_key_checking policy?"}
Policy -- "True (Default)" --> Reject["Connection rejected / Asks for interactive confirmation"]
Policy -- "accept-new" --> Save["Save the new Host Key to known_hosts & Continue"]
Policy -- "False" --> Bypass["Skip verification & Continue the connection"]
Bypass -. "Vulnerable to eavesdropping / MITM!" .-> Success3. Global Privilege Escalation with Excessive become #
Setting the become: true parameter at the top level (play-level) so all tasks run as root is a bad habit we often encounter. It’s usually done out of laziness to identify which tasks actually need elevated access.
Why Is This Dangerous? #
The Least Privilege principle teaches us to grant administrative access only when truly needed. If we apply become: true globally, simple tasks like reading local configuration files, downloading application artifacts, or printing debug status run with root privileges. If a bug or logic error occurs in such a task (e.g. a task deleting a temporary folder), the impact can destroy the entire operating system because it executes with full access. Additionally, if a third-party module we download from Ansible Galaxy turns out to contain malicious code (malware), that code immediately gets root access to our hosts.
# ANTI-PATTERN: applying become globally at the play level
- name: Configure and Deploy the Web Application
hosts: webservers
become: true # ✗ DON'T: All tasks below run as root by default
tasks:
- name: Download the application source code from the internal repository
git:
repo: "https://git.company.internal/app/web.git"
dest: "/var/www/myapp"
# This task doesn't need root if /var/www/myapp is owned by the deployer user
- name: Copy the nginx configuration file
template:
src: "nginx.conf.j2"
dest: "/etc/nginx/nginx.conf"
# Only this task needs root administrative access
- name: Run the application's local configuration validation
command: "npm run test"
become_user: "appuser" # Too cumbersome to switch users back because become: true is set above
# CORRECT: Run the playbook with regular access and enable become per task
- name: Configure and Deploy the Web Application Safely
hosts: webservers
become: false # ✓ CORRECT: Default runs tasks with the regular SSH user (non-root)
tasks:
- name: Ensure the application directory is owned by the managing user
file:
path: "/var/www/myapp"
state: directory
owner: "deployer"
group: "deployer"
mode: "0755"
become: true # ✓ Enable root privilege only for directory ownership setup
- name: Download the application source code (without become)
git:
repo: "https://git.company.internal/app/web.git"
dest: "/var/www/myapp"
# Runs as the regular deployer user (non-root), minimizing security risk
- name: Copy the nginx configuration file (needs root)
template:
src: "nginx.conf.j2"
dest: "/etc/nginx/nginx.conf"
become: true # ✓ Enable root privilege only to write into the /etc system directory
- name: Restart the nginx service (needs root)
systemd:
name: nginx
state: restarted
become: true # ✓ Enable root privilege only to interact with systemd
4. Overly Permissive sudoers Configuration on Managed Nodes #
So Ansible can manage remote servers without interactive obstacles, we often configure the Ansible user in /etc/sudoers or the /etc/sudoers.d/ directory on target hosts to use sudo without requiring a password.
Why Is This Dangerous? #
If the sudo configuration is set too freely (e.g. ALL=(ALL) NOPASSWD: ALL), we’ve given uncontrollable absolute power. If our control node laptop is compromised, or the SSH key used to access servers is stolen, the attacker immediately has unlimited administrative control over all remote servers without additional verification barriers. This also makes security auditing harder because there are no clear command restrictions.
# ANTI-PATTERN: giving sudo rights without command restrictions on remote servers
# File: /etc/sudoers.d/ansible
ansible ALL=(ALL) NOPASSWD: ALL # ✗ DON'T: Gives unrestricted, passwordless root access
To improve infrastructure security, we must restrict the sudo commands our automation user may execute. Create a list of specific commands our playbooks truly need to run with elevated access.
# CORRECT: Restricting sudo rights to only specific administrative commands
# File: /etc/sudoers.d/ansible
ansible ALL=(ALL) NOPASSWD: /usr/bin/apt-get update, /usr/bin/apt-get install -y *, /bin/systemctl restart nginx, /bin/systemctl status nginx, /bin/systemctl reload nginx, /usr/bin/systemd-tmpfiles *
# ✓ CORRECT: The ansible user can only run package manager commands and nginx service management as root.
# All attempts to run other commands (like sudo su, sudo vi, or sudo rm -rf /) are immediately rejected.
In addition to command restrictions, if our playbooks don’t need dynamic execution of deep system configuration, we can consider using a vault password to verify privilege escalation execution.
# CORRECT: Require a secure sudo password from Ansible Vault
# File: group_vars/all.yml
# Store the ansible user's sudo password in an encrypted vault variable
ansible_become_password: "{{ vault_ansible_become_password }}"
# When running the playbook, make sure the password is passed securely through the vault
# ansible-playbook -i inventory/production/ site.yml --ask-vault-pass
5. Sensitive Information Leaking into Log and Debug Output #
Ansible by default records all task execution details, including arguments sent to modules, variables used, and standard output (stdout and stderr) to the terminal console screen and configured log files.
Why Is This Dangerous? #
When we execute modules like command or shell carrying sensitive parameters (e.g. database passwords or API tokens), those values are clearly visible in the console output. These credentials also get stored in CI/CD system logs, central server logs, or local log dump files readable by other operations teams or third parties with access to monitoring systems.
# ANTI-PATTERN: exposing credentials to execution logs
- name: Connect and initialize the database schema
command: >
psql postgresql://{{ db_user }}:{{ db_password }}@{{ db_host }}/{{ db_name }}
-c "CREATE EXTENSION IF NOT EXISTS citext;"
# ✗ DON'T: db_password gets written directly into terminal log output if a failure occurs or verbose mode is on
- name: Configure the application environment configuration file
lineinfile:
path: "/home/appuser/app/.env"
line: "STRIPE_API_KEY={{ stripe_api_key }}"
# ✗ DON'T: This lineinfile change displays the stripe_api_key value in the Ansible diff output
The solution to this problem is using the no_log: true parameter on every task handling sensitive data. This instructs Ansible to hide all input parameters and output of that task from the logging system.
# CORRECT: Hiding sensitive parameters with no_log
- name: Connect and initialize the database schema (Hide credentials)
command: >
psql postgresql://{{ db_user }}:{{ db_password }}@{{ db_host }}/{{ db_name }}
-c "CREATE EXTENSION IF NOT EXISTS citext;"
no_log: true # ✓ CORRECT: Hides the command log carrying plaintext credentials
- name: Configure the application environment configuration file
lineinfile:
path: "/home/appuser/app/.env"
line: "STRIPE_API_KEY={{ stripe_api_key }}"
no_log: true # ✓ CORRECT: Prevents the diff output from showing the real API key
# How to handle debugging on tasks using no_log
- name: Run the cloud agent registration securely
uri:
url: "https://api.cloudprovider.com/v1/register"
method: POST
body_format: json
headers:
Authorization: "Bearer {{ secure_api_token }}"
body:
instance_id: "{{ ansible_machine_id }}"
no_log: true
register: registration_result
- name: Display the registration result status (Without showing the API token)
debug:
msg: "Registration done. HTTP status: {{ registration_result.status }}. Message: {{ registration_result.json.message | default('No message') }}"
# ✓ CORRECT: We can still debug the status without exposing the real authorization token
6. Ignoring External Input Validation (Command Injection Risk) #
Ansible is often triggered from external systems, like input forms in Jenkins/GitLab CI, external webhooks, or direct developer input through extra variables (-e or --extra-vars).
Why Is This Dangerous? #
If we directly use those external input variables in important directives like the target host name (hosts:) or shell command execution arguments without strict validation, our system is vulnerable to Command Injection or mis-targeted playbook execution. For example, if an attacker inserts command separator characters like ; or && into an input variable, they can execute arbitrary commands on the control node or target hosts with Ansible’s privileges.
# ANTI-PATTERN: accepting external input directly without validation
- name: Dynamic Server Maintenance
hosts: "{{ target_hosts }}" # ✗ DON'T: If target_hosts is 'all', the playbook runs on every server!
tasks:
- name: Run the custom maintenance script
shell: "bash /opt/scripts/cleanup.sh --user {{ user_input }}"
# ✗ DON'T: If user_input is '; rm -rf / ;', this command destroys the remote server!
To secure our playbooks from malicious input, we must filter and validate every external variable at the start of execution (pre_tasks) using the assert module.
# CORRECT: Strictly validate all external variables before the main execution
- name: Dynamic Server Maintenance Safely
hosts: all
gather_facts: false
pre_tasks:
- name: Ensure the target_host parameter is properly defined
assert:
that:
- target_host is defined
- target_host != ""
- target_host in groups['all']
fail_msg: "Target host '{{ target_host | default('EMPTY') }}' is not registered in the inventory!"
delegate_to: localhost
run_once: true
- name: Validate the username to only contain safe alphanumeric characters (prevents injection)
assert:
that:
- user_input is defined
- user_input is match('^[a-zA-Z0-9_-]+$')
fail_msg: "User input '{{ user_input | default('') }}' is invalid! Only alphanumeric characters, dashes, and underscores are allowed."
delegate_to: localhost
run_once: true
tasks:
- name: Run the custom maintenance script (Now safe from command injection)
shell: "bash /opt/scripts/cleanup.sh --user {{ user_input }}"
# Run this task only on the validated host
when: inventory_hostname == target_host
7. Using SSH Protocols with Insecure Configuration #
Ansible relies heavily on the strength and security of the SSH protocol to communicate with remote servers. However, we often ignore SSH configuration at the system level and let Ansible use the OS’s outdated default values.
Why Is This Dangerous? #
If the control node and target servers negotiate encryption with old weak ciphers (like 3DES, RC4, or Blowfish) or vulnerable key exchange methods (like Diffie-Hellman Group 1 SHA1), our communication sessions can be decrypted by attackers able to intercept network traffic. Likewise, if the SSH port is left standard (22) without brute force protection or allowing direct root login with passwords.
We must tighten the SSH connection configuration both in ansible.cfg and in the target server configuration.
# CORRECT: Configuring secure SSH parameters in ansible.cfg
# File: ansible.cfg
[ssh_connection]
# Enable pipelining and restrict SSH argument options to modern ciphers
ssh_args = -C -o ControlMaster=auto -o ControlPersist=60s -o [email protected],diffie-hellman-group16-sha512 -o [email protected],[email protected]
# The options above force SSH to use very strong elliptic curve key exchange algorithms and modern decryption-resistant ciphers.
To apply standardized secure SSH across the entire target infrastructure, we must use a dedicated playbook to configure the SSH daemon (sshd_config).
# CORRECT: Applying secure SSH Daemon configuration on managed nodes
- name: Secure the SSH Service on Remote Servers
hosts: all
become: true
tasks:
- name: Disable direct root login with passwords
lineinfile:
path: "/etc/ssh/sshd_config"
regexp: "^#?PermitRootLogin"
line: "PermitRootLogin prohibit-password"
state: present
notify: Reload SSH Service
- name: Disable plain password authentication (Force SSH Key usage)
lineinfile:
path: "/etc/ssh/sshd_config"
regexp: "^#?PasswordAuthentication"
line: "PasswordAuthentication no"
state: present
notify: Reload SSH Service
- name: Restrict weak SSH ciphers on the server side
lineinfile:
path: "/etc/ssh/sshd_config"
regexp: "^#?Ciphers"
line: "Ciphers [email protected],[email protected],[email protected]"
state: present
notify: Reload SSH Service
handlers:
- name: Reload SSH Service
systemd:
name: sshd
state: reloaded
Summary #
- Ansible Vault Is Mandatory — Sensitive credentials like passwords and API tokens must not be stored plaintext in Git repositories. Encrypt files with Ansible Vault and make sure local password files are listed in
.gitignore.- Maintain SSH Host Key Checking — Avoid disabling
host_key_checking = Falsein production because it opens a Man-in-the-Middle (MITM) attack gap. Use theStrictHostKeyChecking=accept-newoption for ephemeral CI/CD runners.- Limit Privilege Escalation Properly — Don’t use the
become: truedeclaration globally at the play level. Enable root privileges specifically only on tasks that truly need high administrative access.- Apply the Restricted Sudoers Principle — Configure the sudoers file on remote servers so the Ansible user can only execute specific administrative commands with
NOPASSWD, avoiding universalALLaccess.- Use no_log: true to Prevent Credential Leaks — Hide sensitive input parameters and output from stdout/stderr logs and CI/CD system logs using the
no_log: trueoption on relevant tasks.- Strictly Validate External Input — Protect the system from command injection gaps by validating every input parameter from extra variables (
-e) using theassertmodule with regular expressions.- Consistently Tighten the SSH Protocol — Configure
ssh_argsinansible.cfgto force the use of secure modern key exchange algorithms (KexAlgorithms) and ciphers, and disable direct password login on managed nodes.
← Previous: Variable Anti Pattern Next: Performance Anti Pattern →