CYBER-0 initial concept ready

This commit is contained in:
Ole Valente
2026-09-22 00:00:45 +02:00
parent 29eecd4f70
commit edb6cde069
70 changed files with 1976 additions and 3140 deletions
@@ -0,0 +1,291 @@
---
# 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*(\d+)', '\1') | default(['0'], true) | first | 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 | regex_search('^[0-9]+$') and
(_max_days.stdout | trim | int > 0) and
(_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
@@ -0,0 +1,165 @@
---
# suites/fr2_use_control.yml
#
# FR2 — Use Control (UC)
# IEC 62443-3-3 SL2 requirements:
# SR 2.1 Authorization enforcement (sudo/privilege separation)
# SR 2.4 Audit log integrity
# SR 2.5 Session lock (terminal idle timeout)
# SR 2.8 Auditable events (auditd active)
# SR 2.9 Audit storage capacity
# ── SR 2.1: Sudo privilege separation ───────────────────────────
- block:
- name: "Gather: Check for unrestricted sudo access (NOPASSWD ALL)"
ansible.builtin.shell: |
grep -r 'NOPASSWD.*ALL' /etc/sudoers /etc/sudoers.d/ 2>/dev/null |
grep -v '^\s*#' | grep -v '^$' || true
register: _nopasswd
changed_when: false
- name: "Evaluate: UC-01"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'UC-01',
'category': 'FR2 — Use Control',
'requirement': 'SR 2.1 — Authorization Enforcement',
'description': 'No user shall have unrestricted NOPASSWD sudo access to all commands',
'passed': (_nopasswd.stdout | trim | length == 0),
'expected': 'No NOPASSWD ALL entries in sudoers',
'actual': (_nopasswd.stdout | trim | default('None found', true)),
'severity': 'high',
'remediation': 'Restrict sudo rules to specific commands and require authentication'
}] }}"
ignore_errors: yes
- block:
- name: "Gather: Check sudoers file permissions"
ansible.builtin.stat:
path: /etc/sudoers
register: _sudoers_stat
- name: "Evaluate: UC-02"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'UC-02',
'category': 'FR2 — Use Control',
'requirement': 'SR 2.1 — Authorization Enforcement',
'description': '/etc/sudoers shall be owned by root:root with mode 0440',
'passed': (
_sudoers_stat.stat.exists and
_sudoers_stat.stat.pw_name == 'root' and
_sudoers_stat.stat.gr_name == 'root' and
_sudoers_stat.stat.mode == '0440'
),
'expected': 'root:root 0440',
'actual': (_sudoers_stat.stat.pw_name + ':' + _sudoers_stat.stat.gr_name + ' ' + _sudoers_stat.stat.mode) if _sudoers_stat.stat.exists else 'FILE NOT FOUND',
'severity': 'critical',
'remediation': 'chown root:root /etc/sudoers && chmod 0440 /etc/sudoers'
}] }}"
ignore_errors: yes
# ── SR 2.5: Session lock / terminal timeout ─────────────────────
- block:
- name: "Gather: Check TMOUT setting in /etc/profile or /etc/bash.bashrc"
ansible.builtin.shell: |
for f in /etc/profile /etc/bash.bashrc /etc/profile.d/*.sh; do
[ -f "$f" ] && grep -h 'TMOUT=' "$f" 2>/dev/null
done | tail -1 | grep -oP 'TMOUT=\K[0-9]+' || echo "NOT SET"
register: _tmout
changed_when: false
- name: "Evaluate: UC-03"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'UC-03',
'category': 'FR2 — Use Control',
'requirement': 'SR 2.5 — Session Lock',
'description': 'Interactive shell sessions shall timeout after ≤ 900 seconds of inactivity',
'passed': (
_tmout.stdout | trim | regex_search('^[0-9]+$') and
(_tmout.stdout | trim | int > 0) and
(_tmout.stdout | trim | int <= 900)
),
'expected': 'TMOUT set between 1-900 seconds',
'actual': 'TMOUT=' + (_tmout.stdout | trim),
'severity': 'medium',
'remediation': 'Add "readonly TMOUT=900" to /etc/profile'
}] }}"
ignore_errors: yes
# ── SR 2.4: Audit log integrity (immutability) ──────────────────
- block:
- name: "Gather: Check if auditd immutable mode is configured"
ansible.builtin.shell: |
grep -c '^\s*-e\s*2' /etc/audit/rules.d/*.rules /etc/audit/audit.rules 2>/dev/null || echo "0"
register: _audit_immutable
changed_when: false
- name: "Evaluate: UC-04"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'UC-04',
'category': 'FR2 — Use Control',
'requirement': 'SR 2.4 — Audit Log Integrity',
'description': 'Audit configuration shall be immutable (-e 2)',
'passed': (_audit_immutable.stdout | trim | int > 0),
'expected': 'audit.rules contains -e 2',
'actual': ('Immutable rules found: ' + _audit_immutable.stdout) if (_audit_immutable.stdout | trim | int > 0) else 'Immutable flag NOT set',
'severity': 'high',
'remediation': 'Add "-e 2" to /etc/audit/audit.rules (requires reboot)'
}] }}"
ignore_errors: yes
# ── SR 2.8: Auditable events (auditd running) ───────────────────
- block:
- name: "Gather: Check auditd service status"
ansible.builtin.shell: |
systemctl is-active auditd 2>/dev/null || echo "inactive"
register: _auditd_active
changed_when: false
- name: "Evaluate: UC-05"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'UC-05',
'category': 'FR2 — Use Control',
'requirement': 'SR 2.8 — Auditable Events',
'description': 'The audit daemon (auditd) shall be running and enabled',
'passed': (_auditd_active.stdout | trim == 'active'),
'expected': 'auditd service is active',
'actual': 'auditd is ' + (_auditd_active.stdout | trim),
'severity': 'high',
'remediation': 'systemctl enable --now auditd'
}] }}"
ignore_errors: yes
- block:
- name: "Gather: Check if critical system calls are audited"
ansible.builtin.shell: |
rules=0
for call in execve execveat mount umount2 creat open openat truncate ftruncate; do
grep -rq "$call" /etc/audit/rules.d/ 2>/dev/null && rules=$((rules+1))
done
echo "$rules"
register: _audit_sc
changed_when: false
- name: "Evaluate: UC-06"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'UC-06',
'category': 'FR2 — Use Control',
'requirement': 'SR 2.8 — Auditable Events',
'description': 'Critical system events (exec, mount, file modifications) shall be audited',
'passed': (_audit_sc.stdout | trim | int >= 4),
'expected': '≥ 4 critical syscall types audited',
'actual': (_audit_sc.stdout | trim) + ' of 9 critical syscall types audited',
'severity': 'medium',
'remediation': 'Add audit rules for execve, mount, creat/open/openat, truncate/ftruncate'
}] }}"
ignore_errors: yes
@@ -0,0 +1,119 @@
---
# suites/fr5_data_flow.yml
#
# FR5 — Restricted Data Flow (RDF)
# IEC 62443-3-3 SL2 requirements:
# SR 5.1 Network segmentation (firewall active, boundary filtering)
# SR 5.2 Zone boundary protection
# SR 5.3 General-purpose communication constraints (unnecessary services)
# ── SR 5.1: Host-based firewall active ──────────────────────────
- block:
- name: "Gather: Check iptables/nftables rules exist"
ansible.builtin.shell: |
if command -v nft >/dev/null 2>&1; then
nft list ruleset 2>/dev/null | grep -c 'accept\|drop\|reject' || echo "0"
elif command -v iptables >/dev/null 2>&1; then
iptables -L -n 2>/dev/null | grep -c 'ACCEPT\|DROP\|REJECT' || echo "0"
else
echo "NO FIREWALL"
fi
register: _fw_rules
changed_when: false
- name: "Evaluate: RDF-01"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'RDF-01',
'category': 'FR5 — Restricted Data Flow',
'requirement': 'SR 5.1 — Network Segmentation',
'description': 'A host-based firewall with active rules shall be present',
'passed': (
_fw_rules.stdout | trim != 'NO FIREWALL' and
(_fw_rules.stdout | trim | int > 0)
),
'expected': 'Firewall with active filter rules',
'actual': ('Rules found: ' + (_fw_rules.stdout | trim)) if (_fw_rules.stdout | trim != 'NO FIREWALL' and (_fw_rules.stdout | trim | int > 0)) else (_fw_rules.stdout | trim),
'severity': 'critical',
'remediation': 'Install and configure iptables/nftables with default-deny inbound policy'
}] }}"
ignore_errors: yes
- block:
- name: "Gather: Check default inbound policy"
ansible.builtin.shell: |
if command -v nft >/dev/null 2>&1; then
nft list chain inet filter INPUT 2>/dev/null | grep policy | awk '{print $NF}' || echo "UNKNOWN"
elif command -v iptables >/dev/null 2>&1; then
iptables -L INPUT -n 2>/dev/null | head -1 | awk '{print $4}' | tr -d ')'
else
echo "NO FIREWALL"
fi
register: _default_policy
changed_when: false
- name: "Evaluate: RDF-02"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'RDF-02',
'category': 'FR5 — Restricted Data Flow',
'requirement': 'SR 5.1 — Network Segmentation',
'description': 'Default inbound firewall policy shall be DROP',
'passed': (_default_policy.stdout | trim | lower == 'drop'),
'expected': 'Default inbound policy is DROP',
'actual': 'Default policy: ' + (_default_policy.stdout | trim),
'severity': 'high',
'remediation': 'Set default INPUT policy to DROP: iptables -P INPUT DROP'
}] }}"
ignore_errors: yes
# ── SR 5.3: Unnecessary services ────────────────────────────────
- block:
- name: "Gather: Check for unnecessary network services"
ansible.builtin.shell: |
# Services commonly flagged as unnecessary on ICS/OT systems
for svc in telnet.socket rsh.socket rexec.socket rlogin.socket \
ftp.service vsftpd.service xinetd.service; do
systemctl is-active "$svc" 2>/dev/null | grep -q 'active' && echo "$svc"
done
register: _bad_services
changed_when: false
- name: "Evaluate: RDF-03"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'RDF-03',
'category': 'FR5 — Restricted Data Flow',
'requirement': 'SR 5.3 — Communication Constraints',
'description': 'Insecure network services (telnet, rsh, ftp) shall be disabled',
'passed': (_bad_services.stdout | trim | length == 0),
'expected': 'No insecure legacy services active',
'actual': (_bad_services.stdout | trim | default('None', true)),
'severity': 'critical',
'remediation': 'Disable: systemctl disable --now <service>'
}] }}"
ignore_errors: yes
- block:
- name: "Gather: Check listening TCP ports"
ansible.builtin.shell: |
ss -tlnp 2>/dev/null | awk 'NR>1 {print $4}' | awk -F: '{print $NF}' | sort -n | uniq | tr '\n' ' '
register: _listening
changed_when: false
- name: "Evaluate: RDF-04"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'RDF-04',
'category': 'FR5 — Restricted Data Flow',
'requirement': 'SR 5.3 — Communication Constraints',
'description': 'Only necessary TCP ports shall be listening (document in exception list)',
'passed': 'review',
'expected': 'Documented and approved port list',
'actual': 'Listening ports: ' + (_listening.stdout | trim | default('Unable to determine', true)),
'severity': 'low',
'remediation': 'Review and disable unnecessary listening services'
}] }}"
ignore_errors: yes