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