--- # suites/fr1_auth.yml # # FR1 — Identification and Authentication Control (IAC) # IEC 62443-3-3 SL2 requirements: # SR 1.1 Unique user identification # SR 1.3 Account management (no default/unused accounts) # SR 1.4 Identifier strength (no empty passwords) # SR 1.5 Authenticator strength (password policy) # SR 1.7 Password lifetime/aging # SR 1.11 Unsuccessful login attempts (account lockout) # # Pattern: Every test is wrapped in a block + ignore_errors. # "Gather" tasks collect facts; "Evaluate" tasks judge pass/fail # and append to test_results[]. # ── SR 1.1: Unique User Identification ────────────────────────── - block: - name: "Gather: Scan for duplicate UIDs" ansible.builtin.shell: | awk -F: '{print $3}' /etc/passwd | sort -n | uniq -d register: _dup_uid changed_when: false - name: "Evaluate: IAC-01" ansible.builtin.set_fact: test_results: "{{ test_results | default([]) + [{ 'test_id': 'IAC-01', 'category': 'FR1 - Identification and Authentication Control', 'requirement': 'SR 1.1 — Unique User Identification', 'description': 'Every user account shall have a unique UID', 'passed': (_dup_uid.stdout | trim | length == 0), 'expected': 'No duplicate UIDs in /etc/passwd', 'actual': (_dup_uid.stdout | trim | default('None found', true)), 'severity': 'high', 'remediation': 'Change duplicate UIDs with: usermod -u ' }] }}" ignore_errors: yes # ── SR 1.3: No default/unused accounts ────────────────────────── - block: - name: "Gather: Check for well-known default accounts" ansible.builtin.shell: | for acct in games news gopher ftp nobody; do if grep -q "^${acct}:" /etc/passwd; then echo "$acct" fi done register: _default_accts changed_when: false - name: "Evaluate: IAC-02" ansible.builtin.set_fact: test_results: "{{ test_results + [{ 'test_id': 'IAC-02', 'category': 'FR1 - Identification and Authentication Control', 'requirement': 'SR 1.3 — Account Management', 'description': 'Default/unnecessary system accounts shall be removed or disabled', 'passed': (_default_accts.stdout | trim | length == 0), 'expected': 'No default accounts present', 'actual': (_default_accts.stdout | trim | default('None found', true)), 'severity': 'medium', 'remediation': 'Delete default accounts: userdel ' }] }}" ignore_errors: yes - block: - name: "Gather: Check accounts with valid shells that have never logged in" ansible.builtin.shell: | # Accounts with real shells that show no login records join -t: -1 1 -2 1 \ <(grep -E ':(/bin/(ba)?sh|/usr/bin/(ba)?sh)$' /etc/passwd | sort) \ <(lastlog | tail -n +2 | awk '{print $1, $NF}' | sort) 2>/dev/null | awk '$NF == "**Never" {print $1 " (shell: " $7 ")"}' || true args: executable: /bin/bash register: _unused changed_when: false - name: "Evaluate: IAC-03" ansible.builtin.set_fact: test_results: "{{ test_results + [{ 'test_id': 'IAC-03', 'category': 'FR1 - Identification and Authentication Control', 'requirement': 'SR 1.3 — Account Management', 'description': 'Human user accounts that have never logged in shall be reviewed', 'passed': (_unused.stdout | trim | length == 0), 'expected': 'No unused human accounts with valid shells', 'actual': (_unused.stdout | trim | default('None found', true)), 'severity': 'low', 'remediation': 'Lock stale accounts: usermod -L ' }] }}" ignore_errors: yes # ── SR 1.4: No empty passwords ────────────────────────────────── - block: - name: "Gather: Check /etc/shadow for empty password fields" ansible.builtin.shell: | awk -F: '($2 == "" || $2 == "!" || $2 ~ /^\$[156]\$/) {next} $2 == "!!" || $2 == "*" {next} {print $1 " (field: " $2 ")"}' /etc/shadow register: _empty_pw changed_when: false - name: "Evaluate: IAC-04" ansible.builtin.set_fact: test_results: "{{ test_results + [{ 'test_id': 'IAC-04', 'category': 'FR1 - Identification and Authentication Control', 'requirement': 'SR 1.4 — Identifier Strength', 'description': 'No account shall have an empty or trivially-weak password hash', 'passed': (_empty_pw.stdout | trim | length == 0), 'expected': 'All accounts have proper password hashes', 'actual': (_empty_pw.stdout | trim | default('All accounts OK', true)), 'severity': 'critical', 'remediation': 'Set a password or lock the account: passwd -l ' }] }}" ignore_errors: yes # ── SR 1.5: Password complexity (via pwquality / PAM) ─────────── - block: - name: "Gather: Check pwquality minlen" ansible.builtin.shell: | grep -E '^\s*minlen\s*=' /etc/security/pwquality.conf 2>/dev/null | tail -1 || echo "NOT SET" register: _minlen changed_when: false - name: "Evaluate: IAC-05" ansible.builtin.set_fact: test_results: "{{ test_results + [{ 'test_id': 'IAC-05', 'category': 'FR1 - Identification and Authentication Control', 'requirement': 'SR 1.5 — Authenticator Strength', 'description': 'Password minimum length shall be ≥ 14 characters', 'passed': ( _minlen.stdout | regex_search('minlen\s*=\s*([0-9]+)') | regex_replace('minlen\s*=\s*', '') | int >= 14 ), 'expected': 'minlen >= 14 in /etc/security/pwquality.conf', 'actual': _minlen.stdout | trim, 'severity': 'high', 'remediation': 'Set minlen=14 in /etc/security/pwquality.conf' }] }}" ignore_errors: yes - block: - name: "Gather: Check pwquality dcredit/ocredit/ucredit/lcredit" ansible.builtin.shell: | for p in dcredit ucredit lcredit ocredit minclass; do val=$(grep -E "^\s*${p}\s*=" /etc/security/pwquality.conf 2>/dev/null | tail -1 | awk -F= '{print $2}' | tr -d ' ') echo "${p}=${val:-NOT SET}" done register: _pwquality changed_when: false - name: "Evaluate: IAC-06" ansible.builtin.set_fact: test_results: "{{ test_results + [{ 'test_id': 'IAC-06', 'category': 'FR1 - Identification and Authentication Control', 'requirement': 'SR 1.5 — Authenticator Strength', 'description': 'Password shall require at least 1 of each character class', 'passed': ( (_pwquality.stdout | regex_search('dcredit\s*=\s*-?1') and _pwquality.stdout | regex_search('ucredit\s*=\s*-?1') and _pwquality.stdout | regex_search('lcredit\s*=\s*-?1') and _pwquality.stdout | regex_search('ocredit\s*=\s*-?1')) or (_pwquality.stdout | regex_search('minclass\s*=\s*[3-4]')) ), 'expected': 'At least 3 character classes required', 'actual': _pwquality.stdout | trim, 'severity': 'medium', 'remediation': 'Set dcredit=-1, ucredit=-1, lcredit=-1, ocredit=-1 in pwquality.conf' }] }}" ignore_errors: yes # ── SR 1.7: Password aging (max days) ─────────────────────────── - block: - name: "Gather: Check PASS_MAX_DAYS in login.defs" ansible.builtin.shell: | grep '^\s*PASS_MAX_DAYS' /etc/login.defs | awk '{print $2}' register: _max_days changed_when: false - name: "Evaluate: IAC-07" ansible.builtin.set_fact: test_results: "{{ test_results + [{ 'test_id': 'IAC-07', 'category': 'FR1 - Identification and Authentication Control', 'requirement': 'SR 1.7 — Password Lifetime', 'description': 'Password maximum age shall be ≤ 90 days', 'passed': (_max_days.stdout | trim | int <= 90), 'expected': 'PASS_MAX_DAYS ≤ 90', 'actual': 'PASS_MAX_DAYS=' + (_max_days.stdout | trim | default('NOT SET', true)), 'severity': 'medium', 'remediation': 'Set PASS_MAX_DAYS 90 in /etc/login.defs' }] }}" ignore_errors: yes - block: - name: "Gather: Check PASS_MIN_DAYS in login.defs" ansible.builtin.shell: | grep '^\s*PASS_MIN_DAYS' /etc/login.defs | awk '{print $2}' register: _min_days changed_when: false - name: "Evaluate: IAC-08" ansible.builtin.set_fact: test_results: "{{ test_results + [{ 'test_id': 'IAC-08', 'category': 'FR1 - Identification and Authentication Control', 'requirement': 'SR 1.7 — Password Lifetime', 'description': 'Password minimum change interval shall be ≥ 1 day', 'passed': (_min_days.stdout | trim | int >= 1), 'expected': 'PASS_MIN_DAYS ≥ 1', 'actual': 'PASS_MIN_DAYS=' + (_min_days.stdout | trim | default('NOT SET', true)), 'severity': 'low', 'remediation': 'Set PASS_MIN_DAYS 1 in /etc/login.defs' }] }}" ignore_errors: yes # ── SR 1.11: Account lockout ──────────────────────────────────── - block: - name: "Gather: Check pam_tally2 or pam_faillock configuration" ansible.builtin.shell: | if grep -q 'pam_faillock\.so' /etc/pam.d/common-auth 2>/dev/null; then grep 'deny=' /etc/pam.d/common-auth | grep -oP 'deny=\K[0-9]+' || echo "0" elif grep -q 'pam_tally2\.so' /etc/pam.d/common-auth 2>/dev/null; then grep 'deny=' /etc/pam.d/common-auth | grep -oP 'deny=\K[0-9]+' || echo "0" else echo "LOCKOUT NOT CONFIGURED" fi register: _lockout changed_when: false - name: "Evaluate: IAC-09" ansible.builtin.set_fact: test_results: "{{ test_results + [{ 'test_id': 'IAC-09', 'category': 'FR1 - Identification and Authentication Control', 'requirement': 'SR 1.11 — Unsuccessful Login Attempts', 'description': 'Account lockout shall trigger after ≤ 5 failed attempts', 'passed': ( _lockout.stdout | trim | regex_search('^[0-9]+$') and (_lockout.stdout | trim | int > 0) and (_lockout.stdout | trim | int <= 5) ), 'expected': 'Account lockout configured with deny ≤ 5', 'actual': _lockout.stdout | trim, 'severity': 'high', 'remediation': 'Configure pam_faillock in /etc/pam.d/common-auth: deny=5' }] }}" ignore_errors: yes # ── SR 1.5 (cont): Password history ───────────────────────────── - block: - name: "Gather: Check password history in PAM" ansible.builtin.shell: | grep -E 'pam_pwhistory\.so|remember=' /etc/pam.d/common-password 2>/dev/null | grep -oP 'remember=\K[0-9]+' || echo "NOT SET" register: _pw_history changed_when: false - name: "Evaluate: IAC-10" ansible.builtin.set_fact: test_results: "{{ test_results + [{ 'test_id': 'IAC-10', 'category': 'FR1 - Identification and Authentication Control', 'requirement': 'SR 1.6 — Password History', 'description': 'Password history shall prevent reuse of last 5+ passwords', 'passed': ( _pw_history.stdout | trim | regex_search('^[0-9]+$') and (_pw_history.stdout | trim | int >= 5) ), 'expected': 'pam_pwhistory remember ≥ 5', 'actual': 'remember=' + (_pw_history.stdout | trim), 'severity': 'medium', 'remediation': 'Add "remember=5" to pam_pwhistory.so in /etc/pam.d/common-password' }] }}" ignore_errors: yes