Add playbooks/suites/fr1_auth.yml

This commit is contained in:
2026-07-07 16:57:37 +00:00
parent 7d1f610a23
commit bde8d5c91a
+287
View File
@@ -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 <new-uid> <user>'
}] }}"
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 <account>'
}] }}"
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 <user>'
}] }}"
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 <user>'
}] }}"
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