Repository / Automation and IaC /Deployment Guide /Ansible Playbook for DISA STIG Baseline Hardening on Linux

Ansible Playbook for DISA STIG Baseline Hardening on Linux

Ansible Playbook for DISA STIG Baseline Hardening on Linux

Security Technical Implementation Guides (STIGs) are the Defense Information Systems Agency's configuration standards for hardening systems. If you work in federal IT or defense contracting, STIGs aren't optional -- they're the baseline that auditors measure against.

Manually applying STIG controls to a single server takes hours. Doing it across 50, 200, or 1,000 servers by hand is a career-ending amount of work. Ansible makes it repeatable, auditable, and fast.

This guide covers building an Ansible playbook for STIG baseline hardening on Ubuntu and RHEL, the practical decisions around which controls to automate versus handle manually, and why passing a STIG scan and being secure are not always the same thing.

Understanding the STIG structure

Each STIG is organized into findings, identified by a Vulnerability ID (V-number) and a Rule ID (SV-number). Each finding has:

  • Severity: CAT I (Critical), CAT II (High), CAT III (Medium)
  • Check: How to verify if the system is compliant
  • Fix: How to remediate a non-compliant system
  • Discussion: Why this control matters

For a typical Ubuntu or RHEL STIG, expect 200-300 individual findings. Of those:

  • ~20-30 are CAT I (address these first -- they represent the most significant risk)
  • ~150-200 are CAT II (the bulk of the work)
  • ~30-50 are CAT III (lower risk, but still expected for full compliance)

Not every finding can be automated. Some require organizational decisions (password policy parameters), some require physical controls (console access restrictions), and some require manual verification (checking application-specific configurations). A realistic automation target is 70-80% of findings.

Playbook architecture

Role structure

Organize the hardening playbook as an Ansible role with tasks grouped by STIG category:

roles/
└── stig_baseline/
    ├── defaults/
    │   └── main.yml          # Default variables (tuneable per environment)
    ├── tasks/
    │   ├── main.yml           # Entry point
    │   ├── cat1.yml           # CAT I findings (critical)
    │   ├── cat2_auth.yml      # CAT II - Authentication
    │   ├── cat2_audit.yml     # CAT II - Auditing
    │   ├── cat2_network.yml   # CAT II - Network
    │   ├── cat2_filesystem.yml # CAT II - Filesystem
    │   ├── cat2_services.yml  # CAT II - Services
    │   └── cat3.yml           # CAT III findings
    ├── handlers/
    │   └── main.yml           # Service restart handlers
    ├── templates/
    │   ├── audit.rules.j2     # Auditd rules template
    │   ├── sshd_config.j2     # SSH hardening template
    │   ├── login.defs.j2      # Password policy template
    │   └── limits.conf.j2     # Resource limits template
    └── vars/
        ├── ubuntu2404.yml     # Ubuntu 24.04-specific vars
        └── rhel9.yml          # RHEL 9-specific vars

Variable-driven configuration

Make the playbook configurable, not hard-coded. Different environments have different requirements:

# defaults/main.yml
stig_password_min_length: 15
stig_password_min_days: 1
stig_password_max_days: 60
stig_password_warn_days: 7
stig_password_remember: 5

stig_session_timeout: 600    # 10 minutes
stig_login_attempts: 3
stig_lockout_duration: 900   # 15 minutes

stig_ssh_max_auth_tries: 4
stig_ssh_client_alive_interval: 600
stig_ssh_client_alive_count_max: 1
stig_ssh_permit_root_login: "no"
stig_ssh_protocol: 2
stig_ssh_allowed_ciphers: "aes256-ctr,aes192-ctr,aes128-ctr"
stig_ssh_allowed_macs: "hmac-sha2-512,hmac-sha2-256"

stig_audit_disk_full_action: "SYSLOG"
stig_audit_max_log_file: 25
stig_audit_num_logs: 10

# Controls that may need to be disabled in certain environments
stig_enable_fips: true
stig_enable_aide: true
stig_disable_usb_storage: true
stig_disable_bluetooth: true

This allows the same role to be applied across dev, staging, and production with different parameters. A development environment might relax the session timeout. A production environment in a SCIF might disable USB storage and Bluetooth.

Core hardening tasks

CAT I: The critical findings

CAT I findings represent the most severe risk. Address these first.

SSH root login:

# V-XXXXX: The SSH daemon must not allow root login
- name: "CAT I | Disable SSH root login"
  ansible.builtin.lineinfile:
    path: /etc/ssh/sshd_config
    regexp: '^#?PermitRootLogin'
    line: "PermitRootLogin {{ stig_ssh_permit_root_login }}"
    validate: 'sshd -t -f %s'
  notify: restart sshd
  tags: [cat1, ssh]

The validate parameter is critical. It runs sshd -t (config test) against the modified file before committing the change. If the config is invalid, Ansible rolls back. Without validation, a typo in sshd_config locks you out of the server.

FIPS mode:

# V-XXXXX: The system must implement NIST FIPS-validated cryptography
- name: "CAT I | Enable FIPS mode"
  when: stig_enable_fips
  block:
    - name: Install FIPS packages
      ansible.builtin.package:
        name: "{{ stig_fips_packages }}"
        state: present

    - name: Enable FIPS
      ansible.builtin.command: fips-mode-setup --enable
      register: fips_result
      changed_when: "'already enabled' not in fips_result.stdout"
      notify: reboot required
  tags: [cat1, fips]

FIPS mode requires a reboot and can break applications that rely on non-FIPS-approved cryptographic algorithms. Test thoroughly in a non-production environment first.

CAT II: Authentication and access control

Password complexity (PAM):

# V-XXXXX: Passwords must have minimum complexity
- name: "CAT II | Configure password complexity"
  ansible.builtin.lineinfile:
    path: /etc/security/pwquality.conf
    regexp: "^{{ item.key }}"
    line: "{{ item.key }} = {{ item.value }}"
  loop:
    - { key: "minlen", value: "{{ stig_password_min_length }}" }
    - { key: "dcredit", value: "-1" }
    - { key: "ucredit", value: "-1" }
    - { key: "lcredit", value: "-1" }
    - { key: "ocredit", value: "-1" }
    - { key: "difok", value: "8" }
    - { key: "maxrepeat", value: "3" }
  tags: [cat2, auth, pam]

Account lockout:

# V-XXXXX: Account lockout after failed attempts
- name: "CAT II | Configure account lockout"
  ansible.builtin.template:
    src: faillock.conf.j2
    dest: /etc/security/faillock.conf
    owner: root
    group: root
    mode: '0644'
  tags: [cat2, auth, pam]

CAT II: Audit configuration

Auditd is the backbone of STIG compliance monitoring. The audit rules define which system events are logged.

# V-XXXXX: Audit system must be configured to audit account modifications
- name: "CAT II | Deploy audit rules"
  ansible.builtin.template:
    src: audit.rules.j2
    dest: /etc/audit/rules.d/stig.rules
    owner: root
    group: root
    mode: '0640'
  notify: restart auditd
  tags: [cat2, audit]

The audit rules template covers:

# audit.rules.j2 (excerpt)

# User/group modification
-w /etc/passwd -p wa -k identity
-w /etc/group -p wa -k identity
-w /etc/shadow -p wa -k identity
-w /etc/gshadow -p wa -k identity
-w /etc/security/opasswd -p wa -k identity

# Login events
-w /var/log/faillog -p wa -k logins
-w /var/log/lastlog -p wa -k logins
-w /var/log/tallylog -p wa -k logins

# Privileged command execution
-a always,exit -F path=/usr/bin/sudo -F perm=x -F auid>=1000 -F auid!=4294967295 -k privileged
-a always,exit -F path=/usr/bin/su -F perm=x -F auid>=1000 -F auid!=4294967295 -k privileged

# File permission changes
-a always,exit -F arch=b64 -S chmod,fchmod,fchmodat -F auid>=1000 -F auid!=4294967295 -k perm_mod
-a always,exit -F arch=b64 -S chown,fchown,fchownat,lchown -F auid>=1000 -F auid!=4294967295 -k perm_mod

# Kernel module loading
-w /sbin/insmod -p x -k modules
-w /sbin/rmmod -p x -k modules
-w /sbin/modprobe -p x -k modules

# Make the audit configuration immutable (must be last rule)
-e 2

The -e 2 at the end makes the audit configuration immutable until reboot. This prevents an attacker from modifying audit rules to cover their tracks. It also means you need to reboot after changing audit rules -- which is intentional in a security context.

CAT II: Filesystem and services

Disable unnecessary services:

# V-XXXXX: Disable unnecessary services
- name: "CAT II | Disable and mask unnecessary services"
  ansible.builtin.systemd:
    name: "{{ item }}"
    state: stopped
    enabled: false
    masked: true
  loop:
    - autofs
    - avahi-daemon
    - bluetooth
    - cups
    - rpcbind
    - nfs-server
  failed_when: false  # Don't fail if service doesn't exist
  tags: [cat2, services]

Filesystem mount options:

# V-XXXXX: /tmp must be mounted with noexec
- name: "CAT II | Configure /tmp mount options"
  ansible.posix.mount:
    path: /tmp
    src: tmpfs
    fstype: tmpfs
    opts: "defaults,noexec,nosuid,nodev,size=2G"
    state: mounted
  tags: [cat2, filesystem]

Running the playbook

Dry run first. Always.

ansible-playbook -i inventory/production stig-hardening.yml --check --diff

--check mode shows what would change without making changes. --diff shows the actual content differences. Review the output before running for real.

Staged rollout

Never apply STIG hardening to all servers simultaneously. The playbook will break something -- a service that depends on a disabled protocol, an application that can't handle the session timeout, a monitoring agent that uses a non-FIPS cipher.

Rollout order:

  1. Lab/dev environment (break things here)
  2. Single production server (canary)
  3. Small production batch (10-20%)
  4. Full production fleet

Wait at least 24 hours between stages. Some failures don't appear immediately -- they surface when a user hits a specific workflow, when a certificate renews, or when a scheduled job runs.

Tagging for selective application

The tag structure allows you to apply specific categories:

# Apply only CAT I findings
ansible-playbook stig-hardening.yml --tags cat1

# Apply only SSH hardening
ansible-playbook stig-hardening.yml --tags ssh

# Apply everything except FIPS (for environments where FIPS breaks things)
ansible-playbook stig-hardening.yml --skip-tags fips

Scanning after hardening

After applying the playbook, validate compliance with a STIG scanning tool:

  • SCAP Compliance Checker (SCC) -- DISA's official scanner
  • OpenSCAP -- Open-source alternative (oscap CLI)
  • STIGMAN -- DISA's enterprise STIG management platform

The scan will generate a checklist showing compliant (green), non-compliant (red), and not applicable (not reviewed) findings. No automated playbook achieves 100% compliance -- the remaining findings require manual review, organizational policy decisions, or compensating controls.

Expect your first scan after automated hardening to show 70-80% compliance. The remaining 20-30% is the manual work that can't be automated.

The gap between compliance and security

A STIG-compliant system is not necessarily a secure system, and a secure system is not necessarily STIG-compliant. STIGs are a baseline -- they represent minimum acceptable configuration. They don't cover:

  • Application-level security
  • Network architecture
  • Access control policy decisions
  • Monitoring and incident response
  • Patch management cadence

Conversely, a well-architected system with defense in depth may have intentional STIG deviations documented as compensating controls. A CAT II finding for "USB storage not disabled" might be acceptable in a system that's air-gapped with physical access controls.

The goal isn't a perfect STIG score. The goal is a secure system with documented, justified deviations. The playbook gets you to baseline. Engineering judgment gets you to secure.