247 lines
13 KiB
YAML
247 lines
13 KiB
YAML
---
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
|
# 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
|