Files

784 lines
35 KiB
Markdown
Raw Permalink Normal View History

2026-07-07 16:57:36 +00:00
# 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.
2026-08-17 15:12:16 +02:00
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.
2026-07-07 16:57:36 +00:00
---
## Table of Contents
1. [Architecture](#architecture)
2026-08-17 15:12:16 +02:00
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)
2026-07-07 16:57:36 +00:00
9. [Rendering Reports](#rendering-reports)
2026-08-17 15:12:16 +02:00
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)
2026-07-07 16:57:36 +00:00
---
## Architecture
```
playbooks/
2026-08-17 15:12:16 +02:00
├── 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)
2026-07-07 16:57:36 +00:00
├── library/
│ └── report.yml # Aggregates test_results[] → JSON → disk
2026-08-17 15:12:16 +02:00
├── 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
2026-07-07 16:57:36 +00:00
reports/
2026-08-17 15:12:16 +02:00
├── render_report.py # Python renderer (terminal + markdown output)
2026-08-31 13:35:40 +02:00
├── report.gohtml # Go template rendered by gomplate (terminal box-drawing report)
2026-08-17 15:12:16 +02:00
└── 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
2026-07-07 16:57:36 +00:00
```
### Data Flow
```
2026-08-17 15:12:16 +02:00
ansible-playbook <playbook>.yml
2026-07-07 16:57:36 +00:00
│
2026-08-17 15:12:16 +02:00
├── Gather tasks ──┐
│ ├─ collect system state (shell, stat, ios_command, etc.)
├── Evaluate tasks─┘
│ judge pass/fail, append to test_results[]
2026-07-07 16:57:36 +00:00
│
▼
library/report.yml
2026-08-17 15:12:16 +02:00
- Assembles __report dict with summary, by_category, by_severity, failures
- Prints summary box to console
2026-07-07 16:57:36 +00:00
- Writes JSON to reports/<hostname>-<date>.json
│
▼
2026-08-17 15:12:16 +02:00
run.sh / render manually:
python3 reports/render_report.py reports/<hostname>-<date>.json
2026-08-31 13:35:40 +02:00
gomplate --context .=reports/<hostname>-<date>.json --file reports/report.gohtml
2026-08-17 15:12:16 +02:00
```
### 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)
2026-08-31 13:35:40 +02:00
- [gomplate](https://docs.gomplate.ca/installing/) (optional, single static binary, for the `.gohtml` template renderer)
2026-08-17 15:12:16 +02:00
- 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
2026-08-31 13:35:40 +02:00
# gomplate template renderer:
gomplate --context .=reports/localhost-2026-08-14.json --file reports/report.gohtml
2026-07-07 16:57:36 +00:00
```
---
2026-08-17 15:12:16 +02:00
## Platform-Specific Examples
2026-07-07 16:57:36 +00:00
2026-08-17 15:12:16 +02:00
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.
2026-08-31 13:35:40 +02:00
### gomplate Renderer (`reports/report.gohtml`)
2026-08-17 15:12:16 +02:00
2026-08-31 13:35:40 +02:00
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:
2026-08-17 15:12:16 +02:00
```bash
cd reports
2026-08-31 13:35:40 +02:00
gomplate --context .=../reports/hostname-2026-08-14.json --file report.gohtml
2026-08-17 15:12:16 +02:00
```
2026-08-31 13:35:40 +02:00
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:
2026-08-17 15:12:16 +02:00
| Function | Purpose |
|---|---|
2026-08-31 13:35:40 +02:00
| `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 |
2026-08-17 15:12:16 +02:00
Custom templates:
```bash
2026-08-31 13:35:40 +02:00
gomplate --context .=reports/<hostname>-<date>.json --file my-custom.gohtml
2026-08-17 15:12:16 +02:00
```
---
## 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 |
|---|---|---|---|
2026-08-17 15:12:16 +02:00
| **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`).
2026-07-07 16:57:36 +00:00
### Build & Launch
```bash
# Prerequisites: Docker, QEMU (qemu-full), passwordless sudo for mount
2026-08-17 15:12:16 +02:00
# 1. Build the Ansible container image
./scripts/build-ansible.sh
# Push to a registry:
REGISTRY=my-registry ./scripts/build-ansible.sh --push
2026-08-17 15:12:16 +02:00
# 2. Build the VM disk (optional — only needed for QEMU deployment)
./scripts/build-qemu.sh # Alpine + Docker + SSH → qcow2
# 3. Boot the VM
2026-08-17 15:12:16 +02:00
./scripts/run-qemu.sh # QEMU pc-q35-10.0, KVM, 1 GB, 2 vCPUs
2026-07-07 16:57:36 +00:00
```
2026-08-17 15:12:16 +02:00
### Interacting with the VM
2026-07-07 16:57:36 +00:00
```
QEMU VM boots in ~6 seconds
2026-07-07 16:57:36 +00:00
│
2026-08-17 15:12:16 +02:00
├── 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 ║
│ ╚══════════════════════════════════════╝
2026-07-07 16:57:36 +00:00
│
2026-08-17 15:12:16 +02:00
├── :8080 ──► Web UI (browser dashboard)
│ • Run buttons per playbook
│ • Live output streaming
│ • Report downloads (JSON / Markdown / PDF)
2026-07-07 16:57:36 +00:00
│
└── :22 ──► SSH (ansible / ansible)
```
2026-08-17 15:12:16 +02:00
### Deployment Targets for the Ansible Container
| Environment | How |
|---|---|
2026-08-17 15:12:16 +02:00
| 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 |
2026-07-07 16:57:36 +00:00
---
## File Reference
2026-08-17 15:12:16 +02:00
| 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 |
2026-08-31 13:35:40 +02:00
| `reports/report.gohtml` | Go template producing the terminal box-drawing report, rendered by `gomplate` |
2026-08-17 15:12:16 +02:00
| `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` |
2026-07-07 16:57:36 +00:00
---
## Design Decisions & Tradeoffs
| Decision | Rationale |
|---|---|
2026-08-17 15:12:16 +02:00
| **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. |
2026-07-07 16:57:36 +00:00
### Known Limitations
2026-08-17 15:12:16 +02:00
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.
2026-07-07 16:57:36 +00:00
2026-08-17 15:12:16 +02:00
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).
2026-07-07 16:57:36 +00:00
2026-08-17 15:12:16 +02:00
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`.
2026-07-07 16:57:36 +00:00
2026-08-17 15:12:16 +02:00
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.
2026-07-07 16:57:36 +00:00
2026-08-17 15:12:16 +02:00
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.
2026-07-07 16:57:36 +00:00
---
## 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 |
2026-08-17 15:12:16 +02:00
| **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 |
2026-07-07 16:57:36 +00:00
---
## Roadmap
2026-08-17 15:12:16 +02:00
- [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