Ansible Security and Compliance

01 — Security Architecture Considerations

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.

Control Node Hardening

SSH Key Management

# 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
Warning RSA keys below 4096-bit are deprecated in FIPS-140-2 environments. Use Ed25519 or ECDSA P-384.

Inventory Security

02 — Secrets Management with Ansible Vault

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.

Basic Usage

# 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

Multiple Vault IDs

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

External Secrets Integration

For production, prefer pulling secrets at runtime from a dedicated secrets manager rather than storing them vault-encrypted in the repo:

BackendMechanismCollection
HashiCorp Vaultlookup plugin, dynamic credentialscommunity.hashi_vault
AWS Secrets Managerlookup pluginamazon.aws
Azure Key Vaultlookup pluginazure.azcollection
CyberArklookup + connection plugincyberark.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') }}"
Critical Never pass secrets via --extra-vars on the CLI. They appear in process listings (ps aux) and shell history. Use vault files or lookup plugins.

03 — Privilege Escalation Controls

Ansible's become mechanism wraps sudo, su, pbrun, and others. Misuse is one of the most common security findings in Ansible audits.

Least-Privilege Principle

# /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

become_method Hardening

MethodRiskRecommendation
sudoLow–MedDefault; restrict via sudoers
suMediumRequires root password; avoid
pbrun / pfexecLowUse in PAM-controlled environments
doasLowPreferred on OpenBSD
runasMediumWindows; integrate with AD GPO

04 — OS Hardening Playbooks

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

Key Kernel Parameters (sysctl)

# 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'}

05 — Compliance Automation (CIS / STIG)

Ansible can both remediate and audit compliance states. Red Hat's rhel-system-roles and community roles from OpenSCAP cover the major frameworks.

OpenSCAP Integration

- 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 Benchmark Remediation

# 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 Coverage Matrix

FrameworkTool / RoleScope
CIS Benchmarkdevsec.hardening, ansible-lockdown/*-CISLinux, Windows Server
DISA STIGansible-lockdown/*-STIG, OpenSCAPRHEL 7/8/9, Windows
PCI-DSS v4Custom roles + community.generalCardholder environment
NIST 800-53rhel-system-roles + OpenSCAP profilesFedRAMP baseline
HIPAACustom audit roles, auditd configurationHealthcare environments

06 — Auditing & Logging

auditd Configuration

- 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

Ansible Playbook Logging

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
Note Use the 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

07 — Network & Firewall Automation

firewalld (RHEL / CentOS)

- 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}

ufw (Debian / Ubuntu)

- 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'

Network Device Hardening (Cisco IOS)

- 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

08 — Static Analysis with ansible-lint

ansible-lint catches security misconfigurations before code reaches infrastructure. It ships with a security profile and integrates into pre-commit hooks and CI pipelines.

Security-Relevant Rules

Rule IDDescriptionProfile
no-log-passwordDetects tasks with passwords that lack no_logsecurity
risky-file-permissionsFlags world-writable file modessafety
command-instead-of-moduleShell/command instead of idempotent moduleidiom
package-lateststate: latest creates non-deterministic installssafety
galaxy[version-incorrect]Unpinned collection versionproduction
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 Integration

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/ansible/ansible-lint
    rev: v24.2.0
    hooks:
      - id: ansible-lint
        pass_filenames: false

09 — CI/CD Security Gates

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 }}
Best Practice Block merge if TruffleHog or git-secrets detects plaintext credentials. Vault-encrypted blobs are safe to commit; raw passwords, API keys, and private keys are not.

10 — RBAC in Automation Controller

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 Hierarchy

RoleScopeTypical Assignee
System AdministratorGlobalPlatform ops team
Organization AdminOrg-levelTeam lead
Project AdminProject-levelSenior engineer
ExecuteJob templateDeveloper, operator
UseCredential / InventoryCI/CD service account
ReadAny objectAuditor

Credential Isolation

Automation via the Controller API

# 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>}'
Warning Service accounts used by CI/CD pipelines should have Execute role on specific job templates only—never Organization Admin. Rotate their tokens on a schedule (≤90 days) and use OAuth2 application tokens rather than personal tokens.