Ansible-Test: IEC 62443-3-3 SL2 Compliance Validation

Ansible playbooks reimagined as a test framework for industrial control system (ICS/OT) security compliance. Every task is a test that collects evidence, never aborts on failure, produces structured JSON, and renders into human-readable reports via Go templates or Python.

Supports automated testing across Linux, Windows Server, Windows Clients, MS SQL Server, VMware vSphere, Hyper-V, Cisco IOS switches, and Cisco ASA firewalls — from a single containerised Ansible control node.


Table of Contents

  1. Architecture
  2. Quick Start
  3. Platform-Specific Examples
  4. The Test Pattern
  5. Human-in-the-Loop Tests
  6. Adding a New Test
  7. JSON Output Schema
  8. IEC 62443-3-3 SL2 Coverage
  9. Rendering Reports
  10. Ansible Control Node — Container & QEMU VM
  11. File Reference
  12. Design Decisions & Tradeoffs
  13. Comparison to Alternatives
  14. Roadmap

Architecture

playbooks/
├── site.yml                      # Entry point for Linux targets (all FR suites)
├── suites/                       # Included task-list suites for Linux
│   ├── fr1_auth.yml              # FR1: Identification & Authentication (10 tests)
│   ├── fr2_use_control.yml       # FR2: Use Control — audit, sudo, sessions (6 tests)
│   └── fr5_data_flow.yml         # FR5: Restricted Data Flow — firewall, services (4 tests)
├── library/
│   └── report.yml                # Aggregates test_results[] → JSON → disk
├── examples/                     # ◄ Standalone playbooks — one per platform type
│   ├── linux_vm.yml              #   Linux: SSH hardening, sysctl, sudo logging
│   ├── windows_server.yml        #   Windows Server: WinRM, security policy, audit
│   ├── windows_client.yml        #   Windows Client: BitLocker, screen lock, USB
│   ├── mssql_server.yml          #   MS SQL Server: auth mode, sa, xp_cmdshell, audit
│   ├── vmware_vsphere.yml        #   VMware ESXi: lockdown, NTP, SSH/Shell services
│   ├── hyperv_cluster.yml        #   Hyper-V: Gen2, vSwitch isolation, integration svc
│   ├── cisco_switch.yml          #   Cisco IOS: SSH v2, no SNMPv1, banner, NTP
│   └── cisco_firewall.yml        #   Cisco ASA: no Telnet, IKEv2, syslog, AAA, ACL
└── templates/                    # ◄ Copy-and-fill templates for writing new tests
    ├── test_automated.yml        #   Shell-based gather → evaluate pattern
    ├── test_file_check.yml       #   File permission check via ansible.builtin.stat
    ├── test_service_check.yml    #   Service state check via service_facts
    └── test_hitl.yml             #   Human-in-the-Loop with ansible.builtin.pause

reports/
├── render_report.py              # Python renderer (terminal + markdown output)
├── report.gohtml                 # Go template rendered by gomplate (terminal box-drawing report)
└── sample-output.json            # Example output for offline renderer tests
inventory.ini                     # Ansible inventory — all platform groups defined
run.sh                            # End-to-end wrapper: ansible → find JSON → render

Data Flow

  ansible-playbook <playbook>.yml
         │
         ├── Gather tasks ──┐
         │                  ├─ collect system state (shell, stat, ios_command, etc.)
         ├── Evaluate tasks─┘
         │                    judge pass/fail, append to test_results[]
         │
         ▼
  library/report.yml
    - Assembles __report dict with summary, by_category, by_severity, failures
    - Prints summary box to console
    - Writes JSON to reports/<hostname>-<date>.json
         │
         ▼
  run.sh / render manually:
    python3 reports/render_report.py reports/<hostname>-<date>.json
    gomplate --context .=reports/<hostname>-<date>.json --file reports/report.gohtml

Target Integrations

Platform Ansible Collection Connection Python library
Linux built-in SSH —
Windows Server / Client ansible.windows WinRM + NTLM/Kerberos pywinrm
MS SQL Server ansible.windows WinRM → PowerShell Invoke-Sqlcmd pywinrm
VMware vSphere ESXi community.vmware vSphere SOAP API (delegate_to: localhost) pyvmomi
Hyper-V ansible.windows WinRM → PowerShell Hyper-V cmdlets pywinrm
Cisco IOS / IOS-XE cisco.ios SSH via network_cli paramiko, netmiko
Cisco ASA cisco.asa SSH via network_cli paramiko
Cisco NX-OS cisco.nxos SSH / NX-API via network_cli ncclient

Quick Start

Prerequisites

  • Ansible ≥ 2.9 with the collections listed above (pre-installed in the Docker image)
  • Python ≥ 3.6 (for the Python report renderer)
  • gomplate (optional, single static binary, for the .gohtml template renderer)
  • For Windows / VMware / Cisco targets: the Python libraries listed above

Step 1: Configure Inventory

Edit inventory.ini. Each platform group has the required connection variables already set — just uncomment the hosts:

[linux_vms]
linux-vm-01.example.com  ansible_user=auditor

[windows_servers]
win-srv-01.example.com

[cisco_switches]
sw-core-01.example.com

Store passwords in Ansible Vault:

ansible-vault encrypt_string 'MyPassword' --name ansible_password

Step 2: Run a Playbook

# Linux targets — all FR suites via the main entry point:
ansible-playbook -i inventory.ini playbooks/site.yml --limit linux_vms -K

# Or use the wrapper script (finds & renders the report automatically):
./run.sh --limit linux_vms -K

# Platform-specific examples:
ansible-playbook -i inventory.ini playbooks/examples/windows_server.yml
ansible-playbook -i inventory.ini playbooks/examples/cisco_switch.yml
ansible-playbook -i inventory.ini playbooks/examples/vmware_vsphere.yml

# Local self-test (localhost is pre-configured in inventory.ini):
ansible-playbook -i inventory.ini playbooks/site.yml --limit localhost -K

Step 3: Render the Report

# Terminal box-drawing format (default):
python3 reports/render_report.py reports/localhost-2026-08-14.json

# Markdown (for GitHub / GitLab wikis, PR comments):
python3 reports/render_report.py reports/localhost-2026-08-14.json --format md

# gomplate template renderer:
gomplate --context .=reports/localhost-2026-08-14.json --file reports/report.gohtml

Platform-Specific Examples

The playbooks/examples/ directory contains a complete, runnable playbook for each supported platform. Each file:

  • Has a header comment with the required inventory.ini group vars and any prerequisites (WinRM setup, SQLPS module, vCenter permissions, etc.)
  • Follows the identical block → gather → evaluate → ignore_errors pattern
  • Uses the most Ansible-native module available for each check (e.g. win_security_policy instead of win_shell for Windows password policy)
  • Ends with include_tasks: ../library/report.yml to produce a JSON report
  • Includes at least one Human-in-the-Loop test where automated checks cannot cover the full control

Linux VMs — playbooks/examples/linux_vm.yml

ansible-playbook -i inventory.ini playbooks/examples/linux_vm.yml -K

Checks beyond the core FR suites: SSH root login and password auth settings, sudo session logging (Defaults log_input,log_output), kernel IP forwarding and ICMP redirect sysctl values, core dump disabled.

Windows Server — playbooks/examples/windows_server.yml

ansible-playbook -i inventory.ini playbooks/examples/windows_server.yml

Uses ansible.windows.win_security_policy for password and lockout policy (no PowerShell shell-out needed), win_audit_policy_system for audit subcategories, win_service_info for Telnet/FTP service state, and win_shell with ConvertTo-Json for Firewall profile states parsed via Ansible's from_json filter.

Windows Client — playbooks/examples/windows_client.yml

ansible-playbook -i inventory.ini playbooks/examples/windows_client.yml

Checks: domain membership, BitLocker status on the OS drive, screen lock timeout via registry (win_reg_stat), Public firewall profile. Includes a HITL check for USB storage port controls with registry evidence displayed.

MS SQL Server — playbooks/examples/mssql_server.yml

ansible-playbook -i inventory.ini playbooks/examples/mssql_server.yml
# Add per-host: mssql_instance=NAMED_INSTANCE  (default: MSSQLSERVER)

Connects via WinRM to the Windows host, then runs Invoke-Sqlcmd via win_shell to query SQL Server internals. Checks: Windows-only authentication mode, SA account disabled, xp_cmdshell disabled, login audit level. HITL check for sysadmin role membership against an authorised list.

Prerequisite on target: Install-Module SqlServer -Force -AllowClobber

VMware vSphere — playbooks/examples/vmware_vsphere.yml

ansible-playbook -i inventory.ini playbooks/examples/vmware_vsphere.yml

All tasks use delegate_to: localhost — no SSH to ESXi hosts. The inventory host is the ESXi FQDN; vcenter_hostname/username/password are group vars pointing at vCenter. Uses community.vmware info modules: vmware_host_lockdown_info, vmware_host_ntp_info, vmware_host_service_info, vmware_host_config_info. HITL check for vSwitch isolation using vmware_vswitch_info.

Required vCenter read-only permissions: Host → Configuration → Security Profile, Advanced Settings; Global → Settings.

Hyper-V Cluster — playbooks/examples/hyperv_cluster.yml

ansible-playbook -i inventory.ini playbooks/examples/hyperv_cluster.yml

Connects via WinRM and runs PowerShell Hyper-V cmdlets via win_shell. Checks: all VMs use Generation 2 with Secure Boot enabled, External vSwitch AllowManagementOS exposure (flagged as review), Hyper-V VMMS Admin event log active, integration services enabled on all running VMs. HITL check for physical NIC segregation between ICS and management networks.

Cisco Switch (IOS / IOS-XE) — playbooks/examples/cisco_switch.yml

ansible-playbook -i inventory.ini playbooks/examples/cisco_switch.yml

Uses cisco.ios.ios_command to run show commands and parses output with regex_search / regex_findall. Uses ios_facts (gathered automatically via gather_facts: yes) for interface data. Checks: SSH v2 / no Telnet on VTY, no SNMPv1/v2c community strings, login banner, NTP synchronised. HITL check for port VLAN assignment and physical port labelling.

Cisco Firewall (ASA) — playbooks/examples/cisco_firewall.yml

ansible-playbook -i inventory.ini playbooks/examples/cisco_firewall.yml

Uses cisco.asa.asa_command with ansible_become=yes (enable mode). Checks: no Telnet management access, SSH access configured, IKEv1 disabled (IKEv2 only), remote syslog server configured, AAA authentication for SSH/enable, inbound ACLs applied on zone interfaces. HITL check for ACL rule review (no broad permit ip any any).


The Test Pattern

Every test follows a rigid Gather → Evaluate → Record structure inside an Ansible block with ignore_errors: yes. This ensures the run never aborts regardless of what is found on the target.

# ── SR 1.5: Password minimum length ────────────────────────────────────────

- 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                    # gather tasks must never say "changed"

    - 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                         # NEVER abort the run

Why block + ignore_errors Instead of failed_when

Approach Behaviour
failed_when: false on shell task Shell always "succeeds"; non-zero exit still shows red in Ansible output
block + ignore_errors: yes Failures are captured and marked orange; execution continues; registered variables remain available in the evaluate task

Tips for Robust Checks

Tip Why
changed_when: false on all gather tasks Tests must never report as "changed"
| trim on shell output Shell often returns trailing newlines
| default('NOT SET', true) Prevents undefined-variable errors when files or keys are absent
Guard numeric comparisons '' | int = 0 in Jinja2 — a missing value can silently pass a ≤ 90 check; always verify the value exists and is non-zero first
| regex_search(pattern, '\\1') Use capture group syntax to extract a number cleanly, avoiding chained regex_replace calls that crash on None
ConvertTo-Json on Windows Return structured data from win_shell and parse with Ansible's from_json filter instead of regex
Platform branching Use when: ansible_os_family == 'Debian' variants for distro-specific commands

Human-in-the-Loop Tests

Some IEC 62443 SL2 controls cannot be verified automatically — physical access controls, policy document review, proprietary vendor interfaces, or checks where the output must be interpreted by a qualified reviewer.

HITL tests use ansible.builtin.pause with delegate_to: localhost, which prompts the reviewer on the Ansible control node even when running against remote targets. For multi-host runs the prompt fires once per host, so each target gets an independent verdict.

- block:
    - name: "Gather: [HITL] Collect evidence"
      ansible.builtin.shell: your-gather-command
      register: _evidence
      changed_when: false

    - name: "Display: [HITL] TEST_ID — evidence"
      ansible.builtin.debug:
        msg: |
          ══════════════════════════════════════════════════════════════
           MANUAL REVIEW REQUIRED  ·  TEST_ID  ·  {{ inventory_hostname }}
          ══════════════════════════════════════════════════════════════
           {{ _evidence.stdout | indent(1) }}
          ══════════════════════════════════════════════════════════════

    - name: "Prompt: TEST_ID — verdict"
      ansible.builtin.pause:
        prompt: "Enter verdict [pass / fail / skip]:"
      register: _verdict
      delegate_to: localhost               # ← always prompts on the control node

    - name: "Prompt: TEST_ID — notes on failure"
      ansible.builtin.pause:
        prompt: "Describe the finding:"
      register: _notes
      delegate_to: localhost
      when: _verdict.user_input | lower | trim in ['fail', 'f']

    - name: "Evaluate: TEST_ID"
      ansible.builtin.set_fact:
        test_results: "{{ test_results + [{
          'test_id':  'TEST_ID',
          'passed': (
            'skipped' if (_verdict.user_input | lower | trim in ['skip', 's', ''])
            else (_verdict.user_input | lower | trim in ['pass', 'p'])
          ),
          'reviewer': ansible_user_id,
          'notes':    (_notes.user_input | trim) if _notes is defined else ''
        }] }}"
  ignore_errors: yes

The reviewer and notes fields are written into the JSON report alongside the automated evidence, creating an auditable record of who reviewed what and when. See playbooks/templates/test_hitl.yml for the full copy-and-fill template.

Note: Playbooks containing HITL tests require an interactive terminal and cannot be run unattended in a CI pipeline. Keep HITL tests in separate playbooks or use Ansible tags to separate them from automated checks.


Adding a New Test

Step 1: Choose a Template

The playbooks/templates/ directory contains four ready-to-use templates. Copy the one that matches your check type:

Template Use when
playbooks/templates/test_automated.yml Running a shell command and evaluating its output
playbooks/templates/test_file_check.yml Checking file ownership, permissions, or existence (ansible.builtin.stat)
playbooks/templates/test_service_check.yml Checking service running/enabled state (service_facts)
playbooks/templates/test_hitl.yml Control requires human reviewer input

Every template has detailed comments explaining the passed expression patterns relevant to that check type.

Step 2: Fill in the Placeholders

Replace all UPPERCASE placeholders: TEST_ID, FR_NUMBER, CATEGORY_NAME, REQUIREMENT, DESCRIPTION, SEVERITY, REMEDIATION, and the gather command.

Step 3: Add to a Suite or Example

For Linux targets, append the block to the appropriate suites/frN_*.yml file and ensure that suite is included in site.yml:

- name: "Suite: FRN — Category Name"
  block:
    - ansible.builtin.include_tasks: suites/frN_category.yml
  ignore_errors: yes

For other platforms, append the block to the relevant examples/ playbook.

Test ID Naming Convention

  • Prefix: Platform abbreviation + category (e.g., IAC, UC, RDF for Linux; WIN-IAC, SQL-UC, FW-RDF, SW-RDF, VMW-IAC for platform examples)
  • Number: Sequential within prefix, zero-padded (01, 02, ...)
  • HITL suffix: Append -HITL for human-in-the-loop tests (e.g., WIN-CLI-HITL-01)

JSON Output Schema

Each test produces one entry in test_results[]. The complete report schema:

{
  "meta": {
    "standard":       "IEC 62443-3-3",
    "security_level": "SL2",
    "target":         "ics-gateway-01",          // inventory_hostname
    "timestamp":      "2026-08-14T09:00:00Z",    // ISO 8601
    "executed_by":    "auditor"                  // ansible_user_id
  },
  "summary": {
    "total":   20,
    "passed":  14,
    "failed":   4,
    "review":   1,   // passed == "review" (manual review items)
    "skipped":  1    // passed == "skipped" (HITL skipped or not applicable)
  },
  "by_category": [                         // [["FR1 — ...", [{...}]], ...]
    ["FR1 — Identification and Authentication Control", [{...}, {...}]],
    ["FR2 — Use Control", [{...}]]
  ],
  "by_severity": {
    "critical": [{...}],
    "high":     [{...}],
    "medium":   [{...}],
    "low":      [{...}]
  },
  "failures": [{...}],                     // Only tests where passed == false
  "results": [
    {
      "test_id":     "IAC-05",
      "category":    "FR1 — ...",
      "requirement": "SR 1.5 — Authenticator Strength",
      "description": "Password minimum length shall be >= 14 characters",
      "passed":      false,                // bool | "review" | "skipped"
      "expected":    "minlen >= 14",
      "actual":      "minlen = 8",
      "severity":    "high",              // critical | high | medium | low
      "remediation": "Set minlen=14 in /etc/security/pwquality.conf"
      // HITL tests also carry: "reviewer", "notes"
    }
  ]
}

passed Field Semantics

Value Meaning Report icon
true Automated check passed ✅
false Automated check failed ❌
"review" Listed for human assessment (port inventory, ACL review) 🔍
"skipped" Reviewer entered skip; not applicable to this target ⏭️

severity Field Semantics

Value Meaning
critical Allows immediate compromise (empty passwords, world-writable sudoers)
high Defeats a core SL2 control (no firewall, no auditd, weak password policy)
medium Weakens a control (password aging not set, no session timeout)
low Best-practice gap (extra listening ports, stale accounts)

IEC 62443-3-3 SL2 Coverage

Core Linux Suites (playbooks/suites/)

Test ID FR SR Description Severity
IAC-01 FR1 SR 1.1 No duplicate UIDs in /etc/passwd high
IAC-02 FR1 SR 1.3 No default/unnecessary system accounts medium
IAC-03 FR1 SR 1.3 No human accounts that have never logged in low
IAC-04 FR1 SR 1.4 No empty or trivially-weak password hashes critical
IAC-05 FR1 SR 1.5 Password min length >= 14 (pwquality) high
IAC-06 FR1 SR 1.5 >= 3 character classes required (pwquality) medium
IAC-07 FR1 SR 1.7 PASS_MAX_DAYS <= 90 and is set medium
IAC-08 FR1 SR 1.7 PASS_MIN_DAYS >= 1 low
IAC-09 FR1 SR 1.11 Account lockout after <= 5 failures (pam_faillock) high
IAC-10 FR1 SR 1.6 Password history >= 5 (pam_pwhistory) medium
UC-01 FR2 SR 2.1 No unrestricted NOPASSWD sudo high
UC-02 FR2 SR 2.1 /etc/sudoers owned root:root, mode 0440 critical
UC-03 FR2 SR 2.5 Shell idle timeout <= 900s (TMOUT) medium
UC-04 FR2 SR 2.4 Audit rules immutable (-e 2) high
UC-05 FR2 SR 2.8 auditd service active and enabled high
UC-06 FR2 SR 2.8 >= 4 critical syscall types audited medium
RDF-01 FR5 SR 5.1 Host-based firewall with active rules critical
RDF-02 FR5 SR 5.1 Default INPUT policy is DROP high
RDF-03 FR5 SR 5.3 No insecure legacy services (telnet, rsh, ftp) critical
RDF-04 FR5 SR 5.3 Listening TCP ports — review low

Additional Checks in Platform Examples

Platform Example file FR areas covered
Linux VM examples/linux_vm.yml FR1 (SSH hardening), FR2 (sudo logging), FR3 (sysctl, core dumps)
Windows Server examples/windows_server.yml FR1 (password/lockout policy), FR2 (audit policy), FR5 (firewall, Telnet)
Windows Client examples/windows_client.yml FR1 (domain join), FR3 (BitLocker), FR2 (screen lock), FR5 (firewall)
MS SQL Server examples/mssql_server.yml FR1 (auth mode, SA account, role review), FR2 (xp_cmdshell, audit)
VMware vSphere examples/vmware_vsphere.yml FR1 (lockdown, account lockout), FR2 (NTP), FR5 (SSH/Shell, vSwitch)
Hyper-V examples/hyperv_cluster.yml FR3 (SecureBoot, integration svc), FR2 (event log), FR5 (vSwitch/NIC)
Cisco Switch examples/cisco_switch.yml FR1 (SNMP, banner), FR2 (NTP), FR5 (SSH/Telnet, port shutdown)
Cisco Firewall examples/cisco_firewall.yml FR1 (IKEv2, AAA), FR2 (syslog), FR5 (Telnet, ACLs)

Not Yet Implemented as Dedicated Suites

FR Key SL2 Controls Suggested Checks
FR3 — System Integrity File integrity monitoring AIDE/IMA service, /proc/sys/kernel/kexec_load_disabled
FR4 — Data Confidentiality Encryption at rest/transit TLS cipher audit on listening ports, LUKS/dm-crypt, SSH cipher suite
FR6 — Timely Response Log forwarding, alerting rsyslog remote config, auditd dispatcher, journald persistence
FR7 — Resource Availability DoS protection, backup Disk quota, systemd resource limits, backup schedule

Rendering Reports

Python Renderer (reports/render_report.py)

Zero dependencies beyond Python 3 stdlib. Two output formats:

# Terminal box-drawing (default):
python3 reports/render_report.py reports/hostname-2026-08-14.json

# Markdown for GitHub / GitLab:
python3 reports/render_report.py reports/hostname-2026-08-14.json --format md > REPORT.md

Terminal output groups results by FR category with a failure-detail section. Markdown output produces GFM tables plus per-failure sections with remediation.

gomplate Renderer (reports/report.gohtml)

Requires gomplate (a single static binary — no Go toolchain, no compilation). Renders a .gohtml file against the JSON report loaded as the template's root context:

cd reports
gomplate --context .=../reports/hostname-2026-08-14.json --file report.gohtml

The template file is standalone — customise it without recompiling anything. Fields are accessed with plain dot notation (e.g. .meta.target), and the template only relies on gomplate's built-in functions:

Function Purpose
passIcon (in-template) Maps passed value to ✅ PASS / ❌ FAIL / ❓ MANUAL
severityIcon (in-template) Maps severity to 🔴/🟠/🟡/🟢
strings.Title Capitalises first letter of each word
math.Div, math.Mul Compliance rate percentage

Custom templates:

gomplate --context .=reports/<hostname>-<date>.json --file my-custom.gohtml

Ansible Control Node — Container & QEMU VM

Two independent deployment artifacts. The container image runs anywhere Docker is available; the QEMU VM provides a self-contained appliance for environments without existing Docker infrastructure.

The Two Artifacts

Artifact Defined by Built with Result
Alpine Docker Host Dockerfile.alpine-host ./scripts/build-qemu.sh output/ansible-node.qcow2 (~470 MB)
Ansible Control Node Dockerfile.ansible ./scripts/build-ansible.sh ansible-node Docker image (~793 MB)

The container image includes: Ansible 2.17, 10 collections (ansible.windows, cisco.asa, cisco.ios, cisco.nxos, community.vmware, community.general, community.crypto, ansible.netcommon, ansible.utils, microsoft.sql), and all required Python libraries (pywinrm, pyvmomi, pymssql, paramiko, netmiko, ncclient).

Build & Launch

# Prerequisites: Docker, QEMU (qemu-full), passwordless sudo for mount

# 1. Build the Ansible container image
./scripts/build-ansible.sh
# Push to a registry:
REGISTRY=my-registry ./scripts/build-ansible.sh --push

# 2. Build the VM disk (optional — only needed for QEMU deployment)
./scripts/build-qemu.sh          # Alpine + Docker + SSH → qcow2

# 3. Boot the VM
./scripts/run-qemu.sh            # QEMU pc-q35-10.0, KVM, 1 GB, 2 vCPUs

Interacting with the VM

QEMU VM boots in ~6 seconds
    │
    ├── ttyS0 (serial console) ──► TTY menu
    │   ╔══════════════════════════════════════╗
    │   ║  1) Run all tests                   ║
    │   ║  2) Run FR1 — Auth                  ║
    │   ║  3) Run FR2 — Use Control           ║
    │   ║  4) Run FR5 — Data Flow             ║
    │   ║  5) Download reports (tar.gz)       ║
    │   ║  6) View latest report              ║
    │   ║  7) Shell (Ansible container)       ║
    │   ║  8) Shell (Docker host)             ║
    │   ║  0) Shutdown                        ║
    │   ╚══════════════════════════════════════╝
    │
    ├── :8080 ──► Web UI (browser dashboard)
    │   • Run buttons per playbook
    │   • Live output streaming
    │   • Report downloads (JSON / Markdown / PDF)
    │
    └── :22 ──► SSH (ansible / ansible)

Deployment Targets for the Ansible Container

Environment How
QEMU VM (Windows host) docker run ansible-node inside the Alpine Docker Host
Docker Swarm docker stack deploy with the ansible-node image
Kubernetes kubectl create job with the ansible-node image
CI/CD pipeline docker run --rm -v ... in GitHub Actions / GitLab CI

File Reference

Path Purpose
playbooks/site.yml Entry-point for Linux targets; orchestrates FR1, FR2, FR5 suites
playbooks/suites/fr1_auth.yml 10 FR1 authentication tests for Linux
playbooks/suites/fr2_use_control.yml 6 FR2 use-control tests for Linux
playbooks/suites/fr5_data_flow.yml 4 FR5 data-flow tests for Linux
playbooks/library/report.yml Aggregates test_results[] → JSON file on localhost
playbooks/examples/linux_vm.yml Standalone example: Linux SSH + kernel hardening
playbooks/examples/windows_server.yml Standalone example: Windows Server via WinRM
playbooks/examples/windows_client.yml Standalone example: Windows Client / HMI workstation
playbooks/examples/mssql_server.yml Standalone example: SQL Server via WinRM + Invoke-Sqlcmd
playbooks/examples/vmware_vsphere.yml Standalone example: ESXi via vSphere API
playbooks/examples/hyperv_cluster.yml Standalone example: Hyper-V via WinRM
playbooks/examples/cisco_switch.yml Standalone example: Cisco IOS via network_cli
playbooks/examples/cisco_firewall.yml Standalone example: Cisco ASA via network_cli
playbooks/templates/test_automated.yml Copy-and-fill template: shell gather → evaluate
playbooks/templates/test_file_check.yml Copy-and-fill template: ansible.builtin.stat
playbooks/templates/test_service_check.yml Copy-and-fill template: service_facts
playbooks/templates/test_hitl.yml Copy-and-fill template: pause + delegate_to: localhost
reports/render_report.py Python renderer: terminal box-drawing and Markdown output
reports/report.gohtml Go template producing the terminal box-drawing report, rendered by gomplate
reports/sample-output.json Hand-crafted example report for offline renderer testing
inventory.ini Ansible inventory with all platform groups and connection vars
run.sh End-to-end wrapper: run ansible → find latest JSON → render
Dockerfile.ansible Ansible control node image (all collections + Python libs)
Dockerfile.alpine-host Alpine VM image (Docker daemon + TTY menu + web UI)
scripts/build-ansible.sh Builds ansible-node Docker image
scripts/build-qemu.sh Converts Dockerfile.alpine-host → bootable qcow2
scripts/run-qemu.sh Launches the Alpine VM in QEMU
scripts/tty-menu.sh Serial console menu (launched by agetty -l on ttyS0)
webui/container-app.py Web UI inside the Ansible container (JSON/Markdown/PDF export)
webui/app.py Web UI for the Alpine Docker Host VM
scripts/entrypoint.sh Container entrypoint: web UI if no args, else ansible-playbook

Design Decisions & Tradeoffs

Decision Rationale
Ansible over dedicated scanners Already deployed in most OT environments. No new agent, no new approval process.
Shell-based checks for Linux shell module is the most flexible. changed_when: false keeps runs clean.
Native modules for Windows/Cisco win_security_policy, win_service_info, ios_command, vmware_host_lockdown_info — typed return values avoid brittle text parsing.
delegate_to: localhost for VMware vSphere API is consumed from the control node; no SSH to ESXi.
pause for HITL Ansible-native, no custom tooling. delegate_to: localhost ensures the prompt always reaches the operator regardless of the remote target.
Inline set_fact vs custom module Custom modules require Python on the control node. Inline facts work everywhere and are easier to audit.
test_results[] list vs file-per-test A single growing list is simpler than per-file concatenation. At 100+ tests the memory footprint is negligible.
JSON as canonical output Machine-readable, schema-validatable, ingestible by SIEM/SOAR/Jira/ServiceNow.
Go templates for rendering text/template supports external template files so reports can be restyled without modifying Go code.

Known Limitations

  1. Shell-heavy for Linux: Linux checks depend on shell commands. Different distros may use different paths or tools. Mitigate with when: ansible_os_family == 'Debian' variants.

  2. No diff / drift detection: Each run is independent. To detect configuration drift between runs, diff two JSON reports externally (jd, diff, or a time-series database).

  3. No CI exit code: ansible-playbook exits 0 unless a task fails without ignore_errors. For pipeline gates, parse summary.failed from the JSON report and exit non-zero if > 0.

  4. HITL tests block automation: Any playbook containing HITL tests requires an interactive terminal. Keep HITL tests in separate playbooks or use Ansible tags to separate them from fully-automated runs.

  5. Scalability at 500+ targets: Multi-host runs work well, but one JSON file per host can be unwieldy. Consider post-processing into a single aggregated report.


Comparison to Alternatives

Tool Type Pros Cons
Inspec Ruby DSL, Chef ecosystem Rich compliance profiles, CIS/STIG built-in Ruby runtime; less common in OT
Goss YAML config, Go binary Fast, simple No native IEC mapping; local checks only
OpenSCAP XML/SCAP standard NIST/STIG aligned, XCCDF/OVAL Heavy, complex, US-govt focused, Linux-only
Lynis Shell script Broad Linux coverage Non-extensible output; Linux-only
This project Ansible + JSON + Go/Python Zero new agents; multi-platform; IEC 62443 mapped; HITL support Requires Ansible; shell-dependent Linux checks

Roadmap

  • FR1, FR2, FR5 core suites for Linux
  • Multi-platform examples: Windows, MSSQL, VMware, Hyper-V, Cisco IOS, Cisco ASA
  • Human-in-the-Loop test pattern with ansible.builtin.pause
  • Four copy-and-fill test templates (shell, stat, service_facts, HITL)
  • FR3 suite: File integrity (AIDE/IMA), malware scanner status, secure boot, /tmp noexec
  • FR4 suite: TLS version/cipher audit, disk encryption (LUKS), SSH cipher hardening
  • FR6 suite: rsyslog remote forwarding, auditd dispatcher, journald persistent storage
  • FR7 suite: Disk quotas, CPU/memory limits, backup schedule verification
  • Aggregated multi-host report: Single HTML/PDF across all inventory hosts
  • CI/CD integration: GitHub Actions / GitLab CI pipeline with Markdown report posted as PR comment
  • CIS Benchmark dual-mapping: Each test maps to both IEC 62443-3-3 SR and CIS Benchmark control
S
Description
IEC 62443-3-3 SL2 compliance validation framework with minimal QEMU-bootable Ansible control node for Windows, Cisco, VMware, and MSSQL targets
Readme
382 KiB
Languages
Python 79.6%
Shell 11.1%
Dockerfile 8.6%
HTML 0.7%