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.
---
## Table of Contents
1. [Architecture ](#architecture )
2. [Ansible Control Node — QEMU-Bootable ](#ansible-control-node--qemu-bootable-alpine-linux )
3. [Quick Start ](#quick-start )
4. [How It Works ](#how-it-works )
5. [The Test Pattern (The "Secret Sauce") ](#the-test-pattern-the-secret-sauce )
6. [JSON Output Schema ](#json-output-schema )
7. [IEC 62443-3-3 SL2 Coverage ](#iec-62443-3-3-sl2-coverage )
8. [Adding a New Test ](#adding-a-new-test )
9. [Rendering Reports ](#rendering-reports )
10. [File Reference ](#file-reference )
11. [Design Decisions & Tradeoffs ](#design-decisions--tradeoffs )
12. [Comparison to Alternatives ](#comparison-to-alternatives )
13. [Roadmap ](#roadmap )
---
## Architecture
```
playbooks/
├── site.yml # Entry point: orchestrates all FR suites
├── suites/
│ ├── fr1_auth.yml # FR1: Identification & Authentication Control
│ ├── fr2_use_control.yml # FR2: Use Control (audit, sudo, sessions)
│ └── fr5_data_flow.yml # FR5: Restricted Data Flow (firewall, services)
├── library/
│ └── report.yml # Aggregates test_results[] → JSON → disk
reports/
├── render.go # Go renderer (text/template on external .gohtml)
├── render_report.py # Python renderer (terminal + markdown modes)
├── report.gohtml # Go template file for terminal-box-drawing report
├── go.mod # Go module definition
└── sample-output.json # Example output for offline rendering tests
inventory.ini # Ansible inventory (target host list)
run.sh # End-to-end wrapper: ansible → find json → render
README.md # This file
```
### Data Flow
```
ansible-playbook site.yml
│
├── FR1 suite ──┐
├── FR2 suite ──┤ Each task appends to test_results[]
├── FR5 suite ──┘ via set_fact, wrapped in ignore_errors
│
▼
library/report.yml
- Assembles __report dict with summary, by_category, failures
- Prints summary to console
- Writes JSON to reports/<hostname>-<date>.json
│
▼
run.sh → python3 reports/render_report.py reports/*.json
→ go run reports/render.go reports/*.json reports/report.gohtml
```
---
2026-07-07 17:24:13 +00:00
## Ansible Control Node — Split Architecture
2026-07-07 16:57:36 +00:00
2026-07-07 17:24:13 +00:00
Two independent artifacts. The QEMU VM provides Docker; the Ansible container
runs the tests. Same container image runs on Docker Swarm or Kubernetes without
the VM layer.
### The Two Artifacts
| Artifact | Defined by | Built with | Result |
|---|---|---|---|
| **Alpine Docker Host ** | `Dockerfile.alpine-host` | `./scripts/build-qemu.sh` | `output/ansible-node.qcow2` (~470MB) |
| **Ansible Control Node ** | `Dockerfile.ansible` | `./scripts/build-ansible.sh` | `ansible-node` Docker image (~793MB) |
2026-07-07 16:57:36 +00:00
### Build & Launch
``` bash
# Prerequisites: Docker, QEMU (qemu-full), passwordless sudo for mount
2026-07-07 17:24:13 +00:00
# 1. Build the VM disk
./scripts/build-qemu.sh # Alpine + Docker + SSH → qcow2
# 2. Build the Ansible image
./scripts/build-ansible.sh # Ansible + 10 collections → Docker image
# Or push to a registry:
REGISTRY = my-registry ./scripts/build-ansible.sh --push
# 3. Boot the VM
./scripts/run-qemu.sh # QEMU pc-q35-10.0, KVM, 1GB, 2 vCPUs
2026-07-07 16:57:36 +00:00
```
2026-07-07 17:24:13 +00:00
### How the User Interacts
2026-07-07 16:57:36 +00:00
```
2026-07-07 17:24:13 +00:00
QEMU VM boots in ~6 seconds
2026-07-07 16:57:36 +00:00
│
2026-07-07 17:24:13 +00:00
├── ttyS0 (serial console) ──► auto-menu driven
│ ╔══════════════════════════════════════╗
│ ║ 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-07-07 17:24:13 +00:00
├── :8080 ──► Web UI (browser-based dashboard)
│ • Run buttons for each playbook
│ • Output streaming
│ • Report downloads (JSON)
2026-07-07 16:57:36 +00:00
│
2026-07-07 17:24:13 +00:00
└── :22 ──► SSH (ansible / ansible)
```
Every option in the menu and every button in the web UI runs:
``` bash
docker run --rm -it \
-v /ansible/playbooks:/ansible/playbooks:ro \
-v /ansible/reports:/ansible/reports \
-v /ansible/inventory:/ansible/inventory:ro \
ansible-node site.yml
```
### Architecture Diagram
```
Dockerfile.alpine-host Dockerfile.ansible
│ │
▼ ▼
┌─────────────────────┐ ┌─────────────────────┐
│ Alpine 3.20 │ │ Alpine 3.20 │
│ Docker 26.1 daemon │ │ Ansible 2.17 │
│ OpenRC (no systemd) │ │ 10 collections │
│ tty-menu.sh │ │ pywinrm, pyvmomi, │
│ webui.py (:8080) │ │ pymssql, paramiko, │
│ sshd (:22) │ │ netmiko, ncclient │
│ ~470MB qcow2 │ │ ~793MB Docker image │
└─────────────────────┘ └─────────────────────┘
│ │
│ docker run ansible-node │
└───────────────────────────────┘
│
┌───────────────┼───────────────┐
▼ ▼ ▼
┌────────┐ ┌──────────┐ ┌─────────┐
│Windows │ │ Cisco │ │ VMware │
│ WinRM │ │ SSH/CLI │ │ SOAP │
└────────┘ └──────────┘ └─────────┘
2026-07-07 16:57:36 +00:00
```
### Target Integrations
| Target | Ansible Collection | Protocol | Python |
|---|---|---|---|
| **Windows ** | `ansible.windows` 2.3 | WinRM + Kerberos | `pywinrm` 0.5 |
| **Cisco ASA ** | `cisco.asa` 4.0 | SSH/CLI | `paramiko` 4.0 |
| **Cisco IOS/Catalyst ** | `cisco.ios` 5.3 | SSH/CLI | `netmiko` 4.7 |
| **Cisco NX-OS ** | `cisco.nxos` 5.3 | SSH/NX-API | `ncclient` 0.7 |
| **VMware vSphere ** | `community.vmware` 4.3 | SOAP API | `pyvmomi` 9.1 |
| **MSSQL ** | `microsoft.ad` 1.5 | TDS | `pymssql` 2.3 |
| **Linux ** | built-in SSH | SSH | — |
2026-07-07 17:24:13 +00:00
### Deployment Targets for the Ansible Image
| Environment | How |
|---|---|
| **QEMU VM on Windows ** | `docker run ansible-node` inside the Alpine Docker Host |
| **Docker Swarm ** | `docker stack deploy` the `ansible-node` image directly |
| **Kubernetes ** | `kubectl run` a Job or Pod with the `ansible-node` image |
2026-07-07 16:57:36 +00:00
### Key Design Decisions
| Decision | Rationale |
|---|---|
2026-07-07 17:24:13 +00:00
| Split: host vs. container | VM provides Docker; Ansible runs in a container. Same image on Swarm/K8s |
| Dockerfile as build system | Dependency resolution, layer caching. Dockerfile → `docker export` → qcow2 |
| `root=/dev/vda` (not LABEL) | Alpine's `nlplug-findfs` can't resolve labels on virtio-block |
2026-07-07 16:57:36 +00:00
| `modules=virtio_blk,ext4` on cmdline | `CONFIG_VIRTIO_BLK=m` — module not auto-detected by initramfs |
2026-07-07 17:24:13 +00:00
| TTY menu via `agetty -l` | Auto-launches menu instead of login prompt on serial console |
| Web UI: Python stdlib only | Zero dependencies; `http.server` + inline HTML + async JS |
| Direct `-kernel` boot (no extlinux) | QEMU loads kernel/initrd directly; no bootloader in disk image |
2026-07-07 16:57:36 +00:00
---
## Quick Start
### Prerequisites
- Ansible ≥ 2.9 (core modules only: `shell` , `stat` , `copy` , `set_fact` , `debug` )
- Python ≥ 3.6 (for the Python report renderer)
- Go ≥ 1.21 (optional, for the Go template renderer)
- Target host: Linux with systemd (Debian/Ubuntu or RHEL/Rocky)
### Run Against Localhost
``` bash
# Edit inventory.ini to add your target, or test locally:
echo "localhost ansible_connection=local" > inventory.ini
# Run with sudo (many checks need root):
ansible-playbook -i inventory.ini playbooks/site.yml --limit localhost -K
# Or use the wrapper script:
KEEP_SUDO = 1 ./run.sh --limit localhost -K
```
### Render a Report
``` bash
# Terminal format (default):
python3 reports/render_report.py reports/localhost-2026-07-07.json
# Markdown format (for GitHub/GitLab wikis):
python3 reports/render_report.py reports/localhost-2026-07-07.json --format md > REPORT.md
# Go template (if Go is installed):
( cd reports && go run render.go ../sample-output.json report.gohtml)
```
---
## How It Works
### The Test Pattern ("The Secret Sauce")
Every test follows a rigid 3-step **Gather → Evaluate → Record ** structure
wrapped in an Ansible `block` with `ignore_errors: yes` :
``` yaml
# ── SR 1.5: Password minimum length ───────────────────────────
- block : # ← 1) Wrap everything
- name : "Gather: Check pwquality minlen" # ← 2) Gather: read system state
ansible.builtin.shell : |
grep -E '^\s*minlen\s*=' /etc/security/pwquality.conf 2>/dev/null | tail -1 || echo "NOT SET"
register : _minlen # store raw output
changed_when : false # never report as "changed"
- name : "Evaluate: IAC-05" # ← 3) Evaluate: judge pass/fail
ansible.builtin.set_fact :
test_results : "{{ test_results + [{ # append to shared list
'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*([0-9]+)')
| regex_replace('minlen\\s*=\\s*', '') | 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 # ← 4) NEVER abort the run
```
**Why this works ** : `ignore_errors: yes` prevents Ansible from stopping. The
`set_fact` always executes (it never fails). The `block` ensures the gather and
evaluate steps share a scope, so `register` ed variables are available.
### Why `block` + `ignore_errors` Instead of `failed_when`
Using `failed_when: false` on individual tasks is tempting, but:
| Approach | Behavior |
|---|---|
| `failed_when: false` on shell task | Shell always "succeeds"; but if the shell returns non-zero, Ansible still marks it red in output |
| `block` + `ignore_errors: yes` | Failures are captured, marked orange, but execution continues to the next block. Registered vars are still set. This is the **cleanest ** pattern. |
---
## JSON Output Schema
Each test produces one entry in `test_results[]` . The complete report looks like:
``` jsonc
{
"meta" : {
"standard" : "IEC 62443-3-3" , // Standard being validated
"security_level" : "SL2" , // Target security level
"target" : "ics-gateway-01" , // inventory_hostname
"timestamp" : "2026-07-07T16:42:00+02:00" , // ISO 8601
"executed_by" : "auditor" // ansible_user_id
} ,
"summary" : {
"total" : 15 , // Total test count
"passed" : 10 , // passed == true
"failed" : 4 , // passed == false
"skipped" : 1 // passed == "review" or "skipped"
} ,
"by_category" : [ // Results grouped by FR category
[ "FR1 - ..." , [ { . . . } , { . . . } ] ] ,
[ "FR2 — Use Control" , [ { . . . } ] ]
] ,
"by_severity" : { // Drill-down by criticality
"critical" : [ { . . . } ] ,
"high" : [ { . . . } ] ,
"medium" : [ { . . . } ] ,
"low" : [ { . . . } ]
} ,
"failures" : [ { . . . } ] , // Only tests where passed == false
"results" : [ // All results, ordered by execution
{
"test_id" : "IAC-05" , // Unique identifier (category-number)
"category" : "FR1 - ..." , // Which IEC 62443 FR section
"requirement" : "SR 1.5 — ..." , // Specific SR (System Requirement)
"description" : "Password..." , // Human-readable check
"passed" : false , // bool | "review" | "skipped"
"expected" : "minlen >= 14..." , // What compliance looks like
"actual" : "minlen = 8" , // What we found
"severity" : "high" , // critical | high | medium | low
"remediation" : "Set minlen=14..." // How to fix
}
]
}
```
### `passed` Field Semantics
| Value | Meaning | Icon |
|---|---|---|
| `true` | Automated check passed | ✅ |
| `false` | Automated check failed | ❌ |
| `"review"` | Requires human assessment | 🔍 |
| `"skipped"` | Not applicable to this target | ⏭️ |
### `severity` Field Semantics
| Value | Meaning | Examples |
|---|---|---|
| `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, some unused accounts |
| `low` | Best-practice gap | Extra listening ports, stale accounts |
---
## IEC 62443-3-3 SL2 Coverage
IEC 62443-3-3 defines 7 Foundational Requirements (FRs), each with System
Requirements (SRs) at escalating Security Levels (SL1– SL4). SL2 adds to SL1.
### Mapping of Implemented Tests
| 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 | 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 | high |
| UC-06 | FR2 | SR 2.8 | ≥ 4 critical syscall types audited | medium |
| RDF-01 | FR5 | SR 5.1 | Host-based firewall with 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 documented | low |
### Not Yet Implemented (FR3, FR4, FR6, FR7)
| FR | Key SL2 Controls | Suggested Checks |
|---|---|---|
| FR3 — System Integrity | Malware protection, file integrity, secure boot | AIDE/IMA, ClamAV service, /proc/sys/kernel/secure_boot |
| FR4 — Data Confidentiality | Encryption at rest/transit | TLS version on listening ports, LUKS/dm-crypt, SSH ciphers |
| FR6 — Timely Response | Audit log forwarding, alerting | rsyslog remote config, auditd dispatcher |
| FR7 — Resource Availability | DoS protection, backup | Disk quota, CPU limits, backup cron/schedule |
---
## Adding a New Test
### Step-by-Step
1. **Choose an FR category file ** (or create a new `suites/frN_*.yml` ).
2. **Copy the test template ** :
```yaml
# ── SR X.Y: Short description ───────────────────────────────
- block:
- name: "Gather: What to check"
ansible.builtin.shell: |
your-check-command-here
register: _myvar
changed_when: false
- name: "Evaluate: XXX-NN"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'XXX-NN',
'category': 'FRN — Category Name',
'requirement': 'SR X.Y — Requirement Name',
'description': 'One-line description of the check',
'passed': ( _myvar.stdout | trim | length > 0 ), # boolean expression
'expected': 'What compliance looks like',
'actual': _myvar.stdout | trim | default('NOT FOUND', true),
'severity': 'high', # critical|high|medium|low
'remediation': 'Command or steps to fix'
}] }}"
ignore_errors: yes
` ``
3. **Register the suite** in ` playbooks/site.yml`:
` ``yaml
- name: "Suite: FRN — Category Name"
block:
- ansible.builtin.include_tasks: suites/frN_category.yml
ignore_errors: yes
` ``
4. **Add to the coverage table** in this README.
### Test ID Naming Convention
- **Prefix**: 3-letter abbreviation of the FR (e.g., ` IAC`, ` UC`, ` RDF`,
` SYS`, ` CONF`, ` RESP`, ` AVAIL`)
- **Number**: Sequential within that FR, padded to 2 digits (` 01`, ` 02`, ...)
- **Examples**: ` IAC-01`, ` UC-12`, ` RDF-05`
### Tips for Writing Robust Checks
| Tip | Why |
|---|---|
| Always use ` changed_when: false` on gather tasks | Tests must never report as "changed" |
| Use ` | trim` on shell output | Shell often returns trailing newlines |
| Use ` | default('NOT FOUND', true)` for missing files | Prevents undefined-variable errors |
| Parse numbers with ` | int` before comparison | String ` "8"` ≠ integer 8 in Jinja2 |
| Use ` | regex_search(...)` for structured config | More robust than exact-match grep |
| Test an ` or` of ` pam_faillock` and ` pam_tally2` | Different distros use different PAM modules |
---
## Rendering Reports
### Python Renderer (` reports/render_report.py`)
No dependencies beyond Python 3 stdlib. Two output formats:
` ``bash
# Terminal box-drawing (default):
python3 reports/render_report.py reports/localhost-2026-07-07.json
# Markdown (for wiki, PR comment, etc.):
python3 reports/render_report.py reports/localhost-2026-07-07.json --format md
` ``
**Terminal output** uses Unicode box-drawing characters (╔═╗║╚╝), groups results by
FR category, and shows a failure-detail section at the bottom.
**Markdown output** produces a GitHub-flavored table plus per-failure sections
with remediation instructions.
### Go Renderer (` reports/render.go`)
Requires Go ≥ 1.21. Uses ` text/template` with an external template file:
` ``bash
cd reports
go run render.go sample-output.json report.gohtml
` ``
The template (` report.gohtml`) is a standalone file — you can customize it without
recompiling. Template functions:
| Function | Purpose |
|---|---|
| ` passIcon` | Maps ` passed` value to ✅/❌/🔍 |
| ` severityIcon` | Maps severity string to 🔴/🟠/🟡/🟢 |
| ` title` | Capitalizes first letter of each word |
| ` divf` | Float division for compliance rate |
### Extending: Custom Go Templates
Copy ` report.gohtml` to ` my-report.gohtml`, modify it, and run:
` ``bash
go run reports/render.go reports/*.json my-report.gohtml
` ``
The template receives the full JSON document as ` .` (a ` map[string]any`). Access
fields with ` (index . "key")` since it's untyped.
---
## File Reference
### ` playbooks/site.yml`
Entry-point playbook. Runs against ` hosts: all`. Pre-tasks create the local
` reports/` directory. Each suite is included as a named task wrapped in a ` block`
with ` ignore_errors: yes`. The final task includes ` library/report.yml` to
aggregate results.
Variables set here:
- ` report_dir: "./reports"` — where JSON output lands
### ` playbooks/suites/fr1_auth.yml`
**FR1 — Identification and Authentication Control**. 10 tests covering:
- Duplicate UIDs (SR 1.1)
- Default/unused accounts (SR 1.3)
- Empty password hashes (SR 1.4)
- Password complexity via libpwquality (SR 1.5)
- Password history via pam_pwhistory (SR 1.6)
- Password aging via login.defs (SR 1.7)
- Account lockout via pam_faillock (SR 1.11)
Uses: ` /etc/passwd`, ` /etc/shadow`, ` /etc/security/pwquality.conf`,
` /etc/login.defs`, ` /etc/pam.d/common-auth`, ` /etc/pam.d/common-password`,
` lastlog`
### ` playbooks/suites/fr2_use_control.yml`
**FR2 — Use Control**. 6 tests covering:
- Unrestricted sudo access (SR 2.1)
- sudoers file permissions (SR 2.1)
- Shell idle timeout via TMOUT (SR 2.5)
- auditd immutable mode (SR 2.4)
- auditd service status (SR 2.8)
- Audited syscall coverage (SR 2.8)
Uses: ` /etc/sudoers`, ` /etc/sudoers.d/`, ` /etc/profile`, ` /etc/bash.bashrc`,
` /etc/audit/audit.rules`, ` systemctl`
### ` playbooks/suites/fr5_data_flow.yml`
**FR5 — Restricted Data Flow**. 4 tests covering:
- Host-based firewall with active rules (SR 5.1)
- Default DROP inbound policy (SR 5.1)
- Insecure legacy services disabled (SR 5.3)
- Listening port inventory for review (SR 5.3)
Uses: ` iptables`/` nft`, ` systemctl`, ` ss`
### ` playbooks/library/report.yml`
**Report aggregation**. Included last by ` site.yml`. Builds a ` __report` dict
from the ` test_results[]` list, prints a summary box to the console, and writes
` reports/<hostname>-<date>.json` to the local machine.
Key computed fields:
- ` summary.total/passed/failed/skipped` — basic counts
- ` by_category` — results grouped by FR category string
- ` by_severity` — results grouped into critical/high/medium/low buckets
- ` failures` — only tests where ` passed == false`
### ` reports/render_report.py`
Python-based report renderer. Two modes:
- ` terminal` (default) — Unicode box-drawing, grouped by category, failure details
- ` md` — GitHub-flavored Markdown tables
Functions:
- ` pass_icon(v)` → ✅/❌/🔍/⏭️ based on bool/string
- ` severity_icon(s)` → 🔴/🟠/🟡/🟢
- ` render_terminal(report)` — writes to stdout
- ` render_markdown(report)` — writes to stdout
### ` reports/render.go`
Go-based report renderer. Uses ` text/template` and ` golang.org/x/text/cases`.
Loads an external ` .gohtml` template file so templates can be customized without
rebuilding. Registers template functions for pass/severity icons and arithmetic.
### ` reports/report.gohtml`
Go template producing a terminal box-drawing report. Iterates over ` results`,
renders a summary header, a per-result listing with icons, and a failure-detail
section. Uses template functions registered by ` render.go`.
### ` reports/sample-output.json`
A hand-crafted example JSON report for testing the renderers without running
Ansible. Contains 15 results (10 passed, 4 failed, 1 review) across FR1 and FR2.
Useful for CI validation of the report rendering pipeline.
2026-07-07 17:24:13 +00:00
### ` Dockerfile.alpine-host`
Alpine Linux 3.20 with Docker daemon, SSH, TTY menu, and web UI. Used as the
blueprint for the QEMU-bootable VM disk. Built via ` scripts/build-qemu.sh`.
Installs: ` alpine-base`, ` linux-virt`, ` docker`, ` openssh-server`, ` python3`.
Provides: serial console menu (` scripts/tty-menu.sh`) and web UI (` webui/app.py`).
### ` Dockerfile.ansible`
Ansible control node container image. Installs ` ansible` + ` sshpass` + 10
Ansible collections + Python packages for Windows (pywinrm), Cisco (paramiko,
netmiko, ncclient), VMware (pyvmomi), and MSSQL (pymssql). Built via
` scripts/build-ansible.sh`. Deployable on Docker Swarm, Kubernetes, or inside
the Alpine Docker Host VM.
### ` scripts/build-qemu.sh`
Converts ` Dockerfile.alpine-host` into a bootable QEMU disk. Steps: Docker build
→ export rootfs → extract kernel + initramfs → create ext4 disk → convert to
qcow2. Outputs: ` output/ansible-node.qcow2`, ` output/vmlinuz-virt`,
` output/initramfs-virt`.
### ` scripts/build-ansible.sh`
Builds the ` ansible-node` Docker image from ` Dockerfile.ansible`. Reports the
image size and layers. With ` --push` and ` REGISTRY` set, pushes to a container
registry for deployment.
### ` scripts/run-qemu.sh`
Launches the Alpine Docker Host VM on QEMU ` pc-q35-10.0`. Uses direct kernel
boot (` -kernel` / ` -initrd`), virtio devices, user-mode networking with port
forwards (SSH on ` :2222`, web UI on ` :8090`). Supports ` --gui`, ` --vnc`, and
` --debug` modes.
### ` scripts/tty-menu.sh`
Serial console menu script. Launched automatically by ` agetty -l` on ` ttyS0`.
Options: run individual playbooks, view reports, shell into the Ansible
container, shell into the Docker host, shutdown VM. All test options run
` docker run ansible-node` with shared volumes.
### ` webui/app.py`
2026-07-07 18:49:40 +00:00
Minimal web dashboard for the **Alpine Docker Host VM**. Python 3 stdlib only,
zero dependencies. Serves on port 8080. Features: dark-themed UI with run buttons
per playbook, async output streaming, JSON report downloads. Runs playbooks
via ` docker run ansible-node`.
### ` webui/container-app.py`
Web dashboard for the **Ansible container** itself. Serves on port 8080. Features:
dark-themed UI, run buttons, async output, **JSON / Markdown / PDF export**.
PDF generation via ` fpdf2` (pure Python, zero system deps). Markdown via bundled
` render_report.py`. Started automatically when the container runs with no arguments.
### ` scripts/entrypoint.sh`
Container entrypoint. If invoked with no arguments, starts the web UI
(` container-app.py`). If arguments are given, passes them directly to
` ansible-playbook`. Enables both ` docker run -p 8080:8080 ansible-node` and
` docker run ansible-node site.yml`.
2026-07-07 17:24:13 +00:00
2026-07-07 16:57:36 +00:00
### ` inventory.ini`
Standard Ansible inventory. Structure:
` ``ini
[all]
localhost ansible_connection=local
[ics_assets]
# Add real targets here
` ``
### ` run.sh`
End-to-end wrapper that:
1. Runs ` ansible-playbook` with the given arguments
2. Finds the latest JSON report in ` reports/`
3. Renders it via ` render_report.py` (or ` render.go` if available)
4. Falls back to a Python one-liner summary if neither is available
---
## Design Decisions & Tradeoffs
### Why Ansible and Not a Dedicated Compliance Scanner?
| Decision | Rationale |
|---|---|
| **Ansible** | Already deployed in most OT environments. No new agent, no new approval. |
| **Shell-based checks** | ` shell` module is the most flexible. We trade idempotence for expressiveness. ` changed_when: false` keeps it clean. |
| **Inline ` set_fact`** vs custom module | Custom Ansible modules require Python on the control node. Inline facts work everywhere. Less code to maintain. |
| **` 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 (~50KB). |
| **` ignore_errors` at block level** vs task level | Block-level isolates failures cleanly. A failed gather still reaches evaluate. |
| **JSON as canonical output** | Machine-readable, schema-validatable, ingestible by SIEM/SOAR/Jira. |
| **Go templates for rendering** | ` text/template` is standard, fast, and supports external template files for customization without recompilation. |
### Known Limitations
1. **Shell-heavy**: Tests depend on shell commands. Different distros may need
different check commands (e.g., ` apt` vs ` rpm` package verification). Mitigate
with ` when: ansible_os_family == 'Debian'` variants.
2. **No built-in Windows support**: Currently Linux-only. Windows checks would
need ` win_shell`/` win_regedit` modules and a separate suite.
3. **No diff/drift detection**: Each run is independent. To detect drift between
runs, diff two JSON reports externally (` jd`, ` diff`, or a time-series DB).
4. **No immediate pass/fail exit code**: ` ansible-playbook` always exits 0 unless
a task fails without ` ignore_errors`. For CI integration, parse the JSON
summary and use ` --format json` output.
5. **Scalability at 500+ targets**: Running against large inventories is fine
(Ansible's strength), but the 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, YAML-based | No native IEC mapping; limited to local checks |
| **OpenSCAP** | XML-based, SCAP standard | NIST/STIG aligned, XCCDF/OVAL | Heavy, complex, US-govt focused |
| **Lynis** | Shell script, single binary | Broad Linux coverage, professional reports | Non-extensible output format |
| **This project** | Ansible + JSON + Go/Python templates | Zero new agents, customizable, IEC 62443 mapped | Requires Ansible; shell-dependent checks |
---
## Roadmap
- [ ] **FR3 — System Integrity**: File integrity (AIDE/IMA), malware scanner status, secure boot, /tmp noexec
- [ ] **FR4 — Data Confidentiality**: TLS version/cipher audit on listening ports, disk encryption, SSH hardening
- [ ] **FR6 — Timely Response**: rsyslog remote forwarding, auditd dispatcher, journald persistent storage
- [ ] **FR7 — Resource Availability**: Disk quotas, CPU/memory limits, backup schedule verification
- [ ] **Aggregated multi-host report**: Single HTML/PDF report across all inventory hosts
- [ ] **GitLab CI / GitHub Actions integration**: Pipeline stage that runs playbook + posts markdown report as PR comment
- [ ] **Custom Ansible module**: Replace shell-based checks with a native ` iec62443_test` module for cleaner code
- [ ] **CIS Benchmark mapping ** : Dual-map tests to both IEC 62443-3-3 and CIS distribution benchmarks