--- # ══════════════════════════════════════════════════════════════════════════════ # IEC 62443-3-3 SL2 — Windows Server Compliance # # Target type : Windows Server 2016 / 2019 / 2022 # Connection : WinRM (HTTP :5985 or HTTPS :5986) # Collections : ansible.windows (ships with Ansible) # Python pkg : pywinrm (installed in ansible-node image) # Privilege : No become required — WinRM user needs local admin rights # # Inventory group : [windows_servers] (see inventory.ini) # # Run: # ansible-playbook -i inventory.ini playbooks/examples/windows_server.yml # # WinRM quick-enable on target (run as Administrator): # winrm quickconfig -q # winrm set winrm/config/service/auth '@{Basic="true"}' # winrm set winrm/config/service '@{AllowUnencrypted="true"}' # # For production: use HTTPS and Kerberos transport instead # # Vault usage (recommended for passwords): # ansible-vault encrypt_string 'MyPassword' --name ansible_password # ══════════════════════════════════════════════════════════════════════════════ - name: "IEC 62443-3-3 SL2 — Windows Server Compliance" hosts: windows_servers gather_facts: 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.5: Minimum password length ───────────────────────────────── # Uses win_security_policy — no PowerShell shell-out needed. - block: - name: "Gather: Minimum password length (security policy)" ansible.windows.win_security_policy: section: System Access key: MinimumPasswordLength register: _min_pw_len - name: "Evaluate: WIN-IAC-01 — Password minimum length ≥ 14" ansible.builtin.set_fact: test_results: "{{ test_results | default([]) + [{ 'test_id': 'WIN-IAC-01', 'category': 'FR1 — Identification and Authentication Control', 'requirement': 'SR 1.5 — Authenticator Strength', 'description': 'Windows local password policy minimum length shall be ≥ 14 characters', 'passed': (_min_pw_len.value | int >= 14), 'expected': 'MinimumPasswordLength ≥ 14', 'actual': 'MinimumPasswordLength = ' + (_min_pw_len.value | string), 'severity': 'high', 'remediation': 'Computer Configuration → Windows Settings → Security Settings → Account Policies → Password Policy → Minimum password length: 14' }] }}" ignore_errors: yes # ── FR1 · SR 1.11: Account lockout threshold ────────────────────────────── - block: - name: "Gather: Account lockout threshold" ansible.windows.win_security_policy: section: System Access key: LockoutBadCount register: _lockout_count - name: "Evaluate: WIN-IAC-02 — Account lockout ≤ 5 attempts" ansible.builtin.set_fact: test_results: "{{ test_results + [{ 'test_id': 'WIN-IAC-02', 'category': 'FR1 — Identification and Authentication Control', 'requirement': 'SR 1.11 — Unsuccessful Login Attempts', 'description': 'Account lockout shall trigger after ≤ 5 failed login attempts', 'passed': ( (_lockout_count.value | int > 0) and (_lockout_count.value | int <= 5) ), 'expected': 'LockoutBadCount between 1 and 5', 'actual': 'LockoutBadCount = ' + (_lockout_count.value | string), 'severity': 'high', 'remediation': 'Set Account lockout threshold to 5 in Local Security Policy' }] }}" ignore_errors: yes # ── FR2 · SR 2.8: Audit policy — logon events ───────────────────────────── # Uses win_audit_policy_system — no auditpol.exe shell-out needed. - block: - name: "Gather: Audit policy — Logon subcategory" ansible.windows.win_audit_policy_system: subcategory: Logon register: _audit_logon - name: "Evaluate: WIN-UC-01 — Logon events audited" ansible.builtin.set_fact: test_results: "{{ test_results + [{ 'test_id': 'WIN-UC-01', 'category': 'FR2 — Use Control', 'requirement': 'SR 2.8 — Auditable Events', 'description': 'Logon/logoff events shall be audited (success and failure)', 'passed': (_audit_logon.auditing_mode == 'success and failure'), 'expected': 'Logon: success and failure', 'actual': 'Logon: ' + _audit_logon.auditing_mode, 'severity': 'high', 'remediation': 'auditpol /set /subcategory:"Logon" /success:enable /failure:enable' }] }}" ignore_errors: yes # ── FR2 · SR 2.8: Audit policy — privilege use ──────────────────────────── - block: - name: "Gather: Audit policy — Sensitive Privilege Use" ansible.windows.win_audit_policy_system: subcategory: Sensitive Privilege Use register: _audit_privuse - name: "Evaluate: WIN-UC-02 — Privilege use audited" ansible.builtin.set_fact: test_results: "{{ test_results + [{ 'test_id': 'WIN-UC-02', 'category': 'FR2 — Use Control', 'requirement': 'SR 2.8 — Auditable Events', 'description': 'Sensitive privilege use (SeDebugPrivilege, SeTcbPrivilege, etc.) shall be audited', 'passed': (_audit_privuse.auditing_mode in ['success and failure', 'failure']), 'expected': 'Sensitive Privilege Use: failure (at minimum)', 'actual': 'Sensitive Privilege Use: ' + _audit_privuse.auditing_mode, 'severity': 'medium', 'remediation': 'auditpol /set /subcategory:"Sensitive Privilege Use" /failure:enable' }] }}" ignore_errors: yes # ── FR5 · SR 5.1: Windows Firewall enabled on all profiles ──────────────── # PowerShell ConvertTo-Json + Ansible from_json filter avoids regex parsing. - block: - name: "Gather: Windows Firewall profile states" ansible.windows.win_shell: | @(Get-NetFirewallProfile | Select-Object Name, Enabled, DefaultInboundAction) | ConvertTo-Json -Compress register: _fw_profiles - name: "Evaluate: WIN-RDF-01 — Firewall enabled on all profiles" ansible.builtin.set_fact: _fw_json: "{{ _fw_profiles.stdout | from_json }}" - name: "Evaluate: WIN-RDF-01 — record result" ansible.builtin.set_fact: test_results: "{{ test_results + [{ 'test_id': 'WIN-RDF-01', 'category': 'FR5 — Restricted Data Flow', 'requirement': 'SR 5.1 — Network Segmentation', 'description': 'Windows Firewall shall be enabled on Domain, Private, and Public profiles', 'passed': (_fw_json | selectattr('Enabled', 'equalto', true) | list | length == 3), 'expected': 'All 3 firewall profiles Enabled = True', 'actual': _fw_json | map(attribute='Name') | zip(_fw_json | map(attribute='Enabled')) | list | string, 'severity': 'critical', 'remediation': 'Set-NetFirewallProfile -All -Enabled True' }] }}" ignore_errors: yes # ── FR5 · SR 5.3: Telnet / FTP / RDP services ───────────────────────────── # Uses win_service_info — typed return dict, no regex needed. - block: - name: "Gather: Telnet service state" ansible.windows.win_service_info: name: TlntSvr register: _telnet_svc - name: "Evaluate: WIN-RDF-02 — Telnet service disabled" ansible.builtin.set_fact: test_results: "{{ test_results + [{ 'test_id': 'WIN-RDF-02', 'category': 'FR5 — Restricted Data Flow', 'requirement': 'SR 5.3 — Communication Constraints', 'description': 'The Telnet Server service (TlntSvr) shall be absent or disabled', 'passed': ( _telnet_svc.services | length == 0 or _telnet_svc.services[0].start_mode == 'disabled' ), 'expected': 'TlntSvr: absent or start_mode=disabled', 'actual': ( 'TlntSvr: state=' + _telnet_svc.services[0].state + ', start_mode=' + _telnet_svc.services[0].start_mode ) if _telnet_svc.services | length > 0 else 'TlntSvr: not installed', 'severity': 'critical', 'remediation': 'Stop-Service TlntSvr; Set-Service TlntSvr -StartupType Disabled' }] }}" ignore_errors: yes # ── Generate report ──────────────────────────────────────────────────────── - name: "Generate compliance report" ansible.builtin.include_tasks: ../library/report.yml