# 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](#architecture) 2. [Quick Start](#quick-start) 3. [Platform-Specific Examples](#platform-specific-examples) 4. [The Test Pattern](#the-test-pattern) 5. [Human-in-the-Loop Tests](#human-in-the-loop-tests) 6. [Adding a New Test](#adding-a-new-test) 7. [JSON Output Schema](#json-output-schema) 8. [IEC 62443-3-3 SL2 Coverage](#iec-62443-3-3-sl2-coverage) 9. [Rendering Reports](#rendering-reports) 10. [Ansible Control Node — Container & QEMU VM](#ansible-control-node--container--qemu-vm) 11. [File Reference](#file-reference) 12. [Design Decisions & Tradeoffs](#design-decisions--tradeoffs) 13. [Comparison to Alternatives](#comparison-to-alternatives) 14. [Roadmap](#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 .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/-.json │ ▼ run.sh / render manually: python3 reports/render_report.py reports/-.json gomplate --context .=reports/-.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](https://docs.gomplate.ca/installing/) (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: ```ini [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: ```bash ansible-vault encrypt_string 'MyPassword' --name ansible_password ``` ### Step 2: Run a Playbook ```bash # 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 ```bash # 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` ```bash 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` ```bash 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` ```bash 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` ```bash 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` ```bash 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` ```bash 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` ```bash 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` ```bash 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. ```yaml # ── 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; `register`ed 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. ```yaml - 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`: ```yaml - 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: ```jsonc { "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: ```bash # 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](https://docs.gomplate.ca/installing/) (a single static binary — no Go toolchain, no compilation). Renders a `.gohtml` file against the JSON report loaded as the template's root context: ```bash 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: ```bash gomplate --context .=reports/-.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 ```bash # 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 - [x] FR1, FR2, FR5 core suites for Linux - [x] Multi-platform examples: Windows, MSSQL, VMware, Hyper-V, Cisco IOS, Cisco ASA - [x] Human-in-the-Loop test pattern with `ansible.builtin.pause` - [x] 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