Add playbooks/suites/fr1_auth.yml
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user