# 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/-.json │ ▼ run.sh → python3 reports/render_report.py reports/*.json → go run reports/render.go reports/*.json reports/report.gohtml ``` --- ## Ansible Control Node — Split Architecture 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) | ### Build & Launch ```bash # Prerequisites: Docker, QEMU (qemu-full), passwordless sudo for mount # 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 ``` ### How the User Interacts ``` QEMU VM boots in ~6 seconds │ ├── 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 ║ │ ╚══════════════════════════════════════╝ │ ├── :8080 ──► Web UI (browser-based dashboard) │ • Run buttons for each playbook │ • Output streaming │ • Report downloads (JSON) │ └── :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 │ └────────┘ └──────────┘ └─────────┘ ``` ### 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 | — | ### 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 | ### Key Design Decisions | Decision | Rationale | |---|---| | 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 | | `modules=virtio_blk,ext4` on cmdline | `CONFIG_VIRTIO_BLK=m` — module not auto-detected by initramfs | | 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 | --- ## 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/-.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. ### `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` 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`. ### `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