Ansible's agentless model reduces attack surface but shifts risk to the control node and SSH key management. These are the high-priority controls before running any playbooks in production.
ansible.cfg to 0640 permissions; world-readable configs leak inventory and vault settings.requirements.txt / requirements.yml. Floating versions allow silent supply-chain substitution.host_key_checking only in ephemeral CI; never in production environments.# Generate a dedicated deployment key (Ed25519, no passphrase in CI) ssh-keygen -t ed25519 -C "ansible-deploy@ci" -f ~/.ssh/ansible_ed25519 -N "" # Restrict to specific source IPs on managed hosts from="10.0.1.0/24",no-agent-forwarding,no-X11-forwarding,no-pty \ ssh-ed25519 AAAA... ansible-deploy@ci
inventory/ subdirectory layout, not a single hosts file.Vault provides symmetric AES-256-CTR encryption for secrets stored alongside playbooks. It is not a secrets manager—it is a way to safely commit encrypted data to version control.
# Encrypt a file ansible-vault encrypt group_vars/prod/secrets.yml # Encrypt a single value for inline use ansible-vault encrypt_string 'p@ssw0rd' --name 'db_password' # Run a playbook with vault password from file (CI-friendly) ansible-playbook site.yml --vault-password-file ~/.vault_pass
Use vault IDs to separate secrets by classification level or team, avoiding a single shared password.
# Encrypt with a named vault ID ansible-vault encrypt_string 'secret' --vault-id prod@prompt --name 'api_key' # Decrypt at runtime by supplying multiple sources ansible-playbook site.yml \ --vault-id dev@~/.vault_dev \ --vault-id prod@~/.vault_prod
For production, prefer pulling secrets at runtime from a dedicated secrets manager rather than storing them vault-encrypted in the repo:
| Backend | Mechanism | Collection |
|---|---|---|
| HashiCorp Vault | lookup plugin, dynamic credentials | community.hashi_vault |
| AWS Secrets Manager | lookup plugin | amazon.aws |
| Azure Key Vault | lookup plugin | azure.azcollection |
| CyberArk | lookup + connection plugin | cyberark.pas |
# Pull a secret from HashiCorp Vault at task runtime - name: Configure database ansible.builtin.template: src: db.conf.j2 dest: /etc/app/db.conf mode: '0640' vars: db_pass: "{{ lookup('community.hashi_vault.hashi_vault', 'secret=secret/prod/db:password') }}"
--extra-vars on the CLI. They appear in process listings (ps aux) and shell history. Use vault files or lookup plugins.
Ansible's become mechanism wraps sudo, su, pbrun, and others. Misuse is one of the most common security findings in Ansible audits.
become: false at the play level; override with become: true only at the task level where root is actually required.become_user to escalate to a service account rather than root when possible.sudoers with NOPASSWD only for the specific commands Ansible needs, not blanket ALL.
# /etc/sudoers.d/ansible — restrict to exact commands
ansible ALL=(root) NOPASSWD: /usr/bin/systemctl restart nginx, \
/usr/bin/apt-get install -y nginx
# Play-level default: no escalation - hosts: webservers become: false tasks: - name: Deploy config (no root needed) ansible.builtin.copy: src: app.conf dest: /home/app/config/app.conf - name: Restart nginx (root required) ansible.builtin.systemd: name: nginx state: restarted become: true
| Method | Risk | Recommendation |
|---|---|---|
| sudo | Low–Med | Default; restrict via sudoers |
| su | Medium | Requires root password; avoid |
| pbrun / pfexec | Low | Use in PAM-controlled environments |
| doas | Low | Preferred on OpenBSD |
| runas | Medium | Windows; integrate with AD GPO |
The devsec.hardening collection implements CIS Benchmark controls as idempotent roles for Linux and Windows. It is the de facto standard starting point.
# requirements.yml collections: - name: devsec.hardening version: ">=8.0"
# site.yml — apply OS and SSH hardening roles - hosts: all become: true roles: - role: devsec.hardening.os_hardening vars: os_security_kernel_enable_sysrq: false os_security_kernel_enable_core_dump: false os_auth_pam_passwdqc_enable: true os_auth_pw_max_age: 60 os_auth_pw_min_age: 7 - role: devsec.hardening.ssh_hardening vars: ssh_permit_root_login: 'no' ssh_password_authentication: 'no' ssh_kex: - curve25519-sha256 - diffie-hellman-group16-sha512 ssh_ciphers: - chacha20-poly1305@openssh.com - aes256-gcm@openssh.com
# Delivered via ansible.posix.sysctl module - name: Harden kernel parameters ansible.posix.sysctl: name: "{{ item.name }}" value: "{{ item.value }}" state: present reload: true loop: - {name: net.ipv4.ip_forward, value: '0'} - {name: net.ipv4.conf.all.rp_filter, value: '1'} - {name: net.ipv4.conf.all.accept_redirects, value: '0'} - {name: kernel.randomize_va_space, value: '2'} - {name: kernel.dmesg_restrict, value: '1'} - {name: fs.suid_dumpable, value: '0'}
Ansible can both remediate and audit compliance states. Red Hat's rhel-system-roles and community roles from OpenSCAP cover the major frameworks.
- name: Run OpenSCAP scan and generate report hosts: rhel_nodes become: true tasks: - name: Install OpenSCAP scanner ansible.builtin.package: name: ['openscap-scanner', 'scap-security-guide'] state: present - name: Execute STIG scan ansible.builtin.command: cmd: > oscap xccdf eval --profile xccdf_org.ssgproject.content_profile_stig --results /tmp/scan_results.xml --report /tmp/scan_report.html /usr/share/xml/scap/ssg/content/ssg-rhel9-ds.xml register: scan_result changed_when: false failed_when: scan_result.rc not in [0, 2] - name: Fetch report to control node ansible.builtin.fetch: src: /tmp/scan_report.html dest: reports/{{ inventory_hostname }}_stig.html flat: true
# CIS Level 1 — Password policy enforcement (Debian/Ubuntu) - name: CIS 5.4.1 — Set password expiration policy ansible.builtin.lineinfile: path: /etc/login.defs regexp: "{{ item.regexp }}" line: "{{ item.line }}" state: present loop: - {regexp: '^PASS_MAX_DAYS', line: 'PASS_MAX_DAYS 90'} - {regexp: '^PASS_MIN_DAYS', line: 'PASS_MIN_DAYS 7'} - {regexp: '^PASS_WARN_AGE', line: 'PASS_WARN_AGE 14'}
| Framework | Tool / Role | Scope |
|---|---|---|
| CIS Benchmark | devsec.hardening, ansible-lockdown/*-CIS | Linux, Windows Server |
| DISA STIG | ansible-lockdown/*-STIG, OpenSCAP | RHEL 7/8/9, Windows |
| PCI-DSS v4 | Custom roles + community.general | Cardholder environment |
| NIST 800-53 | rhel-system-roles + OpenSCAP profiles | FedRAMP baseline |
| HIPAA | Custom audit roles, auditd configuration | Healthcare environments |
- name: Configure auditd rules for compliance hosts: all become: true roles: - role: ansible-role-auditd vars: auditd_rules: - "-w /etc/passwd -p wa -k identity" - "-w /etc/shadow -p wa -k identity" - "-w /etc/sudoers -p wa -k privilege_escalation" - "-a always,exit -F arch=b64 -S execve -k exec" - "-a always,exit -F arch=b64 -S open -F exit=-EPERM -k access" auditd_max_log_file_action: rotate auditd_num_logs: 10 auditd_max_log_file: 50
All playbook runs should produce structured, centralized logs. Configure the log_path in ansible.cfg and ship logs to a SIEM:
# ansible.cfg [defaults] log_path = /var/log/ansible/ansible.log callback_whitelist = json, timer [callback_json] # Use JSON callback for machine-parseable output ANSIBLE_STDOUT_CALLBACK = json
no_log: true task attribute on any task that handles secrets. Without it, variable values—including those pulled from Vault—will appear in logs and --diff output.
- name: Set database password ansible.builtin.lineinfile: path: /etc/app/db.conf regexp: '^DB_PASS=' line: "DB_PASS={{ db_password }}" no_log: true
- name: Enforce firewall policy ansible.posix.firewalld: service: "{{ item.service }}" permanent: true state: "{{ item.state }}" zone: public immediate: true loop: - {service: ssh, state: enabled} - {service: https, state: enabled} - {service: http, state: disabled} - {service: telnet, state: disabled}
- name: Set ufw default deny inbound community.general.ufw: state: enabled policy: deny direction: incoming - name: Allow management SSH from bastion only community.general.ufw: rule: allow port: '22' proto: tcp src: '10.0.1.10'
- name: Disable unused Cisco services hosts: ios_switches connection: ansible.netcommon.network_cli tasks: - name: Apply security baseline cisco.ios.ios_config: lines: - no service tcp-small-servers - no service udp-small-servers - no ip http server - no ip finger - no cdp run - service password-encryption
ansible-lint catches security misconfigurations before code reaches infrastructure. It ships with a security profile and integrates into pre-commit hooks and CI pipelines.
| Rule ID | Description | Profile |
|---|---|---|
| no-log-password | Detects tasks with passwords that lack no_log | security |
| risky-file-permissions | Flags world-writable file modes | safety |
| command-instead-of-module | Shell/command instead of idempotent module | idiom |
| package-latest | state: latest creates non-deterministic installs | safety |
| galaxy[version-incorrect] | Unpinned collection version | production |
| yaml[truthy] | Ambiguous truthy values (yes/no instead of true/false) | formatting |
# .ansible-lint — project config profile: production # min: basic, moderate, safety, shared, production warn_list: - experimental skip_list: - role-name # Skip if using internal naming conventions exclude_paths: - .cache/ - molecule/ rulesdir: - custom_rules/ # Project-specific rules
# .pre-commit-config.yaml
repos:
- repo: https://github.com/ansible/ansible-lint
rev: v24.2.0
hooks:
- id: ansible-lint
pass_filenames: false
Every Ansible change should pass a security gate before merging. A minimal pipeline checks syntax, linting, and runs Molecule tests against a hardened image.
# .github/workflows/ansible-security.yml (GitHub Actions)
name: Ansible Security Gate
on: [pull_request]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: pip install ansible ansible-lint
- name: Syntax check
run: ansible-playbook --syntax-check site.yml -i inventory/staging
- name: ansible-lint (production profile)
run: ansible-lint --profile production
molecule:
needs: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Molecule tests
run: molecule test
env:
MOLECULE_DISTRO: registry.access.redhat.com/ubi9/ubi-init
secrets-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Detect committed secrets
uses: trufflesecurity/trufflehog@main
with:
path: ./
base: ${{ github.event.repository.default_branch }}
Ansible Automation Controller (formerly AWX / Tower) provides multi-tenant RBAC over inventories, credentials, job templates, and organizations. Access control is applied at the object level.
| Role | Scope | Typical Assignee |
|---|---|---|
| System Administrator | Global | Platform ops team |
| Organization Admin | Org-level | Team lead |
| Project Admin | Project-level | Senior engineer |
| Execute | Job template | Developer, operator |
| Use | Credential / Inventory | CI/CD service account |
| Read | Any object | Auditor |
# Create a team and assign Execute role on a job template
curl -sX POST https://controller.example.com/api/v2/teams/ \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "ops-team", "organization": 1}'
curl -sX POST \
https://controller.example.com/api/v2/job_templates/42/access_list/ \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"role": "execute", "team": <team_id>}'