--- # ══════════════════════════════════════════════════════════════════════════════ # IEC 62443-3-3 SL2 — Windows Client / Workstation Compliance # # Target type : Windows 10 / 11 workstations, operator HMI stations # Connection : WinRM — same as windows_server.yml # Collections : ansible.windows # Python pkg : pywinrm # # Inventory group : windows_clients (see assets.yml) # # Run: # ansible-playbook -i assets.yml methodologies/ansible/playbooks/examples/windows_client.yml # # Notes: # - Operator HMI workstations often run as local accounts (not domain-joined). # The domain_check HITL test flags this for reviewer attention. # - BitLocker status requires the Hyper-V / TPM chip; VMs may legitimately # fail WIN-CLI-02 if they are not TPM-enabled. # ══════════════════════════════════════════════════════════════════════════════ - name: "IEC 62443-3-3 SL2 — Windows Client Compliance" hosts: windows_clients 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.3: Domain membership ─────────────────────────────────────── # Domain-joined workstations inherit password and lockout policy via GPO. # Standalone / local-account machines need local policy verified separately. - block: - name: "Gather: Domain membership status" ansible.windows.win_shell: | $cs = Get-WmiObject -Class Win32_ComputerSystem [PSCustomObject]@{ PartOfDomain = $cs.PartOfDomain Domain = if ($cs.PartOfDomain) { $cs.Domain } else { 'WORKGROUP' } } | ConvertTo-Json -Compress register: _domain_info - name: "Evaluate: WIN-CLI-01 — Workstation is domain-joined" ansible.builtin.set_fact: test_results: "{{ test_results | default([]) + [{ 'test_id': 'WIN-CLI-01', 'category': 'FR1 — Identification and Authentication Control', 'requirement': 'SR 1.3 — Account Management', 'description': 'Workstations shall be domain-joined for centralised identity management', 'passed': ((_domain_info.stdout | from_json).PartOfDomain | bool), 'expected': 'PartOfDomain = true', 'actual': 'Domain = ' + (_domain_info.stdout | from_json).Domain, 'severity': 'medium', 'remediation': 'Join workstation to Active Directory domain' }] }}" ignore_errors: yes # ── FR3 · SR 3.4: BitLocker full-disk encryption ────────────────────────── - block: - name: "Gather: BitLocker protection status on OS drive" ansible.windows.win_shell: | $vol = Get-BitLockerVolume -MountPoint $env:SystemDrive -ErrorAction SilentlyContinue if ($vol) { [PSCustomObject]@{ ProtectionStatus = $vol.ProtectionStatus.ToString() EncryptionMethod = $vol.EncryptionMethod.ToString() VolumeStatus = $vol.VolumeStatus.ToString() } | ConvertTo-Json -Compress } else { '{"ProtectionStatus":"NotFound","EncryptionMethod":"None","VolumeStatus":"None"}' } register: _bitlocker - name: "Evaluate: WIN-CLI-02 — BitLocker enabled on OS drive" ansible.builtin.set_fact: _bl: "{{ _bitlocker.stdout | from_json }}" - name: "Evaluate: WIN-CLI-02 — record result" ansible.builtin.set_fact: test_results: "{{ test_results + [{ 'test_id': 'WIN-CLI-02', 'category': 'FR3 — System Integrity', 'requirement': 'SR 3.4 — Software and Information Integrity', 'description': 'OS drive shall be protected with BitLocker full-disk encryption', 'passed': (_bl.ProtectionStatus == 'On'), 'expected': 'ProtectionStatus = On', 'actual': 'ProtectionStatus = ' + _bl.ProtectionStatus + ', Method = ' + _bl.EncryptionMethod, 'severity': 'high', 'remediation': 'Enable-BitLocker -MountPoint C: -RecoveryPasswordProtector -EncryptionMethod XtsAes256' }] }}" ignore_errors: yes # ── FR2 · SR 2.5: Screen lock timeout (registry-based check) ───────────── # GPO sets HKLM\Software\Policies\Microsoft\Windows\Personalization\ScreenSaveTimeOut # or the legacy HKCU path. We check the machine-level policy value. - block: - name: "Gather: Screen saver timeout registry value (machine policy)" ansible.windows.win_reg_stat: path: HKLM:\Software\Policies\Microsoft\Windows\Control Panel\Desktop name: ScreenSaveTimeOut register: _screensaver_timeout - name: "Gather: Screen saver active registry value" ansible.windows.win_reg_stat: path: HKLM:\Software\Policies\Microsoft\Windows\Control Panel\Desktop name: ScreenSaveActive register: _screensaver_active - name: "Evaluate: WIN-CLI-03 — Screen lock timeout ≤ 900 seconds" ansible.builtin.set_fact: test_results: "{{ test_results + [{ 'test_id': 'WIN-CLI-03', 'category': 'FR2 — Use Control', 'requirement': 'SR 2.5 — Session Lock', 'description': 'Workstation shall lock after ≤ 900 seconds (15 min) of inactivity', 'passed': ( _screensaver_active.exists and (_screensaver_active.value | string == '1') and _screensaver_timeout.exists and (_screensaver_timeout.value | int > 0) and (_screensaver_timeout.value | int <= 900) ), 'expected': 'ScreenSaveActive=1, ScreenSaveTimeOut ≤ 900', 'actual': ( 'ScreenSaveActive=' + (_screensaver_active.value | default('NOT SET') | string) + ', ScreenSaveTimeOut=' + (_screensaver_timeout.value | default('NOT SET') | string) ), 'severity': 'medium', 'remediation': 'Apply GPO: Computer Configuration → Admin Templates → Control Panel → Personalization → Screen saver timeout = 900' }] }}" ignore_errors: yes # ── FR5 · SR 5.1: Windows Firewall on Public profile ───────────────────── # Clients in the ICS zone should enforce the Public profile firewall. - block: - name: "Gather: Public firewall profile state and default inbound action" ansible.windows.win_shell: | Get-NetFirewallProfile -Name Public | Select-Object Name, Enabled, DefaultInboundAction | ConvertTo-Json -Compress register: _pub_fw - name: "Evaluate: WIN-CLI-04 — Public firewall profile blocks inbound" ansible.builtin.set_fact: _pub_fw_json: "{{ _pub_fw.stdout | from_json }}" - name: "Evaluate: WIN-CLI-04 — record result" ansible.builtin.set_fact: test_results: "{{ test_results + [{ 'test_id': 'WIN-CLI-04', 'category': 'FR5 — Restricted Data Flow', 'requirement': 'SR 5.1 — Network Segmentation', 'description': 'Public firewall profile shall be enabled with default inbound Block', 'passed': ( _pub_fw_json.Enabled | bool and _pub_fw_json.DefaultInboundAction == 'Block' ), 'expected': 'Public profile: Enabled=True, DefaultInboundAction=Block', 'actual': 'Public: Enabled=' + (_pub_fw_json.Enabled | string) + ', DefaultInboundAction=' + _pub_fw_json.DefaultInboundAction, 'severity': 'high', 'remediation': 'Set-NetFirewallProfile -Name Public -Enabled True -DefaultInboundAction Block' }] }}" ignore_errors: yes # ── HITL · FR1 · SR 1.3: Physical access and USB port controls ──────────── # Cannot be verified remotely; requires physical inspection or policy review. - block: - name: "Gather: [HITL] USB storage device policy registry" ansible.windows.win_reg_stat: path: HKLM:\SYSTEM\CurrentControlSet\Services\UsbStor name: Start register: _usb_stor - name: "Display: [HITL] WIN-CLI-HITL-01 — USB storage and physical access" ansible.builtin.debug: msg: | ══════════════════════════════════════════════════════════════ MANUAL REVIEW REQUIRED · WIN-CLI-HITL-01 · {{ inventory_hostname }} ══════════════════════════════════════════════════════════════ Requirement : SR 1.3 — Account Management Check : Physical USB ports and removable media controls Registry evidence (UsbStor Start value): HKLM\SYSTEM\CurrentControlSet\Services\UsbStor\Start Value: {{ _usb_stor.value | default('KEY NOT FOUND') }} (4 = disabled, 3 = manual/enabled, key absent = check GPO) Also confirm: - Unused USB ports physically blocked or disabled in BIOS - No unauthorised removable media found on the workstation ══════════════════════════════════════════════════════════════ - name: "Prompt: WIN-CLI-HITL-01 — verdict for {{ inventory_hostname }}" ansible.builtin.pause: prompt: | USB storage is {{ 'DISABLED (Start=4)' if _usb_stor.value | default(3) | int == 4 else 'ENABLED or UNKNOWN' }} by registry policy. After physical confirmation, does this workstation satisfy SR 1.3 USB/removable media controls? Enter verdict [pass / fail / skip]: register: _hitl_usb_verdict delegate_to: localhost - name: "Prompt: WIN-CLI-HITL-01 — notes on failure" ansible.builtin.pause: prompt: "Describe finding (e.g. 'USB port active, no media policy applied'):" register: _hitl_usb_notes delegate_to: localhost when: _hitl_usb_verdict.user_input | lower | trim in ['fail', 'f'] - name: "Evaluate: WIN-CLI-HITL-01" ansible.builtin.set_fact: test_results: "{{ test_results + [{ 'test_id': 'WIN-CLI-HITL-01', 'category': 'FR1 — Identification and Authentication Control', 'requirement': 'SR 1.3 — Account Management', 'description': 'USB storage and removable media shall be disabled or physically controlled', 'passed': ( 'skipped' if (_hitl_usb_verdict.user_input | lower | trim in ['skip', 's', '']) else (_hitl_usb_verdict.user_input | lower | trim in ['pass', 'p']) ), 'expected': 'USB storage disabled (UsbStor Start=4) AND physical ports secured', 'actual': 'UsbStor Start = ' + (_usb_stor.value | default('NOT SET') | string), 'severity': 'high', 'remediation': 'Set HKLM\SYSTEM\CurrentControlSet\Services\UsbStor\Start = 4 via GPO, and physically block or tape ports', 'reviewer': ansible_user_id, 'notes': (_hitl_usb_notes.user_input | trim) if _hitl_usb_notes is defined else '' }] }}" ignore_errors: yes # ── Generate report ──────────────────────────────────────────────────────── - name: "Generate compliance report" ansible.builtin.include_tasks: ../library/report.yml