CYBER-0 initial concept ready
This commit is contained in:
@@ -1,783 +1,96 @@
|
||||
# Ansible-Test: IEC 62443-3-3 SL2 Compliance Validation
|
||||
# Test Automation Template
|
||||
|
||||
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.
|
||||
This repository is a GitLab CI template for repeatable testing of virtual and physical HLC environments. A pipeline selects an environment, runs enabled testing methodologies as Kubernetes pods on `testserv`, normalizes each tool's output, and publishes JSON, Markdown, HTML, and PDF artifacts.
|
||||
|
||||
Supports automated testing across **Linux, Windows Server, Windows Clients,
|
||||
MS SQL Server, VMware vSphere, Hyper-V, Cisco IOS switches, and Cisco ASA
|
||||
firewalls** — from a single containerised Ansible control node.
|
||||
## Dependencies
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Architecture](#architecture)
|
||||
2. [Quick Start](#quick-start)
|
||||
3. [Platform-Specific Examples](#platform-specific-examples)
|
||||
4. [The Test Pattern](#the-test-pattern)
|
||||
5. [Human-in-the-Loop Tests](#human-in-the-loop-tests)
|
||||
6. [Adding a New Test](#adding-a-new-test)
|
||||
7. [JSON Output Schema](#json-output-schema)
|
||||
8. [IEC 62443-3-3 SL2 Coverage](#iec-62443-3-3-sl2-coverage)
|
||||
9. [Rendering Reports](#rendering-reports)
|
||||
10. [Ansible Control Node — Container & QEMU VM](#ansible-control-node--container--qemu-vm)
|
||||
11. [File Reference](#file-reference)
|
||||
12. [Design Decisions & Tradeoffs](#design-decisions--tradeoffs)
|
||||
13. [Comparison to Alternatives](#comparison-to-alternatives)
|
||||
14. [Roadmap](#roadmap)
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
playbooks/
|
||||
├── site.yml # Entry point for Linux targets (all FR suites)
|
||||
├── suites/ # Included task-list suites for Linux
|
||||
│ ├── fr1_auth.yml # FR1: Identification & Authentication (10 tests)
|
||||
│ ├── fr2_use_control.yml # FR2: Use Control — audit, sudo, sessions (6 tests)
|
||||
│ └── fr5_data_flow.yml # FR5: Restricted Data Flow — firewall, services (4 tests)
|
||||
├── library/
|
||||
│ └── report.yml # Aggregates test_results[] → JSON → disk
|
||||
├── examples/ # ◄ Standalone playbooks — one per platform type
|
||||
│ ├── linux_vm.yml # Linux: SSH hardening, sysctl, sudo logging
|
||||
│ ├── windows_server.yml # Windows Server: WinRM, security policy, audit
|
||||
│ ├── windows_client.yml # Windows Client: BitLocker, screen lock, USB
|
||||
│ ├── mssql_server.yml # MS SQL Server: auth mode, sa, xp_cmdshell, audit
|
||||
│ ├── vmware_vsphere.yml # VMware ESXi: lockdown, NTP, SSH/Shell services
|
||||
│ ├── hyperv_cluster.yml # Hyper-V: Gen2, vSwitch isolation, integration svc
|
||||
│ ├── cisco_switch.yml # Cisco IOS: SSH v2, no SNMPv1, banner, NTP
|
||||
│ └── cisco_firewall.yml # Cisco ASA: no Telnet, IKEv2, syslog, AAA, ACL
|
||||
└── templates/ # ◄ Copy-and-fill templates for writing new tests
|
||||
├── test_automated.yml # Shell-based gather → evaluate pattern
|
||||
├── test_file_check.yml # File permission check via ansible.builtin.stat
|
||||
├── test_service_check.yml # Service state check via service_facts
|
||||
└── test_hitl.yml # Human-in-the-Loop with ansible.builtin.pause
|
||||
|
||||
reports/
|
||||
├── render_report.py # Python renderer (terminal + markdown output)
|
||||
├── report.gohtml # Go template rendered by gomplate (terminal box-drawing report)
|
||||
└── sample-output.json # Example output for offline renderer tests
|
||||
inventory.ini # Ansible inventory — all platform groups defined
|
||||
run.sh # End-to-end wrapper: ansible → find JSON → render
|
||||
```mermaid
|
||||
flowchart TD
|
||||
U[Tester starts GitLab pipeline] --> P[GitLab CI]
|
||||
A[assets.yml<br/>project and targets] --> P
|
||||
V[Environment-scoped GitLab variables<br/>credentials and keys] --> P
|
||||
P --> R[GitLab Kubernetes Runner]
|
||||
R --> K[k3s namespace: test-automation]
|
||||
K --> C[(tool-cache PVC<br/>downloaded databases)]
|
||||
K --> AN[Ansible methodology pod]
|
||||
K --> Z[ZAP methodology pod]
|
||||
AN --> T[Linux, Windows, SQL,<br/>VMware, Hyper-V, Cisco]
|
||||
Z --> W[Web applications]
|
||||
AN --> N[Normalized JSON schema]
|
||||
Z --> N
|
||||
N --> O[Markdown, HTML, PDF]
|
||||
O --> G[GitLab pipeline artifacts]
|
||||
```
|
||||
|
||||
### Data Flow
|
||||
Required infrastructure:
|
||||
|
||||
```
|
||||
ansible-playbook <playbook>.yml
|
||||
│
|
||||
├── Gather tasks ──┐
|
||||
│ ├─ collect system state (shell, stat, ios_command, etc.)
|
||||
├── Evaluate tasks─┘
|
||||
│ judge pass/fail, append to test_results[]
|
||||
│
|
||||
▼
|
||||
library/report.yml
|
||||
- Assembles __report dict with summary, by_category, by_severity, failures
|
||||
- Prints summary box to console
|
||||
- Writes JSON to reports/<hostname>-<date>.json
|
||||
│
|
||||
▼
|
||||
run.sh / render manually:
|
||||
python3 reports/render_report.py reports/<hostname>-<date>.json
|
||||
gomplate --context .=reports/<hostname>-<date>.json --file reports/report.gohtml
|
||||
- GitLab project and Kubernetes-executor runner on `testserv`.
|
||||
- k3s namespace, RBAC, and cache PVC from `platform/kubernetes/`.
|
||||
- Runner configuration from `platform/gitlab-runner/values.example.yml`.
|
||||
- Container registry containing the Ansible image.
|
||||
- Network access from k3s pods to the selected test environment.
|
||||
- Environment-scoped GitLab CI/CD variables for credentials.
|
||||
|
||||
## Start A Test
|
||||
|
||||
1. Define project metadata and targets in `assets.yml`.
|
||||
2. In GitLab, create protected and masked CI/CD variables with an environment scope matching `all.vars.test_project.environment`.
|
||||
3. Start a pipeline and set:
|
||||
|
||||
| Variable | Purpose |
|
||||
| --- | --- |
|
||||
| `TARGET_ENVIRONMENT` | GitLab environment scope; must match `assets.yml` |
|
||||
| `RUN_ANSIBLE=true` | Enable infrastructure tests |
|
||||
| `RUN_ZAP=true` | Enable web tests after the ZAP methodology is implemented |
|
||||
| `ANSIBLE_LIMIT` | Optional Ansible host/group limit; defaults to `all` |
|
||||
|
||||
The pipeline validates that `TARGET_ENVIRONMENT` matches `assets.yml`. A mismatch stops before any testing begins.
|
||||
|
||||
## Secrets
|
||||
|
||||
The Ansible job requires `ANSIBLE_SECRET_VARS` as an environment-scoped GitLab **File** variable containing Ansible variables. SSH keys may be supplied as `ANSIBLE_PRIVATE_KEY_FILE`, also as a File variable.
|
||||
|
||||
Validation, normalization, and report jobs do not declare a GitLab environment and therefore do not receive environment-scoped credentials. See [docs/secrets.md](docs/secrets.md) for variable examples and rotation guidance.
|
||||
|
||||
## Pipeline Flow
|
||||
|
||||
1. `validate`: validate `assets.yml`, compile adapters, and test the report contract.
|
||||
2. `platform`: manually verify that the persistent tool cache is mounted.
|
||||
3. `test`: run enabled methodology pods against selected assets.
|
||||
4. `normalize`: convert native tool output to `schemas/test-report.schema.json`.
|
||||
5. `report`: create Markdown, HTML, and PDF reports.
|
||||
|
||||
Generated files are written below `artifacts/`:
|
||||
|
||||
```text
|
||||
artifacts/
|
||||
├── raw/<methodology>/
|
||||
├── normalized/
|
||||
└── rendered/
|
||||
```
|
||||
|
||||
### Target Integrations
|
||||
GitLab artifacts are the authoritative test evidence. The Kubernetes PVC stores only replaceable tool databases and caches.
|
||||
|
||||
| Platform | Ansible Collection | Connection | Python library |
|
||||
|---|---|---|---|
|
||||
| **Linux** | built-in | SSH | — |
|
||||
| **Windows Server / Client** | `ansible.windows` | WinRM + NTLM/Kerberos | `pywinrm` |
|
||||
| **MS SQL Server** | `ansible.windows` | WinRM → PowerShell `Invoke-Sqlcmd` | `pywinrm` |
|
||||
| **VMware vSphere ESXi** | `community.vmware` | vSphere SOAP API (delegate_to: localhost) | `pyvmomi` |
|
||||
| **Hyper-V** | `ansible.windows` | WinRM → PowerShell Hyper-V cmdlets | `pywinrm` |
|
||||
| **Cisco IOS / IOS-XE** | `cisco.ios` | SSH via `network_cli` | `paramiko`, `netmiko` |
|
||||
| **Cisco ASA** | `cisco.asa` | SSH via `network_cli` | `paramiko` |
|
||||
| **Cisco NX-OS** | `cisco.nxos` | SSH / NX-API via `network_cli` | `ncclient` |
|
||||
## Methodologies
|
||||
|
||||
---
|
||||
- [Ansible](methodologies/ansible/README.md): active infrastructure and platform checks.
|
||||
- [OWASP ZAP](methodologies/zap/README.md): planned web application testing with ASVS mappings.
|
||||
|
||||
## Quick Start
|
||||
Shared pipeline code stays at the repository root. Methodology-specific images, runners, adapters, fixtures, and references stay under `methodologies/<name>/`.
|
||||
|
||||
### Prerequisites
|
||||
## Platform Bootstrap
|
||||
|
||||
- Ansible ≥ 2.9 with the collections listed above (pre-installed in the Docker image)
|
||||
- Python ≥ 3.6 (for the Python report renderer)
|
||||
- [gomplate](https://docs.gomplate.ca/installing/) (optional, single static binary, for the `.gohtml` template renderer)
|
||||
- For Windows / VMware / Cisco targets: the Python libraries listed above
|
||||
|
||||
### Step 1: Configure Inventory
|
||||
|
||||
Edit `inventory.ini`. Each platform group has the required connection variables
|
||||
already set — just uncomment the hosts:
|
||||
|
||||
```ini
|
||||
[linux_vms]
|
||||
linux-vm-01.example.com ansible_user=auditor
|
||||
|
||||
[windows_servers]
|
||||
win-srv-01.example.com
|
||||
|
||||
[cisco_switches]
|
||||
sw-core-01.example.com
|
||||
```
|
||||
|
||||
Store passwords in Ansible Vault:
|
||||
```bash
|
||||
ansible-vault encrypt_string 'MyPassword' --name ansible_password
|
||||
```
|
||||
|
||||
### Step 2: Run a Playbook
|
||||
Apply the Kubernetes resources once with an administrator context:
|
||||
|
||||
```bash
|
||||
# Linux targets — all FR suites via the main entry point:
|
||||
ansible-playbook -i inventory.ini playbooks/site.yml --limit linux_vms -K
|
||||
|
||||
# Or use the wrapper script (finds & renders the report automatically):
|
||||
./run.sh --limit linux_vms -K
|
||||
|
||||
# Platform-specific examples:
|
||||
ansible-playbook -i inventory.ini playbooks/examples/windows_server.yml
|
||||
ansible-playbook -i inventory.ini playbooks/examples/cisco_switch.yml
|
||||
ansible-playbook -i inventory.ini playbooks/examples/vmware_vsphere.yml
|
||||
|
||||
# Local self-test (localhost is pre-configured in inventory.ini):
|
||||
ansible-playbook -i inventory.ini playbooks/site.yml --limit localhost -K
|
||||
export KUBECONFIG=/etc/rancher/k3s/admin/kubeconfig.yaml
|
||||
kubectl apply -k platform/kubernetes
|
||||
```
|
||||
|
||||
### Step 3: Render the Report
|
||||
Build and push the Ansible image before enabling `RUN_ANSIBLE`:
|
||||
|
||||
```bash
|
||||
# Terminal box-drawing format (default):
|
||||
python3 reports/render_report.py reports/localhost-2026-08-14.json
|
||||
|
||||
# Markdown (for GitHub / GitLab wikis, PR comments):
|
||||
python3 reports/render_report.py reports/localhost-2026-08-14.json --format md
|
||||
|
||||
# gomplate template renderer:
|
||||
gomplate --context .=reports/localhost-2026-08-14.json --file reports/report.gohtml
|
||||
REGISTRY=<registry>/<project> ./methodologies/ansible/scripts/build-image.sh --push
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Platform-Specific Examples
|
||||
|
||||
The `playbooks/examples/` directory contains a complete, runnable playbook for
|
||||
each supported platform. Each file:
|
||||
|
||||
- Has a header comment with the required `inventory.ini` group vars and any
|
||||
prerequisites (WinRM setup, SQLPS module, vCenter permissions, etc.)
|
||||
- Follows the identical `block` → gather → evaluate → `ignore_errors` pattern
|
||||
- Uses the most Ansible-native module available for each check (e.g.
|
||||
`win_security_policy` instead of `win_shell` for Windows password policy)
|
||||
- Ends with `include_tasks: ../library/report.yml` to produce a JSON report
|
||||
- Includes at least one Human-in-the-Loop test where automated checks cannot
|
||||
cover the full control
|
||||
|
||||
### Linux VMs — `playbooks/examples/linux_vm.yml`
|
||||
|
||||
```bash
|
||||
ansible-playbook -i inventory.ini playbooks/examples/linux_vm.yml -K
|
||||
```
|
||||
|
||||
Checks beyond the core FR suites: SSH root login and password auth settings,
|
||||
sudo session logging (`Defaults log_input,log_output`), kernel IP forwarding
|
||||
and ICMP redirect sysctl values, core dump disabled.
|
||||
|
||||
### Windows Server — `playbooks/examples/windows_server.yml`
|
||||
|
||||
```bash
|
||||
ansible-playbook -i inventory.ini playbooks/examples/windows_server.yml
|
||||
```
|
||||
|
||||
Uses `ansible.windows.win_security_policy` for password and lockout policy
|
||||
(no PowerShell shell-out needed), `win_audit_policy_system` for audit subcategories,
|
||||
`win_service_info` for Telnet/FTP service state, and `win_shell` with
|
||||
`ConvertTo-Json` for Firewall profile states parsed via Ansible's `from_json` filter.
|
||||
|
||||
### Windows Client — `playbooks/examples/windows_client.yml`
|
||||
|
||||
```bash
|
||||
ansible-playbook -i inventory.ini playbooks/examples/windows_client.yml
|
||||
```
|
||||
|
||||
Checks: domain membership, BitLocker status on the OS drive, screen lock timeout
|
||||
via registry (`win_reg_stat`), Public firewall profile. Includes a HITL check
|
||||
for USB storage port controls with registry evidence displayed.
|
||||
|
||||
### MS SQL Server — `playbooks/examples/mssql_server.yml`
|
||||
|
||||
```bash
|
||||
ansible-playbook -i inventory.ini playbooks/examples/mssql_server.yml
|
||||
# Add per-host: mssql_instance=NAMED_INSTANCE (default: MSSQLSERVER)
|
||||
```
|
||||
|
||||
Connects via WinRM to the Windows host, then runs `Invoke-Sqlcmd` via `win_shell`
|
||||
to query SQL Server internals. Checks: Windows-only authentication mode, SA account
|
||||
disabled, `xp_cmdshell` disabled, login audit level. HITL check for sysadmin
|
||||
role membership against an authorised list.
|
||||
|
||||
**Prerequisite on target:** `Install-Module SqlServer -Force -AllowClobber`
|
||||
|
||||
### VMware vSphere — `playbooks/examples/vmware_vsphere.yml`
|
||||
|
||||
```bash
|
||||
ansible-playbook -i inventory.ini playbooks/examples/vmware_vsphere.yml
|
||||
```
|
||||
|
||||
All tasks use `delegate_to: localhost` — no SSH to ESXi hosts. The inventory
|
||||
host is the ESXi FQDN; `vcenter_hostname/username/password` are group vars
|
||||
pointing at vCenter. Uses `community.vmware` info modules:
|
||||
`vmware_host_lockdown_info`, `vmware_host_ntp_info`, `vmware_host_service_info`,
|
||||
`vmware_host_config_info`. HITL check for vSwitch isolation using
|
||||
`vmware_vswitch_info`.
|
||||
|
||||
**Required vCenter read-only permissions:**
|
||||
Host → Configuration → Security Profile, Advanced Settings; Global → Settings.
|
||||
|
||||
### Hyper-V Cluster — `playbooks/examples/hyperv_cluster.yml`
|
||||
|
||||
```bash
|
||||
ansible-playbook -i inventory.ini playbooks/examples/hyperv_cluster.yml
|
||||
```
|
||||
|
||||
Connects via WinRM and runs PowerShell Hyper-V cmdlets via `win_shell`. Checks:
|
||||
all VMs use Generation 2 with Secure Boot enabled, External vSwitch
|
||||
`AllowManagementOS` exposure (flagged as review), Hyper-V VMMS Admin event log
|
||||
active, integration services enabled on all running VMs. HITL check for
|
||||
physical NIC segregation between ICS and management networks.
|
||||
|
||||
### Cisco Switch (IOS / IOS-XE) — `playbooks/examples/cisco_switch.yml`
|
||||
|
||||
```bash
|
||||
ansible-playbook -i inventory.ini playbooks/examples/cisco_switch.yml
|
||||
```
|
||||
|
||||
Uses `cisco.ios.ios_command` to run `show` commands and parses output with
|
||||
`regex_search` / `regex_findall`. Uses `ios_facts` (gathered automatically via
|
||||
`gather_facts: yes`) for interface data. Checks: SSH v2 / no Telnet on VTY,
|
||||
no SNMPv1/v2c community strings, login banner, NTP synchronised. HITL check
|
||||
for port VLAN assignment and physical port labelling.
|
||||
|
||||
### Cisco Firewall (ASA) — `playbooks/examples/cisco_firewall.yml`
|
||||
|
||||
```bash
|
||||
ansible-playbook -i inventory.ini playbooks/examples/cisco_firewall.yml
|
||||
```
|
||||
|
||||
Uses `cisco.asa.asa_command` with `ansible_become=yes` (enable mode). Checks:
|
||||
no Telnet management access, SSH access configured, IKEv1 disabled (IKEv2 only),
|
||||
remote syslog server configured, AAA authentication for SSH/enable, inbound
|
||||
ACLs applied on zone interfaces. HITL check for ACL rule review
|
||||
(no broad `permit ip any any`).
|
||||
|
||||
---
|
||||
|
||||
## The Test Pattern
|
||||
|
||||
Every test follows a rigid **Gather → Evaluate → Record** structure inside an
|
||||
Ansible `block` with `ignore_errors: yes`. This ensures the run never aborts
|
||||
regardless of what is found on the target.
|
||||
|
||||
```yaml
|
||||
# ── SR 1.5: Password minimum length ────────────────────────────────────────
|
||||
|
||||
- block:
|
||||
- name: "Gather: Check pwquality minlen"
|
||||
ansible.builtin.shell: |
|
||||
grep -E '^\s*minlen\s*=' /etc/security/pwquality.conf 2>/dev/null | tail -1 || echo "NOT SET"
|
||||
register: _minlen
|
||||
changed_when: false # gather tasks must never say "changed"
|
||||
|
||||
- name: "Evaluate: IAC-05"
|
||||
ansible.builtin.set_fact:
|
||||
test_results: "{{ test_results + [{
|
||||
'test_id': 'IAC-05',
|
||||
'category': 'FR1 — Identification and Authentication Control',
|
||||
'requirement': 'SR 1.5 — Authenticator Strength',
|
||||
'description': 'Password minimum length shall be ≥ 14 characters',
|
||||
'passed': (
|
||||
(_minlen.stdout | regex_search('minlen\\s*=\\s*(\\d+)', '\\1')
|
||||
| default(['0'], true) | first | int) >= 14
|
||||
),
|
||||
'expected': 'minlen >= 14 in /etc/security/pwquality.conf',
|
||||
'actual': _minlen.stdout | trim,
|
||||
'severity': 'high',
|
||||
'remediation': 'Set minlen=14 in /etc/security/pwquality.conf'
|
||||
}] }}"
|
||||
ignore_errors: yes # NEVER abort the run
|
||||
```
|
||||
|
||||
### Why `block` + `ignore_errors` Instead of `failed_when`
|
||||
|
||||
| Approach | Behaviour |
|
||||
|---|---|
|
||||
| `failed_when: false` on shell task | Shell always "succeeds"; non-zero exit still shows red in Ansible output |
|
||||
| `block` + `ignore_errors: yes` | Failures are captured and marked orange; execution continues; `register`ed variables remain available in the evaluate task |
|
||||
|
||||
### Tips for Robust Checks
|
||||
|
||||
| Tip | Why |
|
||||
|---|---|
|
||||
| `changed_when: false` on all gather tasks | Tests must never report as "changed" |
|
||||
| `\| trim` on shell output | Shell often returns trailing newlines |
|
||||
| `\| default('NOT SET', true)` | Prevents undefined-variable errors when files or keys are absent |
|
||||
| Guard numeric comparisons | `'' \| int` = 0 in Jinja2 — a missing value can silently pass a `≤ 90` check; always verify the value exists and is non-zero first |
|
||||
| `\| regex_search(pattern, '\\1')` | Use capture group syntax to extract a number cleanly, avoiding chained `regex_replace` calls that crash on `None` |
|
||||
| `ConvertTo-Json` on Windows | Return structured data from `win_shell` and parse with Ansible's `from_json` filter instead of regex |
|
||||
| Platform branching | Use `when: ansible_os_family == 'Debian'` variants for distro-specific commands |
|
||||
|
||||
---
|
||||
|
||||
## Human-in-the-Loop Tests
|
||||
|
||||
Some IEC 62443 SL2 controls cannot be verified automatically — physical access
|
||||
controls, policy document review, proprietary vendor interfaces, or checks where
|
||||
the output must be interpreted by a qualified reviewer.
|
||||
|
||||
**HITL tests use `ansible.builtin.pause` with `delegate_to: localhost`**, which
|
||||
prompts the reviewer on the Ansible control node even when running against remote
|
||||
targets. For multi-host runs the prompt fires once per host, so each target gets
|
||||
an independent verdict.
|
||||
|
||||
```yaml
|
||||
- block:
|
||||
- name: "Gather: [HITL] Collect evidence"
|
||||
ansible.builtin.shell: your-gather-command
|
||||
register: _evidence
|
||||
changed_when: false
|
||||
|
||||
- name: "Display: [HITL] TEST_ID — evidence"
|
||||
ansible.builtin.debug:
|
||||
msg: |
|
||||
══════════════════════════════════════════════════════════════
|
||||
MANUAL REVIEW REQUIRED · TEST_ID · {{ inventory_hostname }}
|
||||
══════════════════════════════════════════════════════════════
|
||||
{{ _evidence.stdout | indent(1) }}
|
||||
══════════════════════════════════════════════════════════════
|
||||
|
||||
- name: "Prompt: TEST_ID — verdict"
|
||||
ansible.builtin.pause:
|
||||
prompt: "Enter verdict [pass / fail / skip]:"
|
||||
register: _verdict
|
||||
delegate_to: localhost # ← always prompts on the control node
|
||||
|
||||
- name: "Prompt: TEST_ID — notes on failure"
|
||||
ansible.builtin.pause:
|
||||
prompt: "Describe the finding:"
|
||||
register: _notes
|
||||
delegate_to: localhost
|
||||
when: _verdict.user_input | lower | trim in ['fail', 'f']
|
||||
|
||||
- name: "Evaluate: TEST_ID"
|
||||
ansible.builtin.set_fact:
|
||||
test_results: "{{ test_results + [{
|
||||
'test_id': 'TEST_ID',
|
||||
'passed': (
|
||||
'skipped' if (_verdict.user_input | lower | trim in ['skip', 's', ''])
|
||||
else (_verdict.user_input | lower | trim in ['pass', 'p'])
|
||||
),
|
||||
'reviewer': ansible_user_id,
|
||||
'notes': (_notes.user_input | trim) if _notes is defined else ''
|
||||
}] }}"
|
||||
ignore_errors: yes
|
||||
```
|
||||
|
||||
The `reviewer` and `notes` fields are written into the JSON report alongside
|
||||
the automated evidence, creating an auditable record of who reviewed what and
|
||||
when. See `playbooks/templates/test_hitl.yml` for the full copy-and-fill template.
|
||||
|
||||
**Note:** Playbooks containing HITL tests require an interactive terminal and
|
||||
cannot be run unattended in a CI pipeline. Keep HITL tests in separate playbooks
|
||||
or use Ansible tags to separate them from automated checks.
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Test
|
||||
|
||||
### Step 1: Choose a Template
|
||||
|
||||
The `playbooks/templates/` directory contains four ready-to-use templates.
|
||||
Copy the one that matches your check type:
|
||||
|
||||
| Template | Use when |
|
||||
|---|---|
|
||||
| `playbooks/templates/test_automated.yml` | Running a shell command and evaluating its output |
|
||||
| `playbooks/templates/test_file_check.yml` | Checking file ownership, permissions, or existence (`ansible.builtin.stat`) |
|
||||
| `playbooks/templates/test_service_check.yml` | Checking service running/enabled state (`service_facts`) |
|
||||
| `playbooks/templates/test_hitl.yml` | Control requires human reviewer input |
|
||||
|
||||
Every template has detailed comments explaining the `passed` expression patterns
|
||||
relevant to that check type.
|
||||
|
||||
### Step 2: Fill in the Placeholders
|
||||
|
||||
Replace all UPPERCASE placeholders: `TEST_ID`, `FR_NUMBER`, `CATEGORY_NAME`,
|
||||
`REQUIREMENT`, `DESCRIPTION`, `SEVERITY`, `REMEDIATION`, and the gather command.
|
||||
|
||||
### Step 3: Add to a Suite or Example
|
||||
|
||||
For Linux targets, append the block to the appropriate `suites/frN_*.yml` file
|
||||
and ensure that suite is included in `site.yml`:
|
||||
|
||||
```yaml
|
||||
- name: "Suite: FRN — Category Name"
|
||||
block:
|
||||
- ansible.builtin.include_tasks: suites/frN_category.yml
|
||||
ignore_errors: yes
|
||||
```
|
||||
|
||||
For other platforms, append the block to the relevant `examples/` playbook.
|
||||
|
||||
### Test ID Naming Convention
|
||||
|
||||
- **Prefix**: Platform abbreviation + category (e.g., `IAC`, `UC`, `RDF` for
|
||||
Linux; `WIN-IAC`, `SQL-UC`, `FW-RDF`, `SW-RDF`, `VMW-IAC` for platform examples)
|
||||
- **Number**: Sequential within prefix, zero-padded (`01`, `02`, ...)
|
||||
- **HITL suffix**: Append `-HITL` for human-in-the-loop tests (e.g., `WIN-CLI-HITL-01`)
|
||||
|
||||
---
|
||||
|
||||
## JSON Output Schema
|
||||
|
||||
Each test produces one entry in `test_results[]`. The complete report schema:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"meta": {
|
||||
"standard": "IEC 62443-3-3",
|
||||
"security_level": "SL2",
|
||||
"target": "ics-gateway-01", // inventory_hostname
|
||||
"timestamp": "2026-08-14T09:00:00Z", // ISO 8601
|
||||
"executed_by": "auditor" // ansible_user_id
|
||||
},
|
||||
"summary": {
|
||||
"total": 20,
|
||||
"passed": 14,
|
||||
"failed": 4,
|
||||
"review": 1, // passed == "review" (manual review items)
|
||||
"skipped": 1 // passed == "skipped" (HITL skipped or not applicable)
|
||||
},
|
||||
"by_category": [ // [["FR1 — ...", [{...}]], ...]
|
||||
["FR1 — Identification and Authentication Control", [{...}, {...}]],
|
||||
["FR2 — Use Control", [{...}]]
|
||||
],
|
||||
"by_severity": {
|
||||
"critical": [{...}],
|
||||
"high": [{...}],
|
||||
"medium": [{...}],
|
||||
"low": [{...}]
|
||||
},
|
||||
"failures": [{...}], // Only tests where passed == false
|
||||
"results": [
|
||||
{
|
||||
"test_id": "IAC-05",
|
||||
"category": "FR1 — ...",
|
||||
"requirement": "SR 1.5 — Authenticator Strength",
|
||||
"description": "Password minimum length shall be >= 14 characters",
|
||||
"passed": false, // bool | "review" | "skipped"
|
||||
"expected": "minlen >= 14",
|
||||
"actual": "minlen = 8",
|
||||
"severity": "high", // critical | high | medium | low
|
||||
"remediation": "Set minlen=14 in /etc/security/pwquality.conf"
|
||||
// HITL tests also carry: "reviewer", "notes"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### `passed` Field Semantics
|
||||
|
||||
| Value | Meaning | Report icon |
|
||||
|---|---|---|
|
||||
| `true` | Automated check passed | ✅ |
|
||||
| `false` | Automated check failed | ❌ |
|
||||
| `"review"` | Listed for human assessment (port inventory, ACL review) | 🔍 |
|
||||
| `"skipped"` | Reviewer entered `skip`; not applicable to this target | ⏭️ |
|
||||
|
||||
### `severity` Field Semantics
|
||||
|
||||
| Value | Meaning |
|
||||
|---|---|
|
||||
| `critical` | Allows immediate compromise (empty passwords, world-writable sudoers) |
|
||||
| `high` | Defeats a core SL2 control (no firewall, no auditd, weak password policy) |
|
||||
| `medium` | Weakens a control (password aging not set, no session timeout) |
|
||||
| `low` | Best-practice gap (extra listening ports, stale accounts) |
|
||||
|
||||
---
|
||||
|
||||
## IEC 62443-3-3 SL2 Coverage
|
||||
|
||||
### Core Linux Suites (`playbooks/suites/`)
|
||||
|
||||
| Test ID | FR | SR | Description | Severity |
|
||||
|---|---|---|---|---|
|
||||
| IAC-01 | FR1 | SR 1.1 | No duplicate UIDs in /etc/passwd | high |
|
||||
| IAC-02 | FR1 | SR 1.3 | No default/unnecessary system accounts | medium |
|
||||
| IAC-03 | FR1 | SR 1.3 | No human accounts that have never logged in | low |
|
||||
| IAC-04 | FR1 | SR 1.4 | No empty or trivially-weak password hashes | critical |
|
||||
| IAC-05 | FR1 | SR 1.5 | Password min length >= 14 (pwquality) | high |
|
||||
| IAC-06 | FR1 | SR 1.5 | >= 3 character classes required (pwquality) | medium |
|
||||
| IAC-07 | FR1 | SR 1.7 | PASS_MAX_DAYS <= 90 and is set | medium |
|
||||
| IAC-08 | FR1 | SR 1.7 | PASS_MIN_DAYS >= 1 | low |
|
||||
| IAC-09 | FR1 | SR 1.11 | Account lockout after <= 5 failures (pam_faillock) | high |
|
||||
| IAC-10 | FR1 | SR 1.6 | Password history >= 5 (pam_pwhistory) | medium |
|
||||
| UC-01 | FR2 | SR 2.1 | No unrestricted NOPASSWD sudo | high |
|
||||
| UC-02 | FR2 | SR 2.1 | /etc/sudoers owned root:root, mode 0440 | critical |
|
||||
| UC-03 | FR2 | SR 2.5 | Shell idle timeout <= 900s (TMOUT) | medium |
|
||||
| UC-04 | FR2 | SR 2.4 | Audit rules immutable (-e 2) | high |
|
||||
| UC-05 | FR2 | SR 2.8 | auditd service active and enabled | high |
|
||||
| UC-06 | FR2 | SR 2.8 | >= 4 critical syscall types audited | medium |
|
||||
| RDF-01 | FR5 | SR 5.1 | Host-based firewall with active rules | critical |
|
||||
| RDF-02 | FR5 | SR 5.1 | Default INPUT policy is DROP | high |
|
||||
| RDF-03 | FR5 | SR 5.3 | No insecure legacy services (telnet, rsh, ftp) | critical |
|
||||
| RDF-04 | FR5 | SR 5.3 | Listening TCP ports — review | low |
|
||||
|
||||
### Additional Checks in Platform Examples
|
||||
|
||||
| Platform | Example file | FR areas covered |
|
||||
|---|---|---|
|
||||
| Linux VM | `examples/linux_vm.yml` | FR1 (SSH hardening), FR2 (sudo logging), FR3 (sysctl, core dumps) |
|
||||
| Windows Server | `examples/windows_server.yml` | FR1 (password/lockout policy), FR2 (audit policy), FR5 (firewall, Telnet) |
|
||||
| Windows Client | `examples/windows_client.yml` | FR1 (domain join), FR3 (BitLocker), FR2 (screen lock), FR5 (firewall) |
|
||||
| MS SQL Server | `examples/mssql_server.yml` | FR1 (auth mode, SA account, role review), FR2 (xp_cmdshell, audit) |
|
||||
| VMware vSphere | `examples/vmware_vsphere.yml` | FR1 (lockdown, account lockout), FR2 (NTP), FR5 (SSH/Shell, vSwitch) |
|
||||
| Hyper-V | `examples/hyperv_cluster.yml` | FR3 (SecureBoot, integration svc), FR2 (event log), FR5 (vSwitch/NIC) |
|
||||
| Cisco Switch | `examples/cisco_switch.yml` | FR1 (SNMP, banner), FR2 (NTP), FR5 (SSH/Telnet, port shutdown) |
|
||||
| Cisco Firewall | `examples/cisco_firewall.yml` | FR1 (IKEv2, AAA), FR2 (syslog), FR5 (Telnet, ACLs) |
|
||||
|
||||
### Not Yet Implemented as Dedicated Suites
|
||||
|
||||
| FR | Key SL2 Controls | Suggested Checks |
|
||||
|---|---|---|
|
||||
| FR3 — System Integrity | File integrity monitoring | AIDE/IMA service, `/proc/sys/kernel/kexec_load_disabled` |
|
||||
| FR4 — Data Confidentiality | Encryption at rest/transit | TLS cipher audit on listening ports, LUKS/dm-crypt, SSH cipher suite |
|
||||
| FR6 — Timely Response | Log forwarding, alerting | `rsyslog` remote config, auditd dispatcher, journald persistence |
|
||||
| FR7 — Resource Availability | DoS protection, backup | Disk quota, systemd resource limits, backup schedule |
|
||||
|
||||
---
|
||||
|
||||
## Rendering Reports
|
||||
|
||||
### Python Renderer (`reports/render_report.py`)
|
||||
|
||||
Zero dependencies beyond Python 3 stdlib. Two output formats:
|
||||
|
||||
```bash
|
||||
# Terminal box-drawing (default):
|
||||
python3 reports/render_report.py reports/hostname-2026-08-14.json
|
||||
|
||||
# Markdown for GitHub / GitLab:
|
||||
python3 reports/render_report.py reports/hostname-2026-08-14.json --format md > REPORT.md
|
||||
```
|
||||
|
||||
Terminal output groups results by FR category with a failure-detail section.
|
||||
Markdown output produces GFM tables plus per-failure sections with remediation.
|
||||
|
||||
### gomplate Renderer (`reports/report.gohtml`)
|
||||
|
||||
Requires [gomplate](https://docs.gomplate.ca/installing/) (a single static
|
||||
binary — no Go toolchain, no compilation). Renders a `.gohtml` file against
|
||||
the JSON report loaded as the template's root context:
|
||||
|
||||
```bash
|
||||
cd reports
|
||||
gomplate --context .=../reports/hostname-2026-08-14.json --file report.gohtml
|
||||
```
|
||||
|
||||
The template file is standalone — customise it without recompiling anything.
|
||||
Fields are accessed with plain dot notation (e.g. `.meta.target`), and the
|
||||
template only relies on gomplate's built-in functions:
|
||||
|
||||
| Function | Purpose |
|
||||
|---|---|
|
||||
| `passIcon` (in-template) | Maps `passed` value to ✅ PASS / ❌ FAIL / ❓ MANUAL |
|
||||
| `severityIcon` (in-template) | Maps severity to 🔴/🟠/🟡/🟢 |
|
||||
| `strings.Title` | Capitalises first letter of each word |
|
||||
| `math.Div`, `math.Mul` | Compliance rate percentage |
|
||||
|
||||
Custom templates:
|
||||
```bash
|
||||
gomplate --context .=reports/<hostname>-<date>.json --file my-custom.gohtml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Ansible Control Node — Container & QEMU VM
|
||||
|
||||
Two independent deployment artifacts. The container image runs anywhere Docker
|
||||
is available; the QEMU VM provides a self-contained appliance for environments
|
||||
without existing Docker infrastructure.
|
||||
|
||||
### The Two Artifacts
|
||||
|
||||
| Artifact | Defined by | Built with | Result |
|
||||
|---|---|---|---|
|
||||
| **Alpine Docker Host** | `Dockerfile.alpine-host` | `./scripts/build-qemu.sh` | `output/ansible-node.qcow2` (~470 MB) |
|
||||
| **Ansible Control Node** | `Dockerfile.ansible` | `./scripts/build-ansible.sh` | `ansible-node` Docker image (~793 MB) |
|
||||
|
||||
The container image includes: Ansible 2.17, 10 collections (`ansible.windows`,
|
||||
`cisco.asa`, `cisco.ios`, `cisco.nxos`, `community.vmware`, `community.general`,
|
||||
`community.crypto`, `ansible.netcommon`, `ansible.utils`, `microsoft.sql`),
|
||||
and all required Python libraries (`pywinrm`, `pyvmomi`, `pymssql`, `paramiko`,
|
||||
`netmiko`, `ncclient`).
|
||||
|
||||
### Build & Launch
|
||||
|
||||
```bash
|
||||
# Prerequisites: Docker, QEMU (qemu-full), passwordless sudo for mount
|
||||
|
||||
# 1. Build the Ansible container image
|
||||
./scripts/build-ansible.sh
|
||||
# Push to a registry:
|
||||
REGISTRY=my-registry ./scripts/build-ansible.sh --push
|
||||
|
||||
# 2. Build the VM disk (optional — only needed for QEMU deployment)
|
||||
./scripts/build-qemu.sh # Alpine + Docker + SSH → qcow2
|
||||
|
||||
# 3. Boot the VM
|
||||
./scripts/run-qemu.sh # QEMU pc-q35-10.0, KVM, 1 GB, 2 vCPUs
|
||||
```
|
||||
|
||||
### Interacting with the VM
|
||||
|
||||
```
|
||||
QEMU VM boots in ~6 seconds
|
||||
│
|
||||
├── ttyS0 (serial console) ──► TTY menu
|
||||
│ ╔══════════════════════════════════════╗
|
||||
│ ║ 1) Run all tests ║
|
||||
│ ║ 2) Run FR1 — Auth ║
|
||||
│ ║ 3) Run FR2 — Use Control ║
|
||||
│ ║ 4) Run FR5 — Data Flow ║
|
||||
│ ║ 5) Download reports (tar.gz) ║
|
||||
│ ║ 6) View latest report ║
|
||||
│ ║ 7) Shell (Ansible container) ║
|
||||
│ ║ 8) Shell (Docker host) ║
|
||||
│ ║ 0) Shutdown ║
|
||||
│ ╚══════════════════════════════════════╝
|
||||
│
|
||||
├── :8080 ──► Web UI (browser dashboard)
|
||||
│ • Run buttons per playbook
|
||||
│ • Live output streaming
|
||||
│ • Report downloads (JSON / Markdown / PDF)
|
||||
│
|
||||
└── :22 ──► SSH (ansible / ansible)
|
||||
```
|
||||
|
||||
### Deployment Targets for the Ansible Container
|
||||
|
||||
| Environment | How |
|
||||
|---|---|
|
||||
| QEMU VM (Windows host) | `docker run ansible-node` inside the Alpine Docker Host |
|
||||
| Docker Swarm | `docker stack deploy` with the `ansible-node` image |
|
||||
| Kubernetes | `kubectl create job` with the `ansible-node` image |
|
||||
| CI/CD pipeline | `docker run --rm -v ...` in GitHub Actions / GitLab CI |
|
||||
|
||||
---
|
||||
|
||||
## File Reference
|
||||
|
||||
| Path | Purpose |
|
||||
|---|---|
|
||||
| `playbooks/site.yml` | Entry-point for Linux targets; orchestrates FR1, FR2, FR5 suites |
|
||||
| `playbooks/suites/fr1_auth.yml` | 10 FR1 authentication tests for Linux |
|
||||
| `playbooks/suites/fr2_use_control.yml` | 6 FR2 use-control tests for Linux |
|
||||
| `playbooks/suites/fr5_data_flow.yml` | 4 FR5 data-flow tests for Linux |
|
||||
| `playbooks/library/report.yml` | Aggregates `test_results[]` → JSON file on localhost |
|
||||
| `playbooks/examples/linux_vm.yml` | Standalone example: Linux SSH + kernel hardening |
|
||||
| `playbooks/examples/windows_server.yml` | Standalone example: Windows Server via WinRM |
|
||||
| `playbooks/examples/windows_client.yml` | Standalone example: Windows Client / HMI workstation |
|
||||
| `playbooks/examples/mssql_server.yml` | Standalone example: SQL Server via WinRM + Invoke-Sqlcmd |
|
||||
| `playbooks/examples/vmware_vsphere.yml` | Standalone example: ESXi via vSphere API |
|
||||
| `playbooks/examples/hyperv_cluster.yml` | Standalone example: Hyper-V via WinRM |
|
||||
| `playbooks/examples/cisco_switch.yml` | Standalone example: Cisco IOS via network_cli |
|
||||
| `playbooks/examples/cisco_firewall.yml` | Standalone example: Cisco ASA via network_cli |
|
||||
| `playbooks/templates/test_automated.yml` | Copy-and-fill template: shell gather → evaluate |
|
||||
| `playbooks/templates/test_file_check.yml` | Copy-and-fill template: `ansible.builtin.stat` |
|
||||
| `playbooks/templates/test_service_check.yml` | Copy-and-fill template: `service_facts` |
|
||||
| `playbooks/templates/test_hitl.yml` | Copy-and-fill template: `pause` + `delegate_to: localhost` |
|
||||
| `reports/render_report.py` | Python renderer: terminal box-drawing and Markdown output |
|
||||
| `reports/report.gohtml` | Go template producing the terminal box-drawing report, rendered by `gomplate` |
|
||||
| `reports/sample-output.json` | Hand-crafted example report for offline renderer testing |
|
||||
| `inventory.ini` | Ansible inventory with all platform groups and connection vars |
|
||||
| `run.sh` | End-to-end wrapper: run ansible → find latest JSON → render |
|
||||
| `Dockerfile.ansible` | Ansible control node image (all collections + Python libs) |
|
||||
| `Dockerfile.alpine-host` | Alpine VM image (Docker daemon + TTY menu + web UI) |
|
||||
| `scripts/build-ansible.sh` | Builds `ansible-node` Docker image |
|
||||
| `scripts/build-qemu.sh` | Converts `Dockerfile.alpine-host` → bootable qcow2 |
|
||||
| `scripts/run-qemu.sh` | Launches the Alpine VM in QEMU |
|
||||
| `scripts/tty-menu.sh` | Serial console menu (launched by `agetty -l` on ttyS0) |
|
||||
| `webui/container-app.py` | Web UI inside the Ansible container (JSON/Markdown/PDF export) |
|
||||
| `webui/app.py` | Web UI for the Alpine Docker Host VM |
|
||||
| `scripts/entrypoint.sh` | Container entrypoint: web UI if no args, else `ansible-playbook` |
|
||||
|
||||
---
|
||||
|
||||
## Design Decisions & Tradeoffs
|
||||
|
||||
| Decision | Rationale |
|
||||
|---|---|
|
||||
| **Ansible** over dedicated scanners | Already deployed in most OT environments. No new agent, no new approval process. |
|
||||
| **Shell-based checks** for Linux | `shell` module is the most flexible. `changed_when: false` keeps runs clean. |
|
||||
| **Native modules** for Windows/Cisco | `win_security_policy`, `win_service_info`, `ios_command`, `vmware_host_lockdown_info` — typed return values avoid brittle text parsing. |
|
||||
| **`delegate_to: localhost` for VMware** | vSphere API is consumed from the control node; no SSH to ESXi. |
|
||||
| **`pause` for HITL** | Ansible-native, no custom tooling. `delegate_to: localhost` ensures the prompt always reaches the operator regardless of the remote target. |
|
||||
| **Inline `set_fact`** vs custom module | Custom modules require Python on the control node. Inline facts work everywhere and are easier to audit. |
|
||||
| **`test_results[]` list** vs file-per-test | A single growing list is simpler than per-file concatenation. At 100+ tests the memory footprint is negligible. |
|
||||
| **JSON as canonical output** | Machine-readable, schema-validatable, ingestible by SIEM/SOAR/Jira/ServiceNow. |
|
||||
| **Go templates for rendering** | `text/template` supports external template files so reports can be restyled without modifying Go code. |
|
||||
|
||||
### Known Limitations
|
||||
|
||||
1. **Shell-heavy for Linux**: Linux checks depend on shell commands. Different
|
||||
distros may use different paths or tools. Mitigate with
|
||||
`when: ansible_os_family == 'Debian'` variants.
|
||||
|
||||
2. **No diff / drift detection**: Each run is independent. To detect configuration
|
||||
drift between runs, diff two JSON reports externally (`jd`, `diff`,
|
||||
or a time-series database).
|
||||
|
||||
3. **No CI exit code**: `ansible-playbook` exits 0 unless a task fails without
|
||||
`ignore_errors`. For pipeline gates, parse `summary.failed` from the JSON
|
||||
report and exit non-zero if `> 0`.
|
||||
|
||||
4. **HITL tests block automation**: Any playbook containing HITL tests requires
|
||||
an interactive terminal. Keep HITL tests in separate playbooks or use Ansible
|
||||
tags to separate them from fully-automated runs.
|
||||
|
||||
5. **Scalability at 500+ targets**: Multi-host runs work well, but one JSON file
|
||||
per host can be unwieldy. Consider post-processing into a single aggregated
|
||||
report.
|
||||
|
||||
---
|
||||
|
||||
## 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 | No native IEC mapping; local checks only |
|
||||
| **OpenSCAP** | XML/SCAP standard | NIST/STIG aligned, XCCDF/OVAL | Heavy, complex, US-govt focused, Linux-only |
|
||||
| **Lynis** | Shell script | Broad Linux coverage | Non-extensible output; Linux-only |
|
||||
| **This project** | Ansible + JSON + Go/Python | Zero new agents; multi-platform; IEC 62443 mapped; HITL support | Requires Ansible; shell-dependent Linux checks |
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [x] FR1, FR2, FR5 core suites for Linux
|
||||
- [x] Multi-platform examples: Windows, MSSQL, VMware, Hyper-V, Cisco IOS, Cisco ASA
|
||||
- [x] Human-in-the-Loop test pattern with `ansible.builtin.pause`
|
||||
- [x] Four copy-and-fill test templates (shell, stat, service_facts, HITL)
|
||||
- [ ] **FR3 suite**: File integrity (AIDE/IMA), malware scanner status, secure boot, `/tmp noexec`
|
||||
- [ ] **FR4 suite**: TLS version/cipher audit, disk encryption (LUKS), SSH cipher hardening
|
||||
- [ ] **FR6 suite**: rsyslog remote forwarding, auditd dispatcher, journald persistent storage
|
||||
- [ ] **FR7 suite**: Disk quotas, CPU/memory limits, backup schedule verification
|
||||
- [ ] **Aggregated multi-host report**: Single HTML/PDF across all inventory hosts
|
||||
- [ ] **CI/CD integration**: GitHub Actions / GitLab CI pipeline with Markdown report posted as PR comment
|
||||
- [ ] **CIS Benchmark dual-mapping**: Each test maps to both IEC 62443-3-3 SR and CIS Benchmark control
|
||||
Set the CI image reference in `.gitlab-ci.yml` or publish it as `$CI_REGISTRY_IMAGE/ansible:latest`.
|
||||
|
||||
Reference in New Issue
Block a user