--- # ══════════════════════════════════════════════════════════════════════════════ # IEC 62443-3-3 SL2 — Linux VM / Server Compliance # # Target type : Linux (any distro with systemd + SSH) # Connection : SSH — native Ansible, no extra collection required # Privilege : become: yes (sudo) for /etc/shadow, audit rules, sysctl # # Inventory group : linux_vms (see assets.yml) # # Run: # ansible-playbook -i assets.yml methodologies/ansible/playbooks/examples/linux_vm.yml -K # ansible-playbook -i assets.yml methodologies/ansible/playbooks/examples/linux_vm.yml \ # --limit linux-vm-01.example.com -K # # What this covers (beyond the core fr1/fr2/fr5 suites): # FR1: SSH daemon hardening (PermitRootLogin, PasswordAuthentication) # FR2: Sudo command logging (Defaults log_input/log_output) # FR3: Kernel integrity — /proc/sys hardening via sysctl # FR5: IPv4 forwarding disabled (host is not a router) # Kernel module loading restricted (kmod_blacklist) # ══════════════════════════════════════════════════════════════════════════════ - name: "IEC 62443-3-3 SL2 — Linux VM Compliance" hosts: linux_vms gather_facts: yes become: yes vars: report_dir: "../../reports" pre_tasks: - name: "Ensure report directory exists" ansible.builtin.file: path: "{{ report_dir }}" state: directory mode: "0755" delegate_to: localhost run_once: true tasks: # ── FR1 · SR 1.1: SSH root login disabled ───────────────────────────────── - block: - name: "Gather: SSH PermitRootLogin setting" ansible.builtin.shell: | sshd -T 2>/dev/null | grep -i '^permitrootlogin' | awk '{print $2}' register: _sshd_rootlogin changed_when: false - name: "Evaluate: LX-IAC-01 — SSH root login disabled" ansible.builtin.set_fact: test_results: "{{ test_results | default([]) + [{ 'test_id': 'LX-IAC-01', 'category': 'FR1 — Identification and Authentication Control', 'requirement': 'SR 1.1 — Unique User Identification', 'description': 'SSH shall not permit direct root login', 'passed': (_sshd_rootlogin.stdout | trim | lower in ['no', 'prohibit-password', 'forced-commands-only']), 'expected': 'PermitRootLogin no (or prohibit-password / forced-commands-only)', 'actual': 'PermitRootLogin ' + (_sshd_rootlogin.stdout | trim | default('NOT SET', true)), 'severity': 'high', 'remediation': 'Set PermitRootLogin no in /etc/ssh/sshd_config and restart sshd' }] }}" ignore_errors: yes # ── FR1 · SR 1.4: SSH password authentication disabled ──────────────────── - block: - name: "Gather: SSH PasswordAuthentication setting" ansible.builtin.shell: | sshd -T 2>/dev/null | grep -i '^passwordauthentication' | awk '{print $2}' register: _sshd_pwauth changed_when: false - name: "Evaluate: LX-IAC-02 — SSH key-only authentication" ansible.builtin.set_fact: test_results: "{{ test_results + [{ 'test_id': 'LX-IAC-02', 'category': 'FR1 — Identification and Authentication Control', 'requirement': 'SR 1.4 — Identifier Strength', 'description': 'SSH shall use key-based authentication only (PasswordAuthentication no)', 'passed': (_sshd_pwauth.stdout | trim | lower == 'no'), 'expected': 'PasswordAuthentication no', 'actual': 'PasswordAuthentication ' + (_sshd_pwauth.stdout | trim | default('NOT SET', true)), 'severity': 'high', 'remediation': 'Set PasswordAuthentication no in /etc/ssh/sshd_config and restart sshd' }] }}" ignore_errors: yes # ── FR2 · SR 2.1: Sudo command logging ──────────────────────────────────── - block: - name: "Gather: Sudo log_input / log_output Defaults" ansible.builtin.shell: | grep -rh 'Defaults.*log_' /etc/sudoers /etc/sudoers.d/ 2>/dev/null | grep -v '^\s*#' | tr -s ' ' | head -5 register: _sudo_log changed_when: false - name: "Evaluate: LX-UC-01 — Sudo session logging enabled" ansible.builtin.set_fact: test_results: "{{ test_results + [{ 'test_id': 'LX-UC-01', 'category': 'FR2 — Use Control', 'requirement': 'SR 2.4 — Audit Log Integrity', 'description': 'Sudo shall log all input and output (Defaults log_input, log_output)', 'passed': ( 'log_input' in _sudo_log.stdout and 'log_output' in _sudo_log.stdout ), 'expected': 'Defaults log_input, log_output in /etc/sudoers[.d]', 'actual': _sudo_log.stdout | trim | default('No sudo logging Defaults found', true), 'severity': 'medium', 'remediation': 'Add "Defaults log_input,log_output" to /etc/sudoers' }] }}" ignore_errors: yes # ── FR3 · SR 3.1: Kernel IP forwarding disabled ──────────────────────────── - block: - name: "Gather: IPv4 forwarding sysctl" ansible.builtin.command: cmd: sysctl net.ipv4.ip_forward register: _ip_forward changed_when: false - name: "Evaluate: LX-SI-01 — IP forwarding disabled" ansible.builtin.set_fact: test_results: "{{ test_results + [{ 'test_id': 'LX-SI-01', 'category': 'FR3 — System Integrity', 'requirement': 'SR 3.1 — Communication Integrity', 'description': 'Kernel IP forwarding shall be disabled (host is not a router)', 'passed': (_ip_forward.stdout | trim | regex_search('= 0$') is not none), 'expected': 'net.ipv4.ip_forward = 0', 'actual': _ip_forward.stdout | trim, 'severity': 'high', 'remediation': 'Add "net.ipv4.ip_forward = 0" to /etc/sysctl.d/99-ics-hardening.conf and run sysctl -p' }] }}" ignore_errors: yes # ── FR3 · SR 3.1: ICMP redirect acceptance disabled ────────────────────── - block: - name: "Gather: ICMP accept_redirects (all interfaces)" ansible.builtin.shell: | sysctl net.ipv4.conf.all.accept_redirects net.ipv4.conf.default.accept_redirects 2>/dev/null register: _redirects changed_when: false - name: "Evaluate: LX-SI-02 — ICMP redirects rejected" ansible.builtin.set_fact: test_results: "{{ test_results + [{ 'test_id': 'LX-SI-02', 'category': 'FR3 — System Integrity', 'requirement': 'SR 3.1 — Communication Integrity', 'description': 'ICMP redirect acceptance shall be disabled on all interfaces', 'passed': ( _redirects.stdout | regex_findall('= ([01])') | unique | list == ['0'] ), 'expected': 'net.ipv4.conf.*.accept_redirects = 0', 'actual': _redirects.stdout | trim, 'severity': 'medium', 'remediation': 'Set net.ipv4.conf.all.accept_redirects = 0 in /etc/sysctl.d/99-ics-hardening.conf' }] }}" ignore_errors: yes # ── FR5 · SR 5.3: Core dump disabled ────────────────────────────────────── # Core dumps can expose sensitive memory content; disable on ICS nodes. - block: - name: "Gather: Core dump hard limit (ulimit -c)" ansible.builtin.shell: | grep -rh '^[^#]*hard.*core' /etc/security/limits.conf /etc/security/limits.d/ 2>/dev/null | awk '{print $NF}' | head -1 || echo "NOT SET" register: _core_limit changed_when: false - name: "Gather: systemd DefaultLimitCORE" ansible.builtin.shell: | grep -h 'DefaultLimitCORE' /etc/systemd/system.conf /etc/systemd/user.conf 2>/dev/null | tail -1 | awk -F= '{print $2}' || echo "NOT SET" register: _systemd_core changed_when: false - name: "Evaluate: LX-RDF-01 — Core dumps disabled" ansible.builtin.set_fact: test_results: "{{ test_results + [{ 'test_id': 'LX-RDF-01', 'category': 'FR5 — Restricted Data Flow', 'requirement': 'SR 5.3 — Communication Constraints', 'description': 'Core dumps shall be disabled to prevent memory disclosure', 'passed': ( (_core_limit.stdout | trim in ['0', '']) or (_systemd_core.stdout | trim == '0') ), 'expected': 'hard core 0 in limits.conf OR DefaultLimitCORE=0 in systemd', 'actual': 'limits.conf: ' + _core_limit.stdout | trim + ' | systemd: ' + _systemd_core.stdout | trim, 'severity': 'medium', 'remediation': 'Add "* hard core 0" to /etc/security/limits.d/99-no-core.conf' }] }}" ignore_errors: yes # ── Generate report ──────────────────────────────────────────────────────── - name: "Generate compliance report" ansible.builtin.include_tasks: ../library/report.yml