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
- Architecture
- Quick Start
- Platform-Specific Examples
- The Test Pattern
- Human-in-the-Loop Tests
- Adding a New Test
- JSON Output Schema
- IEC 62443-3-3 SL2 Coverage
- Rendering Reports
- Ansible Control Node — Container & QEMU VM
- File Reference
- Design Decisions & Tradeoffs
- Comparison to Alternatives
- 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
.gohtmltemplate 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.inigroup vars and any prerequisites (WinRM setup, SQLPS module, vCenter permissions, etc.) - Follows the identical
block→ gather → evaluate →ignore_errorspattern - Uses the most Ansible-native module available for each check (e.g.
win_security_policyinstead ofwin_shellfor Windows password policy) - Ends with
include_tasks: ../library/report.ymlto 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,RDFfor Linux;WIN-IAC,SQL-UC,FW-RDF,SW-RDF,VMW-IACfor platform examples) - Number: Sequential within prefix, zero-padded (
01,02, ...) - HITL suffix: Append
-HITLfor 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
-
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. -
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). -
No CI exit code:
ansible-playbookexits 0 unless a task fails withoutignore_errors. For pipeline gates, parsesummary.failedfrom the JSON report and exit non-zero if> 0. -
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.
-
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