CYBER-0 initial concept ready

This commit is contained in:
Ole Valente
2026-09-22 00:00:45 +02:00
parent 29eecd4f70
commit edb6cde069
70 changed files with 1976 additions and 3140 deletions
+77
View File
@@ -0,0 +1,77 @@
FROM alpine:3.20
LABEL org.opencontainers.image.title="Ansible Test Executor"
LABEL org.opencontainers.image.description="Ansible integrations for infrastructure test automation"
RUN apk add --no-cache \
ansible \
bash \
ca-certificates \
curl \
freetds \
freetds-dev \
gcc \
git \
krb5 \
krb5-dev \
libffi-dev \
musl-dev \
openssh-client \
openssl-dev \
py3-pip \
python3 \
python3-dev \
sshpass \
&& pip3 install --no-cache-dir --break-system-packages \
'cryptography>=41.0' \
'fpdf2>=2.7' \
'jmespath>=1.0' \
'jsonschema>=4.23' \
'ncclient>=0.6' \
'netmiko>=4.0' \
packaging \
'paramiko>=2.7' \
'pymssql>=2.2' \
'pyvmomi>=8.0' \
'pywinrm[kerberos]>=0.4' \
'pyyaml>=6.0' \
requests \
requests-kerberos \
requests-ntlm \
scp \
'xmltodict>=0.13' \
&& ansible-galaxy collection install \
ansible.netcommon \
ansible.utils \
ansible.windows \
cisco.asa \
cisco.ios \
cisco.nxos \
community.crypto \
community.general \
community.vmware \
microsoft.sql \
&& apk del --no-network \
freetds-dev \
gcc \
krb5-dev \
libffi-dev \
musl-dev \
openssl-dev \
python3-dev \
&& apk add --no-cache util-linux \
&& mkdir -p /etc/ansible \
&& printf '%s\n' \
'[defaults]' \
'host_key_checking = False' \
'stdout_callback = yaml' \
'retry_files_enabled = False' \
'inventory = /workspace/assets.yml' \
'' \
'[ssh_connection]' \
'pipelining = True' \
'control_path = /tmp/ansible-%%h-%%p-%%r' \
> /etc/ansible/ansible.cfg
WORKDIR /workspace
CMD ["ansible-playbook", "--version"]
+25
View File
@@ -0,0 +1,25 @@
# Ansible Methodology
Ansible performs host, operating-system, hypervisor, database, and network-device checks. The GitLab job reads targets from the root `assets.yml` inventory and credentials from environment-scoped GitLab File variables.
## Contents
- `Dockerfile`: Kubernetes-executor image with Ansible integrations.
- `playbooks/`: suites, platform examples, templates, and raw report generation.
- `run.sh`: execute, normalize, and render one Ansible run.
- `scripts/normalize.py`: convert native Ansible reports to the common schema.
- `fixtures/sample-output.json`: adapter and renderer validation input.
## Invocation
GitLab runs this methodology when `RUN_ANSIBLE=true`. For local use from the repository root:
```bash
ANSIBLE_VARS_FILE=/secure/vars.yml ./methodologies/ansible/run.sh --limit linux_vms
```
Build the image with:
```bash
./methodologies/ansible/scripts/build-image.sh
```
@@ -0,0 +1,228 @@
{
"meta": {
"standard": "IEC 62443-3-3",
"security_level": "SL2",
"target": "ics-gateway-01",
"timestamp": "2026-07-07T16:42:00+02:00",
"executed_by": "auditor"
},
"summary": {
"total": 15,
"passed": 10,
"failed": 4,
"skipped": 1
},
"results": [
{
"test_id": "IAC-01",
"category": "FR1 - Identification and Authentication Control",
"requirement": "SR 1.1 — Unique User Identification",
"description": "Every user account shall have a unique UID",
"passed": true,
"expected": "No duplicate UIDs in /etc/passwd",
"actual": "None found",
"severity": "high",
"remediation": "Change duplicate UIDs with: usermod -u <new-uid> <user>"
},
{
"test_id": "IAC-02",
"category": "FR1 - Identification and Authentication Control",
"requirement": "SR 1.3 — Account Management",
"description": "Default/unnecessary system accounts shall be removed or disabled",
"passed": false,
"expected": "No default accounts present",
"actual": "games\nftp",
"severity": "medium",
"remediation": "Delete default accounts: userdel <account>"
},
{
"test_id": "IAC-03",
"category": "FR1 - Identification and Authentication Control",
"requirement": "SR 1.3 — Account Management",
"description": "Human user accounts that have never logged in shall be reviewed",
"passed": false,
"expected": "No unused human accounts with valid shells",
"actual": "operator (shell: /bin/bash)",
"severity": "low",
"remediation": "Lock stale accounts: usermod -L <user>"
},
{
"test_id": "IAC-04",
"category": "FR1 - Identification and Authentication Control",
"requirement": "SR 1.4 — Identifier Strength",
"description": "No account shall have an empty or trivially-weak password hash",
"passed": true,
"expected": "All accounts have proper password hashes",
"actual": "All accounts OK",
"severity": "critical",
"remediation": "Set a password or lock the account: passwd -l <user>"
},
{
"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": false,
"expected": "minlen >= 14 in /etc/security/pwquality.conf",
"actual": "minlen = 8",
"severity": "high",
"remediation": "Set minlen=14 in /etc/security/pwquality.conf"
},
{
"test_id": "IAC-06",
"category": "FR1 - Identification and Authentication Control",
"requirement": "SR 1.5 — Authenticator Strength",
"description": "Password shall require at least 1 of each character class",
"passed": true,
"expected": "At least 3 character classes required",
"actual": "dcredit=-1\nucredit=-1\nlcredit=-1\nocredit=-1\nminclass=NOT SET",
"severity": "medium",
"remediation": "Set dcredit=-1, ucredit=-1, lcredit=-1, ocredit=-1 in pwquality.conf"
},
{
"test_id": "IAC-07",
"category": "FR1 - Identification and Authentication Control",
"requirement": "SR 1.7 — Password Lifetime",
"description": "Password maximum age shall be ≤ 90 days",
"passed": true,
"expected": "PASS_MAX_DAYS ≤ 90",
"actual": "PASS_MAX_DAYS=90",
"severity": "medium",
"remediation": "Set PASS_MAX_DAYS 90 in /etc/login.defs"
},
{
"test_id": "IAC-08",
"category": "FR1 - Identification and Authentication Control",
"requirement": "SR 1.7 — Password Lifetime",
"description": "Password minimum change interval shall be ≥ 1 day",
"passed": true,
"expected": "PASS_MIN_DAYS ≥ 1",
"actual": "PASS_MIN_DAYS=1",
"severity": "low",
"remediation": "Set PASS_MIN_DAYS 1 in /etc/login.defs"
},
{
"test_id": "IAC-09",
"category": "FR1 - Identification and Authentication Control",
"requirement": "SR 1.11 — Unsuccessful Login Attempts",
"description": "Account lockout shall trigger after ≤ 5 failed attempts",
"passed": true,
"expected": "Account lockout configured with deny ≤ 5",
"actual": "3",
"severity": "high",
"remediation": "Configure pam_faillock in /etc/pam.d/common-auth: deny=5"
},
{
"test_id": "IAC-10",
"category": "FR1 - Identification and Authentication Control",
"requirement": "SR 1.6 — Password History",
"description": "Password history shall prevent reuse of last 5+ passwords",
"passed": true,
"expected": "pam_pwhistory remember ≥ 5",
"actual": "remember=5",
"severity": "medium",
"remediation": "Add \"remember=5\" to pam_pwhistory.so in /etc/pam.d/common-password"
},
{
"test_id": "UC-01",
"category": "FR2 — Use Control",
"requirement": "SR 2.1 — Authorization Enforcement",
"description": "No user shall have unrestricted NOPASSWD sudo access to all commands",
"passed": true,
"expected": "No NOPASSWD ALL entries in sudoers",
"actual": "None found",
"severity": "high",
"remediation": "Restrict sudo rules to specific commands and require authentication"
},
{
"test_id": "UC-02",
"category": "FR2 — Use Control",
"requirement": "SR 2.1 — Authorization Enforcement",
"description": "/etc/sudoers shall be owned by root:root with mode 0440",
"passed": true,
"expected": "root:root 0440",
"actual": "root:root 0440",
"severity": "critical",
"remediation": "chown root:root /etc/sudoers && chmod 0440 /etc/sudoers"
},
{
"test_id": "UC-03",
"category": "FR2 — Use Control",
"requirement": "SR 2.5 — Session Lock",
"description": "Interactive shell sessions shall timeout after ≤ 900 seconds",
"passed": true,
"expected": "TMOUT set between 1-900 seconds",
"actual": "TMOUT=900",
"severity": "medium",
"remediation": "Add \"readonly TMOUT=900\" to /etc/profile"
},
{
"test_id": "UC-04",
"category": "FR2 — Use Control",
"requirement": "SR 2.4 — Audit Log Integrity",
"description": "Audit configuration shall be immutable (-e 2)",
"passed": false,
"expected": "audit.rules contains -e 2",
"actual": "Immutable flag NOT set",
"severity": "high",
"remediation": "Add \"-e 2\" to /etc/audit/audit.rules (requires reboot)"
},
{
"test_id": "UC-05",
"category": "FR2 — Use Control",
"requirement": "SR 2.8 — Auditable Events",
"description": "The audit daemon (auditd) shall be running and enabled",
"passed": true,
"expected": "auditd service is active",
"actual": "auditd is active",
"severity": "high",
"remediation": "systemctl enable --now auditd"
}
],
"failures": [
{
"test_id": "IAC-02",
"category": "FR1 - Identification and Authentication Control",
"requirement": "SR 1.3 — Account Management",
"description": "Default/unnecessary system accounts shall be removed or disabled",
"passed": false,
"expected": "No default accounts present",
"actual": "games\nftp",
"severity": "medium",
"remediation": "Delete default accounts: userdel <account>"
},
{
"test_id": "IAC-03",
"category": "FR1 - Identification and Authentication Control",
"requirement": "SR 1.3 — Account Management",
"description": "Human user accounts that have never logged in shall be reviewed",
"passed": false,
"expected": "No unused human accounts with valid shells",
"actual": "operator (shell: /bin/bash)",
"severity": "low",
"remediation": "Lock stale accounts: usermod -L <user>"
},
{
"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": false,
"expected": "minlen >= 14 in /etc/security/pwquality.conf",
"actual": "minlen = 8",
"severity": "high",
"remediation": "Set minlen=14 in /etc/security/pwquality.conf"
},
{
"test_id": "UC-04",
"category": "FR2 — Use Control",
"requirement": "SR 2.4 — Audit Log Integrity",
"description": "Audit configuration shall be immutable (-e 2)",
"passed": false,
"expected": "audit.rules contains -e 2",
"actual": "Immutable flag NOT set",
"severity": "high",
"remediation": "Add \"-e 2\" to /etc/audit/audit.rules (requires reboot)"
}
]
}
@@ -0,0 +1,92 @@
---
- name: "Demo: Ubuntu SSH and nginx checks"
hosts: linux_vms
gather_facts: true
become: false
vars:
report_dir: "./artifacts/raw/ansible"
pre_tasks:
- name: "Ensure report directory exists"
ansible.builtin.file:
path: "{{ report_dir }}"
state: directory
mode: "0755"
delegate_to: localhost
run_once: true
tasks:
- name: "Gather SSH effective configuration"
ansible.builtin.shell: grep -Ei '^(PasswordAuthentication|PermitRootLogin)' /etc/ssh/sshd_config
register: sshd_config
changed_when: false
- name: "Record SSH password authentication result"
ansible.builtin.set_fact:
test_results: "{{ test_results | default([]) + [{
'test_id': 'DEMO-SSH-01',
'category': 'Secure remote administration',
'requirement': 'IEC 62443-3-3 SR 1.7',
'description': 'SSH password authentication shall be disabled',
'passed': ('passwordauthentication no' in sshd_config.stdout),
'expected': 'PasswordAuthentication no',
'actual': sshd_config.stdout_lines | select('match', '^passwordauthentication ') | first | default('not found'),
'severity': 'high',
'remediation': 'Disable SSH password authentication and use managed keys'
}] }}"
- name: "Gather nginx configuration"
ansible.builtin.shell: grep -E '^[[:space:]]*ssl_(protocols|ciphers)' /etc/nginx/sites-enabled/default
register: nginx_config
changed_when: false
- name: "Record obsolete TLS protocol result"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'DEMO-TLS-01',
'category': 'Secure communications',
'requirement': 'IEC 62443-3-3 SR 4.1',
'description': 'The web server shall allow only TLS 1.2 and TLS 1.3',
'passed': ('TLSv1 ' not in nginx_config.stdout and 'TLSv1.1' not in nginx_config.stdout),
'expected': 'ssl_protocols TLSv1.2 TLSv1.3',
'actual': nginx_config.stdout_lines | select('search', 'ssl_protocols') | first | default('not found'),
'severity': 'high',
'remediation': 'Remove TLSv1 and TLSv1.1 from ssl_protocols'
}] }}"
- name: "Record weak CBC cipher result"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'DEMO-TLS-02',
'category': 'Secure communications',
'requirement': 'IEC 62443-3-3 SR 4.1',
'description': 'The web server shall not enable legacy CBC cipher suites',
'passed': ('AES128-SHA' not in nginx_config.stdout),
'expected': 'Modern AEAD cipher suites only',
'actual': nginx_config.stdout_lines | select('search', 'ssl_ciphers') | first | default('not found'),
'severity': 'medium',
'remediation': 'Use a modern Mozilla intermediate TLS cipher configuration'
}] }}"
- name: "Check nginx process"
ansible.builtin.command: pgrep -x nginx
register: nginx_process
changed_when: false
failed_when: false
- name: "Record nginx availability result"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'DEMO-SVC-01',
'category': 'Service availability',
'requirement': 'IEC 62443-3-3 SR 7.1',
'description': 'The nginx service shall be running',
'passed': (nginx_process.rc == 0),
'expected': 'At least one nginx process',
'actual': nginx_process.stdout | default('not running', true),
'severity': 'medium',
'remediation': 'Start nginx and configure service supervision'
}] }}"
- name: "Generate raw Ansible report"
ansible.builtin.include_tasks: library/report.yml
@@ -0,0 +1,268 @@
---
# ══════════════════════════════════════════════════════════════════════════════
# IEC 62443-3-3 SL2 — Cisco ASA Firewall Compliance
#
# Target type : Cisco ASA 9.x+ (physical or virtual)
# Connection : SSH via ansible.netcommon.network_cli
# Collections : cisco.asa, ansible.netcommon (installed in ansible-node image)
# Python pkg : paramiko (installed in ansible-node image)
#
# Inventory group : cisco_firewalls (see assets.yml)
#
# Run:
# ansible-playbook -i assets.yml methodologies/ansible/playbooks/examples/cisco_firewall.yml
#
# ASA-specific notes:
# - asa_command returns stdout as a list, same as ios_command.
# - 'show running-config' on ASA is a single large string; use regex_search
# and regex_findall to extract specific configuration lines.
# - 'enable' privilege is required for most 'show' commands.
# ansible_become=yes + ansible_become_method=enable handles this.
# - Multi-context ASAs: add 'changeto context <name>' as a command prefix,
# or target individual context admin contexts.
# ══════════════════════════════════════════════════════════════════════════════
- name: "IEC 62443-3-3 SL2 — Cisco ASA Firewall Compliance"
hosts: cisco_firewalls
gather_facts: yes # runs cisco.asa.asa_facts → ansible_net_*
vars:
report_dir: "../../reports"
pre_tasks:
- name: "Gather local facts for report timestamp and user"
ansible.builtin.setup:
gather_subset:
- date_time
- user_id
delegate_to: localhost
run_once: true
- name: "Ensure report directory exists"
ansible.builtin.file:
path: "{{ report_dir }}"
state: directory
mode: "0755"
delegate_to: localhost
run_once: true
tasks:
# ── FR5 · SR 5.3: No Telnet on VTY lines — SSH only ──────────────────────
- block:
- name: "Gather: VTY line transport configuration"
cisco.asa.asa_command:
commands:
- show running-config | include telnet|ssh
register: _mgmt_access
- name: "Evaluate: FW-RDF-01 — Telnet management access disabled"
ansible.builtin.set_fact:
test_results: "{{ test_results | default([]) + [{
'test_id': 'FW-RDF-01',
'category': 'FR5 — Restricted Data Flow',
'requirement': 'SR 5.3 — Communication Constraints',
'description': 'Telnet management access to the ASA shall be disabled',
'passed': (
_mgmt_access.stdout[0] | regex_search('telnet [0-9]') is none
),
'expected': 'No telnet <network> lines in running-config',
'actual': _mgmt_access.stdout[0] | regex_findall('telnet[^\n]+') | join(' | ') | default('No telnet statements found', true),
'severity': 'critical',
'remediation': 'Remove all "telnet" management statements; use "ssh" only'
}] }}"
- name: "Evaluate: FW-RDF-02 — SSH management access configured"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'FW-RDF-02',
'category': 'FR5 — Restricted Data Flow',
'requirement': 'SR 5.3 — Communication Constraints',
'description': 'SSH management access shall be restricted to specific management networks',
'passed': (_mgmt_access.stdout[0] | regex_search('ssh [0-9]') is not none),
'expected': 'At least one ssh <network> statement present',
'actual': _mgmt_access.stdout[0] | regex_findall('ssh[^\n]+') | join(' | ') | default('No SSH access statements', true),
'severity': 'high',
'remediation': 'ssh <management-net> <mask> <interface>\nssh version 2'
}] }}"
ignore_errors: yes
# ── FR1 · SR 1.2: IKEv1 disabled — IKEv2 only for VPN ────────────────────
# IKEv1 is vulnerable to several known attacks. IEC 62443 SL2 requires v2.
- block:
- name: "Gather: IKE/ISAKMP policy configuration"
cisco.asa.asa_command:
commands:
- show running-config | include crypto isakmp|crypto ikev
register: _ike_cfg
- name: "Evaluate: FW-IAC-01 — IKEv1 disabled"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'FW-IAC-01',
'category': 'FR1 — Identification and Authentication Control',
'requirement': 'SR 1.2 — Software Process and Device Identification',
'description': 'IKEv1 (crypto isakmp) shall be disabled; only IKEv2 is permitted',
'passed': (
_ike_cfg.stdout[0] | regex_search('crypto isakmp enable') is none and
_ike_cfg.stdout[0] | regex_search('crypto isakmp policy') is none
),
'expected': 'No crypto isakmp enable or isakmp policy statements',
'actual': _ike_cfg.stdout[0] | regex_findall('crypto isakmp[^\n]+') | join(' | ') | default('No IKEv1 config found', true),
'severity': 'high',
'remediation': 'no crypto isakmp enable\nno crypto isakmp policy <n>'
}] }}"
ignore_errors: yes
# ── FR2 · SR 2.8: Syslog forwarding to remote server ─────────────────────
- block:
- name: "Gather: Syslog configuration"
cisco.asa.asa_command:
commands:
- show running-config | include logging
register: _syslog_cfg
- name: "Evaluate: FW-UC-01 — Syslog forwarding to remote host"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'FW-UC-01',
'category': 'FR2 — Use Control',
'requirement': 'SR 2.8 — Auditable Events',
'description': 'ASA syslog shall be forwarded to a remote syslog server (not stored locally only)',
'passed': (
_syslog_cfg.stdout[0] | regex_search('logging host') is not none and
_syslog_cfg.stdout[0] | regex_search('logging enable') is not none
),
'expected': 'logging enable; logging host <interface> <syslog-server>',
'actual': _syslog_cfg.stdout[0] | regex_findall('logging[^\n]+') | join(' | ') | default('No logging config', true),
'severity': 'high',
'remediation': 'logging enable\nlogging host <inside/mgmt> <syslog-server-ip>'
}] }}"
ignore_errors: yes
# ── FR1 · SR 1.11: AAA authentication for management ─────────────────────
# Require AAA (TACACS+/RADIUS) for management access; reject local fallback
# without documented justification.
- block:
- name: "Gather: AAA and local user configuration"
cisco.asa.asa_command:
commands:
- show running-config | include ^aaa|^username
register: _aaa_cfg
- name: "Evaluate: FW-IAC-02 — AAA authentication configured for SSH/enable"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'FW-IAC-02',
'category': 'FR1 — Identification and Authentication Control',
'requirement': 'SR 1.11 — Unsuccessful Login Attempts',
'description': 'Management access (SSH and enable) shall use AAA authentication',
'passed': (
_aaa_cfg.stdout[0] | regex_search('aaa authentication ssh') is not none or
_aaa_cfg.stdout[0] | regex_search('aaa authentication enable') is not none
),
'expected': 'aaa authentication ssh|enable console <server-group> LOCAL',
'actual': _aaa_cfg.stdout[0] | regex_findall('aaa authentication[^\n]+') | join(' | ') | default('No AAA authentication config', true),
'severity': 'high',
'remediation': 'aaa authentication ssh console TACACS+ LOCAL\naaa authentication enable console TACACS+ LOCAL'
}] }}"
ignore_errors: yes
# ── FR5 · SR 5.2: Default deny — check access-group on interfaces ─────────
- block:
- name: "Gather: Interface ACL bindings and ACL counts"
cisco.asa.asa_command:
commands:
- show running-config | include access-group
- show access-list | include elements
register: _acl_cfg
- name: "Evaluate: FW-RDF-03 — Access lists applied inbound on all interfaces"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'FW-RDF-03',
'category': 'FR5 — Restricted Data Flow',
'requirement': 'SR 5.2 — Zone Boundary Protection',
'description': 'Access control lists shall be applied inbound on all zone-facing interfaces',
'passed': (_acl_cfg.stdout[0] | regex_findall('access-group.*in interface') | length > 0),
'expected': 'At least one access-group <name> in interface <name>',
'actual': _acl_cfg.stdout[0] | regex_findall('access-group[^\n]+') | join(' | ') | default('No access-group statements', true),
'severity': 'critical',
'remediation': 'access-group <ACL_NAME> in interface <outside|ics_zone>'
}] }}"
ignore_errors: yes
# ── HITL · FR5 · SR 5.2: Firewall rule review ────────────────────────────
# Collect the full ACL and ask the reviewer if rules are minimal / correct.
- block:
- name: "Gather: [HITL] Full access-list detail for review"
cisco.asa.asa_command:
commands:
- show access-list
register: _full_acl
- name: "Display: [HITL] FW-RDF-HITL-01 — ACL rule review"
ansible.builtin.debug:
msg: |
══════════════════════════════════════════════════════════════
MANUAL REVIEW REQUIRED · FW-RDF-HITL-01 · {{ inventory_hostname }}
══════════════════════════════════════════════════════════════
Requirement : SR 5.2 — Zone Boundary Protection
Check : Firewall rules implement least-privilege; no
'permit any any' or broad permit rules exist
Access list summary
───────────────────
{{ _full_acl.stdout[0] | truncate(1200, false) | indent(1) }}
Verify:
- No 'permit ip any any' or 'permit any any' rules present
- Rules are specific (source/destination/service all named)
- Each rule has a documented business justification
- Implicit deny at the end of each list
══════════════════════════════════════════════════════════════
- name: "Prompt: FW-RDF-HITL-01 — verdict for {{ inventory_hostname }}"
ansible.builtin.pause:
prompt: |
Do the firewall ACLs implement least-privilege with no broad permit rules?
Enter verdict [pass / fail / skip]:
register: _hitl_acl_verdict
delegate_to: localhost
- name: "Prompt: FW-RDF-HITL-01 — notes on failure"
ansible.builtin.pause:
prompt: "Describe the offending rule(s) (e.g. 'ACL OUTSIDE line 3: permit ip any any'):"
register: _hitl_acl_notes
delegate_to: localhost
when: _hitl_acl_verdict.user_input | lower | trim in ['fail', 'f']
- name: "Evaluate: FW-RDF-HITL-01"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'FW-RDF-HITL-01',
'category': 'FR5 — Restricted Data Flow',
'requirement': 'SR 5.2 — Zone Boundary Protection',
'description': 'Firewall rules shall implement least-privilege; no broad permit rules',
'passed': (
'skipped' if (_hitl_acl_verdict.user_input | lower | trim in ['skip', 's', ''])
else (_hitl_acl_verdict.user_input | lower | trim in ['pass', 'p'])
),
'expected': 'All permit rules are specific (src/dst/svc); no permit any any',
'actual': 'Full ACL captured — see report evidence',
'severity': 'critical',
'remediation': 'Replace broad permit rules with specific source/destination/service entries',
'reviewer': ansible_user_id,
'notes': (_hitl_acl_notes.user_input | trim) if _hitl_acl_notes is defined else ''
}] }}"
ignore_errors: yes
# ── Generate report ────────────────────────────────────────────────────────
- name: "Generate compliance report"
ansible.builtin.include_tasks: ../library/report.yml
@@ -0,0 +1,285 @@
---
# ══════════════════════════════════════════════════════════════════════════════
# IEC 62443-3-3 SL2 — Cisco Switch Compliance (IOS / IOS-XE)
#
# Target type : Cisco Catalyst / IOS-XE access and distribution switches
# Connection : SSH via ansible.netcommon.network_cli
# Collections : cisco.ios, ansible.netcommon (installed in ansible-node image)
# Python pkg : paramiko, netmiko (installed in ansible-node image)
#
# Inventory group : cisco_switches (see assets.yml)
#
# Run:
# ansible-playbook -i assets.yml methodologies/ansible/playbooks/examples/cisco_switch.yml
#
# How network_cli output works:
# - cisco.ios.ios_command returns stdout as a list, one entry per command.
# Access the first command's output with: _result.stdout[0]
# - Output is a plain text string; use regex_search / regex_findall to parse.
# - ios_facts populates ansible_net_* variables (hostname, version, interfaces)
# and is used here to gather facts once for multiple tests.
#
# Note on gather_facts:
# Ansible's default gather_facts runs ios_facts automatically when
# ansible_network_os is set. Set gather_facts: yes to use ansible_net_*
# variables, or gather_facts: no and call ios_facts explicitly.
# ══════════════════════════════════════════════════════════════════════════════
- name: "IEC 62443-3-3 SL2 — Cisco Switch Compliance"
hosts: cisco_switches
gather_facts: yes # runs cisco.ios.ios_facts → populates ansible_net_*
vars:
report_dir: "../../reports"
pre_tasks:
- name: "Gather local facts for report timestamp and user"
ansible.builtin.setup:
gather_subset:
- date_time
- user_id
delegate_to: localhost
run_once: true
- name: "Ensure report directory exists"
ansible.builtin.file:
path: "{{ report_dir }}"
state: directory
mode: "0755"
delegate_to: localhost
run_once: true
tasks:
# ── FR5 · SR 5.3: SSH v2 only, Telnet disabled on VTY lines ──────────────
- block:
- name: "Gather: SSH version and VTY transport settings"
cisco.ios.ios_command:
commands:
- show ip ssh
- show running-config | section line vty
register: _ssh_vty
- name: "Evaluate: SW-RDF-01 — SSH version 2 configured"
ansible.builtin.set_fact:
test_results: "{{ test_results | default([]) + [{
'test_id': 'SW-RDF-01',
'category': 'FR5 — Restricted Data Flow',
'requirement': 'SR 5.3 — Communication Constraints',
'description': 'SSH version shall be 2 (SSHv1 disabled)',
'passed': (_ssh_vty.stdout[0] | regex_search('SSH Enabled.*version 2') is not none),
'expected': 'SSH Enabled - version 2.0',
'actual': _ssh_vty.stdout[0] | regex_search('SSH Enabled[^\n]+') | default('SSH status not found', true),
'severity': 'high',
'remediation': 'ip ssh version 2'
}] }}"
- name: "Evaluate: SW-RDF-02 — Telnet disabled on VTY lines"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'SW-RDF-02',
'category': 'FR5 — Restricted Data Flow',
'requirement': 'SR 5.3 — Communication Constraints',
'description': 'VTY lines shall only permit SSH transport (no Telnet)',
'passed': (
'transport input ssh' in _ssh_vty.stdout[1] and
'transport input telnet' not in _ssh_vty.stdout[1] and
'transport input all' not in _ssh_vty.stdout[1]
),
'expected': 'transport input ssh (only) on all VTY lines',
'actual': _ssh_vty.stdout[1] | regex_findall('transport input[^\n]+') | join(' | ') | default('NOT SET', true),
'severity': 'critical',
'remediation': 'line vty 0 15\n transport input ssh'
}] }}"
ignore_errors: yes
# ── FR1 · SR 1.1: No SNMPv1 or SNMPv2c communities ───────────────────────
# SNMPv1/v2c use cleartext community strings — equivalent to passwords in clear.
- block:
- name: "Gather: SNMP community string configuration"
cisco.ios.ios_command:
commands:
- show running-config | include snmp-server community
register: _snmp_config
- name: "Evaluate: SW-IAC-01 — No SNMPv1/v2c community strings"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'SW-IAC-01',
'category': 'FR1 — Identification and Authentication Control',
'requirement': 'SR 1.1 — Unique User Identification',
'description': 'SNMPv1/v2c community strings shall be absent; use SNMPv3 with auth+priv',
'passed': (_snmp_config.stdout[0] | trim | length == 0),
'expected': 'No snmp-server community lines in running-config',
'actual': (
_snmp_config.stdout[0] | trim | default('No SNMP community strings found', true)
),
'severity': 'high',
'remediation': 'Remove all snmp-server community entries; configure snmp-server group/user with authPriv'
}] }}"
ignore_errors: yes
# ── FR1 · SR 1.7: Login banner configured ─────────────────────────────────
# A warning banner is a legal and technical requirement under IEC 62443.
- block:
- name: "Gather: Login banner text"
cisco.ios.ios_command:
commands:
- show running-config | section banner login
register: _banner
- name: "Evaluate: SW-IAC-02 — Login warning banner configured"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'SW-IAC-02',
'category': 'FR1 — Identification and Authentication Control',
'requirement': 'SR 1.7 — Strength of Password-based Authentication',
'description': 'A warning banner shall be displayed before login (authorised use only)',
'passed': ('banner login' in _banner.stdout[0]),
'expected': 'banner login block configured',
'actual': _banner.stdout[0] | regex_search('banner login [^\n]+') | default('No banner login configured', true),
'severity': 'medium',
'remediation': "banner login ^C\nAUTHORIZED ACCESS ONLY. Unauthorised access is prohibited.\n^C"
}] }}"
ignore_errors: yes
# ── FR5 · SR 5.3: Unused interfaces shut down ─────────────────────────────
# Uses ios_facts (already gathered) — no additional command needed.
- block:
- name: "Evaluate: SW-RDF-03 — No interfaces in admin-down state with connected status"
# ansible_net_interfaces is a dict keyed by interface name.
# We look for interfaces that are 'up' operationally but not in shutdown config.
# A simpler check: count of interfaces in 'administratively down' that have
# a connected line protocol (should never exist if properly shut).
ansible.builtin.set_fact:
_intf_up_no_shutdown: "{{ ansible_net_interfaces | dict2items
| selectattr('value.operstatus', 'equalto', 'up')
| selectattr('value.lineprotocol', 'equalto', 'down')
| map(attribute='key') | list }}"
- name: "Gather: Interfaces shutdown in config (no description = unused)"
cisco.ios.ios_command:
commands:
- show interfaces status | include notconnect|disabled
register: _intf_status
- name: "Evaluate: SW-RDF-03 — record result"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'SW-RDF-03',
'category': 'FR5 — Restricted Data Flow',
'requirement': 'SR 5.3 — Communication Constraints',
'description': 'All unused access ports shall be administratively shut down',
'passed': 'review',
'expected': 'All notconnect ports in shutdown state in config',
'actual': 'Not-connected or disabled ports:\n' + _intf_status.stdout[0] | trim | truncate(300, false),
'severity': 'medium',
'remediation': 'interface range <unused> shutdown'
}] }}"
ignore_errors: yes
# ── FR2 · SR 2.8: NTP authentication ──────────────────────────────────────
- block:
- name: "Gather: NTP configuration"
cisco.ios.ios_command:
commands:
- show ntp status
- show running-config | include ntp
register: _ntp_cfg
- name: "Evaluate: SW-UC-01 — NTP configured and synchronised"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'SW-UC-01',
'category': 'FR2 — Use Control',
'requirement': 'SR 2.8 — Auditable Events',
'description': 'NTP shall be configured and the clock synchronised for audit log accuracy',
'passed': (
_ntp_cfg.stdout[0] | regex_search('Clock is synchronized') is not none and
_ntp_cfg.stdout[1] | regex_search('ntp server') is not none
),
'expected': 'Clock is synchronized; ntp server configured with authentication',
'actual': 'NTP status: ' + (_ntp_cfg.stdout[0] | regex_search('Clock is [^\n]+') | default('NOT synchronised', true))
+ ' | Config: ' + (_ntp_cfg.stdout[1] | regex_findall('ntp [^\n]+') | join('; ') | default('no ntp config', true)),
'severity': 'high',
'remediation': 'ntp authenticate\nntp authentication-key 1 md5 <key>\nntp trusted-key 1\nntp server <ip> key 1'
}] }}"
ignore_errors: yes
# ── HITL · FR5 · SR 5.2: Port labelling and physical access ──────────────
- block:
- name: "Gather: [HITL] Interface descriptions and VLAN assignments"
cisco.ios.ios_command:
commands:
- show interfaces description
- show vlan brief
register: _intf_desc
- name: "Display: [HITL] SW-RDF-HITL-01 — Physical port labelling review"
ansible.builtin.debug:
msg: |
══════════════════════════════════════════════════════════════
MANUAL REVIEW REQUIRED · SW-RDF-HITL-01 · {{ inventory_hostname }}
══════════════════════════════════════════════════════════════
Requirement : SR 5.2 — Zone Boundary Protection
Check : ICS-connected ports are in the correct VLAN and
physically labelled to prevent misconnection
Interface descriptions
─────────────────────
{{ _intf_desc.stdout[0] | truncate(800, false) | indent(1) }}
VLAN assignments
────────────────
{{ _intf_desc.stdout[1] | truncate(400, false) | indent(1) }}
Verify:
- ICS device ports are in the dedicated ICS VLAN (not default VLAN 1)
- All connected ports have a description identifying the device
- Physical port labels match the connected device
══════════════════════════════════════════════════════════════
- name: "Prompt: SW-RDF-HITL-01 — verdict for {{ inventory_hostname }}"
ansible.builtin.pause:
prompt: |
Are ICS device ports in the correct VLAN and physically labelled?
Enter verdict [pass / fail / skip]:
register: _hitl_port_verdict
delegate_to: localhost
- name: "Prompt: SW-RDF-HITL-01 — notes on failure"
ansible.builtin.pause:
prompt: "Describe the finding (e.g. 'Port Gi0/5 in VLAN 1, no label'):"
register: _hitl_port_notes
delegate_to: localhost
when: _hitl_port_verdict.user_input | lower | trim in ['fail', 'f']
- name: "Evaluate: SW-RDF-HITL-01"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'SW-RDF-HITL-01',
'category': 'FR5 — Restricted Data Flow',
'requirement': 'SR 5.2 — Zone Boundary Protection',
'description': 'ICS ports shall be in the dedicated ICS VLAN and physically labelled',
'passed': (
'skipped' if (_hitl_port_verdict.user_input | lower | trim in ['skip', 's', ''])
else (_hitl_port_verdict.user_input | lower | trim in ['pass', 'p'])
),
'expected': 'ICS ports in ICS VLAN, not VLAN 1; physical labels applied',
'actual': 'See interface description and VLAN table in evidence',
'severity': 'high',
'remediation': 'Move ICS ports to ICS VLAN; apply description labels; physically label ports',
'reviewer': ansible_user_id,
'notes': (_hitl_port_notes.user_input | trim) if _hitl_port_notes is defined else ''
}] }}"
ignore_errors: yes
# ── Generate report ────────────────────────────────────────────────────────
- name: "Generate compliance report"
ansible.builtin.include_tasks: ../library/report.yml
@@ -0,0 +1,261 @@
---
# ══════════════════════════════════════════════════════════════════════════════
# IEC 62443-3-3 SL2 — Hyper-V Cluster Node Compliance
#
# Target type : Windows Server Hyper-V hosts (standalone or cluster nodes)
# Connection : WinRM — same as windows_server.yml
# Collections : ansible.windows
# Python pkg : pywinrm
#
# Inventory group : hyperv_hosts (see assets.yml)
#
# Run:
# ansible-playbook -i assets.yml methodologies/ansible/playbooks/examples/hyperv_cluster.yml
#
# This playbook runs two layers of checks:
# Host layer — Windows Server hardening (same as windows_server.yml)
# Hyper-V layer — VM configuration, vSwitch isolation, secure boot
#
# PowerShell modules used:
# Hyper-V — built-in on all Hyper-V hosts
# FailoverClusters — for cluster-aware checks (if applicable)
# ══════════════════════════════════════════════════════════════════════════════
- name: "IEC 62443-3-3 SL2 — Hyper-V Cluster Node Compliance"
hosts: hyperv_hosts
gather_facts: yes
vars:
report_dir: "../../reports"
pre_tasks:
- name: "Ensure report directory exists"
ansible.builtin.file:
path: "{{ report_dir }}"
state: directory
mode: "0755"
delegate_to: localhost
run_once: true
tasks:
# ── FR3 · SR 3.4: VMs using Generation 2 (UEFI + Secure Boot) ────────────
# Gen 2 VMs support UEFI, Secure Boot, and vTPM. Gen 1 cannot.
- block:
- name: "Gather: VM list with generation and Secure Boot state"
ansible.windows.win_shell: |
@(Get-VM | Where-Object { $_.State -ne 'Off' -or $true } |
ForEach-Object {
$sb = $false
try { $sb = (Get-VMFirmware -VM $_ -ErrorAction Stop).SecureBootEnabled } catch {}
[PSCustomObject]@{
Name = $_.Name
Generation = $_.Generation
State = $_.State.ToString()
SecureBoot = $sb
}
}) | ConvertTo-Json -AsArray -Compress
register: _vm_list
- name: "Evaluate: HV-SI-01 — All VMs use Generation 2 with Secure Boot"
ansible.builtin.set_fact:
_vms: "{{ _vm_list.stdout | from_json }}"
- name: "Evaluate: HV-SI-01 — record result"
ansible.builtin.set_fact:
test_results: "{{ test_results | default([]) + [{
'test_id': 'HV-SI-01',
'category': 'FR3 — System Integrity',
'requirement': 'SR 3.4 — Software and Information Integrity',
'description': 'All VMs shall use Generation 2 (UEFI) with Secure Boot enabled',
'passed': (
_vms | length > 0 and
(_vms | rejectattr('Generation', 'equalto', 2) | list | length == 0) and
(_vms | selectattr('SecureBoot', 'equalto', false) | list | length == 0)
),
'expected': 'All VMs: Generation=2, SecureBoot=true',
'actual': 'Gen1: ' + (_vms | rejectattr('Generation', 'equalto', 2) | map(attribute='Name') | join(', ') | default('none', true))
+ ' | SecureBoot off: ' + (_vms | selectattr('SecureBoot', 'equalto', false) | map(attribute='Name') | join(', ') | default('none', true)),
'severity': 'high',
'remediation': 'Convert Gen1 VMs to Gen2 at next maintenance window; Set-VMFirmware <VMName> -EnableSecureBoot On'
}] }}"
ignore_errors: yes
# ── FR5 · SR 5.2: Virtual switch types — no external switch for ICS VMs ──
- block:
- name: "Gather: Virtual switch list with type and bound adapters"
ansible.windows.win_shell: |
@(Get-VMSwitch | Select-Object Name, SwitchType, AllowManagementOS,
@{N='NetAdapterNames';E={ ($_ | Get-VMSwitchTeam -ErrorAction SilentlyContinue).NetAdapterNames -join ',' }}) |
ConvertTo-Json -AsArray -Compress
register: _vswitches
- name: "Evaluate: HV-RDF-01 — No ICS VM on switch shared with management OS"
ansible.builtin.set_fact:
_sw_json: "{{ _vswitches.stdout | from_json }}"
- name: "Evaluate: HV-RDF-01 — External switches with AllowManagementOS=true"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'HV-RDF-01',
'category': 'FR5 — Restricted Data Flow',
'requirement': 'SR 5.2 — Zone Boundary Protection',
'description': 'External vSwitches shared with the management OS shall not carry ICS VM traffic',
'passed': 'review',
'expected': 'ICS VMs connected to Private or Internal switches only',
'actual': 'External switches with AllowManagementOS=true: '
+ (_sw_json | selectattr('SwitchType', 'equalto', 'External')
| selectattr('AllowManagementOS')
| map(attribute='Name') | join(', ') | default('none', true)),
'severity': 'critical',
'remediation': 'Assign ICS VMs to a dedicated Internal vSwitch; disable AllowManagementOS on ICS switches'
}] }}"
ignore_errors: yes
# ── FR2 · SR 2.8: Hyper-V audit logging — VM connect activity ────────────
- block:
- name: "Gather: Hyper-V audit log entries (last 50 events)"
ansible.windows.win_shell: |
$events = Get-WinEvent -LogName 'Microsoft-Windows-Hyper-V-VMMS-Admin' `
-MaxEvents 50 -ErrorAction SilentlyContinue
$count = if ($events) { $events.Count } else { 0 }
[PSCustomObject]@{
LogExists = [bool](Get-WinEvent -ListLog 'Microsoft-Windows-Hyper-V-VMMS-Admin' -ErrorAction SilentlyContinue)
EventCount = $count
} | ConvertTo-Json -Compress
register: _hv_audit
- name: "Evaluate: HV-UC-01 — Hyper-V admin event log active"
ansible.builtin.set_fact:
_hv_audit_json: "{{ _hv_audit.stdout | from_json }}"
- name: "Evaluate: HV-UC-01 — record result"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'HV-UC-01',
'category': 'FR2 — Use Control',
'requirement': 'SR 2.8 — Auditable Events',
'description': 'Hyper-V VMMS Admin event log shall be present and collecting events',
'passed': (_hv_audit_json.LogExists | bool),
'expected': 'Microsoft-Windows-Hyper-V-VMMS-Admin log exists',
'actual': 'LogExists=' + (_hv_audit_json.LogExists | string) + ', RecentEvents=' + (_hv_audit_json.EventCount | string),
'severity': 'medium',
'remediation': 'Enable the Hyper-V VMMS Admin event log via Event Viewer or wevtutil'
}] }}"
ignore_errors: yes
# ── FR3 · SR 3.2: VM integration services version ─────────────────────────
# Outdated integration services can expose VMs to vulnerabilities.
- block:
- name: "Gather: VM integration services version summary"
ansible.windows.win_shell: |
@(Get-VM | Where-Object { $_.State -eq 'Running' } |
ForEach-Object {
$vmics = Get-VMIntegrationService -VM $_ |
Where-Object { -not $_.Enabled }
[PSCustomObject]@{
VMName = $_.Name
DisabledServices = ($vmics | Select-Object -ExpandProperty Name) -join ', '
DisabledCount = $vmics.Count
}
}) | ConvertTo-Json -AsArray -Compress
register: _ics_versions
- name: "Evaluate: HV-SI-02 — All critical integration services enabled"
ansible.builtin.set_fact:
_ics_json: "{{ _ics_versions.stdout | from_json }}"
- name: "Evaluate: HV-SI-02 — record result"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'HV-SI-02',
'category': 'FR3 — System Integrity',
'requirement': 'SR 3.2 — Malicious Code Protection',
'description': 'All running VMs shall have Hyper-V Integration Services fully enabled',
'passed': (
_ics_json | selectattr('DisabledCount', 'greaterthan', 0) | list | length == 0
),
'expected': 'No disabled integration services on any running VM',
'actual': (
'VMs with disabled services: '
+ (_ics_json | selectattr('DisabledCount', 'greaterthan', 0) | map(attribute='VMName') | join(', ') | default('none', true))
),
'severity': 'medium',
'remediation': 'Enable-VMIntegrationService -VMName <name> -Name "Guest Service Interface"'
}] }}"
ignore_errors: yes
# ── HITL · FR5 · SR 5.2: Physical host network cable review ──────────────
# Confirm management NIC and ICS NIC are physically separate cables/switches.
- block:
- name: "Gather: [HITL] Physical network adapter list"
ansible.windows.win_shell: |
@(Get-NetAdapter | Where-Object { $_.Status -ne 'Not Present' } |
Select-Object Name, InterfaceDescription, Status, LinkSpeed, MacAddress) |
ConvertTo-Json -AsArray -Compress
register: _net_adapters
- name: "Display: [HITL] HV-RDF-HITL-01 — Physical NIC segregation"
ansible.builtin.debug:
msg: |
══════════════════════════════════════════════════════════════
MANUAL REVIEW REQUIRED · HV-RDF-HITL-01 · {{ inventory_hostname }}
══════════════════════════════════════════════════════════════
Requirement : SR 5.2 — Zone Boundary Protection
Check : Physical NICs for ICS vSwitch are on a separate
physical switch from the management/IT network
Detected network adapters
─────────────────────────
{% for nic in _net_adapters.stdout | from_json %}
{{ nic.Name }} | {{ nic.InterfaceDescription }} | {{ nic.Status }} | {{ nic.LinkSpeed }}
{% endfor %}
Verify physically:
- Which adapters are bound to the ICS vSwitch?
- Do those cables go to a different physical switch than management NICs?
══════════════════════════════════════════════════════════════
- name: "Prompt: HV-RDF-HITL-01 — verdict for {{ inventory_hostname }}"
ansible.builtin.pause:
prompt: |
Are ICS vSwitch uplink NICs physically separated (different switch) from management NICs?
Enter verdict [pass / fail / skip]:
register: _hitl_nic_verdict
delegate_to: localhost
- name: "Prompt: HV-RDF-HITL-01 — notes on failure"
ansible.builtin.pause:
prompt: "Describe the cabling gap (e.g. 'ICS and management share same ToR switch'):"
register: _hitl_nic_notes
delegate_to: localhost
when: _hitl_nic_verdict.user_input | lower | trim in ['fail', 'f']
- name: "Evaluate: HV-RDF-HITL-01"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'HV-RDF-HITL-01',
'category': 'FR5 — Restricted Data Flow',
'requirement': 'SR 5.2 — Zone Boundary Protection',
'description': 'ICS vSwitch uplink NICs shall be physically separate from management network NICs',
'passed': (
'skipped' if (_hitl_nic_verdict.user_input | lower | trim in ['skip', 's', ''])
else (_hitl_nic_verdict.user_input | lower | trim in ['pass', 'p'])
),
'expected': 'Separate physical cables and switches for ICS and management traffic',
'actual': 'Adapters found: ' + (_net_adapters.stdout | from_json | map(attribute='Name') | join(', ')),
'severity': 'critical',
'remediation': 'Install dedicated NICs for ICS vSwitch and connect to isolated physical switch',
'reviewer': ansible_user_id,
'notes': (_hitl_nic_notes.user_input | trim) if _hitl_nic_notes is defined else ''
}] }}"
ignore_errors: yes
# ── Generate report ────────────────────────────────────────────────────────
- name: "Generate compliance report"
ansible.builtin.include_tasks: ../library/report.yml
@@ -0,0 +1,207 @@
---
# ══════════════════════════════════════════════════════════════════════════════
# IEC 62443-3-3 SL2 — Linux VM / Server Compliance
#
# Target type : Linux (any distro with systemd + SSH)
# Connection : SSH — native Ansible, no extra collection required
# Privilege : become: yes (sudo) for /etc/shadow, audit rules, sysctl
#
# Inventory group : linux_vms (see assets.yml)
#
# Run:
# ansible-playbook -i assets.yml methodologies/ansible/playbooks/examples/linux_vm.yml -K
# ansible-playbook -i assets.yml methodologies/ansible/playbooks/examples/linux_vm.yml \
# --limit linux-vm-01.example.com -K
#
# What this covers (beyond the core fr1/fr2/fr5 suites):
# FR1: SSH daemon hardening (PermitRootLogin, PasswordAuthentication)
# FR2: Sudo command logging (Defaults log_input/log_output)
# FR3: Kernel integrity — /proc/sys hardening via sysctl
# FR5: IPv4 forwarding disabled (host is not a router)
# Kernel module loading restricted (kmod_blacklist)
# ══════════════════════════════════════════════════════════════════════════════
- name: "IEC 62443-3-3 SL2 — Linux VM Compliance"
hosts: linux_vms
gather_facts: yes
become: yes
vars:
report_dir: "../../reports"
pre_tasks:
- name: "Ensure report directory exists"
ansible.builtin.file:
path: "{{ report_dir }}"
state: directory
mode: "0755"
delegate_to: localhost
run_once: true
tasks:
# ── FR1 · SR 1.1: SSH root login disabled ─────────────────────────────────
- block:
- name: "Gather: SSH PermitRootLogin setting"
ansible.builtin.shell: |
sshd -T 2>/dev/null | grep -i '^permitrootlogin' | awk '{print $2}'
register: _sshd_rootlogin
changed_when: false
- name: "Evaluate: LX-IAC-01 — SSH root login disabled"
ansible.builtin.set_fact:
test_results: "{{ test_results | default([]) + [{
'test_id': 'LX-IAC-01',
'category': 'FR1 — Identification and Authentication Control',
'requirement': 'SR 1.1 — Unique User Identification',
'description': 'SSH shall not permit direct root login',
'passed': (_sshd_rootlogin.stdout | trim | lower in ['no', 'prohibit-password', 'forced-commands-only']),
'expected': 'PermitRootLogin no (or prohibit-password / forced-commands-only)',
'actual': 'PermitRootLogin ' + (_sshd_rootlogin.stdout | trim | default('NOT SET', true)),
'severity': 'high',
'remediation': 'Set PermitRootLogin no in /etc/ssh/sshd_config and restart sshd'
}] }}"
ignore_errors: yes
# ── FR1 · SR 1.4: SSH password authentication disabled ────────────────────
- block:
- name: "Gather: SSH PasswordAuthentication setting"
ansible.builtin.shell: |
sshd -T 2>/dev/null | grep -i '^passwordauthentication' | awk '{print $2}'
register: _sshd_pwauth
changed_when: false
- name: "Evaluate: LX-IAC-02 — SSH key-only authentication"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'LX-IAC-02',
'category': 'FR1 — Identification and Authentication Control',
'requirement': 'SR 1.4 — Identifier Strength',
'description': 'SSH shall use key-based authentication only (PasswordAuthentication no)',
'passed': (_sshd_pwauth.stdout | trim | lower == 'no'),
'expected': 'PasswordAuthentication no',
'actual': 'PasswordAuthentication ' + (_sshd_pwauth.stdout | trim | default('NOT SET', true)),
'severity': 'high',
'remediation': 'Set PasswordAuthentication no in /etc/ssh/sshd_config and restart sshd'
}] }}"
ignore_errors: yes
# ── FR2 · SR 2.1: Sudo command logging ────────────────────────────────────
- block:
- name: "Gather: Sudo log_input / log_output Defaults"
ansible.builtin.shell: |
grep -rh 'Defaults.*log_' /etc/sudoers /etc/sudoers.d/ 2>/dev/null |
grep -v '^\s*#' | tr -s ' ' | head -5
register: _sudo_log
changed_when: false
- name: "Evaluate: LX-UC-01 — Sudo session logging enabled"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'LX-UC-01',
'category': 'FR2 — Use Control',
'requirement': 'SR 2.4 — Audit Log Integrity',
'description': 'Sudo shall log all input and output (Defaults log_input, log_output)',
'passed': (
'log_input' in _sudo_log.stdout and
'log_output' in _sudo_log.stdout
),
'expected': 'Defaults log_input, log_output in /etc/sudoers[.d]',
'actual': _sudo_log.stdout | trim | default('No sudo logging Defaults found', true),
'severity': 'medium',
'remediation': 'Add "Defaults log_input,log_output" to /etc/sudoers'
}] }}"
ignore_errors: yes
# ── FR3 · SR 3.1: Kernel IP forwarding disabled ────────────────────────────
- block:
- name: "Gather: IPv4 forwarding sysctl"
ansible.builtin.command:
cmd: sysctl net.ipv4.ip_forward
register: _ip_forward
changed_when: false
- name: "Evaluate: LX-SI-01 — IP forwarding disabled"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'LX-SI-01',
'category': 'FR3 — System Integrity',
'requirement': 'SR 3.1 — Communication Integrity',
'description': 'Kernel IP forwarding shall be disabled (host is not a router)',
'passed': (_ip_forward.stdout | trim | regex_search('= 0$') is not none),
'expected': 'net.ipv4.ip_forward = 0',
'actual': _ip_forward.stdout | trim,
'severity': 'high',
'remediation': 'Add "net.ipv4.ip_forward = 0" to /etc/sysctl.d/99-ics-hardening.conf and run sysctl -p'
}] }}"
ignore_errors: yes
# ── FR3 · SR 3.1: ICMP redirect acceptance disabled ──────────────────────
- block:
- name: "Gather: ICMP accept_redirects (all interfaces)"
ansible.builtin.shell: |
sysctl net.ipv4.conf.all.accept_redirects net.ipv4.conf.default.accept_redirects 2>/dev/null
register: _redirects
changed_when: false
- name: "Evaluate: LX-SI-02 — ICMP redirects rejected"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'LX-SI-02',
'category': 'FR3 — System Integrity',
'requirement': 'SR 3.1 — Communication Integrity',
'description': 'ICMP redirect acceptance shall be disabled on all interfaces',
'passed': (
_redirects.stdout | regex_findall('= ([01])') | unique | list == ['0']
),
'expected': 'net.ipv4.conf.*.accept_redirects = 0',
'actual': _redirects.stdout | trim,
'severity': 'medium',
'remediation': 'Set net.ipv4.conf.all.accept_redirects = 0 in /etc/sysctl.d/99-ics-hardening.conf'
}] }}"
ignore_errors: yes
# ── FR5 · SR 5.3: Core dump disabled ──────────────────────────────────────
# Core dumps can expose sensitive memory content; disable on ICS nodes.
- block:
- name: "Gather: Core dump hard limit (ulimit -c)"
ansible.builtin.shell: |
grep -rh '^[^#]*hard.*core' /etc/security/limits.conf /etc/security/limits.d/ 2>/dev/null |
awk '{print $NF}' | head -1 || echo "NOT SET"
register: _core_limit
changed_when: false
- name: "Gather: systemd DefaultLimitCORE"
ansible.builtin.shell: |
grep -h 'DefaultLimitCORE' /etc/systemd/system.conf /etc/systemd/user.conf 2>/dev/null |
tail -1 | awk -F= '{print $2}' || echo "NOT SET"
register: _systemd_core
changed_when: false
- name: "Evaluate: LX-RDF-01 — Core dumps disabled"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'LX-RDF-01',
'category': 'FR5 — Restricted Data Flow',
'requirement': 'SR 5.3 — Communication Constraints',
'description': 'Core dumps shall be disabled to prevent memory disclosure',
'passed': (
(_core_limit.stdout | trim in ['0', '']) or
(_systemd_core.stdout | trim == '0')
),
'expected': 'hard core 0 in limits.conf OR DefaultLimitCORE=0 in systemd',
'actual': 'limits.conf: ' + _core_limit.stdout | trim + ' | systemd: ' + _systemd_core.stdout | trim,
'severity': 'medium',
'remediation': 'Add "* hard core 0" to /etc/security/limits.d/99-no-core.conf'
}] }}"
ignore_errors: yes
# ── Generate report ────────────────────────────────────────────────────────
- name: "Generate compliance report"
ansible.builtin.include_tasks: ../library/report.yml
@@ -0,0 +1,248 @@
---
# ══════════════════════════════════════════════════════════════════════════════
# IEC 62443-3-3 SL2 — Microsoft SQL Server Compliance
#
# Target type : SQL Server 2016+ on Windows Server
# Connection : WinRM to the Windows host; SQL checks run via PowerShell
# Invoke-Sqlcmd on the target (no direct TCP/SQL connection
# from the control node required)
# Collections : ansible.windows
# Python pkg : pywinrm
#
# Inventory group : mssql_servers (see assets.yml)
# Add per-host var "mssql_instance" to target a named instance:
# sql-srv-01.example.com mssql_instance=MSSQLSERVER
# sql-srv-02.example.com mssql_instance=SQLEXPRESS
#
# Run:
# ansible-playbook -i assets.yml methodologies/ansible/playbooks/examples/mssql_server.yml
#
# Prerequisites on target:
# - SQLPS or SqlServer PowerShell module (Invoke-Sqlcmd)
# Install-Module SqlServer -Force -AllowClobber
# - WinRM enabled (see windows_server.yml header)
# - Audit user needs: VIEW SERVER STATE, VIEW ANY DEFINITION on SQL Server
# ══════════════════════════════════════════════════════════════════════════════
- name: "IEC 62443-3-3 SL2 — MS SQL Server Compliance"
hosts: mssql_servers
gather_facts: yes
vars:
report_dir: "../../reports"
# Override per-host with mssql_instance inventory variable
_sql_instance: "{{ mssql_instance | default('MSSQLSERVER') }}"
# Invoke-Sqlcmd connection string fragment reused across tasks
_sql_connect: "-ServerInstance . -TrustServerCertificate -ErrorAction Stop"
pre_tasks:
- name: "Ensure report directory exists"
ansible.builtin.file:
path: "{{ report_dir }}"
state: directory
mode: "0755"
delegate_to: localhost
run_once: true
tasks:
# ── FR1 · SR 1.2: SQL authentication mode (Windows-only preferred) ────────
# Mixed-mode (SQL + Windows auth) allows SQL logins with weaker controls.
# IEC 62443 SL2 requires Windows-integrated (Kerberos) authentication.
- block:
- name: "Gather: SQL Server authentication mode"
ansible.windows.win_shell: |
Import-Module SqlServer -ErrorAction SilentlyContinue
$result = Invoke-Sqlcmd {{ _sql_connect }} -Query "
SELECT SERVERPROPERTY('IsIntegratedSecurityOnly') AS WindowsAuthOnly,
SERVERPROPERTY('ServerName') AS ServerName"
[PSCustomObject]@{
WindowsAuthOnly = [int]$result.WindowsAuthOnly
ServerName = $result.ServerName
} | ConvertTo-Json -Compress
register: _sql_auth_mode
- name: "Evaluate: SQL-IAC-01 — Windows-only authentication"
ansible.builtin.set_fact:
_sql_auth: "{{ _sql_auth_mode.stdout | from_json }}"
- name: "Evaluate: SQL-IAC-01 — record result"
ansible.builtin.set_fact:
test_results: "{{ test_results | default([]) + [{
'test_id': 'SQL-IAC-01',
'category': 'FR1 — Identification and Authentication Control',
'requirement': 'SR 1.2 — Software Process and Device Identification',
'description': 'SQL Server shall use Windows Authentication only (not mixed mode)',
'passed': (_sql_auth.WindowsAuthOnly | int == 1),
'expected': 'IsIntegratedSecurityOnly = 1 (Windows auth only)',
'actual': 'WindowsAuthOnly = ' + (_sql_auth.WindowsAuthOnly | string) + ' on ' + _sql_auth.ServerName,
'severity': 'critical',
'remediation': 'SSMS → Server Properties → Security → Server Authentication: Windows Authentication Mode. Requires SQL service restart.'
}] }}"
ignore_errors: yes
# ── FR1 · SR 1.3: SA account disabled ─────────────────────────────────────
# The default "sa" superuser account shall be disabled when Windows auth is used.
- block:
- name: "Gather: SA account enabled/disabled state"
ansible.windows.win_shell: |
Import-Module SqlServer -ErrorAction SilentlyContinue
$result = Invoke-Sqlcmd {{ _sql_connect }} -Query "
SELECT name, is_disabled
FROM sys.server_principals
WHERE name = 'sa' AND type = 'S'"
if ($result) {
[PSCustomObject]@{ is_disabled = [int]$result.is_disabled } | ConvertTo-Json -Compress
} else {
'{"is_disabled": 2}'
}
register: _sa_account
- name: "Evaluate: SQL-IAC-02 — SA account disabled"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'SQL-IAC-02',
'category': 'FR1 — Identification and Authentication Control',
'requirement': 'SR 1.3 — Account Management',
'description': 'The built-in SA (system administrator) login shall be disabled',
'passed': ((_sa_account.stdout | from_json).is_disabled | int != 0),
'expected': 'sa: is_disabled = 1 (or account not found)',
'actual': 'sa: is_disabled = ' + ((_sa_account.stdout | from_json).is_disabled | string),
'severity': 'critical',
'remediation': 'ALTER LOGIN sa DISABLE; -- run in SSMS or sqlcmd'
}] }}"
ignore_errors: yes
# ── FR2 · SR 2.1: xp_cmdshell disabled ────────────────────────────────────
# xp_cmdshell allows OS command execution from SQL; must be disabled.
- block:
- name: "Gather: xp_cmdshell configuration value"
ansible.windows.win_shell: |
Import-Module SqlServer -ErrorAction SilentlyContinue
$result = Invoke-Sqlcmd {{ _sql_connect }} -Query "
SELECT value_in_use
FROM sys.configurations
WHERE name = 'xp_cmdshell'"
[PSCustomObject]@{ value_in_use = [int]$result.value_in_use } | ConvertTo-Json -Compress
register: _xpcmd
- name: "Evaluate: SQL-UC-01 — xp_cmdshell disabled"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'SQL-UC-01',
'category': 'FR2 — Use Control',
'requirement': 'SR 2.1 — Authorization Enforcement',
'description': 'The xp_cmdshell extended stored procedure shall be disabled',
'passed': ((_xpcmd.stdout | from_json).value_in_use | int == 0),
'expected': 'xp_cmdshell value_in_use = 0',
'actual': 'xp_cmdshell value_in_use = ' + ((_xpcmd.stdout | from_json).value_in_use | string),
'severity': 'critical',
'remediation': "EXEC sp_configure 'xp_cmdshell', 0; RECONFIGURE;"
}] }}"
ignore_errors: yes
# ── FR2 · SR 2.8: Login auditing level ────────────────────────────────────
- block:
- name: "Gather: SQL Server audit level (0=None 1=Success 2=Failure 3=Both)"
ansible.windows.win_shell: |
Import-Module SqlServer -ErrorAction SilentlyContinue
$result = Invoke-Sqlcmd {{ _sql_connect }} -Query "
SELECT value_in_use
FROM sys.configurations
WHERE name = 'audit level'"
[PSCustomObject]@{ audit_level = [int]$result.value_in_use } | ConvertTo-Json -Compress
register: _audit_level
- name: "Evaluate: SQL-UC-02 — Login auditing records failures and successes"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'SQL-UC-02',
'category': 'FR2 — Use Control',
'requirement': 'SR 2.8 — Auditable Events',
'description': 'SQL Server login auditing shall record both successful and failed logins (level 3)',
'passed': ((_audit_level.stdout | from_json).audit_level | int == 3),
'expected': 'audit level = 3 (both success and failure)',
'actual': 'audit level = ' + ((_audit_level.stdout | from_json).audit_level | string) + ' (0=None 1=Success 2=Failure 3=Both)',
'severity': 'high',
'remediation': "EXEC xp_instance_regwrite N'HKEY_LOCAL_MACHINE', N'Software\\Microsoft\\MSSQLServer\\MSSQLServer', N'AuditLevel', REG_DWORD, 3"
}] }}"
ignore_errors: yes
# ── FR1 · SR 1.1: Sysadmin role membership review (HITL) ─────────────────
# Automated: lists current sysadmin members. Human reviewer confirms list.
- block:
- name: "Gather: [HITL] Sysadmin role members"
ansible.windows.win_shell: |
Import-Module SqlServer -ErrorAction SilentlyContinue
Invoke-Sqlcmd {{ _sql_connect }} -Query "
SELECT sp.name AS principal_name,
sp.type_desc AS principal_type,
sp.is_disabled
FROM sys.server_role_members rm
JOIN sys.server_principals sp ON rm.member_principal_id = sp.principal_id
WHERE rm.role_principal_id = SUSER_ID('sysadmin')
ORDER BY sp.name" |
Select-Object principal_name, principal_type, is_disabled |
Format-Table -AutoSize | Out-String
register: _sysadmin_members
- name: "Display: [HITL] SQL-IAC-HITL-01 — Sysadmin role membership"
ansible.builtin.debug:
msg: |
══════════════════════════════════════════════════════════════
MANUAL REVIEW REQUIRED · SQL-IAC-HITL-01 · {{ inventory_hostname }}
══════════════════════════════════════════════════════════════
Requirement : SR 1.1 — Unique User Identification
Check : All sysadmin members are authorised and documented
Current sysadmin role members
─────────────────────────────
{{ _sysadmin_members.stdout | indent(1) }}
Review against your authorised administrator list.
Service accounts should NOT be sysadmin unless explicitly required.
══════════════════════════════════════════════════════════════
- name: "Prompt: SQL-IAC-HITL-01 — verdict for {{ inventory_hostname }}"
ansible.builtin.pause:
prompt: |
Do all listed sysadmin members match the authorised administrator list for {{ inventory_hostname }}?
Enter verdict [pass / fail / skip]:
register: _hitl_sysadmin_verdict
delegate_to: localhost
- name: "Prompt: SQL-IAC-HITL-01 — notes on failure"
ansible.builtin.pause:
prompt: "Name the unauthorised principals found:"
register: _hitl_sysadmin_notes
delegate_to: localhost
when: _hitl_sysadmin_verdict.user_input | lower | trim in ['fail', 'f']
- name: "Evaluate: SQL-IAC-HITL-01"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'SQL-IAC-HITL-01',
'category': 'FR1 — Identification and Authentication Control',
'requirement': 'SR 1.1 — Unique User Identification',
'description': 'All sysadmin role members shall be authorised and documented',
'passed': (
'skipped' if (_hitl_sysadmin_verdict.user_input | lower | trim in ['skip', 's', ''])
else (_hitl_sysadmin_verdict.user_input | lower | trim in ['pass', 'p'])
),
'expected': 'Only approved accounts in sysadmin role',
'actual': _sysadmin_members.stdout | trim,
'severity': 'critical',
'remediation': 'EXEC sp_dropsrvrolemember ''<principal>'', ''sysadmin'';',
'reviewer': ansible_user_id,
'notes': (_hitl_sysadmin_notes.user_input | trim) if _hitl_sysadmin_notes is defined else ''
}] }}"
ignore_errors: yes
# ── Generate report ────────────────────────────────────────────────────────
- name: "Generate compliance report"
ansible.builtin.include_tasks: ../library/report.yml
@@ -0,0 +1,301 @@
---
# ══════════════════════════════════════════════════════════════════════════════
# IEC 62443-3-3 SL2 — VMware vSphere / ESXi Compliance
#
# Target type : VMware ESXi hosts managed by vCenter
# Connection : vSphere REST/SOAP API — all tasks run on the Ansible control
# node (delegate_to: localhost) and talk to vCenter.
# No SSH to ESXi hosts is required or used.
# Collections : community.vmware (installed in ansible-node image)
# Python pkg : pyvmomi (installed in ansible-node image)
#
# Inventory group : vmware_esxi (see assets.yml)
# inventory_hostname = ESXi FQDN as known to vCenter
# vcenter_hostname = group var pointing to the vCenter appliance
# vcenter_username = audit@vsphere.local (read-only role sufficient)
# vcenter_password = from Ansible Vault
#
# Run:
# ansible-playbook -i assets.yml methodologies/ansible/playbooks/examples/vmware_vsphere.yml
#
# Read-only vCenter role needed (minimum permissions):
# Host → Configuration → Security Profile → View
# Host → Configuration → Advanced Settings → View
# Global → Settings → View
#
# Note on gather_facts:
# gather_facts is disabled because Ansible cannot SSH into ESXi.
# A setup task on localhost provides ansible_date_time and ansible_user_id
# for the report metadata.
# ══════════════════════════════════════════════════════════════════════════════
- name: "IEC 62443-3-3 SL2 — VMware vSphere ESXi Compliance"
hosts: vmware_esxi
gather_facts: no
vars:
report_dir: "../../reports"
pre_tasks:
- name: "Gather local facts for report timestamp and user"
ansible.builtin.setup:
gather_subset:
- date_time
- user_id
delegate_to: localhost
run_once: true
- name: "Ensure report directory exists"
ansible.builtin.file:
path: "{{ report_dir }}"
state: directory
mode: "0755"
delegate_to: localhost
run_once: true
tasks:
# ── FR1 · SR 1.1: ESXi lockdown mode ──────────────────────────────────────
# Lockdown mode disables direct API access to ESXi; all management must
# go through vCenter. 'normal' = lockdown, 'strict' = lockdown + DCUI off.
- block:
- name: "Gather: ESXi lockdown mode"
community.vmware.vmware_host_lockdown_info:
hostname: "{{ vcenter_hostname }}"
username: "{{ vcenter_username }}"
password: "{{ vcenter_password }}"
esxi_host_name: "{{ inventory_hostname }}"
validate_certs: "{{ vmware_validate_certs | default(false) }}"
delegate_to: localhost
register: _lockdown_info
- name: "Evaluate: VMW-IAC-01 — ESXi lockdown mode enabled"
ansible.builtin.set_fact:
test_results: "{{ test_results | default([]) + [{
'test_id': 'VMW-IAC-01',
'category': 'FR1 — Identification and Authentication Control',
'requirement': 'SR 1.1 — Unique User Identification',
'description': 'ESXi host shall be in lockdown mode (normal or strict)',
'passed': (
_lockdown_info.host_lockdown_info[inventory_hostname].lockdown_mode
in ['normal', 'strict']
),
'expected': 'lockdown_mode = normal or strict',
'actual': 'lockdown_mode = ' + _lockdown_info.host_lockdown_info[inventory_hostname].lockdown_mode,
'severity': 'high',
'remediation': 'vCenter → Host → Configure → Security Profile → Edit Lockdown Mode → Normal'
}] }}"
ignore_errors: yes
# ── FR2 · SR 2.8: NTP configuration ───────────────────────────────────────
# Accurate time is required for audit log integrity and certificate validity.
- block:
- name: "Gather: ESXi NTP servers"
community.vmware.vmware_host_ntp_info:
hostname: "{{ vcenter_hostname }}"
username: "{{ vcenter_username }}"
password: "{{ vcenter_password }}"
cluster_name: "{{ cluster_name | default(omit) }}"
esxi_host_name: "{{ inventory_hostname }}"
validate_certs: "{{ vmware_validate_certs | default(false) }}"
delegate_to: localhost
register: _ntp_info
- name: "Evaluate: VMW-UC-01 — NTP servers configured"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'VMW-UC-01',
'category': 'FR2 — Use Control',
'requirement': 'SR 2.8 — Auditable Events',
'description': 'ESXi host shall have at least one NTP server configured for audit log time accuracy',
'passed': (
_ntp_info.hosts_ntp_info[inventory_hostname].ntp_servers | length > 0
),
'expected': 'At least 1 NTP server configured',
'actual': 'NTP servers: ' + (_ntp_info.hosts_ntp_info[inventory_hostname].ntp_servers | join(', ') | default('none', true)),
'severity': 'high',
'remediation': 'vCenter → Host → Configure → Time Configuration → Add NTP servers and start ntpd service'
}] }}"
ignore_errors: yes
# ── FR5 · SR 5.3: ESXi Shell and SSH services disabled ────────────────────
# In lockdown mode these should be off; explicit check catches misconfig.
- block:
- name: "Gather: ESXi host services (SSH, Shell, etc.)"
community.vmware.vmware_host_service_info:
hostname: "{{ vcenter_hostname }}"
username: "{{ vcenter_username }}"
password: "{{ vcenter_password }}"
esxi_host_name: "{{ inventory_hostname }}"
validate_certs: "{{ vmware_validate_certs | default(false) }}"
delegate_to: localhost
register: _svc_info
- name: "Evaluate: VMW-RDF-01 — ESXi Shell service disabled"
ansible.builtin.set_fact:
_shell_svc: "{{ _svc_info.host_service_info[inventory_hostname]
| selectattr('key', 'equalto', 'TSM')
| list | first | default({}) }}"
- name: "Evaluate: VMW-RDF-01 — record result"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'VMW-RDF-01',
'category': 'FR5 — Restricted Data Flow',
'requirement': 'SR 5.3 — Communication Constraints',
'description': 'ESXi Shell service (TSM) shall be stopped and not set to automatic start',
'passed': (
_shell_svc | length == 0 or
(not _shell_svc.running and _shell_svc.policy != 'on')
),
'expected': 'TSM: running=false, policy != on',
'actual': (
'TSM: running=' + (_shell_svc.running | string)
+ ', policy=' + (_shell_svc.policy | default('unknown'))
) if _shell_svc | length > 0 else 'TSM service not found',
'severity': 'high',
'remediation': 'vCenter → Host → Configure → Security Profile → Services → ESXi Shell: Stop and set Policy to Off'
}] }}"
ignore_errors: yes
- block:
- name: "Evaluate: VMW-RDF-02 — SSH service disabled"
ansible.builtin.set_fact:
_ssh_svc: "{{ _svc_info.host_service_info[inventory_hostname]
| selectattr('key', 'equalto', 'TSM-SSH')
| list | first | default({}) }}"
- name: "Evaluate: VMW-RDF-02 — record result"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'VMW-RDF-02',
'category': 'FR5 — Restricted Data Flow',
'requirement': 'SR 5.3 — Communication Constraints',
'description': 'ESXi SSH service (TSM-SSH) shall be stopped and not set to automatic start',
'passed': (
_ssh_svc | length == 0 or
(not _ssh_svc.running and _ssh_svc.policy != 'on')
),
'expected': 'TSM-SSH: running=false, policy != on',
'actual': (
'TSM-SSH: running=' + (_ssh_svc.running | string)
+ ', policy=' + (_ssh_svc.policy | default('unknown'))
) if _ssh_svc | length > 0 else 'TSM-SSH service not found',
'severity': 'high',
'remediation': 'vCenter → Host → Configure → Security Profile → Services → SSH: Stop and set Policy to Off'
}] }}"
ignore_errors: yes
# ── FR1 · SR 1.5: ESXi advanced config — account lockout ─────────────────
- block:
- name: "Gather: ESXi advanced settings (account lockout policy)"
community.vmware.vmware_host_config_info:
hostname: "{{ vcenter_hostname }}"
username: "{{ vcenter_username }}"
password: "{{ vcenter_password }}"
esxi_host_name: "{{ inventory_hostname }}"
validate_certs: "{{ vmware_validate_certs | default(false) }}"
delegate_to: localhost
register: _adv_config
- name: "Evaluate: VMW-IAC-02 — Account lockout max failures ≤ 5"
ansible.builtin.set_fact:
_max_failures: "{{ _adv_config.hosts_config_info[inventory_hostname]
| dict2items
| selectattr('key', 'equalto', 'Security.AccountLockFailures')
| map(attribute='value') | first | default('NOT SET') }}"
- name: "Evaluate: VMW-IAC-02 — record result"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'VMW-IAC-02',
'category': 'FR1 — Identification and Authentication Control',
'requirement': 'SR 1.11 — Unsuccessful Login Attempts',
'description': 'ESXi account lockout shall trigger after ≤ 5 failed attempts',
'passed': (
_max_failures != 'NOT SET' and
(_max_failures | int > 0) and
(_max_failures | int <= 5)
),
'expected': 'Security.AccountLockFailures between 1 and 5',
'actual': 'Security.AccountLockFailures = ' + (_max_failures | string),
'severity': 'high',
'remediation': 'vCenter → Host → Configure → Advanced System Settings → Security.AccountLockFailures = 5'
}] }}"
ignore_errors: yes
# ── HITL · FR5 · SR 5.2: Zone boundary and vSwitch isolation ─────────────
- block:
- name: "Gather: [HITL] Virtual switch configuration summary"
community.vmware.vmware_vswitch_info:
hostname: "{{ vcenter_hostname }}"
username: "{{ vcenter_username }}"
password: "{{ vcenter_password }}"
esxi_host_name: "{{ inventory_hostname }}"
validate_certs: "{{ vmware_validate_certs | default(false) }}"
delegate_to: localhost
register: _vswitch_info
- name: "Display: [HITL] VMW-RDF-HITL-01 — vSwitch isolation"
ansible.builtin.debug:
msg: |
══════════════════════════════════════════════════════════════
MANUAL REVIEW REQUIRED · VMW-RDF-HITL-01 · {{ inventory_hostname }}
══════════════════════════════════════════════════════════════
Requirement : SR 5.2 — Zone Boundary Protection
Check : ICS/OT VM network traffic is isolated from IT network
Virtual switches on this host
──────────────────────────────
{{ _vswitch_info.hosts_vswitch_info[inventory_hostname] | to_nice_yaml | indent(1) }}
Confirm:
- ICS VMs are on a dedicated vSwitch with no uplink to the IT LAN
- No vSwitch spans both the ICS zone and the IT/corporate zone
- Promiscuous mode and MAC address changes are DISABLED
══════════════════════════════════════════════════════════════
- name: "Prompt: VMW-RDF-HITL-01 — verdict for {{ inventory_hostname }}"
ansible.builtin.pause:
prompt: |
Review the vSwitch layout for {{ inventory_hostname }}.
Are ICS VMs isolated from the IT network at the vSwitch level?
Enter verdict [pass / fail / skip]:
register: _hitl_vswitch_verdict
delegate_to: localhost
- name: "Prompt: VMW-RDF-HITL-01 — notes on failure"
ansible.builtin.pause:
prompt: "Describe the isolation gap (e.g. 'vSwitch0 carries both ICS and IT VLANs'):"
register: _hitl_vswitch_notes
delegate_to: localhost
when: _hitl_vswitch_verdict.user_input | lower | trim in ['fail', 'f']
- name: "Evaluate: VMW-RDF-HITL-01"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'VMW-RDF-HITL-01',
'category': 'FR5 — Restricted Data Flow',
'requirement': 'SR 5.2 — Zone Boundary Protection',
'description': 'ICS virtual machines shall be isolated on dedicated vSwitches with no IT LAN uplink',
'passed': (
'skipped' if (_hitl_vswitch_verdict.user_input | lower | trim in ['skip', 's', ''])
else (_hitl_vswitch_verdict.user_input | lower | trim in ['pass', 'p'])
),
'expected': 'ICS VMs on isolated vSwitch, no shared uplinks with IT network',
'actual': 'See vSwitch evidence in report',
'severity': 'critical',
'remediation': 'Create a dedicated vSwitch for ICS traffic; remove IT LAN uplinks',
'reviewer': ansible_user_id,
'notes': (_hitl_vswitch_notes.user_input | trim) if _hitl_vswitch_notes is defined else ''
}] }}"
ignore_errors: yes
# ── Generate report ────────────────────────────────────────────────────────
- name: "Generate compliance report"
ansible.builtin.include_tasks: ../library/report.yml
@@ -0,0 +1,246 @@
---
# ══════════════════════════════════════════════════════════════════════════════
# IEC 62443-3-3 SL2 — Windows Client / Workstation Compliance
#
# Target type : Windows 10 / 11 workstations, operator HMI stations
# Connection : WinRM — same as windows_server.yml
# Collections : ansible.windows
# Python pkg : pywinrm
#
# Inventory group : windows_clients (see assets.yml)
#
# Run:
# ansible-playbook -i assets.yml methodologies/ansible/playbooks/examples/windows_client.yml
#
# Notes:
# - Operator HMI workstations often run as local accounts (not domain-joined).
# The domain_check HITL test flags this for reviewer attention.
# - BitLocker status requires the Hyper-V / TPM chip; VMs may legitimately
# fail WIN-CLI-02 if they are not TPM-enabled.
# ══════════════════════════════════════════════════════════════════════════════
- name: "IEC 62443-3-3 SL2 — Windows Client Compliance"
hosts: windows_clients
gather_facts: yes
vars:
report_dir: "../../reports"
pre_tasks:
- name: "Ensure report directory exists"
ansible.builtin.file:
path: "{{ report_dir }}"
state: directory
mode: "0755"
delegate_to: localhost
run_once: true
tasks:
# ── FR1 · SR 1.3: Domain membership ───────────────────────────────────────
# Domain-joined workstations inherit password and lockout policy via GPO.
# Standalone / local-account machines need local policy verified separately.
- block:
- name: "Gather: Domain membership status"
ansible.windows.win_shell: |
$cs = Get-WmiObject -Class Win32_ComputerSystem
[PSCustomObject]@{
PartOfDomain = $cs.PartOfDomain
Domain = if ($cs.PartOfDomain) { $cs.Domain } else { 'WORKGROUP' }
} | ConvertTo-Json -Compress
register: _domain_info
- name: "Evaluate: WIN-CLI-01 — Workstation is domain-joined"
ansible.builtin.set_fact:
test_results: "{{ test_results | default([]) + [{
'test_id': 'WIN-CLI-01',
'category': 'FR1 — Identification and Authentication Control',
'requirement': 'SR 1.3 — Account Management',
'description': 'Workstations shall be domain-joined for centralised identity management',
'passed': ((_domain_info.stdout | from_json).PartOfDomain | bool),
'expected': 'PartOfDomain = true',
'actual': 'Domain = ' + (_domain_info.stdout | from_json).Domain,
'severity': 'medium',
'remediation': 'Join workstation to Active Directory domain'
}] }}"
ignore_errors: yes
# ── FR3 · SR 3.4: BitLocker full-disk encryption ──────────────────────────
- block:
- name: "Gather: BitLocker protection status on OS drive"
ansible.windows.win_shell: |
$vol = Get-BitLockerVolume -MountPoint $env:SystemDrive -ErrorAction SilentlyContinue
if ($vol) {
[PSCustomObject]@{
ProtectionStatus = $vol.ProtectionStatus.ToString()
EncryptionMethod = $vol.EncryptionMethod.ToString()
VolumeStatus = $vol.VolumeStatus.ToString()
} | ConvertTo-Json -Compress
} else {
'{"ProtectionStatus":"NotFound","EncryptionMethod":"None","VolumeStatus":"None"}'
}
register: _bitlocker
- name: "Evaluate: WIN-CLI-02 — BitLocker enabled on OS drive"
ansible.builtin.set_fact:
_bl: "{{ _bitlocker.stdout | from_json }}"
- name: "Evaluate: WIN-CLI-02 — record result"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'WIN-CLI-02',
'category': 'FR3 — System Integrity',
'requirement': 'SR 3.4 — Software and Information Integrity',
'description': 'OS drive shall be protected with BitLocker full-disk encryption',
'passed': (_bl.ProtectionStatus == 'On'),
'expected': 'ProtectionStatus = On',
'actual': 'ProtectionStatus = ' + _bl.ProtectionStatus + ', Method = ' + _bl.EncryptionMethod,
'severity': 'high',
'remediation': 'Enable-BitLocker -MountPoint C: -RecoveryPasswordProtector -EncryptionMethod XtsAes256'
}] }}"
ignore_errors: yes
# ── FR2 · SR 2.5: Screen lock timeout (registry-based check) ─────────────
# GPO sets HKLM\Software\Policies\Microsoft\Windows\Personalization\ScreenSaveTimeOut
# or the legacy HKCU path. We check the machine-level policy value.
- block:
- name: "Gather: Screen saver timeout registry value (machine policy)"
ansible.windows.win_reg_stat:
path: HKLM:\Software\Policies\Microsoft\Windows\Control Panel\Desktop
name: ScreenSaveTimeOut
register: _screensaver_timeout
- name: "Gather: Screen saver active registry value"
ansible.windows.win_reg_stat:
path: HKLM:\Software\Policies\Microsoft\Windows\Control Panel\Desktop
name: ScreenSaveActive
register: _screensaver_active
- name: "Evaluate: WIN-CLI-03 — Screen lock timeout ≤ 900 seconds"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'WIN-CLI-03',
'category': 'FR2 — Use Control',
'requirement': 'SR 2.5 — Session Lock',
'description': 'Workstation shall lock after ≤ 900 seconds (15 min) of inactivity',
'passed': (
_screensaver_active.exists and
(_screensaver_active.value | string == '1') and
_screensaver_timeout.exists and
(_screensaver_timeout.value | int > 0) and
(_screensaver_timeout.value | int <= 900)
),
'expected': 'ScreenSaveActive=1, ScreenSaveTimeOut ≤ 900',
'actual': (
'ScreenSaveActive=' + (_screensaver_active.value | default('NOT SET') | string)
+ ', ScreenSaveTimeOut=' + (_screensaver_timeout.value | default('NOT SET') | string)
),
'severity': 'medium',
'remediation': 'Apply GPO: Computer Configuration → Admin Templates → Control Panel → Personalization → Screen saver timeout = 900'
}] }}"
ignore_errors: yes
# ── FR5 · SR 5.1: Windows Firewall on Public profile ─────────────────────
# Clients in the ICS zone should enforce the Public profile firewall.
- block:
- name: "Gather: Public firewall profile state and default inbound action"
ansible.windows.win_shell: |
Get-NetFirewallProfile -Name Public |
Select-Object Name, Enabled, DefaultInboundAction |
ConvertTo-Json -Compress
register: _pub_fw
- name: "Evaluate: WIN-CLI-04 — Public firewall profile blocks inbound"
ansible.builtin.set_fact:
_pub_fw_json: "{{ _pub_fw.stdout | from_json }}"
- name: "Evaluate: WIN-CLI-04 — record result"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'WIN-CLI-04',
'category': 'FR5 — Restricted Data Flow',
'requirement': 'SR 5.1 — Network Segmentation',
'description': 'Public firewall profile shall be enabled with default inbound Block',
'passed': (
_pub_fw_json.Enabled | bool and
_pub_fw_json.DefaultInboundAction == 'Block'
),
'expected': 'Public profile: Enabled=True, DefaultInboundAction=Block',
'actual': 'Public: Enabled=' + (_pub_fw_json.Enabled | string) + ', DefaultInboundAction=' + _pub_fw_json.DefaultInboundAction,
'severity': 'high',
'remediation': 'Set-NetFirewallProfile -Name Public -Enabled True -DefaultInboundAction Block'
}] }}"
ignore_errors: yes
# ── HITL · FR1 · SR 1.3: Physical access and USB port controls ────────────
# Cannot be verified remotely; requires physical inspection or policy review.
- block:
- name: "Gather: [HITL] USB storage device policy registry"
ansible.windows.win_reg_stat:
path: HKLM:\SYSTEM\CurrentControlSet\Services\UsbStor
name: Start
register: _usb_stor
- name: "Display: [HITL] WIN-CLI-HITL-01 — USB storage and physical access"
ansible.builtin.debug:
msg: |
══════════════════════════════════════════════════════════════
MANUAL REVIEW REQUIRED · WIN-CLI-HITL-01 · {{ inventory_hostname }}
══════════════════════════════════════════════════════════════
Requirement : SR 1.3 — Account Management
Check : Physical USB ports and removable media controls
Registry evidence (UsbStor Start value):
HKLM\SYSTEM\CurrentControlSet\Services\UsbStor\Start
Value: {{ _usb_stor.value | default('KEY NOT FOUND') }}
(4 = disabled, 3 = manual/enabled, key absent = check GPO)
Also confirm:
- Unused USB ports physically blocked or disabled in BIOS
- No unauthorised removable media found on the workstation
══════════════════════════════════════════════════════════════
- name: "Prompt: WIN-CLI-HITL-01 — verdict for {{ inventory_hostname }}"
ansible.builtin.pause:
prompt: |
USB storage is {{ 'DISABLED (Start=4)' if _usb_stor.value | default(3) | int == 4 else 'ENABLED or UNKNOWN' }} by registry policy.
After physical confirmation, does this workstation satisfy SR 1.3 USB/removable media controls?
Enter verdict [pass / fail / skip]:
register: _hitl_usb_verdict
delegate_to: localhost
- name: "Prompt: WIN-CLI-HITL-01 — notes on failure"
ansible.builtin.pause:
prompt: "Describe finding (e.g. 'USB port active, no media policy applied'):"
register: _hitl_usb_notes
delegate_to: localhost
when: _hitl_usb_verdict.user_input | lower | trim in ['fail', 'f']
- name: "Evaluate: WIN-CLI-HITL-01"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'WIN-CLI-HITL-01',
'category': 'FR1 — Identification and Authentication Control',
'requirement': 'SR 1.3 — Account Management',
'description': 'USB storage and removable media shall be disabled or physically controlled',
'passed': (
'skipped' if (_hitl_usb_verdict.user_input | lower | trim in ['skip', 's', ''])
else (_hitl_usb_verdict.user_input | lower | trim in ['pass', 'p'])
),
'expected': 'USB storage disabled (UsbStor Start=4) AND physical ports secured',
'actual': 'UsbStor Start = ' + (_usb_stor.value | default('NOT SET') | string),
'severity': 'high',
'remediation': 'Set HKLM\SYSTEM\CurrentControlSet\Services\UsbStor\Start = 4 via GPO, and physically block or tape ports',
'reviewer': ansible_user_id,
'notes': (_hitl_usb_notes.user_input | trim) if _hitl_usb_notes is defined else ''
}] }}"
ignore_errors: yes
# ── Generate report ────────────────────────────────────────────────────────
- name: "Generate compliance report"
ansible.builtin.include_tasks: ../library/report.yml
@@ -0,0 +1,204 @@
---
# ══════════════════════════════════════════════════════════════════════════════
# IEC 62443-3-3 SL2 — Windows Server Compliance
#
# Target type : Windows Server 2016 / 2019 / 2022
# Connection : WinRM (HTTP :5985 or HTTPS :5986)
# Collections : ansible.windows (ships with Ansible)
# Python pkg : pywinrm (installed in ansible-node image)
# Privilege : No become required — WinRM user needs local admin rights
#
# Inventory group : windows_servers (see assets.yml)
#
# Run:
# ansible-playbook -i assets.yml methodologies/ansible/playbooks/examples/windows_server.yml
#
# WinRM quick-enable on target (run as Administrator):
# winrm quickconfig -q
# winrm set winrm/config/service/auth '@{Basic="true"}'
# winrm set winrm/config/service '@{AllowUnencrypted="true"}'
# # For production: use HTTPS and Kerberos transport instead
#
# Vault usage (recommended for passwords):
# ansible-vault encrypt_string 'MyPassword' --name ansible_password
# ══════════════════════════════════════════════════════════════════════════════
- name: "IEC 62443-3-3 SL2 — Windows Server Compliance"
hosts: windows_servers
gather_facts: yes
vars:
report_dir: "../../reports"
pre_tasks:
- name: "Ensure report directory exists"
ansible.builtin.file:
path: "{{ report_dir }}"
state: directory
mode: "0755"
delegate_to: localhost
run_once: true
tasks:
# ── FR1 · SR 1.5: Minimum password length ─────────────────────────────────
# Uses win_security_policy — no PowerShell shell-out needed.
- block:
- name: "Gather: Minimum password length (security policy)"
ansible.windows.win_security_policy:
section: System Access
key: MinimumPasswordLength
register: _min_pw_len
- name: "Evaluate: WIN-IAC-01 — Password minimum length ≥ 14"
ansible.builtin.set_fact:
test_results: "{{ test_results | default([]) + [{
'test_id': 'WIN-IAC-01',
'category': 'FR1 — Identification and Authentication Control',
'requirement': 'SR 1.5 — Authenticator Strength',
'description': 'Windows local password policy minimum length shall be ≥ 14 characters',
'passed': (_min_pw_len.value | int >= 14),
'expected': 'MinimumPasswordLength ≥ 14',
'actual': 'MinimumPasswordLength = ' + (_min_pw_len.value | string),
'severity': 'high',
'remediation': 'Computer Configuration → Windows Settings → Security Settings → Account Policies → Password Policy → Minimum password length: 14'
}] }}"
ignore_errors: yes
# ── FR1 · SR 1.11: Account lockout threshold ──────────────────────────────
- block:
- name: "Gather: Account lockout threshold"
ansible.windows.win_security_policy:
section: System Access
key: LockoutBadCount
register: _lockout_count
- name: "Evaluate: WIN-IAC-02 — Account lockout ≤ 5 attempts"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'WIN-IAC-02',
'category': 'FR1 — Identification and Authentication Control',
'requirement': 'SR 1.11 — Unsuccessful Login Attempts',
'description': 'Account lockout shall trigger after ≤ 5 failed login attempts',
'passed': (
(_lockout_count.value | int > 0) and
(_lockout_count.value | int <= 5)
),
'expected': 'LockoutBadCount between 1 and 5',
'actual': 'LockoutBadCount = ' + (_lockout_count.value | string),
'severity': 'high',
'remediation': 'Set Account lockout threshold to 5 in Local Security Policy'
}] }}"
ignore_errors: yes
# ── FR2 · SR 2.8: Audit policy — logon events ─────────────────────────────
# Uses win_audit_policy_system — no auditpol.exe shell-out needed.
- block:
- name: "Gather: Audit policy — Logon subcategory"
ansible.windows.win_audit_policy_system:
subcategory: Logon
register: _audit_logon
- name: "Evaluate: WIN-UC-01 — Logon events audited"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'WIN-UC-01',
'category': 'FR2 — Use Control',
'requirement': 'SR 2.8 — Auditable Events',
'description': 'Logon/logoff events shall be audited (success and failure)',
'passed': (_audit_logon.auditing_mode == 'success and failure'),
'expected': 'Logon: success and failure',
'actual': 'Logon: ' + _audit_logon.auditing_mode,
'severity': 'high',
'remediation': 'auditpol /set /subcategory:"Logon" /success:enable /failure:enable'
}] }}"
ignore_errors: yes
# ── FR2 · SR 2.8: Audit policy — privilege use ────────────────────────────
- block:
- name: "Gather: Audit policy — Sensitive Privilege Use"
ansible.windows.win_audit_policy_system:
subcategory: Sensitive Privilege Use
register: _audit_privuse
- name: "Evaluate: WIN-UC-02 — Privilege use audited"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'WIN-UC-02',
'category': 'FR2 — Use Control',
'requirement': 'SR 2.8 — Auditable Events',
'description': 'Sensitive privilege use (SeDebugPrivilege, SeTcbPrivilege, etc.) shall be audited',
'passed': (_audit_privuse.auditing_mode in ['success and failure', 'failure']),
'expected': 'Sensitive Privilege Use: failure (at minimum)',
'actual': 'Sensitive Privilege Use: ' + _audit_privuse.auditing_mode,
'severity': 'medium',
'remediation': 'auditpol /set /subcategory:"Sensitive Privilege Use" /failure:enable'
}] }}"
ignore_errors: yes
# ── FR5 · SR 5.1: Windows Firewall enabled on all profiles ────────────────
# PowerShell ConvertTo-Json + Ansible from_json filter avoids regex parsing.
- block:
- name: "Gather: Windows Firewall profile states"
ansible.windows.win_shell: |
@(Get-NetFirewallProfile | Select-Object Name, Enabled, DefaultInboundAction) |
ConvertTo-Json -Compress
register: _fw_profiles
- name: "Evaluate: WIN-RDF-01 — Firewall enabled on all profiles"
ansible.builtin.set_fact:
_fw_json: "{{ _fw_profiles.stdout | from_json }}"
- name: "Evaluate: WIN-RDF-01 — record result"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'WIN-RDF-01',
'category': 'FR5 — Restricted Data Flow',
'requirement': 'SR 5.1 — Network Segmentation',
'description': 'Windows Firewall shall be enabled on Domain, Private, and Public profiles',
'passed': (_fw_json | selectattr('Enabled', 'equalto', true) | list | length == 3),
'expected': 'All 3 firewall profiles Enabled = True',
'actual': _fw_json | map(attribute='Name') | zip(_fw_json | map(attribute='Enabled')) | list | string,
'severity': 'critical',
'remediation': 'Set-NetFirewallProfile -All -Enabled True'
}] }}"
ignore_errors: yes
# ── FR5 · SR 5.3: Telnet / FTP / RDP services ─────────────────────────────
# Uses win_service_info — typed return dict, no regex needed.
- block:
- name: "Gather: Telnet service state"
ansible.windows.win_service_info:
name: TlntSvr
register: _telnet_svc
- name: "Evaluate: WIN-RDF-02 — Telnet service disabled"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'WIN-RDF-02',
'category': 'FR5 — Restricted Data Flow',
'requirement': 'SR 5.3 — Communication Constraints',
'description': 'The Telnet Server service (TlntSvr) shall be absent or disabled',
'passed': (
_telnet_svc.services | length == 0 or
_telnet_svc.services[0].start_mode == 'disabled'
),
'expected': 'TlntSvr: absent or start_mode=disabled',
'actual': (
'TlntSvr: state=' + _telnet_svc.services[0].state
+ ', start_mode=' + _telnet_svc.services[0].start_mode
) if _telnet_svc.services | length > 0 else 'TlntSvr: not installed',
'severity': 'critical',
'remediation': 'Stop-Service TlntSvr; Set-Service TlntSvr -StartupType Disabled'
}] }}"
ignore_errors: yes
# ── Generate report ────────────────────────────────────────────────────────
- name: "Generate compliance report"
ansible.builtin.include_tasks: ../library/report.yml
@@ -0,0 +1,62 @@
---
# library/report.yml
#
# Included last in every playbook run by site.yml. Aggregates the
# shared test_results[] list into a structured JSON compliance report.
#
# What it does:
# 1. Assembles __report dict with meta, summary, by_category,
# by_severity, failures, and the full results list
# 2. Prints a boxed summary to the Ansible console
# 3. Writes JSON to artifacts/raw/ansible/<hostname>-<date>.json
# (delegated to localhost so reports land on the control node)
#
# The JSON schema is documented in README.md § "JSON Output Schema".
- name: "REPORT: Assemble compliance report"
ansible.builtin.set_fact:
__report: "{{ {
'meta': {
'standard': 'IEC 62443-3-3',
'security_level': 'SL2',
'target': inventory_hostname,
'timestamp': ansible_date_time.iso8601,
'executed_by': ansible_user_id
},
'summary': {
'total': test_results | length,
'passed': test_results | selectattr('passed', 'equalto', true) | list | length,
'failed': test_results | selectattr('passed', 'equalto', false) | list | length,
'review': test_results | selectattr('passed', 'equalto', 'review') | list | length,
'skipped': test_results | selectattr('passed', 'equalto', 'skipped') | list | length
},
'by_category': test_results | groupby('category') | list,
'by_severity': {
'critical': test_results | selectattr('severity', 'equalto', 'critical') | list,
'high': test_results | selectattr('severity', 'equalto', 'high') | list,
'medium': test_results | selectattr('severity', 'equalto', 'medium') | list,
'low': test_results | selectattr('severity', 'equalto', 'low') | list
},
'failures': test_results | selectattr('passed', 'equalto', false) | list,
'results': test_results
} }}"
- name: "REPORT: Display summary to console"
ansible.builtin.debug:
msg: |
╔══════════════════════════════════════════════════════════════╗
║ IEC 62443-3-3 SL2 Compliance Report — {{ inventory_hostname }}
╠══════════════════════════════════════════════════════════════╣
║ Total: {{ __report.summary.total }} Passed: {{ __report.summary.passed }} Failed: {{ __report.summary.failed }} Skipped: {{ __report.summary.skipped }}
╚══════════════════════════════════════════════════════════════╝
{% for f in __report.failures %}
✗ {{ f.test_id }}: {{ f.description }}
Expected: {{ f.expected }}
Actual: {{ f.actual }}
{% endfor %}
- name: "REPORT: Write JSON to local file"
ansible.builtin.copy:
content: "{{ __report | to_nice_json(indent=2) }}"
dest: "{{ report_dir }}/{{ inventory_hostname }}-{{ ansible_date_time.date }}.json"
delegate_to: localhost
+65
View File
@@ -0,0 +1,65 @@
---
# site.yml — IEC 62443-3-3 SL2 Compliance Validation
#
# Entry point for the compliance testing framework. This playbook:
#
# 1. Runs against all hosts in the inventory
# 2. Gathers facts first (ansible_date_time, ansible_user_id, etc.)
# 3. Executes each FR suite as a named task inside an ignore_errors block
# 4. Invokes library/report.yml to aggregate test_results[] and write JSON
#
# Usage:
# ansible-playbook -i assets.yml methodologies/ansible/playbooks/site.yml --limit <host> -K
# ./methodologies/ansible/run.sh --limit <host> -K
#
# Adding a new suite:
# Copy the block below, change the name and include_tasks path:
#
# - name: "Suite: FRN — Category Name"
# block:
# - ansible.builtin.include_tasks: suites/frN_category.yml
# ignore_errors: yes
#
# Variables:
# report_dir: Where raw JSON reports land (default: artifacts/raw/ansible)
- name: "IEC 62443-3-3 SL2 Compliance — All Targets"
hosts: all
gather_facts: yes
become: yes
vars:
report_dir: "./artifacts/raw/ansible"
pre_tasks:
- name: "Ensure report directory exists"
ansible.builtin.file:
path: "{{ report_dir }}"
state: directory
mode: "0755"
delegate_to: localhost
run_once: true
tasks:
# ─────── FR1: Identification & Authentication ──────────────
- name: "Suite: FR1 — Identification & Authentication Control"
block:
- ansible.builtin.include_tasks: suites/fr1_auth.yml
ignore_errors: yes
# ─────── FR2: Use Control ─────────────────────────────────
- name: "Suite: FR2 — Use Control"
block:
- ansible.builtin.include_tasks: suites/fr2_use_control.yml
ignore_errors: yes
# ─────── FR5: Restricted Data Flow ────────────────────────
- name: "Suite: FR5 — Restricted Data Flow"
block:
- ansible.builtin.include_tasks: suites/fr5_data_flow.yml
ignore_errors: yes
# TODO: Add FR3, FR4, FR6, FR7 suites
# ─────── REPORT ───────────────────────────────────────────
- name: "Generate compliance report"
ansible.builtin.include_tasks: library/report.yml
@@ -0,0 +1,291 @@
---
# suites/fr1_auth.yml
#
# FR1 — Identification and Authentication Control (IAC)
# IEC 62443-3-3 SL2 requirements:
# SR 1.1 Unique user identification
# SR 1.3 Account management (no default/unused accounts)
# SR 1.4 Identifier strength (no empty passwords)
# SR 1.5 Authenticator strength (password policy)
# SR 1.7 Password lifetime/aging
# SR 1.11 Unsuccessful login attempts (account lockout)
#
# Pattern: Every test is wrapped in a block + ignore_errors.
# "Gather" tasks collect facts; "Evaluate" tasks judge pass/fail
# and append to test_results[].
# ── SR 1.1: Unique User Identification ──────────────────────────
- block:
- name: "Gather: Scan for duplicate UIDs"
ansible.builtin.shell: |
awk -F: '{print $3}' /etc/passwd | sort -n | uniq -d
register: _dup_uid
changed_when: false
- name: "Evaluate: IAC-01"
ansible.builtin.set_fact:
test_results: "{{ test_results | default([]) + [{
'test_id': 'IAC-01',
'category': 'FR1 - Identification and Authentication Control',
'requirement': 'SR 1.1 — Unique User Identification',
'description': 'Every user account shall have a unique UID',
'passed': (_dup_uid.stdout | trim | length == 0),
'expected': 'No duplicate UIDs in /etc/passwd',
'actual': (_dup_uid.stdout | trim | default('None found', true)),
'severity': 'high',
'remediation': 'Change duplicate UIDs with: usermod -u <new-uid> <user>'
}] }}"
ignore_errors: yes
# ── SR 1.3: No default/unused accounts ──────────────────────────
- block:
- name: "Gather: Check for well-known default accounts"
ansible.builtin.shell: |
for acct in games news gopher ftp nobody; do
if grep -q "^${acct}:" /etc/passwd; then
echo "$acct"
fi
done
register: _default_accts
changed_when: false
- name: "Evaluate: IAC-02"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'IAC-02',
'category': 'FR1 - Identification and Authentication Control',
'requirement': 'SR 1.3 — Account Management',
'description': 'Default/unnecessary system accounts shall be removed or disabled',
'passed': (_default_accts.stdout | trim | length == 0),
'expected': 'No default accounts present',
'actual': (_default_accts.stdout | trim | default('None found', true)),
'severity': 'medium',
'remediation': 'Delete default accounts: userdel <account>'
}] }}"
ignore_errors: yes
- block:
- name: "Gather: Check accounts with valid shells that have never logged in"
ansible.builtin.shell: |
# Accounts with real shells that show no login records
join -t: -1 1 -2 1 \
<(grep -E ':(/bin/(ba)?sh|/usr/bin/(ba)?sh)$' /etc/passwd | sort) \
<(lastlog | tail -n +2 | awk '{print $1, $NF}' | sort) 2>/dev/null |
awk '$NF == "**Never" {print $1 " (shell: " $7 ")"}' || true
args:
executable: /bin/bash
register: _unused
changed_when: false
- name: "Evaluate: IAC-03"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'IAC-03',
'category': 'FR1 - Identification and Authentication Control',
'requirement': 'SR 1.3 — Account Management',
'description': 'Human user accounts that have never logged in shall be reviewed',
'passed': (_unused.stdout | trim | length == 0),
'expected': 'No unused human accounts with valid shells',
'actual': (_unused.stdout | trim | default('None found', true)),
'severity': 'low',
'remediation': 'Lock stale accounts: usermod -L <user>'
}] }}"
ignore_errors: yes
# ── SR 1.4: No empty passwords ──────────────────────────────────
- block:
- name: "Gather: Check /etc/shadow for empty password fields"
ansible.builtin.shell: |
awk -F: '($2 == "" || $2 == "!" || $2 ~ /^\$[156]\$/) {next}
$2 == "!!" || $2 == "*" {next}
{print $1 " (field: " $2 ")"}' /etc/shadow
register: _empty_pw
changed_when: false
- name: "Evaluate: IAC-04"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'IAC-04',
'category': 'FR1 - Identification and Authentication Control',
'requirement': 'SR 1.4 — Identifier Strength',
'description': 'No account shall have an empty or trivially-weak password hash',
'passed': (_empty_pw.stdout | trim | length == 0),
'expected': 'All accounts have proper password hashes',
'actual': (_empty_pw.stdout | trim | default('All accounts OK', true)),
'severity': 'critical',
'remediation': 'Set a password or lock the account: passwd -l <user>'
}] }}"
ignore_errors: yes
# ── SR 1.5: Password complexity (via pwquality / PAM) ───────────
- 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
- 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
- block:
- name: "Gather: Check pwquality dcredit/ocredit/ucredit/lcredit"
ansible.builtin.shell: |
for p in dcredit ucredit lcredit ocredit minclass; do
val=$(grep -E "^\s*${p}\s*=" /etc/security/pwquality.conf 2>/dev/null | tail -1 | awk -F= '{print $2}' | tr -d ' ')
echo "${p}=${val:-NOT SET}"
done
register: _pwquality
changed_when: false
- name: "Evaluate: IAC-06"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'IAC-06',
'category': 'FR1 - Identification and Authentication Control',
'requirement': 'SR 1.5 — Authenticator Strength',
'description': 'Password shall require at least 1 of each character class',
'passed': (
(_pwquality.stdout | regex_search('dcredit\s*=\s*-?1') and
_pwquality.stdout | regex_search('ucredit\s*=\s*-?1') and
_pwquality.stdout | regex_search('lcredit\s*=\s*-?1') and
_pwquality.stdout | regex_search('ocredit\s*=\s*-?1'))
or
(_pwquality.stdout | regex_search('minclass\s*=\s*[3-4]'))
),
'expected': 'At least 3 character classes required',
'actual': _pwquality.stdout | trim,
'severity': 'medium',
'remediation': 'Set dcredit=-1, ucredit=-1, lcredit=-1, ocredit=-1 in pwquality.conf'
}] }}"
ignore_errors: yes
# ── SR 1.7: Password aging (max days) ───────────────────────────
- block:
- name: "Gather: Check PASS_MAX_DAYS in login.defs"
ansible.builtin.shell: |
grep '^\s*PASS_MAX_DAYS' /etc/login.defs | awk '{print $2}'
register: _max_days
changed_when: false
- name: "Evaluate: IAC-07"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'IAC-07',
'category': 'FR1 - Identification and Authentication Control',
'requirement': 'SR 1.7 — Password Lifetime',
'description': 'Password maximum age shall be ≤ 90 days',
'passed': (
_max_days.stdout | trim | regex_search('^[0-9]+$') and
(_max_days.stdout | trim | int > 0) and
(_max_days.stdout | trim | int <= 90)
),
'expected': 'PASS_MAX_DAYS ≤ 90',
'actual': 'PASS_MAX_DAYS=' + (_max_days.stdout | trim | default('NOT SET', true)),
'severity': 'medium',
'remediation': 'Set PASS_MAX_DAYS 90 in /etc/login.defs'
}] }}"
ignore_errors: yes
- block:
- name: "Gather: Check PASS_MIN_DAYS in login.defs"
ansible.builtin.shell: |
grep '^\s*PASS_MIN_DAYS' /etc/login.defs | awk '{print $2}'
register: _min_days
changed_when: false
- name: "Evaluate: IAC-08"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'IAC-08',
'category': 'FR1 - Identification and Authentication Control',
'requirement': 'SR 1.7 — Password Lifetime',
'description': 'Password minimum change interval shall be ≥ 1 day',
'passed': (_min_days.stdout | trim | int >= 1),
'expected': 'PASS_MIN_DAYS ≥ 1',
'actual': 'PASS_MIN_DAYS=' + (_min_days.stdout | trim | default('NOT SET', true)),
'severity': 'low',
'remediation': 'Set PASS_MIN_DAYS 1 in /etc/login.defs'
}] }}"
ignore_errors: yes
# ── SR 1.11: Account lockout ────────────────────────────────────
- block:
- name: "Gather: Check pam_tally2 or pam_faillock configuration"
ansible.builtin.shell: |
if grep -q 'pam_faillock\.so' /etc/pam.d/common-auth 2>/dev/null; then
grep 'deny=' /etc/pam.d/common-auth | grep -oP 'deny=\K[0-9]+' || echo "0"
elif grep -q 'pam_tally2\.so' /etc/pam.d/common-auth 2>/dev/null; then
grep 'deny=' /etc/pam.d/common-auth | grep -oP 'deny=\K[0-9]+' || echo "0"
else
echo "LOCKOUT NOT CONFIGURED"
fi
register: _lockout
changed_when: false
- name: "Evaluate: IAC-09"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'IAC-09',
'category': 'FR1 - Identification and Authentication Control',
'requirement': 'SR 1.11 — Unsuccessful Login Attempts',
'description': 'Account lockout shall trigger after ≤ 5 failed attempts',
'passed': (
_lockout.stdout | trim | regex_search('^[0-9]+$') and
(_lockout.stdout | trim | int > 0) and
(_lockout.stdout | trim | int <= 5)
),
'expected': 'Account lockout configured with deny ≤ 5',
'actual': _lockout.stdout | trim,
'severity': 'high',
'remediation': 'Configure pam_faillock in /etc/pam.d/common-auth: deny=5'
}] }}"
ignore_errors: yes
# ── SR 1.5 (cont): Password history ─────────────────────────────
- block:
- name: "Gather: Check password history in PAM"
ansible.builtin.shell: |
grep -E 'pam_pwhistory\.so|remember=' /etc/pam.d/common-password 2>/dev/null |
grep -oP 'remember=\K[0-9]+' || echo "NOT SET"
register: _pw_history
changed_when: false
- name: "Evaluate: IAC-10"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'IAC-10',
'category': 'FR1 - Identification and Authentication Control',
'requirement': 'SR 1.6 — Password History',
'description': 'Password history shall prevent reuse of last 5+ passwords',
'passed': (
_pw_history.stdout | trim | regex_search('^[0-9]+$') and
(_pw_history.stdout | trim | int >= 5)
),
'expected': 'pam_pwhistory remember ≥ 5',
'actual': 'remember=' + (_pw_history.stdout | trim),
'severity': 'medium',
'remediation': 'Add "remember=5" to pam_pwhistory.so in /etc/pam.d/common-password'
}] }}"
ignore_errors: yes
@@ -0,0 +1,165 @@
---
# suites/fr2_use_control.yml
#
# FR2 — Use Control (UC)
# IEC 62443-3-3 SL2 requirements:
# SR 2.1 Authorization enforcement (sudo/privilege separation)
# SR 2.4 Audit log integrity
# SR 2.5 Session lock (terminal idle timeout)
# SR 2.8 Auditable events (auditd active)
# SR 2.9 Audit storage capacity
# ── SR 2.1: Sudo privilege separation ───────────────────────────
- block:
- name: "Gather: Check for unrestricted sudo access (NOPASSWD ALL)"
ansible.builtin.shell: |
grep -r 'NOPASSWD.*ALL' /etc/sudoers /etc/sudoers.d/ 2>/dev/null |
grep -v '^\s*#' | grep -v '^$' || true
register: _nopasswd
changed_when: false
- name: "Evaluate: UC-01"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'UC-01',
'category': 'FR2 — Use Control',
'requirement': 'SR 2.1 — Authorization Enforcement',
'description': 'No user shall have unrestricted NOPASSWD sudo access to all commands',
'passed': (_nopasswd.stdout | trim | length == 0),
'expected': 'No NOPASSWD ALL entries in sudoers',
'actual': (_nopasswd.stdout | trim | default('None found', true)),
'severity': 'high',
'remediation': 'Restrict sudo rules to specific commands and require authentication'
}] }}"
ignore_errors: yes
- block:
- name: "Gather: Check sudoers file permissions"
ansible.builtin.stat:
path: /etc/sudoers
register: _sudoers_stat
- name: "Evaluate: UC-02"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'UC-02',
'category': 'FR2 — Use Control',
'requirement': 'SR 2.1 — Authorization Enforcement',
'description': '/etc/sudoers shall be owned by root:root with mode 0440',
'passed': (
_sudoers_stat.stat.exists and
_sudoers_stat.stat.pw_name == 'root' and
_sudoers_stat.stat.gr_name == 'root' and
_sudoers_stat.stat.mode == '0440'
),
'expected': 'root:root 0440',
'actual': (_sudoers_stat.stat.pw_name + ':' + _sudoers_stat.stat.gr_name + ' ' + _sudoers_stat.stat.mode) if _sudoers_stat.stat.exists else 'FILE NOT FOUND',
'severity': 'critical',
'remediation': 'chown root:root /etc/sudoers && chmod 0440 /etc/sudoers'
}] }}"
ignore_errors: yes
# ── SR 2.5: Session lock / terminal timeout ─────────────────────
- block:
- name: "Gather: Check TMOUT setting in /etc/profile or /etc/bash.bashrc"
ansible.builtin.shell: |
for f in /etc/profile /etc/bash.bashrc /etc/profile.d/*.sh; do
[ -f "$f" ] && grep -h 'TMOUT=' "$f" 2>/dev/null
done | tail -1 | grep -oP 'TMOUT=\K[0-9]+' || echo "NOT SET"
register: _tmout
changed_when: false
- name: "Evaluate: UC-03"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'UC-03',
'category': 'FR2 — Use Control',
'requirement': 'SR 2.5 — Session Lock',
'description': 'Interactive shell sessions shall timeout after ≤ 900 seconds of inactivity',
'passed': (
_tmout.stdout | trim | regex_search('^[0-9]+$') and
(_tmout.stdout | trim | int > 0) and
(_tmout.stdout | trim | int <= 900)
),
'expected': 'TMOUT set between 1-900 seconds',
'actual': 'TMOUT=' + (_tmout.stdout | trim),
'severity': 'medium',
'remediation': 'Add "readonly TMOUT=900" to /etc/profile'
}] }}"
ignore_errors: yes
# ── SR 2.4: Audit log integrity (immutability) ──────────────────
- block:
- name: "Gather: Check if auditd immutable mode is configured"
ansible.builtin.shell: |
grep -c '^\s*-e\s*2' /etc/audit/rules.d/*.rules /etc/audit/audit.rules 2>/dev/null || echo "0"
register: _audit_immutable
changed_when: false
- name: "Evaluate: UC-04"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'UC-04',
'category': 'FR2 — Use Control',
'requirement': 'SR 2.4 — Audit Log Integrity',
'description': 'Audit configuration shall be immutable (-e 2)',
'passed': (_audit_immutable.stdout | trim | int > 0),
'expected': 'audit.rules contains -e 2',
'actual': ('Immutable rules found: ' + _audit_immutable.stdout) if (_audit_immutable.stdout | trim | int > 0) else 'Immutable flag NOT set',
'severity': 'high',
'remediation': 'Add "-e 2" to /etc/audit/audit.rules (requires reboot)'
}] }}"
ignore_errors: yes
# ── SR 2.8: Auditable events (auditd running) ───────────────────
- block:
- name: "Gather: Check auditd service status"
ansible.builtin.shell: |
systemctl is-active auditd 2>/dev/null || echo "inactive"
register: _auditd_active
changed_when: false
- name: "Evaluate: UC-05"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'UC-05',
'category': 'FR2 — Use Control',
'requirement': 'SR 2.8 — Auditable Events',
'description': 'The audit daemon (auditd) shall be running and enabled',
'passed': (_auditd_active.stdout | trim == 'active'),
'expected': 'auditd service is active',
'actual': 'auditd is ' + (_auditd_active.stdout | trim),
'severity': 'high',
'remediation': 'systemctl enable --now auditd'
}] }}"
ignore_errors: yes
- block:
- name: "Gather: Check if critical system calls are audited"
ansible.builtin.shell: |
rules=0
for call in execve execveat mount umount2 creat open openat truncate ftruncate; do
grep -rq "$call" /etc/audit/rules.d/ 2>/dev/null && rules=$((rules+1))
done
echo "$rules"
register: _audit_sc
changed_when: false
- name: "Evaluate: UC-06"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'UC-06',
'category': 'FR2 — Use Control',
'requirement': 'SR 2.8 — Auditable Events',
'description': 'Critical system events (exec, mount, file modifications) shall be audited',
'passed': (_audit_sc.stdout | trim | int >= 4),
'expected': '≥ 4 critical syscall types audited',
'actual': (_audit_sc.stdout | trim) + ' of 9 critical syscall types audited',
'severity': 'medium',
'remediation': 'Add audit rules for execve, mount, creat/open/openat, truncate/ftruncate'
}] }}"
ignore_errors: yes
@@ -0,0 +1,119 @@
---
# suites/fr5_data_flow.yml
#
# FR5 — Restricted Data Flow (RDF)
# IEC 62443-3-3 SL2 requirements:
# SR 5.1 Network segmentation (firewall active, boundary filtering)
# SR 5.2 Zone boundary protection
# SR 5.3 General-purpose communication constraints (unnecessary services)
# ── SR 5.1: Host-based firewall active ──────────────────────────
- block:
- name: "Gather: Check iptables/nftables rules exist"
ansible.builtin.shell: |
if command -v nft >/dev/null 2>&1; then
nft list ruleset 2>/dev/null | grep -c 'accept\|drop\|reject' || echo "0"
elif command -v iptables >/dev/null 2>&1; then
iptables -L -n 2>/dev/null | grep -c 'ACCEPT\|DROP\|REJECT' || echo "0"
else
echo "NO FIREWALL"
fi
register: _fw_rules
changed_when: false
- name: "Evaluate: RDF-01"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'RDF-01',
'category': 'FR5 — Restricted Data Flow',
'requirement': 'SR 5.1 — Network Segmentation',
'description': 'A host-based firewall with active rules shall be present',
'passed': (
_fw_rules.stdout | trim != 'NO FIREWALL' and
(_fw_rules.stdout | trim | int > 0)
),
'expected': 'Firewall with active filter rules',
'actual': ('Rules found: ' + (_fw_rules.stdout | trim)) if (_fw_rules.stdout | trim != 'NO FIREWALL' and (_fw_rules.stdout | trim | int > 0)) else (_fw_rules.stdout | trim),
'severity': 'critical',
'remediation': 'Install and configure iptables/nftables with default-deny inbound policy'
}] }}"
ignore_errors: yes
- block:
- name: "Gather: Check default inbound policy"
ansible.builtin.shell: |
if command -v nft >/dev/null 2>&1; then
nft list chain inet filter INPUT 2>/dev/null | grep policy | awk '{print $NF}' || echo "UNKNOWN"
elif command -v iptables >/dev/null 2>&1; then
iptables -L INPUT -n 2>/dev/null | head -1 | awk '{print $4}' | tr -d ')'
else
echo "NO FIREWALL"
fi
register: _default_policy
changed_when: false
- name: "Evaluate: RDF-02"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'RDF-02',
'category': 'FR5 — Restricted Data Flow',
'requirement': 'SR 5.1 — Network Segmentation',
'description': 'Default inbound firewall policy shall be DROP',
'passed': (_default_policy.stdout | trim | lower == 'drop'),
'expected': 'Default inbound policy is DROP',
'actual': 'Default policy: ' + (_default_policy.stdout | trim),
'severity': 'high',
'remediation': 'Set default INPUT policy to DROP: iptables -P INPUT DROP'
}] }}"
ignore_errors: yes
# ── SR 5.3: Unnecessary services ────────────────────────────────
- block:
- name: "Gather: Check for unnecessary network services"
ansible.builtin.shell: |
# Services commonly flagged as unnecessary on ICS/OT systems
for svc in telnet.socket rsh.socket rexec.socket rlogin.socket \
ftp.service vsftpd.service xinetd.service; do
systemctl is-active "$svc" 2>/dev/null | grep -q 'active' && echo "$svc"
done
register: _bad_services
changed_when: false
- name: "Evaluate: RDF-03"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'RDF-03',
'category': 'FR5 — Restricted Data Flow',
'requirement': 'SR 5.3 — Communication Constraints',
'description': 'Insecure network services (telnet, rsh, ftp) shall be disabled',
'passed': (_bad_services.stdout | trim | length == 0),
'expected': 'No insecure legacy services active',
'actual': (_bad_services.stdout | trim | default('None', true)),
'severity': 'critical',
'remediation': 'Disable: systemctl disable --now <service>'
}] }}"
ignore_errors: yes
- block:
- name: "Gather: Check listening TCP ports"
ansible.builtin.shell: |
ss -tlnp 2>/dev/null | awk 'NR>1 {print $4}' | awk -F: '{print $NF}' | sort -n | uniq | tr '\n' ' '
register: _listening
changed_when: false
- name: "Evaluate: RDF-04"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'RDF-04',
'category': 'FR5 — Restricted Data Flow',
'requirement': 'SR 5.3 — Communication Constraints',
'description': 'Only necessary TCP ports shall be listening (document in exception list)',
'passed': 'review',
'expected': 'Documented and approved port list',
'actual': 'Listening ports: ' + (_listening.stdout | trim | default('Unable to determine', true)),
'severity': 'low',
'remediation': 'Review and disable unnecessary listening services'
}] }}"
ignore_errors: yes
@@ -0,0 +1,70 @@
---
# ══════════════════════════════════════════════════════════════════════
# TEMPLATE: Automated Shell-Based Test
# ══════════════════════════════════════════════════════════════════════
#
# The standard gather → evaluate pattern used throughout this framework.
# Copy this block into the appropriate FR suite file (suites/frN_*.yml)
# and fill in all UPPERCASE placeholders.
#
# How to use:
# 1. Copy the block below into suites/frN_category.yml
# 2. Replace every UPPERCASE placeholder
# 3. Write your gather shell command to produce meaningful stdout
# 4. Write the 'passed' Jinja2 expression that evaluates the result
# 5. Set severity: critical | high | medium | low
#
# Pass/fail expression patterns:
#
# # Empty output means no findings (good):
# 'passed': (_result.stdout | trim | length == 0),
#
# # Numeric threshold (value must exist and be within range):
# 'passed': (
# _result.stdout | trim | regex_search('^[0-9]+$') and
# (_result.stdout | trim | int > 0) and
# (_result.stdout | trim | int <= 90)
# ),
#
# # Extract a number from labelled output (e.g. "minlen = 14"):
# 'passed': (
# (_result.stdout | regex_search('label\s*=\s*(\d+)', '\1')
# | default(['0'], true) | first | int) >= 14
# ),
#
# # String match:
# 'passed': (_result.stdout | trim == 'expected_value'),
#
# # Specific value is absent:
# 'passed': ('dangerous_string' not in _result.stdout),
#
# ══════════════════════════════════════════════════════════════════════
# ── SUITE_ID: SHORT_DESCRIPTION ─────────────────────────────────────
- block:
- name: "Gather: DESCRIBE_WHAT_IS_COLLECTED"
ansible.builtin.shell: |
# Replace with your data collection command.
# Guidelines:
# - Use grep/awk/cut to narrow output to only the relevant data.
# - Produce empty stdout when no finding exists (makes 'passed' easy).
# - Exit 0 always; let Ansible evaluate the output, not the exit code.
echo "replace_me"
register: _result
changed_when: false
- name: "Evaluate: TEST_ID"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'TEST_ID',
'category': 'FR_NUMBER — CATEGORY_NAME',
'requirement': 'SR X.Y — REQUIREMENT_NAME',
'description': 'One-line description of what is being checked',
'passed': (_result.stdout | trim | length == 0),
'expected': 'What a passing system looks like',
'actual': (_result.stdout | trim | default('OK', true)),
'severity': 'high',
'remediation': 'Exact command or configuration change to fix this finding'
}] }}"
ignore_errors: yes
@@ -0,0 +1,73 @@
---
# ══════════════════════════════════════════════════════════════════════
# TEMPLATE: File Permission / Ownership Check
# ══════════════════════════════════════════════════════════════════════
#
# Uses ansible.builtin.stat — no shell command needed.
# Prefer this over shelling out to stat(1) for file attribute checks.
# The stat module returns a structured dict with typed values, which
# makes the 'passed' expression straightforward and readable.
#
# Useful stat attributes:
# stat.exists — bool: file is present
# stat.mode — string: octal permissions, e.g. '0640'
# stat.pw_name — string: owning user name, e.g. 'root'
# stat.gr_name — string: owning group name, e.g. 'shadow'
# stat.size — int: file size in bytes
# stat.isreg — bool: is a regular file
# stat.isdir — bool: is a directory
# stat.islnk — bool: is a symlink
#
# Common 'passed' expression patterns:
#
# # File exists with exact owner/group/mode:
# 'passed': (
# _stat.stat.exists and
# _stat.stat.pw_name == 'root' and
# _stat.stat.gr_name == 'root' and
# _stat.stat.mode == '0640'
# ),
#
# # File must NOT exist:
# 'passed': not _stat.stat.exists,
#
# # File must be a regular file (not a symlink) with tight permissions:
# 'passed': (
# _stat.stat.exists and
# _stat.stat.isreg and
# not _stat.stat.islnk and
# _stat.stat.mode in ['0400', '0440', '0600']
# ),
#
# ══════════════════════════════════════════════════════════════════════
# ── SUITE_ID: SHORT_DESCRIPTION ─────────────────────────────────────
- block:
- name: "Gather: Stat /path/to/file"
ansible.builtin.stat:
path: /path/to/file
register: _stat
- name: "Evaluate: TEST_ID"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'TEST_ID',
'category': 'FR_NUMBER — CATEGORY_NAME',
'requirement': 'SR X.Y — REQUIREMENT_NAME',
'description': '/path/to/file shall be owned by root:root with mode 0640',
'passed': (
_stat.stat.exists and
_stat.stat.pw_name == 'root' and
_stat.stat.gr_name == 'root' and
_stat.stat.mode == '0640'
),
'expected': 'root:root 0640',
'actual': (
(_stat.stat.pw_name + ':' + _stat.stat.gr_name + ' ' + _stat.stat.mode)
if _stat.stat.exists else 'FILE NOT FOUND'
),
'severity': 'high',
'remediation': 'chown root:root /path/to/file && chmod 0640 /path/to/file'
}] }}"
ignore_errors: yes
@@ -0,0 +1,107 @@
---
# ══════════════════════════════════════════════════════════════════════
# TEMPLATE: Human-in-the-Loop (HITL) Test
# ══════════════════════════════════════════════════════════════════════
#
# Use when a control cannot be evaluated automatically and requires a
# human reviewer to observe evidence and record a verdict.
#
# Examples of controls that need HITL:
# - Physical access controls / badge logs
# - Operator training records
# - Network diagram review
# - Custom application security configuration
# - Vendor-specific proprietary interfaces
#
# How it works (Ansible-native):
# 1. Gather tasks run against the remote host as normal.
# 2. ansible.builtin.debug displays the evidence on the console.
# 3. ansible.builtin.pause with 'delegate_to: localhost' prompts the
# reviewer on the control node, regardless of the remote target.
# For multi-host runs, the prompt fires once per host so each
# target gets an independent human verdict.
# 4. The reviewer's verdict (pass/fail/skip) and any notes are
# captured in the test_results[] record alongside the raw evidence.
#
# 'passed' field values used here:
# true — reviewer entered 'pass' or 'p'
# false — reviewer entered 'fail' or 'f'
# 'skipped' — reviewer pressed Enter or entered 'skip'/'s'
#
# All three values are handled by the report renderers.
#
# ══════════════════════════════════════════════════════════════════════
# ── SUITE_ID: SHORT_DESCRIPTION ─────────────────────────────────────
- block:
# ── Gather evidence from the remote host ────────────────────────
- name: "Gather: [HITL] DESCRIBE_WHAT_IS_COLLECTED"
ansible.builtin.shell: |
# Collect the evidence the reviewer needs to make a decision.
# Keep output focused: show only what is relevant to the check.
echo "Replace with your evidence-gathering command"
register: _hitl_evidence
changed_when: false
# ── Present the evidence to the reviewer (appears in Ansible log) ─
- name: "Display: [HITL] TEST_ID — evidence for review"
ansible.builtin.debug:
msg: |
══════════════════════════════════════════════════════════════
MANUAL REVIEW REQUIRED · TEST_ID · {{ inventory_hostname }}
══════════════════════════════════════════════════════════════
Requirement : SR X.Y — REQUIREMENT_NAME
Check : DESCRIPTION
Evidence
────────
{{ _hitl_evidence.stdout | default('(no output collected)') | indent(1) }}
══════════════════════════════════════════════════════════════
# ── Reviewer enters verdict on the control node ─────────────────
# delegate_to: localhost ensures the prompt appears locally even
# when this playbook targets remote hosts.
- name: "Prompt: TEST_ID — verdict for {{ inventory_hostname }}"
ansible.builtin.pause:
prompt: |
Review the evidence above for {{ inventory_hostname }}.
Does it satisfy SR X.Y — REQUIREMENT_NAME?
Enter verdict [pass / fail / skip]:
register: _hitl_verdict
delegate_to: localhost
# ── Capture reviewer notes on failure ───────────────────────────
# This task only runs when the verdict is fail/f, so _hitl_notes
# may be undefined for pass/skip results. The evaluate task below
# uses 'is defined' to handle this safely.
- name: "Prompt: TEST_ID — notes for {{ inventory_hostname }} (fail only)"
ansible.builtin.pause:
prompt: "Describe the gap or finding (required for audit trail):"
register: _hitl_notes
delegate_to: localhost
when: _hitl_verdict.user_input | lower | trim in ['fail', 'f']
# ── Evaluate: record verdict + evidence in test_results[] ───────
- name: "Evaluate: TEST_ID"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'TEST_ID',
'category': 'FR_NUMBER — CATEGORY_NAME',
'requirement': 'SR X.Y — REQUIREMENT_NAME',
'description': 'DESCRIPTION',
'passed': (
'skipped'
if (_hitl_verdict.user_input | lower | trim in ['skip', 's', ''])
else (_hitl_verdict.user_input | lower | trim in ['pass', 'p'])
),
'expected': 'Reviewer confirmed control is in place',
'actual': _hitl_evidence.stdout | trim | default('(no evidence collected)', true),
'severity': 'SEVERITY',
'remediation': 'REMEDIATION',
'reviewer': ansible_user_id,
'notes': (_hitl_notes.user_input | trim)
if _hitl_notes is defined
else ''
}] }}"
ignore_errors: yes
@@ -0,0 +1,107 @@
---
# ══════════════════════════════════════════════════════════════════════
# TEMPLATE: Service State Check
# ══════════════════════════════════════════════════════════════════════
#
# Uses ansible.builtin.service_facts — no shell command needed.
# service_facts gathers all service states into ansible_facts.services
# as a dict keyed by service name. Prefer this over shelling out to
# systemctl for any service-related check.
#
# IMPORTANT — run service_facts ONCE per suite, not once per test.
# Put this gather task at the TOP of your suite file:
#
# - name: "Gather: Load all service states"
# ansible.builtin.service_facts:
#
# Then each test block below can query ansible_facts.services without
# running additional commands.
#
# Service dict structure (ansible_facts.services['sshd.service']):
# name: 'sshd.service'
# state: 'running' | 'stopped' | 'failed' | 'inactive'
# status: 'enabled' | 'disabled' | 'masked' | 'static' | 'unknown'
#
# Common 'passed' expression patterns:
#
# # Service must be running and enabled:
# 'passed': (
# ansible_facts.services['sshd.service'] is defined and
# ansible_facts.services['sshd.service'].state == 'running' and
# ansible_facts.services['sshd.service'].status == 'enabled'
# ),
#
# # Service must NOT be running (insecure service check):
# 'passed': (
# ansible_facts.services['telnet.socket'] is not defined or
# ansible_facts.services['telnet.socket'].state != 'running'
# ),
#
# # Any of several insecure services must all be absent/inactive:
# 'passed': (
# ['telnet.socket', 'rsh.socket', 'ftp.service']
# | map('extract', ansible_facts.services)
# | select('defined')
# | selectattr('state', 'equalto', 'running')
# | list | length == 0
# ),
#
# ══════════════════════════════════════════════════════════════════════
# ── Put this ONCE at the top of the suite file ───────────────────────
#
# - name: "Gather: Load all service states (suite-wide)"
# ansible.builtin.service_facts:
#
# ── Per-test blocks below ────────────────────────────────────────────
# ── SUITE_ID: Service must be running ───────────────────────────────
- block:
- name: "Evaluate: TEST_ID — SERVICE_NAME is running and enabled"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'TEST_ID',
'category': 'FR_NUMBER — CATEGORY_NAME',
'requirement': 'SR X.Y — REQUIREMENT_NAME',
'description': 'SERVICE_NAME shall be running and enabled at boot',
'passed': (
ansible_facts.services['SERVICE_NAME.service'] is defined and
ansible_facts.services['SERVICE_NAME.service'].state == 'running' and
ansible_facts.services['SERVICE_NAME.service'].status == 'enabled'
),
'expected': 'SERVICE_NAME: state=running, status=enabled',
'actual': (
'state=' + ansible_facts.services['SERVICE_NAME.service'].state
+ ', status=' + ansible_facts.services['SERVICE_NAME.service'].status
) if ansible_facts.services['SERVICE_NAME.service'] is defined
else 'SERVICE_NAME.service: not found in service facts',
'severity': 'high',
'remediation': 'systemctl enable --now SERVICE_NAME'
}] }}"
ignore_errors: yes
# ── SUITE_ID: Insecure service must NOT be running ──────────────────
- block:
- name: "Evaluate: TEST_ID — INSECURE_SERVICE_NAME is not running"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'TEST_ID',
'category': 'FR_NUMBER — CATEGORY_NAME',
'requirement': 'SR X.Y — REQUIREMENT_NAME',
'description': 'INSECURE_SERVICE_NAME shall be disabled and not running',
'passed': (
ansible_facts.services['INSECURE_SERVICE_NAME.service'] is not defined or
ansible_facts.services['INSECURE_SERVICE_NAME.service'].state != 'running'
),
'expected': 'INSECURE_SERVICE_NAME: absent or not running',
'actual': (
'state=' + ansible_facts.services['INSECURE_SERVICE_NAME.service'].state
) if ansible_facts.services['INSECURE_SERVICE_NAME.service'] is defined
else 'not installed',
'severity': 'critical',
'remediation': 'systemctl disable --now INSECURE_SERVICE_NAME'
}] }}"
ignore_errors: yes
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env bash
# Run the Ansible executor locally through the common artifact pipeline.
#
# Environment:
# INVENTORY inventory path (default: ./assets.yml)
# LIMIT Ansible host pattern (default: all)
# ARTIFACT_ROOT output root (default: ./artifacts)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPOSITORY_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
INVENTORY="${INVENTORY:-$REPOSITORY_DIR/assets.yml}"
PLAYBOOK="${PLAYBOOK:-$SCRIPT_DIR/playbooks/site.yml}"
LIMIT="${LIMIT:-all}"
ARTIFACT_ROOT="${ARTIFACT_ROOT:-$REPOSITORY_DIR/artifacts}"
RAW_DIR="$ARTIFACT_ROOT/raw/ansible"
NORMALIZED_DIR="$ARTIFACT_ROOT/normalized"
RENDERED_DIR="$ARTIFACT_ROOT/rendered"
mkdir -p "$RAW_DIR" "$NORMALIZED_DIR" "$RENDERED_DIR"
ANSIBLE_ARGS=(
-i "$INVENTORY"
"$PLAYBOOK"
--limit "$LIMIT"
--extra-vars "report_dir=$RAW_DIR"
)
if [ -n "${ANSIBLE_VARS_FILE:-}" ]; then
if [ ! -f "$ANSIBLE_VARS_FILE" ]; then
echo "ANSIBLE_VARS_FILE does not point to a readable file" >&2
exit 1
fi
ANSIBLE_ARGS+=(--extra-vars "@$ANSIBLE_VARS_FILE")
fi
if [ -n "${ANSIBLE_PRIVATE_KEY_FILE:-}" ]; then
if [ ! -f "$ANSIBLE_PRIVATE_KEY_FILE" ]; then
echo "ANSIBLE_PRIVATE_KEY_FILE does not point to a readable file" >&2
exit 1
fi
ANSIBLE_ARGS+=(--private-key "$ANSIBLE_PRIVATE_KEY_FILE")
fi
echo "[1/3] Running Ansible tests"
ansible-playbook "${ANSIBLE_ARGS[@]}" "$@"
LATEST_JSON=$(find "$RAW_DIR" -maxdepth 1 -type f -name '*.json' -printf '%T@ %p\n' \
| sort -nr \
| head -n 1 \
| cut -d' ' -f2-)
if [ -z "$LATEST_JSON" ]; then
echo "No Ansible JSON report was generated" >&2
exit 1
fi
TARGET_NAME="$(basename "$LATEST_JSON" .json)"
NORMALIZED_JSON="$NORMALIZED_DIR/ansible-$TARGET_NAME.json"
echo "[2/3] Normalizing $(basename "$LATEST_JSON")"
python3 "$SCRIPT_DIR/scripts/normalize.py" \
"$LATEST_JSON" \
"$NORMALIZED_JSON" \
--schema "$REPOSITORY_DIR/schemas/test-report.schema.json"
echo "[3/3] Rendering reports"
python3 "$REPOSITORY_DIR/scripts/render-normalized.py" \
"$NORMALIZED_JSON" \
--output-dir "$RENDERED_DIR"
echo "Artifacts written to $ARTIFACT_ROOT"
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# Build and optionally push the Ansible test image.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
METHODOLOGY_DIR="$(dirname "$SCRIPT_DIR")"
REPOSITORY_DIR="$(cd "$METHODOLOGY_DIR/../.." && pwd)"
IMAGE_NAME="${IMAGE_NAME:-ansible}"
REGISTRY="${REGISTRY:-}"
TAG="${TAG:-latest}"
FULL_IMAGE="${REGISTRY:+${REGISTRY}/}${IMAGE_NAME}:${TAG}"
docker build \
--file "$METHODOLOGY_DIR/Dockerfile" \
--tag "$FULL_IMAGE" \
"$REPOSITORY_DIR"
if [ "${1:-}" = "--push" ]; then
if [ -z "$REGISTRY" ]; then
echo "REGISTRY is required with --push" >&2
exit 1
fi
docker push "$FULL_IMAGE"
fi
echo "Built $FULL_IMAGE"
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""Convert the existing Ansible compliance report to the common report schema."""
import argparse
import json
import os
from pathlib import Path
from typing import Any
from jsonschema import Draft202012Validator, FormatChecker
STATUS_MAP = {True: "passed", False: "failed", "review": "review", "skipped": "skipped"}
def environment(name: str, fallback: str = "") -> str:
return os.environ.get(name, fallback)
def normalize(source: dict[str, Any], raw_path: Path) -> dict[str, Any]:
source_meta = source["meta"]
results = []
for result in source.get("results", []):
requirement = str(result.get("requirement", ""))
standards = []
if requirement:
standards.append(
{
"framework": source_meta.get("standard", "IEC 62443-3-3"),
"version": source_meta.get("security_level", ""),
"control": requirement,
}
)
results.append(
{
"id": str(result["test_id"]),
"title": str(result.get("description", result["test_id"])),
"description": requirement,
"status": STATUS_MAP.get(result.get("passed"), "error"),
"severity": str(result.get("severity", "info")).lower(),
"category": str(result.get("category", "")),
"expected": str(result.get("expected", "")),
"observed": str(result.get("actual", "")),
"remediation": str(result.get("remediation", "")),
"standards": standards,
"evidence": [{"type": "text", "name": "Ansible observation", "value": str(result.get("actual", ""))}],
}
)
counts = dict.fromkeys(("passed", "failed", "errors", "skipped", "review"), 0)
for result in results:
counter = "errors" if result["status"] == "error" else result["status"]
counts[counter] += 1
scored = counts["passed"] + counts["failed"]
return {
"schema_version": "1.0.0",
"run": {
"id": environment("CI_PIPELINE_ID", source_meta.get("timestamp", "local")),
"started_at": source_meta["timestamp"],
"source": "gitlab" if environment("CI") else "local",
"pipeline_url": environment("CI_PIPELINE_URL"),
"commit_sha": environment("CI_COMMIT_SHA"),
},
"project": {
"id": environment("TEST_PROJECT_ID", "local"),
"name": environment("TEST_PROJECT_NAME", "Local test project"),
"environment": environment("TEST_ENVIRONMENT", "test"),
"customer": environment("TEST_CUSTOMER"),
"location": environment("TEST_LOCATION"),
},
"tool": {"id": "ansible", "name": "Ansible", "adapter_version": "1.0.0"},
"target": {"id": source_meta["target"], "type": "managed_host", "groups": []},
"summary": {
"total": len(results),
**counts,
"score": round(counts["passed"] / scored * 100, 2) if scored else 0,
},
"results": results,
"raw_artifacts": [str(raw_path)],
}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("input", type=Path)
parser.add_argument("output", type=Path)
parser.add_argument("--schema", type=Path, default=Path("schemas/test-report.schema.json"))
args = parser.parse_args()
source = json.loads(args.input.read_text(encoding="utf-8"))
report = normalize(source, args.input)
schema = json.loads(args.schema.read_text(encoding="utf-8"))
Draft202012Validator(schema, format_checker=FormatChecker()).validate(report)
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
print(f"Normalized {len(report['results'])} Ansible results to {args.output}")
return 0
if __name__ == "__main__":
raise SystemExit(main())