Files

744 lines
30 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```
---
## 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 (SL1SL4). 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.
### `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