30 KiB
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
- Architecture
- Ansible Control Node — QEMU-Bootable
- Quick Start
- How It Works
- The Test Pattern (The "Secret Sauce")
- JSON Output Schema
- IEC 62443-3-3 SL2 Coverage
- Adding a New Test
- Rendering Reports
- File Reference
- Design Decisions & Tradeoffs
- Comparison to Alternatives
- 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
# 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:
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
# 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
# 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:
# ── 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 registered 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:
{
"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
-
Choose an FR category file (or create a new
suites/frN_*.yml). -
Copy the test template:
# ── 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 -
Register the suite in
playbooks/site.yml:- name: "Suite: FRN — Category Name" block: - ansible.builtin.include_tasks: suites/frN_category.yml ignore_errors: yes -
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 |
| Use ` | default('NOT FOUND', true)` for missing files |
| Parse numbers with ` | int` before comparison |
| Use ` | regex_search(...)` for structured config |
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:
# 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:
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:
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 countsby_category— results grouped by FR category stringby_severity— results grouped into critical/high/medium/low bucketsfailures— only tests wherepassed == false
reports/render_report.py
Python-based report renderer. Two modes:
terminal(default) — Unicode box-drawing, grouped by category, failure detailsmd— GitHub-flavored Markdown tables
Functions:
pass_icon(v)→ ✅/❌/🔍/⏭️ based on bool/stringseverity_icon(s)→ 🔴/🟠/🟡/🟢render_terminal(report)— writes to stdoutrender_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:
[all]
localhost ansible_connection=local
[ics_assets]
# Add real targets here
run.sh
End-to-end wrapper that:
- Runs
ansible-playbookwith the given arguments - Finds the latest JSON report in
reports/ - Renders it via
render_report.py(orrender.goif available) - 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
-
Shell-heavy: Tests depend on shell commands. Different distros may need different check commands (e.g.,
aptvsrpmpackage verification). Mitigate withwhen: ansible_os_family == 'Debian'variants. -
No built-in Windows support: Currently Linux-only. Windows checks would need
win_shell/win_regeditmodules and a separate suite. -
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). -
No immediate pass/fail exit code:
ansible-playbookalways exits 0 unless a task fails withoutignore_errors. For CI integration, parse the JSON summary and use--format jsonoutput. -
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_testmodule for cleaner code - CIS Benchmark mapping: Dual-map tests to both IEC 62443-3-3 and CIS distribution benchmarks