Security Best Practices
Introduction
Security is paramount when automating infrastructure with Ansible. This guide covers best practices for securing your Ansible automation, protecting sensitive data, implementing proper authentication, managing privileges, and ensuring compliance with security standards.
Security Principles:
- Least Privilege: Grant only necessary permissions
- Defense in Depth: Multiple layers of security
- Secrets Management: Never store passwords in plain text
- Audit Trail: Log all privileged operations
- Regular Updates: Keep Ansible and dependencies current
Ansible Vault
Encrypting Sensitive Data
Use Ansible Vault to encrypt sensitive variables and files:
# Create encrypted file
ansible-vault create secrets.yml
# Edit encrypted file
ansible-vault edit secrets.yml
# Encrypt existing file
ansible-vault encrypt vars/passwords.yml
# Decrypt file
ansible-vault decrypt vars/passwords.yml
# View encrypted file without decrypting
ansible-vault view secrets.yml
# Rekey (change password)
ansible-vault rekey secrets.yml
# Example encrypted variables file (secrets.yml)
---
db_password: "SecureP@ssw0rd123"
api_key: "abcdef123456789"
ssl_certificate_key: |
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC...
-----END PRIVATE KEY-----
Using Vault in Playbooks
---
- name: Secure playbook with vault
hosts: databases
become: yes
vars_files:
- vars/public_vars.yml
- vars/secrets.yml # Encrypted with vault
tasks:
- name: Create database user
postgresql_user:
name: appuser
password: "{{ db_password }}" # From encrypted vault file
state: present
- name: Configure API key
lineinfile:
path: /etc/app/config.yml
line: "api_key: {{ api_key }}"
mode: '0600'
# Run playbook with vault password
ansible-playbook site.yml --ask-vault-pass
# Use password file
ansible-playbook site.yml --vault-password-file ~/.vault_pass
# Use password script
ansible-playbook site.yml --vault-password-file ./get_vault_pass.sh
Multiple Vault Passwords
Use different vault passwords for different environments:
# Create file with specific vault ID
ansible-vault create --vault-id dev@prompt secrets_dev.yml
ansible-vault create --vault-id prod@prompt secrets_prod.yml
# Encrypt with labeled password
ansible-vault encrypt --vault-id prod@~/.vault_pass_prod database_prod.yml
# Playbook using multiple vault IDs
---
- name: Multi-vault playbook
hosts: all
vars_files:
- secrets_dev.yml # Encrypted with dev vault
- secrets_prod.yml # Encrypted with prod vault
# Run with multiple vault passwords
ansible-playbook site.yml \
--vault-id dev@~/.vault_pass_dev \
--vault-id prod@~/.vault_pass_prod
# Configure in ansible.cfg
[defaults]
vault_identity_list = dev@~/.vault_pass_dev, prod@~/.vault_pass_prod
Inline Encrypted Variables
# Encrypt a single string
ansible-vault encrypt_string 'SecretPassword123' --name 'db_password'
# Output (add to vars file):
db_password: !vault |
$ANSIBLE_VAULT;1.1;AES256
66386439653761336662373332343535643632363039376234323561383031656662643332646437
3338356331653361663966346163653235623037666231620a363439643563336365653064626665
# Use in playbook
---
- name: Using encrypted strings
hosts: all
vars:
db_password: !vault |
$ANSIBLE_VAULT;1.1;AES256
66386439653761336662373332343535643632363039376234323561383031656662643332646437
3338356331653361663966346163653235623037666231620a363439643563336365653064626665
tasks:
- name: Use encrypted variable
debug:
msg: "Password is {{ db_password }}"
Preventing Credential Exposure
Using no_log Directive
Prevent sensitive data from appearing in logs:
---
- name: Secure credential handling
hosts: all
vars:
database_password: "{{ vault_db_password }}"
tasks:
- name: Set password (not logged)
user:
name: dbuser
password: "{{ database_password | password_hash('sha512') }}"
no_log: true
- name: Configure with credentials (not logged)
template:
src: config.j2
dest: /etc/app/config.yml
no_log: true
- name: API call with token (not logged)
uri:
url: https://api.example.com/endpoint
headers:
Authorization: "Bearer {{ api_token }}"
no_log: true
register: api_result
- name: Conditional no_log
debug:
msg: "{{ sensitive_output }}"
no_log: "{{ not debug_mode | default(true) }}"
Secure Variable Files
# group_vars/all/vault.yml (encrypted)
---
vault_db_password: "SecurePassword123"
vault_api_key: "abc123def456"
vault_ssh_private_key: |
-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEA...
-----END RSA PRIVATE KEY-----
# group_vars/all/vars.yml (plain text - references only)
---
db_password: "{{ vault_db_password }}"
api_key: "{{ vault_api_key }}"
ssh_private_key: "{{ vault_ssh_private_key }}"
# File permissions
chmod 600 group_vars/all/vault.yml
chmod 600 group_vars/all/vars.yml
SSH Key Management
Secure SSH Configuration
# ansible.cfg
[defaults]
private_key_file = ~/.ssh/ansible_rsa
host_key_checking = True # Enable in production
remote_user = ansible
[ssh_connection]
ssh_args = -o ControlMaster=auto -o ControlPersist=60s -o StrictHostKeyChecking=yes
pipelining = True
# Generate dedicated Ansible SSH key
ssh-keygen -t rsa -b 4096 -f ~/.ssh/ansible_rsa -C "ansible@control-node"
# Distribute public key securely
ssh-copy-id -i ~/.ssh/ansible_rsa.pub user@remote-host
# Set proper permissions
chmod 700 ~/.ssh
chmod 600 ~/.ssh/ansible_rsa
chmod 644 ~/.ssh/ansible_rsa.pub
# Use SSH agent for key management
eval $(ssh-agent -s)
ssh-add ~/.ssh/ansible_rsa
SSH Bastion/Jump Host
# ansible.cfg for bastion access
[ssh_connection]
ssh_args = -o ProxyCommand="ssh -W %h:%p -q bastion.example.com" \
-o ControlMaster=auto \
-o ControlPersist=60s
# Or in inventory
[webservers]
web01 ansible_host=10.0.1.10
web02 ansible_host=10.0.1.11
[webservers:vars]
ansible_ssh_common_args='-o ProxyCommand="ssh -W %h:%p -q bastion.example.com"'
# Per-host bastion configuration
web01 ansible_host=10.0.1.10 ansible_ssh_common_args='-o ProxyCommand="ssh -W %h:%p bastion1"'
web02 ansible_host=10.0.1.11 ansible_ssh_common_args='-o ProxyCommand="ssh -W %h:%p bastion2"'
Privilege Escalation Security
Secure Sudo Configuration
# /etc/sudoers.d/ansible on managed nodes
# Allow ansible user to run commands without password
ansible ALL=(ALL) NOPASSWD: ALL
# More restrictive - specific commands only
ansible ALL=(ALL) NOPASSWD: /usr/bin/systemctl, /usr/bin/apt, /usr/bin/yum
# Require password for specific operations
ansible ALL=(ALL) PASSWD: /usr/sbin/reboot, /usr/sbin/shutdown
# Disable requiretty for pipelining (security consideration)
Defaults:ansible !requiretty
# ansible.cfg privilege escalation
[privilege_escalation]
become = True
become_method = sudo
become_user = root
become_ask_pass = False # Set to True for password prompt
become_flags = -H -S -n
# In playbook - explicit privilege escalation
---
- name: Secure privilege escalation
hosts: all
become: yes
become_method: sudo
become_user: root
tasks:
- name: This runs as root
yum:
name: httpd
state: present
- name: This runs as regular user
command: whoami
become: no
- name: Run as different user
command: /opt/app/deploy.sh
become: yes
become_user: appuser
Alternative Privilege Escalation Methods
# Using su instead of sudo
[privilege_escalation]
become = True
become_method = su
become_user = root
become_ask_pass = True
# Using pbrun (PowerBroker)
[privilege_escalation]
become = True
become_method = pbrun
become_user = root
# Using doas (OpenBSD)
[privilege_escalation]
become = True
become_method = doas
become_user = root
# Per-task privilege escalation
- name: Different escalation methods
hosts: all
tasks:
- name: Use sudo
command: whoami
become: yes
become_method: sudo
- name: Use su
command: whoami
become: yes
become_method: su
become_user: root
Security Hardening Playbooks
System Hardening
---
- name: Linux security hardening
hosts: all
become: yes
tasks:
# SSH Hardening
- name: Secure SSH configuration
lineinfile:
path: /etc/ssh/sshd_config
regexp: "{{ item.regexp }}"
line: "{{ item.line }}"
loop:
- { regexp: '^PermitRootLogin', line: 'PermitRootLogin no' }
- { regexp: '^PasswordAuthentication', line: 'PasswordAuthentication no' }
- { regexp: '^PubkeyAuthentication', line: 'PubkeyAuthentication yes' }
- { regexp: '^PermitEmptyPasswords', line: 'PermitEmptyPasswords no' }
- { regexp: '^X11Forwarding', line: 'X11Forwarding no' }
- { regexp: '^MaxAuthTries', line: 'MaxAuthTries 3' }
- { regexp: '^ClientAliveInterval', line: 'ClientAliveInterval 300' }
- { regexp: '^ClientAliveCountMax', line: 'ClientAliveCountMax 2' }
notify: restart sshd
# Firewall configuration
- name: Install firewalld
yum:
name: firewalld
state: present
- name: Enable firewalld
service:
name: firewalld
state: started
enabled: yes
- name: Configure firewall rules
firewalld:
service: "{{ item }}"
permanent: yes
state: enabled
loop:
- ssh
- https
notify: reload firewall
# Disable unnecessary services
- name: Disable unnecessary services
service:
name: "{{ item }}"
state: stopped
enabled: no
loop:
- telnet
- rsh
- rlogin
ignore_errors: yes
# Security updates
- name: Install security updates (RHEL/CentOS)
yum:
name: '*'
security: yes
state: latest
when: ansible_os_family == "RedHat"
# Audit logging
- name: Enable auditd
service:
name: auditd
state: started
enabled: yes
- name: Configure audit rules
copy:
dest: /etc/audit/rules.d/ansible.rules
content: |
# Monitor user/group changes
-w /etc/group -p wa -k identity
-w /etc/passwd -p wa -k identity
-w /etc/shadow -p wa -k identity
# Monitor sudo usage
-w /etc/sudoers -p wa -k sudoers
-w /var/log/sudo.log -p wa -k sudoers
# Monitor SSH
-w /etc/ssh/sshd_config -p wa -k sshd
notify: restart auditd
handlers:
- name: restart sshd
service:
name: sshd
state: restarted
- name: reload firewall
command: firewall-cmd --reload
- name: restart auditd
service:
name: auditd
state: restarted
CIS Benchmark Compliance
---
- name: CIS Benchmark Level 1 compliance
hosts: all
become: yes
tasks:
# 1.1.1.1 Ensure mounting of cramfs filesystems is disabled
- name: Disable cramfs
lineinfile:
path: /etc/modprobe.d/cramfs.conf
line: 'install cramfs /bin/true'
create: yes
# 1.3.1 Ensure AIDE is installed
- name: Install AIDE
package:
name: aide
state: present
# 1.4.1 Ensure bootloader password is set
- name: Check if GRUB password is set
shell: grep "^password" /boot/grub2/grub.cfg
register: grub_password
failed_when: false
changed_when: false
# 3.3.1 Ensure IPv6 router advertisements are not accepted
- name: Disable IPv6 router advertisements
sysctl:
name: "{{ item }}"
value: '0'
state: present
reload: yes
loop:
- net.ipv6.conf.all.accept_ra
- net.ipv6.conf.default.accept_ra
# 3.3.2 Ensure IPv6 redirects are not accepted
- name: Disable IPv6 redirects
sysctl:
name: "{{ item }}"
value: '0'
state: present
reload: yes
loop:
- net.ipv6.conf.all.accept_redirects
- net.ipv6.conf.default.accept_redirects
# 4.1.1.2 Ensure auditd service is enabled
- name: Enable auditd
service:
name: auditd
enabled: yes
state: started
# 5.2.5 Ensure SSH X11 forwarding is disabled
- name: Disable SSH X11 forwarding
lineinfile:
path: /etc/ssh/sshd_config
regexp: '^X11Forwarding'
line: 'X11Forwarding no'
notify: restart sshd
# 5.4.1.1 Ensure password expiration is 365 days or less
- name: Set password expiration
lineinfile:
path: /etc/login.defs
regexp: '^PASS_MAX_DAYS'
line: 'PASS_MAX_DAYS 90'
handlers:
- name: restart sshd
service:
name: sshd
state: restarted
STIG Compliance
---
- name: DISA STIG compliance checks
hosts: all
become: yes
tasks:
# V-38666 - System must use encrypted passwords
- name: Ensure password encryption
lineinfile:
path: /etc/login.defs
regexp: '^ENCRYPT_METHOD'
line: 'ENCRYPT_METHOD SHA512'
# V-38668 - System must use password complexity
- name: Configure password quality
lineinfile:
path: /etc/security/pwquality.conf
regexp: "{{ item.regexp }}"
line: "{{ item.line }}"
loop:
- { regexp: '^minlen', line: 'minlen = 14' }
- { regexp: '^dcredit', line: 'dcredit = -1' }
- { regexp: '^ucredit', line: 'ucredit = -1' }
- { regexp: '^ocredit', line: 'ocredit = -1' }
- { regexp: '^lcredit', line: 'lcredit = -1' }
# V-38658 - System must have NTP configured
- name: Install chrony
package:
name: chrony
state: present
- name: Enable and start chronyd
service:
name: chronyd
enabled: yes
state: started
# V-38465 - Audit log directory must be owned by root
- name: Set audit log ownership
file:
path: /var/log/audit
owner: root
group: root
mode: '0700'
state: directory
# V-38469 - All system command files must have permissions 755 or less
- name: Set command file permissions
file:
path: "{{ item }}"
mode: '0755'
loop:
- /bin
- /sbin
- /usr/bin
- /usr/sbin
- /usr/local/bin
- /usr/local/sbin
Secrets Management Best Practices
External Secrets Management
# Using HashiCorp Vault
---
- name: Retrieve secrets from Vault
hosts: all
vars:
vault_addr: https://vault.example.com:8200
vault_token: "{{ lookup('env', 'VAULT_TOKEN') }}"
tasks:
- name: Read secret from Vault
set_fact:
db_credentials: "{{ lookup('hashi_vault', 'secret=secret/data/database token={{ vault_token }} url={{ vault_addr }}') }}"
- name: Use credentials
postgresql_user:
name: "{{ db_credentials.username }}"
password: "{{ db_credentials.password }}"
state: present
no_log: true
# Using AWS Secrets Manager
---
- name: Retrieve secrets from AWS Secrets Manager
hosts: localhost
tasks:
- name: Get secret
set_fact:
api_key: "{{ lookup('aws_secret', 'prod/api/key', region='us-east-1') }}"
- name: Use secret
uri:
url: https://api.example.com/endpoint
headers:
Authorization: "Bearer {{ api_key }}"
no_log: true
# Using Azure Key Vault
---
- name: Retrieve secrets from Azure Key Vault
hosts: localhost
tasks:
- name: Get secret from Key Vault
azure_rm_keyvaultsecret_info:
vault_uri: https://myvault.vault.azure.net
name: database-password
register: secret
no_log: true
- name: Use secret
debug:
msg: "Secret retrieved"
no_log: true
Environment-Based Secrets
---
- name: Environment-specific secrets
hosts: all
vars:
environment: production
secrets_file: "vault_{{ environment }}.yml"
vars_files:
- "{{ secrets_file }}"
tasks:
- name: Verify environment
assert:
that:
- environment in ['development', 'staging', 'production']
fail_msg: "Invalid environment: {{ environment }}"
- name: Use environment-specific secret
debug:
msg: "Using {{ environment }} credentials"
no_log: true
# vault_production.yml (encrypted)
---
db_host: prod-db.example.com
db_password: "{{ vault_prod_db_password }}"
api_endpoint: https://api.prod.example.com
# vault_development.yml (encrypted)
---
db_host: localhost
db_password: "{{ vault_dev_db_password }}"
api_endpoint: http://localhost:8000
Security Scanning and Validation
Pre-Flight Security Checks
---
- name: Pre-deployment security validation
hosts: all
gather_facts: yes
tasks:
- name: Check if SELinux is enforcing
assert:
that:
- ansible_selinux.status == "enabled"
- ansible_selinux.mode == "enforcing"
fail_msg: "SELinux must be enabled and enforcing"
when: ansible_os_family == "RedHat"
- name: Verify firewall is active
command: systemctl is-active firewalld
register: firewall_status
failed_when: firewall_status.stdout != "active"
changed_when: false
- name: Check for unencrypted secrets
shell: grep -r "password\|secret\|key" group_vars/ host_vars/ --include="*.yml" | grep -v "vault"
delegate_to: localhost
register: unencrypted_secrets
failed_when: unencrypted_secrets.stdout != ""
changed_when: false
- name: Verify SSH key permissions
stat:
path: ~/.ssh/id_rsa
register: ssh_key
failed_when: ssh_key.stat.mode != "0600"
delegate_to: localhost
- name: Check for weak sudo configuration
shell: grep "NOPASSWD" /etc/sudoers /etc/sudoers.d/*
register: sudo_check
changed_when: false
failed_when: false
- name: Warn about weak sudo config
debug:
msg: "WARNING: Passwordless sudo detected"
when: sudo_check.stdout != ""
Vulnerability Scanning
---
- name: Security vulnerability assessment
hosts: all
become: yes
tasks:
- name: Install security scanning tools
package:
name:
- lynis
- rkhunter
- aide
state: present
- name: Run Lynis security scan
command: lynis audit system --quick
register: lynis_scan
changed_when: false
- name: Save Lynis report
copy:
content: "{{ lynis_scan.stdout }}"
dest: "/var/log/lynis-{{ inventory_hostname }}-{{ ansible_date_time.date }}.log"
- name: Run rootkit scan
command: rkhunter --check --skip-keypress --report-warnings-only
register: rkhunter_scan
changed_when: false
- name: Check for security updates
shell: |
if [ -f /usr/bin/yum ]; then
yum check-update --security | grep -E '^[a-zA-Z]'
elif [ -f /usr/bin/apt ]; then
apt list --upgradable 2>/dev/null | grep -i security
fi
register: security_updates
changed_when: false
failed_when: false
- name: Report pending security updates
debug:
msg: "Security updates available: {{ security_updates.stdout_lines | length }}"
when: security_updates.stdout != ""
Compliance Reporting
Generate Security Report
---
- name: Security compliance report
hosts: all
become: yes
gather_facts: yes
tasks:
- name: Collect security status
set_fact:
security_report:
hostname: "{{ inventory_hostname }}"
timestamp: "{{ ansible_date_time.iso8601 }}"
os: "{{ ansible_distribution }} {{ ansible_distribution_version }}"
kernel: "{{ ansible_kernel }}"
selinux: "{{ ansible_selinux.status | default('N/A') }}"
firewall: "{{ firewall_status.stdout | default('unknown') }}"
ssh_root_login: "{{ ssh_root_login.stdout | default('unknown') }}"
password_auth: "{{ ssh_password_auth.stdout | default('unknown') }}"
- name: Write report to file
copy:
content: "{{ security_report | to_nice_json }}"
dest: "/var/log/security-report-{{ inventory_hostname }}.json"
- name: Fetch report to control node
fetch:
src: "/var/log/security-report-{{ inventory_hostname }}.json"
dest: "./reports/"
flat: yes
Best Practices Summary
Security Best Practices:
- Always use Ansible Vault: Never store secrets in plain text
- Enable no_log: Prevent credentials from appearing in logs
- Use SSH keys: Disable password authentication
- Least privilege: Only grant necessary sudo permissions
- Rotate credentials: Regularly update passwords and keys
- Enable host key checking: Prevent man-in-the-middle attacks
- Audit logging: Log all privileged operations
- Regular updates: Keep Ansible and systems patched
- Security scanning: Regularly scan for vulnerabilities
- Compliance validation: Implement CIS/STIG controls
- File permissions: Protect configuration and vault files
- Network security: Use VPNs or bastions for remote access
Next Steps
- Learn about Ansible Vault in depth
- Explore Logging and Monitoring for audit trails
- Master Configuration security settings
- Study Troubleshooting security issues
- Practice in the Playground with secure playbooks