diff --git a/playbooks/suites/fr1_auth.yml b/playbooks/suites/fr1_auth.yml new file mode 100644 index 0000000..542cb2f --- /dev/null +++ b/playbooks/suites/fr1_auth.yml @@ -0,0 +1,287 @@ +--- +# 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