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
+5 -3
View File
@@ -25,6 +25,8 @@ Thumbs.db
*.retry
ansible.log
# Report output (committed sample is explicit)
reports/*.json
!reports/sample-output.json
# Local and CI-generated artifacts
artifacts/
# Local reference material not consumed by the pipeline
source_documents/
+289
View File
@@ -0,0 +1,289 @@
stages:
- validate
- build
- platform
- test
- normalize
- report
default:
interruptible: true
retry:
max: 1
when:
- runner_system_failure
- stuck_or_timeout_failure
variables:
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
ARTIFACT_ROOT: "$CI_PROJECT_DIR/artifacts"
KUBE_NAMESPACE: "test-automation"
ANSIBLE_IMAGE: "$CI_REGISTRY_IMAGE/ansible:$CI_COMMIT_SHA"
DEMO_TARGET_IMAGE: "$CI_REGISTRY_IMAGE/demo-target:$CI_COMMIT_SHA"
TARGET_ENVIRONMENT:
value: "test"
description: "GitLab environment scope used to select credentials"
.python_job:
image: python:3.13-alpine
cache:
key: python-ci-v1
paths:
- .cache/pip/
before_script:
- python3 -m pip install --disable-pip-version-check -r requirements-ci.txt
validate:assets:
extends: .python_job
stage: validate
script:
- python3 scripts/parse-assets.py assets.yml --expected-environment "$TARGET_ENVIRONMENT" --dotenv artifacts/metadata.env --matrix artifacts/asset-matrix.json
artifacts:
expire_in: 30 days
reports:
dotenv: artifacts/metadata.env
paths:
- artifacts/asset-matrix.json
validate:python:
extends: .python_job
stage: validate
script:
- python3 -m compileall -q scripts methodologies/ansible/scripts methodologies/zap/scripts
- python3 methodologies/ansible/scripts/normalize.py methodologies/ansible/fixtures/sample-output.json artifacts/normalized/sample-ansible.json
artifacts:
expire_in: 7 days
paths:
- artifacts/normalized/sample-ansible.json
.kaniko_build:
stage: build
image:
name: gcr.io/kaniko-project/executor:v1.23.2-debug
entrypoint: [""]
before_script:
- mkdir -p /kaniko/.docker
- printf '{"auths":{"%s":{"username":"%s","password":"%s"}}}' "$CI_REGISTRY" "$CI_REGISTRY_USER" "$CI_REGISTRY_PASSWORD" > /kaniko/.docker/config.json
build:ansible:
extends: .kaniko_build
script:
- /kaniko/executor --context "$CI_PROJECT_DIR" --dockerfile "$CI_PROJECT_DIR/methodologies/ansible/Dockerfile" --destination "$ANSIBLE_IMAGE"
rules:
- if: '$RUN_DEMO == "true"'
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
changes:
- methodologies/ansible/**/*
build:demo-target:
extends: .kaniko_build
script:
- /kaniko/executor --context "$CI_PROJECT_DIR" --dockerfile "$CI_PROJECT_DIR/targets/ubuntu-weak/Dockerfile" --destination "$DEMO_TARGET_IMAGE"
rules:
- if: '$RUN_DEMO == "true"'
platform:verify:
stage: platform
image: alpine:3.20
needs:
- validate:assets
script:
- test -d /cache/tools
- df -h /cache/tools
resource_group: test-automation-platform
rules:
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
when: manual
- when: never
# Tool jobs are introduced behind explicit opt-in variables. Their Kubernetes
# Job templates and adapters are added as each tool contract is implemented.
test:ansible:
stage: test
image:
name: "$CI_REGISTRY_IMAGE/ansible:latest"
entrypoint: [""]
needs:
- validate:assets
environment:
name: "$TARGET_ENVIRONMENT"
action: verify
script:
- test -n "${ANSIBLE_SECRET_VARS:-}" || { echo "ANSIBLE_SECRET_VARS file variable is required" >&2; exit 1; }
- test -f "$ANSIBLE_SECRET_VARS" || { echo "ANSIBLE_SECRET_VARS must be a GitLab file variable" >&2; exit 1; }
- export ANSIBLE_VARS_FILE="$ANSIBLE_SECRET_VARS"
- bash methodologies/ansible/run.sh --limit "${ANSIBLE_LIMIT:-all}"
artifacts:
when: always
expire_in: 90 days
paths:
- artifacts/raw/ansible/
- artifacts/normalized/
- artifacts/rendered/
rules:
- if: '$RUN_ANSIBLE == "true"'
- when: never
test:zap:
stage: test
image:
name: zaproxy/zap-stable:latest
entrypoint: [""]
needs:
- validate:assets
environment:
name: "$TARGET_ENVIRONMENT"
action: verify
script:
- test -n "${ZAP_TARGET_URL:-}" || { echo "ZAP_TARGET_URL is required" >&2; exit 1; }
- NORMALIZE_ZAP=false bash methodologies/zap/run.sh "$ZAP_TARGET_URL"
artifacts:
when: always
expire_in: 90 days
paths:
- artifacts/raw/zaproxy/
rules:
- if: '$RUN_ZAP == "true"'
- when: never
demo:ansible:
stage: test
image:
name: "$ANSIBLE_IMAGE"
entrypoint: [""]
services:
- name: "$DEMO_TARGET_IMAGE"
alias: demo-target
needs:
- build:ansible
- build:demo-target
variables:
DEMO_SSH_PASSWORD: "DemoPassword1!"
INVENTORY: "$CI_PROJECT_DIR/targets/ubuntu-weak/assets.yml"
PLAYBOOK: "$CI_PROJECT_DIR/methodologies/ansible/playbooks/demo_target.yml"
LIMIT: "linux_vms"
TEST_PROJECT_ID: "demo-ubuntu-weak"
TEST_PROJECT_NAME: "Ubuntu Weak Target Demonstration"
TEST_ENVIRONMENT: "test"
TEST_CUSTOMER: "Internal"
TEST_LOCATION: "testserv"
script:
- |
python3 <<'PY'
import socket
import time
for _ in range(60):
try:
with socket.create_connection(("demo-target", 22), 2):
break
except OSError:
time.sleep(2)
else:
raise SystemExit("SSH target did not become ready")
PY
- bash methodologies/ansible/run.sh
artifacts:
when: always
expire_in: 30 days
paths:
- artifacts/raw/ansible/
- artifacts/normalized/
rules:
- if: '$RUN_DEMO == "true"'
demo:zap:
stage: test
image:
name: zaproxy/zap-stable:latest
entrypoint: [""]
services:
- name: "$DEMO_TARGET_IMAGE"
alias: demo-target
needs:
- build:demo-target
variables:
NORMALIZE_ZAP: "false"
script:
- |
python3 <<'PY'
import socket
import time
for _ in range(60):
try:
with socket.create_connection(("demo-target", 443), 2):
break
except OSError:
time.sleep(2)
else:
raise SystemExit("HTTPS target did not become ready")
PY
- bash methodologies/zap/run.sh https://demo-target
artifacts:
when: always
expire_in: 30 days
paths:
- artifacts/raw/zaproxy/
rules:
- if: '$RUN_DEMO == "true"'
normalize:demo-zap:
extends: .python_job
stage: normalize
needs:
- job: demo:zap
artifacts: true
variables:
TEST_PROJECT_ID: "demo-ubuntu-weak"
TEST_PROJECT_NAME: "Ubuntu Weak Target Demonstration"
TEST_ENVIRONMENT: "test"
TEST_CUSTOMER: "Internal"
TEST_LOCATION: "testserv"
script:
- python3 methodologies/zap/scripts/normalize.py artifacts/raw/zaproxy/zap.json artifacts/raw/zaproxy/tls.json artifacts/normalized/zaproxy-demo-web.json --target https://demo-target
artifacts:
expire_in: 30 days
paths:
- artifacts/normalized/zaproxy-demo-web.json
rules:
- if: '$RUN_DEMO == "true"'
report:demo:
extends: .python_job
stage: report
needs:
- job: demo:ansible
artifacts: true
- job: normalize:demo-zap
artifacts: true
script:
- ANSIBLE_REPORT="$(find artifacts/normalized -name 'ansible-*.json' -print -quit)"
- test -n "$ANSIBLE_REPORT"
- python3 scripts/aggregate-reports.py "$ANSIBLE_REPORT" artifacts/normalized/zaproxy-demo-web.json --output artifacts/normalized/combined-demo.json
- python3 scripts/render-normalized.py artifacts/normalized/combined-demo.json --output-dir artifacts/rendered
artifacts:
when: always
expire_in: 90 days
paths:
- artifacts/normalized/combined-demo.json
- artifacts/rendered/combined-project-scope.md
- artifacts/rendered/combined-project-scope.html
- artifacts/rendered/combined-project-scope.pdf
rules:
- if: '$RUN_DEMO == "true"'
report:sample:
extends: .python_job
stage: report
needs:
- validate:assets
- validate:python
script:
- python3 scripts/render-normalized.py artifacts/normalized/sample-ansible.json --output-dir artifacts/rendered
artifacts:
when: always
expire_in: 90 days
paths:
- artifacts/normalized/
- artifacts/rendered/
+2 -2
View File
@@ -1,3 +1,3 @@
[submodule "source_documents/OWASP_ASVS"]
path = source_documents/OWASP_ASVS
[submodule "methodologies/zap/reference/OWASP_ASVS"]
path = methodologies/zap/reference/OWASP_ASVS
url = https://github.com/OWASP/ASVS.git
-132
View File
@@ -1,132 +0,0 @@
# ───────────────────────────────────────────────────────────
# Alpine Docker Host — Minimal QEMU-Bootable Image
#
# Purpose: Temporary Docker host running on QEMU (pc-q35-10.0)
# atop Windows VMs in traditional deployments. Provides the
# Docker daemon that the Ansible control node container runs on.
#
# What it IS:
# • Alpine Linux 3.20 with OpenRC (no systemd)
# • Docker daemon + CLI
# • SSH server for remote management
# • QEMU-bootable via build-qemu.sh
#
# What it is NOT:
# • No Ansible (deployed as a separate container)
# • No GCC or build tools
# • No Python pip packages
# • No quality-of-life packages
#
# Target size: ~200MB Docker image → ~250MB qcow2
# ───────────────────────────────────────────────────────────
FROM alpine:3.20
LABEL org.opencontainers.image.title="Alpine Docker Host (QEMU)"
LABEL org.opencontainers.image.description="Minimal Alpine Linux with Docker daemon for QEMU pc-q35-10.0. Boots in ~6s."
# ── Core system (no bloat) ─────────────────────────────────
RUN apk add --no-cache \
alpine-base \
linux-virt \
e2fsprogs \
docker \
docker-openrc \
docker-cli-compose \
openssh-server \
openssh-client \
dhcpcd \
sudo \
curl \
ca-certificates \
util-linux \
python3
# ── OpenRC: enable just what's needed ──────────────────────
RUN rc-update add devfs sysinit && \
rc-update add dmesg sysinit && \
rc-update add mdev sysinit && \
rc-update add hwdrivers sysinit && \
rc-update add modules boot && \
rc-update add sysctl boot && \
rc-update add bootmisc boot && \
rc-update add hostname boot && \
rc-update add networking boot && \
rc-update add sshd default && \
rc-update add dhcpcd default && \
rc-update add docker default
# ── TTY menu: auto-launch on serial console ────────────────
# Uses agetty -l to replace /bin/login with the menu script
COPY scripts/tty-menu.sh /usr/local/bin/tty-menu.sh
RUN chmod +x /usr/local/bin/tty-menu.sh && \
echo 'ttyS0::respawn:/sbin/agetty -L 115200 ttyS0 xterm-256color -l /usr/local/bin/tty-menu.sh' \
>> /etc/inittab
# ── Web UI ────────────────────────────────────────────────
COPY webui/app.py /usr/local/bin/webui.py
RUN chmod +x /usr/local/bin/webui.py
# OpenRC service for the web UI
RUN printf '#!/sbin/openrc-run\n\
name="webui"\n\
description="IEC 62443-3-3 Web UI"\n\
command="/usr/bin/python3"\n\
command_args="/usr/local/bin/webui.py"\n\
command_background=true\n\
pidfile="/run/webui.pid"\n\
depend() {\n\
need net docker\n\
}\n' \
> /etc/init.d/webui && \
chmod +x /etc/init.d/webui && \
rc-update add webui default
# ── Shared directories (host ↔ ansible container) ─────────
RUN mkdir -p /ansible/playbooks /ansible/reports /ansible/inventory && \
chown -R ansible:ansible /ansible
# ── Hostname ──────────────────────────────────────────────
RUN echo 'alpine-docker' > /etc/hostname
# ── SSH configuration ─────────────────────────────────────
RUN ssh-keygen -A && \
sed -i 's/#PermitRootLogin prohibit-password/PermitRootLogin yes/' \
/etc/ssh/sshd_config && \
sed -i 's/#PasswordAuthentication yes/PasswordAuthentication yes/' \
/etc/ssh/sshd_config && \
echo 'UseDNS no' >> /etc/ssh/sshd_config
# ── Users ─────────────────────────────────────────────────
RUN echo 'root:ansible' | chpasswd && \
adduser -D ansible && \
echo 'ansible:ansible' | chpasswd && \
addgroup ansible wheel && \
addgroup ansible docker && \
echo '%wheel ALL=(ALL) NOPASSWD: ALL' >> /etc/sudoers
# ── First-boot: expand rootfs, regenerate SSH host keys ────
RUN printf '#!/bin/sh\n\
ROOTDEV=$(findmnt -n -o SOURCE / 2>/dev/null || echo /dev/vda)\n\
resize2fs "$ROOTDEV" 2>/dev/null || true\n\
if [ ! -f /etc/ssh/.host-keys-generated ]; then\n\
ssh-keygen -A && touch /etc/ssh/.host-keys-generated\n\
fi\n' \
> /etc/local.d/00-first-boot.start && \
chmod +x /etc/local.d/00-first-boot.start && \
rc-update add local default
# ── MOTD ──────────────────────────────────────────────────
RUN printf '\n\
\e[1;34m╔════════════════════════════════════════════════╗\e[0m\n\
\e[1;34m║ Alpine Docker Host — QEMU pc-q35-10.0 ║\e[0m\n\
\e[1;34m╠════════════════════════════════════════════════╣\e[0m\n\
\e[1;34m║ TTY: This console (auto-menu) ║\e[0m\n\
\e[1;34m║ Web UI: http://<ip>:8080 ║\e[0m\n\
\e[1;34m║ SSH: ssh ansible@<ip> -p 22 ║\e[0m\n\
\e[1;34m║ Pass: ansible ║\e[0m\n\
\e[1;34m╚════════════════════════════════════════════════╝\e[0m\n\
' > /etc/motd
WORKDIR /root
CMD ["/sbin/init"]
-144
View File
@@ -1,144 +0,0 @@
# ───────────────────────────────────────────────────────────
# Ansible Control Node — Docker Image
#
# Purpose: Runs IEC 62443-3-3 compliance tests against
# Windows, Cisco, VMware, MSSQL, and Linux targets.
#
# Deployment targets:
# • Kubernetes / Docker Swarm (native)
# • Alpine Docker Host on QEMU (docker run on Windows VMs)
# • Any Linux with Docker
#
# Integrations:
# • Windows — pywinrm + kerberos → WinRM
# • Cisco ASA — cisco.asa + paramiko → SSH/CLI
# • Cisco Catalyst— cisco.ios + netmiko → SSH/CLI
# • Cisco NX-OS — cisco.nxos + ncclient → SSH/NX-API
# • VMware — pyvmomi → vCenter/ESXi SOAP API
# • MSSQL — pymssql → SQL Server TDS
# • Linux — native SSH (built-in ansible)
#
# Usage:
# docker build -t ansible-node -f Dockerfile.ansible .
# docker run --rm -v $(pwd)/playbooks:/ansible/playbooks \
# -v $(pwd)/inventory.ini:/ansible/inventory.ini \
# ansible-node site.yml
# ───────────────────────────────────────────────────────────
FROM alpine:3.20
LABEL org.opencontainers.image.title="Ansible Control Node"
LABEL org.opencontainers.image.description="Ansible with collections for Windows, Cisco, VMware, MSSQL, and Linux targets"
# ── Runtime + build dependencies ───────────────────────────
RUN apk add --no-cache \
ansible \
sshpass \
openssh-client \
py3-pip \
python3 \
python3-dev \
gcc \
musl-dev \
openssl-dev \
krb5 \
krb5-dev \
libffi-dev \
freetds \
freetds-dev \
bash \
curl \
ca-certificates \
git
# ── Python packages for target integrations ──────────────
RUN pip3 install --no-cache-dir --break-system-packages \
'pywinrm[kerberos]>=0.4' \
requests-kerberos \
requests-ntlm \
paramiko>=2.7 \
ncclient>=0.6 \
netmiko>=4.0 \
scp \
pyvmomi>=8.0 \
requests \
pymssql>=2.2 \
jmespath>=1.0 \
xmltodict>=0.13 \
pyyaml>=6.0 \
cryptography>=41.0 \
packaging \
fpdf2>=2.7
# ── Ansible collections ──────────────────────────────────
RUN ansible-galaxy collection install \
ansible.windows \
ansible.netcommon \
ansible.utils \
cisco.asa \
cisco.ios \
cisco.nxos \
community.vmware \
community.general \
community.crypto \
microsoft.sql
# ── Install gomplate (template renderer) ─────────────────
RUN apk add --no-cache gomplate
# ── Purge build-only dependencies ─────────────────────────
# apk del cascades to shared deps like util-linux (mount/umount).
# Re-add it with network access (not --no-network here).
RUN apk del --no-network \
gcc \
musl-dev \
python3-dev \
openssl-dev \
krb5-dev \
libffi-dev \
freetds-dev \
&& apk add --no-cache util-linux
# ── Ansible config ────────────────────────────────────────
RUN mkdir -p /etc/ansible && \
printf '[defaults]\n\
host_key_checking = False\n\
stdout_callback = yaml\n\
callback_whitelist = profile_tasks\n\
retry_files_enabled = False\n\
inventory = /ansible/inventory/inventory.ini\n\
\n\
[ssh_connection]\n\
pipelining = True\n\
control_path = /tmp/ansible-%%h-%%p-%%r' \
> /etc/ansible/ansible.cfg
# ── Working directory ─────────────────────────────────────
RUN mkdir -p /ansible/playbooks /ansible/inventory
WORKDIR /ansible
# ── Container web UI (JSON / Markdown / PDF export) ──────
COPY webui/container-app.py /usr/local/bin/container-webui.py
COPY reports/render_report.py /ansible/reports/render_report.py
RUN chmod +x /usr/local/bin/container-webui.py
# ── Default inventory (placeholder) ───────────────────────
RUN printf '[windows]\n\
[cisco_asa]\n\
[cisco_ios]\n\
[cisco_nxos]\n\
[vmware]\n\
[mssql]\n\
[linux]\n\
\n\
[all:vars]\n\
ansible_user=ansible\n' \
> /ansible/inventory/inventory.ini
# ── Entrypoint: web UI by default, ansible-playbook if args ─
# docker run -p 8080:8080 ansible-node → web UI
# docker run ansible-node site.yml -i hosts → ansible-playbook
COPY scripts/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD []
+75 -762
View File
@@ -1,783 +1,96 @@
# Ansible-Test: IEC 62443-3-3 SL2 Compliance Validation
# Test Automation Template
Ansible playbooks reimagined as a **test framework** for industrial control system
(ICS/OT) security compliance. Every task is a test that collects evidence, never
aborts on failure, produces structured JSON, and renders into human-readable
reports via Go templates or Python.
This repository is a GitLab CI template for repeatable testing of virtual and physical HLC environments. A pipeline selects an environment, runs enabled testing methodologies as Kubernetes pods on `testserv`, normalizes each tool's output, and publishes JSON, Markdown, HTML, and PDF artifacts.
Supports automated testing across **Linux, Windows Server, Windows Clients,
MS SQL Server, VMware vSphere, Hyper-V, Cisco IOS switches, and Cisco ASA
firewalls** — from a single containerised Ansible control node.
## Dependencies
---
## Table of Contents
1. [Architecture](#architecture)
2. [Quick Start](#quick-start)
3. [Platform-Specific Examples](#platform-specific-examples)
4. [The Test Pattern](#the-test-pattern)
5. [Human-in-the-Loop Tests](#human-in-the-loop-tests)
6. [Adding a New Test](#adding-a-new-test)
7. [JSON Output Schema](#json-output-schema)
8. [IEC 62443-3-3 SL2 Coverage](#iec-62443-3-3-sl2-coverage)
9. [Rendering Reports](#rendering-reports)
10. [Ansible Control Node — Container & QEMU VM](#ansible-control-node--container--qemu-vm)
11. [File Reference](#file-reference)
12. [Design Decisions & Tradeoffs](#design-decisions--tradeoffs)
13. [Comparison to Alternatives](#comparison-to-alternatives)
14. [Roadmap](#roadmap)
---
## Architecture
```
playbooks/
├── site.yml # Entry point for Linux targets (all FR suites)
├── suites/ # Included task-list suites for Linux
│ ├── fr1_auth.yml # FR1: Identification & Authentication (10 tests)
│ ├── fr2_use_control.yml # FR2: Use Control — audit, sudo, sessions (6 tests)
│ └── fr5_data_flow.yml # FR5: Restricted Data Flow — firewall, services (4 tests)
├── library/
│ └── report.yml # Aggregates test_results[] → JSON → disk
├── examples/ # ◄ Standalone playbooks — one per platform type
│ ├── linux_vm.yml # Linux: SSH hardening, sysctl, sudo logging
│ ├── windows_server.yml # Windows Server: WinRM, security policy, audit
│ ├── windows_client.yml # Windows Client: BitLocker, screen lock, USB
│ ├── mssql_server.yml # MS SQL Server: auth mode, sa, xp_cmdshell, audit
│ ├── vmware_vsphere.yml # VMware ESXi: lockdown, NTP, SSH/Shell services
│ ├── hyperv_cluster.yml # Hyper-V: Gen2, vSwitch isolation, integration svc
│ ├── cisco_switch.yml # Cisco IOS: SSH v2, no SNMPv1, banner, NTP
│ └── cisco_firewall.yml # Cisco ASA: no Telnet, IKEv2, syslog, AAA, ACL
└── templates/ # ◄ Copy-and-fill templates for writing new tests
├── test_automated.yml # Shell-based gather → evaluate pattern
├── test_file_check.yml # File permission check via ansible.builtin.stat
├── test_service_check.yml # Service state check via service_facts
└── test_hitl.yml # Human-in-the-Loop with ansible.builtin.pause
reports/
├── render_report.py # Python renderer (terminal + markdown output)
├── report.gohtml # Go template rendered by gomplate (terminal box-drawing report)
└── sample-output.json # Example output for offline renderer tests
inventory.ini # Ansible inventory — all platform groups defined
run.sh # End-to-end wrapper: ansible → find JSON → render
```mermaid
flowchart TD
U[Tester starts GitLab pipeline] --> P[GitLab CI]
A[assets.yml<br/>project and targets] --> P
V[Environment-scoped GitLab variables<br/>credentials and keys] --> P
P --> R[GitLab Kubernetes Runner]
R --> K[k3s namespace: test-automation]
K --> C[(tool-cache PVC<br/>downloaded databases)]
K --> AN[Ansible methodology pod]
K --> Z[ZAP methodology pod]
AN --> T[Linux, Windows, SQL,<br/>VMware, Hyper-V, Cisco]
Z --> W[Web applications]
AN --> N[Normalized JSON schema]
Z --> N
N --> O[Markdown, HTML, PDF]
O --> G[GitLab pipeline artifacts]
```
### Data Flow
Required infrastructure:
```
ansible-playbook <playbook>.yml
│
├── Gather tasks ──┐
│ ├─ collect system state (shell, stat, ios_command, etc.)
├── Evaluate tasks─┘
│ judge pass/fail, append to test_results[]
│
▼
library/report.yml
- Assembles __report dict with summary, by_category, by_severity, failures
- Prints summary box to console
- Writes JSON to reports/<hostname>-<date>.json
│
▼
run.sh / render manually:
python3 reports/render_report.py reports/<hostname>-<date>.json
gomplate --context .=reports/<hostname>-<date>.json --file reports/report.gohtml
- GitLab project and Kubernetes-executor runner on `testserv`.
- k3s namespace, RBAC, and cache PVC from `platform/kubernetes/`.
- Runner configuration from `platform/gitlab-runner/values.example.yml`.
- Container registry containing the Ansible image.
- Network access from k3s pods to the selected test environment.
- Environment-scoped GitLab CI/CD variables for credentials.
## Start A Test
1. Define project metadata and targets in `assets.yml`.
2. In GitLab, create protected and masked CI/CD variables with an environment scope matching `all.vars.test_project.environment`.
3. Start a pipeline and set:
| Variable | Purpose |
| --- | --- |
| `TARGET_ENVIRONMENT` | GitLab environment scope; must match `assets.yml` |
| `RUN_ANSIBLE=true` | Enable infrastructure tests |
| `RUN_ZAP=true` | Enable web tests after the ZAP methodology is implemented |
| `ANSIBLE_LIMIT` | Optional Ansible host/group limit; defaults to `all` |
The pipeline validates that `TARGET_ENVIRONMENT` matches `assets.yml`. A mismatch stops before any testing begins.
## Secrets
The Ansible job requires `ANSIBLE_SECRET_VARS` as an environment-scoped GitLab **File** variable containing Ansible variables. SSH keys may be supplied as `ANSIBLE_PRIVATE_KEY_FILE`, also as a File variable.
Validation, normalization, and report jobs do not declare a GitLab environment and therefore do not receive environment-scoped credentials. See [docs/secrets.md](docs/secrets.md) for variable examples and rotation guidance.
## Pipeline Flow
1. `validate`: validate `assets.yml`, compile adapters, and test the report contract.
2. `platform`: manually verify that the persistent tool cache is mounted.
3. `test`: run enabled methodology pods against selected assets.
4. `normalize`: convert native tool output to `schemas/test-report.schema.json`.
5. `report`: create Markdown, HTML, and PDF reports.
Generated files are written below `artifacts/`:
```text
artifacts/
├── raw/<methodology>/
├── normalized/
└── rendered/
```
### Target Integrations
GitLab artifacts are the authoritative test evidence. The Kubernetes PVC stores only replaceable tool databases and caches.
| Platform | Ansible Collection | Connection | Python library |
|---|---|---|---|
| **Linux** | built-in | SSH | — |
| **Windows Server / Client** | `ansible.windows` | WinRM + NTLM/Kerberos | `pywinrm` |
| **MS SQL Server** | `ansible.windows` | WinRM → PowerShell `Invoke-Sqlcmd` | `pywinrm` |
| **VMware vSphere ESXi** | `community.vmware` | vSphere SOAP API (delegate_to: localhost) | `pyvmomi` |
| **Hyper-V** | `ansible.windows` | WinRM → PowerShell Hyper-V cmdlets | `pywinrm` |
| **Cisco IOS / IOS-XE** | `cisco.ios` | SSH via `network_cli` | `paramiko`, `netmiko` |
| **Cisco ASA** | `cisco.asa` | SSH via `network_cli` | `paramiko` |
| **Cisco NX-OS** | `cisco.nxos` | SSH / NX-API via `network_cli` | `ncclient` |
## Methodologies
---
- [Ansible](methodologies/ansible/README.md): active infrastructure and platform checks.
- [OWASP ZAP](methodologies/zap/README.md): planned web application testing with ASVS mappings.
## Quick Start
Shared pipeline code stays at the repository root. Methodology-specific images, runners, adapters, fixtures, and references stay under `methodologies/<name>/`.
### Prerequisites
## Platform Bootstrap
- Ansible ≥ 2.9 with the collections listed above (pre-installed in the Docker image)
- Python ≥ 3.6 (for the Python report renderer)
- [gomplate](https://docs.gomplate.ca/installing/) (optional, single static binary, for the `.gohtml` template renderer)
- For Windows / VMware / Cisco targets: the Python libraries listed above
### Step 1: Configure Inventory
Edit `inventory.ini`. Each platform group has the required connection variables
already set — just uncomment the hosts:
```ini
[linux_vms]
linux-vm-01.example.com ansible_user=auditor
[windows_servers]
win-srv-01.example.com
[cisco_switches]
sw-core-01.example.com
```
Store passwords in Ansible Vault:
```bash
ansible-vault encrypt_string 'MyPassword' --name ansible_password
```
### Step 2: Run a Playbook
Apply the Kubernetes resources once with an administrator context:
```bash
# Linux targets — all FR suites via the main entry point:
ansible-playbook -i inventory.ini playbooks/site.yml --limit linux_vms -K
# Or use the wrapper script (finds & renders the report automatically):
./run.sh --limit linux_vms -K
# Platform-specific examples:
ansible-playbook -i inventory.ini playbooks/examples/windows_server.yml
ansible-playbook -i inventory.ini playbooks/examples/cisco_switch.yml
ansible-playbook -i inventory.ini playbooks/examples/vmware_vsphere.yml
# Local self-test (localhost is pre-configured in inventory.ini):
ansible-playbook -i inventory.ini playbooks/site.yml --limit localhost -K
export KUBECONFIG=/etc/rancher/k3s/admin/kubeconfig.yaml
kubectl apply -k platform/kubernetes
```
### Step 3: Render the Report
Build and push the Ansible image before enabling `RUN_ANSIBLE`:
```bash
# Terminal box-drawing format (default):
python3 reports/render_report.py reports/localhost-2026-08-14.json
# Markdown (for GitHub / GitLab wikis, PR comments):
python3 reports/render_report.py reports/localhost-2026-08-14.json --format md
# gomplate template renderer:
gomplate --context .=reports/localhost-2026-08-14.json --file reports/report.gohtml
REGISTRY=<registry>/<project> ./methodologies/ansible/scripts/build-image.sh --push
```
---
## Platform-Specific Examples
The `playbooks/examples/` directory contains a complete, runnable playbook for
each supported platform. Each file:
- Has a header comment with the required `inventory.ini` group vars and any
prerequisites (WinRM setup, SQLPS module, vCenter permissions, etc.)
- Follows the identical `block` → gather → evaluate → `ignore_errors` pattern
- Uses the most Ansible-native module available for each check (e.g.
`win_security_policy` instead of `win_shell` for Windows password policy)
- Ends with `include_tasks: ../library/report.yml` to produce a JSON report
- Includes at least one Human-in-the-Loop test where automated checks cannot
cover the full control
### Linux VMs — `playbooks/examples/linux_vm.yml`
```bash
ansible-playbook -i inventory.ini playbooks/examples/linux_vm.yml -K
```
Checks beyond the core FR suites: SSH root login and password auth settings,
sudo session logging (`Defaults log_input,log_output`), kernel IP forwarding
and ICMP redirect sysctl values, core dump disabled.
### Windows Server — `playbooks/examples/windows_server.yml`
```bash
ansible-playbook -i inventory.ini playbooks/examples/windows_server.yml
```
Uses `ansible.windows.win_security_policy` for password and lockout policy
(no PowerShell shell-out needed), `win_audit_policy_system` for audit subcategories,
`win_service_info` for Telnet/FTP service state, and `win_shell` with
`ConvertTo-Json` for Firewall profile states parsed via Ansible's `from_json` filter.
### Windows Client — `playbooks/examples/windows_client.yml`
```bash
ansible-playbook -i inventory.ini playbooks/examples/windows_client.yml
```
Checks: domain membership, BitLocker status on the OS drive, screen lock timeout
via registry (`win_reg_stat`), Public firewall profile. Includes a HITL check
for USB storage port controls with registry evidence displayed.
### MS SQL Server — `playbooks/examples/mssql_server.yml`
```bash
ansible-playbook -i inventory.ini playbooks/examples/mssql_server.yml
# Add per-host: mssql_instance=NAMED_INSTANCE (default: MSSQLSERVER)
```
Connects via WinRM to the Windows host, then runs `Invoke-Sqlcmd` via `win_shell`
to query SQL Server internals. Checks: Windows-only authentication mode, SA account
disabled, `xp_cmdshell` disabled, login audit level. HITL check for sysadmin
role membership against an authorised list.
**Prerequisite on target:** `Install-Module SqlServer -Force -AllowClobber`
### VMware vSphere — `playbooks/examples/vmware_vsphere.yml`
```bash
ansible-playbook -i inventory.ini playbooks/examples/vmware_vsphere.yml
```
All tasks use `delegate_to: localhost` — no SSH to ESXi hosts. The inventory
host is the ESXi FQDN; `vcenter_hostname/username/password` are group vars
pointing at vCenter. Uses `community.vmware` info modules:
`vmware_host_lockdown_info`, `vmware_host_ntp_info`, `vmware_host_service_info`,
`vmware_host_config_info`. HITL check for vSwitch isolation using
`vmware_vswitch_info`.
**Required vCenter read-only permissions:**
Host → Configuration → Security Profile, Advanced Settings; Global → Settings.
### Hyper-V Cluster — `playbooks/examples/hyperv_cluster.yml`
```bash
ansible-playbook -i inventory.ini playbooks/examples/hyperv_cluster.yml
```
Connects via WinRM and runs PowerShell Hyper-V cmdlets via `win_shell`. Checks:
all VMs use Generation 2 with Secure Boot enabled, External vSwitch
`AllowManagementOS` exposure (flagged as review), Hyper-V VMMS Admin event log
active, integration services enabled on all running VMs. HITL check for
physical NIC segregation between ICS and management networks.
### Cisco Switch (IOS / IOS-XE) — `playbooks/examples/cisco_switch.yml`
```bash
ansible-playbook -i inventory.ini playbooks/examples/cisco_switch.yml
```
Uses `cisco.ios.ios_command` to run `show` commands and parses output with
`regex_search` / `regex_findall`. Uses `ios_facts` (gathered automatically via
`gather_facts: yes`) for interface data. Checks: SSH v2 / no Telnet on VTY,
no SNMPv1/v2c community strings, login banner, NTP synchronised. HITL check
for port VLAN assignment and physical port labelling.
### Cisco Firewall (ASA) — `playbooks/examples/cisco_firewall.yml`
```bash
ansible-playbook -i inventory.ini playbooks/examples/cisco_firewall.yml
```
Uses `cisco.asa.asa_command` with `ansible_become=yes` (enable mode). Checks:
no Telnet management access, SSH access configured, IKEv1 disabled (IKEv2 only),
remote syslog server configured, AAA authentication for SSH/enable, inbound
ACLs applied on zone interfaces. HITL check for ACL rule review
(no broad `permit ip any any`).
---
## The Test Pattern
Every test follows a rigid **Gather → Evaluate → Record** structure inside an
Ansible `block` with `ignore_errors: yes`. This ensures the run never aborts
regardless of what is found on the target.
```yaml
# ── SR 1.5: Password minimum length ────────────────────────────────────────
- block:
- name: "Gather: Check pwquality minlen"
ansible.builtin.shell: |
grep -E '^\s*minlen\s*=' /etc/security/pwquality.conf 2>/dev/null | tail -1 || echo "NOT SET"
register: _minlen
changed_when: false # gather tasks must never say "changed"
- name: "Evaluate: IAC-05"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'IAC-05',
'category': 'FR1 — Identification and Authentication Control',
'requirement': 'SR 1.5 — Authenticator Strength',
'description': 'Password minimum length shall be ≥ 14 characters',
'passed': (
(_minlen.stdout | regex_search('minlen\\s*=\\s*(\\d+)', '\\1')
| default(['0'], true) | first | int) >= 14
),
'expected': 'minlen >= 14 in /etc/security/pwquality.conf',
'actual': _minlen.stdout | trim,
'severity': 'high',
'remediation': 'Set minlen=14 in /etc/security/pwquality.conf'
}] }}"
ignore_errors: yes # NEVER abort the run
```
### Why `block` + `ignore_errors` Instead of `failed_when`
| Approach | Behaviour |
|---|---|
| `failed_when: false` on shell task | Shell always "succeeds"; non-zero exit still shows red in Ansible output |
| `block` + `ignore_errors: yes` | Failures are captured and marked orange; execution continues; `register`ed variables remain available in the evaluate task |
### Tips for Robust Checks
| Tip | Why |
|---|---|
| `changed_when: false` on all gather tasks | Tests must never report as "changed" |
| `\| trim` on shell output | Shell often returns trailing newlines |
| `\| default('NOT SET', true)` | Prevents undefined-variable errors when files or keys are absent |
| Guard numeric comparisons | `'' \| int` = 0 in Jinja2 — a missing value can silently pass a `≤ 90` check; always verify the value exists and is non-zero first |
| `\| regex_search(pattern, '\\1')` | Use capture group syntax to extract a number cleanly, avoiding chained `regex_replace` calls that crash on `None` |
| `ConvertTo-Json` on Windows | Return structured data from `win_shell` and parse with Ansible's `from_json` filter instead of regex |
| Platform branching | Use `when: ansible_os_family == 'Debian'` variants for distro-specific commands |
---
## Human-in-the-Loop Tests
Some IEC 62443 SL2 controls cannot be verified automatically — physical access
controls, policy document review, proprietary vendor interfaces, or checks where
the output must be interpreted by a qualified reviewer.
**HITL tests use `ansible.builtin.pause` with `delegate_to: localhost`**, which
prompts the reviewer on the Ansible control node even when running against remote
targets. For multi-host runs the prompt fires once per host, so each target gets
an independent verdict.
```yaml
- block:
- name: "Gather: [HITL] Collect evidence"
ansible.builtin.shell: your-gather-command
register: _evidence
changed_when: false
- name: "Display: [HITL] TEST_ID — evidence"
ansible.builtin.debug:
msg: |
══════════════════════════════════════════════════════════════
MANUAL REVIEW REQUIRED · TEST_ID · {{ inventory_hostname }}
══════════════════════════════════════════════════════════════
{{ _evidence.stdout | indent(1) }}
══════════════════════════════════════════════════════════════
- name: "Prompt: TEST_ID — verdict"
ansible.builtin.pause:
prompt: "Enter verdict [pass / fail / skip]:"
register: _verdict
delegate_to: localhost # ← always prompts on the control node
- name: "Prompt: TEST_ID — notes on failure"
ansible.builtin.pause:
prompt: "Describe the finding:"
register: _notes
delegate_to: localhost
when: _verdict.user_input | lower | trim in ['fail', 'f']
- name: "Evaluate: TEST_ID"
ansible.builtin.set_fact:
test_results: "{{ test_results + [{
'test_id': 'TEST_ID',
'passed': (
'skipped' if (_verdict.user_input | lower | trim in ['skip', 's', ''])
else (_verdict.user_input | lower | trim in ['pass', 'p'])
),
'reviewer': ansible_user_id,
'notes': (_notes.user_input | trim) if _notes is defined else ''
}] }}"
ignore_errors: yes
```
The `reviewer` and `notes` fields are written into the JSON report alongside
the automated evidence, creating an auditable record of who reviewed what and
when. See `playbooks/templates/test_hitl.yml` for the full copy-and-fill template.
**Note:** Playbooks containing HITL tests require an interactive terminal and
cannot be run unattended in a CI pipeline. Keep HITL tests in separate playbooks
or use Ansible tags to separate them from automated checks.
---
## Adding a New Test
### Step 1: Choose a Template
The `playbooks/templates/` directory contains four ready-to-use templates.
Copy the one that matches your check type:
| Template | Use when |
|---|---|
| `playbooks/templates/test_automated.yml` | Running a shell command and evaluating its output |
| `playbooks/templates/test_file_check.yml` | Checking file ownership, permissions, or existence (`ansible.builtin.stat`) |
| `playbooks/templates/test_service_check.yml` | Checking service running/enabled state (`service_facts`) |
| `playbooks/templates/test_hitl.yml` | Control requires human reviewer input |
Every template has detailed comments explaining the `passed` expression patterns
relevant to that check type.
### Step 2: Fill in the Placeholders
Replace all UPPERCASE placeholders: `TEST_ID`, `FR_NUMBER`, `CATEGORY_NAME`,
`REQUIREMENT`, `DESCRIPTION`, `SEVERITY`, `REMEDIATION`, and the gather command.
### Step 3: Add to a Suite or Example
For Linux targets, append the block to the appropriate `suites/frN_*.yml` file
and ensure that suite is included in `site.yml`:
```yaml
- name: "Suite: FRN — Category Name"
block:
- ansible.builtin.include_tasks: suites/frN_category.yml
ignore_errors: yes
```
For other platforms, append the block to the relevant `examples/` playbook.
### Test ID Naming Convention
- **Prefix**: Platform abbreviation + category (e.g., `IAC`, `UC`, `RDF` for
Linux; `WIN-IAC`, `SQL-UC`, `FW-RDF`, `SW-RDF`, `VMW-IAC` for platform examples)
- **Number**: Sequential within prefix, zero-padded (`01`, `02`, ...)
- **HITL suffix**: Append `-HITL` for human-in-the-loop tests (e.g., `WIN-CLI-HITL-01`)
---
## JSON Output Schema
Each test produces one entry in `test_results[]`. The complete report schema:
```jsonc
{
"meta": {
"standard": "IEC 62443-3-3",
"security_level": "SL2",
"target": "ics-gateway-01", // inventory_hostname
"timestamp": "2026-08-14T09:00:00Z", // ISO 8601
"executed_by": "auditor" // ansible_user_id
},
"summary": {
"total": 20,
"passed": 14,
"failed": 4,
"review": 1, // passed == "review" (manual review items)
"skipped": 1 // passed == "skipped" (HITL skipped or not applicable)
},
"by_category": [ // [["FR1 — ...", [{...}]], ...]
["FR1 — Identification and Authentication Control", [{...}, {...}]],
["FR2 — Use Control", [{...}]]
],
"by_severity": {
"critical": [{...}],
"high": [{...}],
"medium": [{...}],
"low": [{...}]
},
"failures": [{...}], // Only tests where passed == false
"results": [
{
"test_id": "IAC-05",
"category": "FR1 — ...",
"requirement": "SR 1.5 — Authenticator Strength",
"description": "Password minimum length shall be >= 14 characters",
"passed": false, // bool | "review" | "skipped"
"expected": "minlen >= 14",
"actual": "minlen = 8",
"severity": "high", // critical | high | medium | low
"remediation": "Set minlen=14 in /etc/security/pwquality.conf"
// HITL tests also carry: "reviewer", "notes"
}
]
}
```
### `passed` Field Semantics
| Value | Meaning | Report icon |
|---|---|---|
| `true` | Automated check passed | ✅ |
| `false` | Automated check failed | ❌ |
| `"review"` | Listed for human assessment (port inventory, ACL review) | 🔍 |
| `"skipped"` | Reviewer entered `skip`; not applicable to this target | ⏭️ |
### `severity` Field Semantics
| Value | Meaning |
|---|---|
| `critical` | Allows immediate compromise (empty passwords, world-writable sudoers) |
| `high` | Defeats a core SL2 control (no firewall, no auditd, weak password policy) |
| `medium` | Weakens a control (password aging not set, no session timeout) |
| `low` | Best-practice gap (extra listening ports, stale accounts) |
---
## IEC 62443-3-3 SL2 Coverage
### Core Linux Suites (`playbooks/suites/`)
| Test ID | FR | SR | Description | Severity |
|---|---|---|---|---|
| IAC-01 | FR1 | SR 1.1 | No duplicate UIDs in /etc/passwd | high |
| IAC-02 | FR1 | SR 1.3 | No default/unnecessary system accounts | medium |
| IAC-03 | FR1 | SR 1.3 | No human accounts that have never logged in | low |
| IAC-04 | FR1 | SR 1.4 | No empty or trivially-weak password hashes | critical |
| IAC-05 | FR1 | SR 1.5 | Password min length >= 14 (pwquality) | high |
| IAC-06 | FR1 | SR 1.5 | >= 3 character classes required (pwquality) | medium |
| IAC-07 | FR1 | SR 1.7 | PASS_MAX_DAYS <= 90 and is set | medium |
| IAC-08 | FR1 | SR 1.7 | PASS_MIN_DAYS >= 1 | low |
| IAC-09 | FR1 | SR 1.11 | Account lockout after <= 5 failures (pam_faillock) | high |
| IAC-10 | FR1 | SR 1.6 | Password history >= 5 (pam_pwhistory) | medium |
| UC-01 | FR2 | SR 2.1 | No unrestricted NOPASSWD sudo | high |
| UC-02 | FR2 | SR 2.1 | /etc/sudoers owned root:root, mode 0440 | critical |
| UC-03 | FR2 | SR 2.5 | Shell idle timeout <= 900s (TMOUT) | medium |
| UC-04 | FR2 | SR 2.4 | Audit rules immutable (-e 2) | high |
| UC-05 | FR2 | SR 2.8 | auditd service active and enabled | high |
| UC-06 | FR2 | SR 2.8 | >= 4 critical syscall types audited | medium |
| RDF-01 | FR5 | SR 5.1 | Host-based firewall with active rules | critical |
| RDF-02 | FR5 | SR 5.1 | Default INPUT policy is DROP | high |
| RDF-03 | FR5 | SR 5.3 | No insecure legacy services (telnet, rsh, ftp) | critical |
| RDF-04 | FR5 | SR 5.3 | Listening TCP ports — review | low |
### Additional Checks in Platform Examples
| Platform | Example file | FR areas covered |
|---|---|---|
| Linux VM | `examples/linux_vm.yml` | FR1 (SSH hardening), FR2 (sudo logging), FR3 (sysctl, core dumps) |
| Windows Server | `examples/windows_server.yml` | FR1 (password/lockout policy), FR2 (audit policy), FR5 (firewall, Telnet) |
| Windows Client | `examples/windows_client.yml` | FR1 (domain join), FR3 (BitLocker), FR2 (screen lock), FR5 (firewall) |
| MS SQL Server | `examples/mssql_server.yml` | FR1 (auth mode, SA account, role review), FR2 (xp_cmdshell, audit) |
| VMware vSphere | `examples/vmware_vsphere.yml` | FR1 (lockdown, account lockout), FR2 (NTP), FR5 (SSH/Shell, vSwitch) |
| Hyper-V | `examples/hyperv_cluster.yml` | FR3 (SecureBoot, integration svc), FR2 (event log), FR5 (vSwitch/NIC) |
| Cisco Switch | `examples/cisco_switch.yml` | FR1 (SNMP, banner), FR2 (NTP), FR5 (SSH/Telnet, port shutdown) |
| Cisco Firewall | `examples/cisco_firewall.yml` | FR1 (IKEv2, AAA), FR2 (syslog), FR5 (Telnet, ACLs) |
### Not Yet Implemented as Dedicated Suites
| FR | Key SL2 Controls | Suggested Checks |
|---|---|---|
| FR3 — System Integrity | File integrity monitoring | AIDE/IMA service, `/proc/sys/kernel/kexec_load_disabled` |
| FR4 — Data Confidentiality | Encryption at rest/transit | TLS cipher audit on listening ports, LUKS/dm-crypt, SSH cipher suite |
| FR6 — Timely Response | Log forwarding, alerting | `rsyslog` remote config, auditd dispatcher, journald persistence |
| FR7 — Resource Availability | DoS protection, backup | Disk quota, systemd resource limits, backup schedule |
---
## Rendering Reports
### Python Renderer (`reports/render_report.py`)
Zero dependencies beyond Python 3 stdlib. Two output formats:
```bash
# Terminal box-drawing (default):
python3 reports/render_report.py reports/hostname-2026-08-14.json
# Markdown for GitHub / GitLab:
python3 reports/render_report.py reports/hostname-2026-08-14.json --format md > REPORT.md
```
Terminal output groups results by FR category with a failure-detail section.
Markdown output produces GFM tables plus per-failure sections with remediation.
### gomplate Renderer (`reports/report.gohtml`)
Requires [gomplate](https://docs.gomplate.ca/installing/) (a single static
binary — no Go toolchain, no compilation). Renders a `.gohtml` file against
the JSON report loaded as the template's root context:
```bash
cd reports
gomplate --context .=../reports/hostname-2026-08-14.json --file report.gohtml
```
The template file is standalone — customise it without recompiling anything.
Fields are accessed with plain dot notation (e.g. `.meta.target`), and the
template only relies on gomplate's built-in functions:
| Function | Purpose |
|---|---|
| `passIcon` (in-template) | Maps `passed` value to ✅ PASS / ❌ FAIL / ❓ MANUAL |
| `severityIcon` (in-template) | Maps severity to 🔴/🟠/🟡/🟢 |
| `strings.Title` | Capitalises first letter of each word |
| `math.Div`, `math.Mul` | Compliance rate percentage |
Custom templates:
```bash
gomplate --context .=reports/<hostname>-<date>.json --file my-custom.gohtml
```
---
## Ansible Control Node — Container & QEMU VM
Two independent deployment artifacts. The container image runs anywhere Docker
is available; the QEMU VM provides a self-contained appliance for environments
without existing Docker infrastructure.
### The Two Artifacts
| Artifact | Defined by | Built with | Result |
|---|---|---|---|
| **Alpine Docker Host** | `Dockerfile.alpine-host` | `./scripts/build-qemu.sh` | `output/ansible-node.qcow2` (~470 MB) |
| **Ansible Control Node** | `Dockerfile.ansible` | `./scripts/build-ansible.sh` | `ansible-node` Docker image (~793 MB) |
The container image includes: Ansible 2.17, 10 collections (`ansible.windows`,
`cisco.asa`, `cisco.ios`, `cisco.nxos`, `community.vmware`, `community.general`,
`community.crypto`, `ansible.netcommon`, `ansible.utils`, `microsoft.sql`),
and all required Python libraries (`pywinrm`, `pyvmomi`, `pymssql`, `paramiko`,
`netmiko`, `ncclient`).
### Build & Launch
```bash
# Prerequisites: Docker, QEMU (qemu-full), passwordless sudo for mount
# 1. Build the Ansible container image
./scripts/build-ansible.sh
# Push to a registry:
REGISTRY=my-registry ./scripts/build-ansible.sh --push
# 2. Build the VM disk (optional — only needed for QEMU deployment)
./scripts/build-qemu.sh # Alpine + Docker + SSH → qcow2
# 3. Boot the VM
./scripts/run-qemu.sh # QEMU pc-q35-10.0, KVM, 1 GB, 2 vCPUs
```
### Interacting with the VM
```
QEMU VM boots in ~6 seconds
│
├── ttyS0 (serial console) ──► TTY menu
│ ╔══════════════════════════════════════╗
│ ║ 1) Run all tests ║
│ ║ 2) Run FR1 — Auth ║
│ ║ 3) Run FR2 — Use Control ║
│ ║ 4) Run FR5 — Data Flow ║
│ ║ 5) Download reports (tar.gz) ║
│ ║ 6) View latest report ║
│ ║ 7) Shell (Ansible container) ║
│ ║ 8) Shell (Docker host) ║
│ ║ 0) Shutdown ║
│ ╚══════════════════════════════════════╝
│
├── :8080 ──► Web UI (browser dashboard)
│ • Run buttons per playbook
│ • Live output streaming
│ • Report downloads (JSON / Markdown / PDF)
│
└── :22 ──► SSH (ansible / ansible)
```
### Deployment Targets for the Ansible Container
| Environment | How |
|---|---|
| QEMU VM (Windows host) | `docker run ansible-node` inside the Alpine Docker Host |
| Docker Swarm | `docker stack deploy` with the `ansible-node` image |
| Kubernetes | `kubectl create job` with the `ansible-node` image |
| CI/CD pipeline | `docker run --rm -v ...` in GitHub Actions / GitLab CI |
---
## File Reference
| Path | Purpose |
|---|---|
| `playbooks/site.yml` | Entry-point for Linux targets; orchestrates FR1, FR2, FR5 suites |
| `playbooks/suites/fr1_auth.yml` | 10 FR1 authentication tests for Linux |
| `playbooks/suites/fr2_use_control.yml` | 6 FR2 use-control tests for Linux |
| `playbooks/suites/fr5_data_flow.yml` | 4 FR5 data-flow tests for Linux |
| `playbooks/library/report.yml` | Aggregates `test_results[]` → JSON file on localhost |
| `playbooks/examples/linux_vm.yml` | Standalone example: Linux SSH + kernel hardening |
| `playbooks/examples/windows_server.yml` | Standalone example: Windows Server via WinRM |
| `playbooks/examples/windows_client.yml` | Standalone example: Windows Client / HMI workstation |
| `playbooks/examples/mssql_server.yml` | Standalone example: SQL Server via WinRM + Invoke-Sqlcmd |
| `playbooks/examples/vmware_vsphere.yml` | Standalone example: ESXi via vSphere API |
| `playbooks/examples/hyperv_cluster.yml` | Standalone example: Hyper-V via WinRM |
| `playbooks/examples/cisco_switch.yml` | Standalone example: Cisco IOS via network_cli |
| `playbooks/examples/cisco_firewall.yml` | Standalone example: Cisco ASA via network_cli |
| `playbooks/templates/test_automated.yml` | Copy-and-fill template: shell gather → evaluate |
| `playbooks/templates/test_file_check.yml` | Copy-and-fill template: `ansible.builtin.stat` |
| `playbooks/templates/test_service_check.yml` | Copy-and-fill template: `service_facts` |
| `playbooks/templates/test_hitl.yml` | Copy-and-fill template: `pause` + `delegate_to: localhost` |
| `reports/render_report.py` | Python renderer: terminal box-drawing and Markdown output |
| `reports/report.gohtml` | Go template producing the terminal box-drawing report, rendered by `gomplate` |
| `reports/sample-output.json` | Hand-crafted example report for offline renderer testing |
| `inventory.ini` | Ansible inventory with all platform groups and connection vars |
| `run.sh` | End-to-end wrapper: run ansible → find latest JSON → render |
| `Dockerfile.ansible` | Ansible control node image (all collections + Python libs) |
| `Dockerfile.alpine-host` | Alpine VM image (Docker daemon + TTY menu + web UI) |
| `scripts/build-ansible.sh` | Builds `ansible-node` Docker image |
| `scripts/build-qemu.sh` | Converts `Dockerfile.alpine-host` → bootable qcow2 |
| `scripts/run-qemu.sh` | Launches the Alpine VM in QEMU |
| `scripts/tty-menu.sh` | Serial console menu (launched by `agetty -l` on ttyS0) |
| `webui/container-app.py` | Web UI inside the Ansible container (JSON/Markdown/PDF export) |
| `webui/app.py` | Web UI for the Alpine Docker Host VM |
| `scripts/entrypoint.sh` | Container entrypoint: web UI if no args, else `ansible-playbook` |
---
## Design Decisions & Tradeoffs
| Decision | Rationale |
|---|---|
| **Ansible** over dedicated scanners | Already deployed in most OT environments. No new agent, no new approval process. |
| **Shell-based checks** for Linux | `shell` module is the most flexible. `changed_when: false` keeps runs clean. |
| **Native modules** for Windows/Cisco | `win_security_policy`, `win_service_info`, `ios_command`, `vmware_host_lockdown_info` — typed return values avoid brittle text parsing. |
| **`delegate_to: localhost` for VMware** | vSphere API is consumed from the control node; no SSH to ESXi. |
| **`pause` for HITL** | Ansible-native, no custom tooling. `delegate_to: localhost` ensures the prompt always reaches the operator regardless of the remote target. |
| **Inline `set_fact`** vs custom module | Custom modules require Python on the control node. Inline facts work everywhere and are easier to audit. |
| **`test_results[]` list** vs file-per-test | A single growing list is simpler than per-file concatenation. At 100+ tests the memory footprint is negligible. |
| **JSON as canonical output** | Machine-readable, schema-validatable, ingestible by SIEM/SOAR/Jira/ServiceNow. |
| **Go templates for rendering** | `text/template` supports external template files so reports can be restyled without modifying Go code. |
### Known Limitations
1. **Shell-heavy for Linux**: Linux checks depend on shell commands. Different
distros may use different paths or tools. Mitigate with
`when: ansible_os_family == 'Debian'` variants.
2. **No diff / drift detection**: Each run is independent. To detect configuration
drift between runs, diff two JSON reports externally (`jd`, `diff`,
or a time-series database).
3. **No CI exit code**: `ansible-playbook` exits 0 unless a task fails without
`ignore_errors`. For pipeline gates, parse `summary.failed` from the JSON
report and exit non-zero if `> 0`.
4. **HITL tests block automation**: Any playbook containing HITL tests requires
an interactive terminal. Keep HITL tests in separate playbooks or use Ansible
tags to separate them from fully-automated runs.
5. **Scalability at 500+ targets**: Multi-host runs work well, but one JSON file
per host can be unwieldy. Consider post-processing into a single aggregated
report.
---
## Comparison to Alternatives
| Tool | Type | Pros | Cons |
|---|---|---|---|
| **Inspec** | Ruby DSL, Chef ecosystem | Rich compliance profiles, CIS/STIG built-in | Ruby runtime; less common in OT |
| **Goss** | YAML config, Go binary | Fast, simple | No native IEC mapping; local checks only |
| **OpenSCAP** | XML/SCAP standard | NIST/STIG aligned, XCCDF/OVAL | Heavy, complex, US-govt focused, Linux-only |
| **Lynis** | Shell script | Broad Linux coverage | Non-extensible output; Linux-only |
| **This project** | Ansible + JSON + Go/Python | Zero new agents; multi-platform; IEC 62443 mapped; HITL support | Requires Ansible; shell-dependent Linux checks |
---
## Roadmap
- [x] FR1, FR2, FR5 core suites for Linux
- [x] Multi-platform examples: Windows, MSSQL, VMware, Hyper-V, Cisco IOS, Cisco ASA
- [x] Human-in-the-Loop test pattern with `ansible.builtin.pause`
- [x] Four copy-and-fill test templates (shell, stat, service_facts, HITL)
- [ ] **FR3 suite**: File integrity (AIDE/IMA), malware scanner status, secure boot, `/tmp noexec`
- [ ] **FR4 suite**: TLS version/cipher audit, disk encryption (LUKS), SSH cipher hardening
- [ ] **FR6 suite**: rsyslog remote forwarding, auditd dispatcher, journald persistent storage
- [ ] **FR7 suite**: Disk quotas, CPU/memory limits, backup schedule verification
- [ ] **Aggregated multi-host report**: Single HTML/PDF across all inventory hosts
- [ ] **CI/CD integration**: GitHub Actions / GitLab CI pipeline with Markdown report posted as PR comment
- [ ] **CIS Benchmark dual-mapping**: Each test maps to both IEC 62443-3-3 SR and CIS Benchmark control
Set the CI image reference in `.gitlab-ci.yml` or publish it as `$CI_REGISTRY_IMAGE/ansible:latest`.
+85
View File
@@ -0,0 +1,85 @@
---
# Canonical project and asset inventory.
#
# This is both an Ansible YAML inventory and the input consumed by GitLab CI.
# Keep credentials out of this file. GitLab selects protected variables by the
# test_project.environment value, which must match TARGET_ENVIRONMENT.
all:
vars:
test_project:
id: "replace-with-project-id"
name: "Replace with project name"
environment: "test"
customer: ""
location: ""
children:
linux_vms:
hosts: {}
# Example:
# linux-app-01:
# ansible_host: 192.0.2.10
# asset_type: linux_vm
# test_profiles: [iec62443, sbom]
windows_servers:
vars:
ansible_connection: winrm
ansible_winrm_transport: ntlm
ansible_winrm_server_cert_validation: ignore
ansible_port: 5985
hosts: {}
windows_clients:
vars:
ansible_connection: winrm
ansible_winrm_transport: ntlm
ansible_winrm_server_cert_validation: ignore
ansible_port: 5985
hosts: {}
mssql_servers:
vars:
ansible_connection: winrm
ansible_winrm_transport: ntlm
ansible_winrm_server_cert_validation: ignore
ansible_port: 5985
hosts: {}
vmware_esxi:
vars:
ansible_connection: local
vmware_validate_certs: false
hosts: {}
hyperv_hosts:
vars:
ansible_connection: winrm
ansible_winrm_transport: ntlm
ansible_winrm_server_cert_validation: ignore
ansible_port: 5985
hosts: {}
cisco_switches:
vars:
ansible_connection: ansible.netcommon.network_cli
ansible_network_os: cisco.ios.ios
ansible_become: true
ansible_become_method: enable
hosts: {}
cisco_firewalls:
vars:
ansible_connection: ansible.netcommon.network_cli
ansible_network_os: cisco.asa.asa
ansible_become: true
ansible_become_method: enable
hosts: {}
web_applications:
hosts: {}
# Example:
# baggage-web:
# target_url: https://baggage-test.example.com
# asset_type: web_application
# test_profiles: [zap-baseline, asvs]
+96
View File
@@ -0,0 +1,96 @@
# Test Automation Architecture
## Responsibilities
GitLab CI is the orchestrator. It validates the project configuration, starts
tool-specific Kubernetes jobs, collects raw output, invokes normalizers, and
publishes rendered reports. Ansible is one test executor alongside ZAP and
future tools; it is not the pipeline controller.
```mermaid
flowchart LR
A[assets.yml] --> V[Validate and prepare]
V --> K[k3s tool jobs]
K --> AN[Ansible]
K --> Z[ZAP]
K --> O[Other tools]
AN --> R[Raw artifacts]
Z --> R
O --> R
R --> N[Tool adapters]
N --> J[Normalized JSON]
J --> D[Markdown / HTML / PDF]
D --> G[GitLab artifacts]
```
## Data Contracts
- `assets.yml` is the canonical project and asset list. It is valid Ansible
YAML inventory and is parsed during pipeline preparation for CI metadata.
- Tool output is retained unchanged under `artifacts/raw/<tool>/`.
- Every adapter writes schema-valid reports under `artifacts/normalized/`.
- `schemas/test-report.schema.json` is the versioned interface between tools
and report generation. Standards mappings belong on individual results, so
IEC 62443 and OWASP ASVS findings can coexist without flattening semantics.
- Generated Markdown, HTML, and PDF files are stored under
`artifacts/rendered/` and uploaded as GitLab artifacts.
- `TARGET_ENVIRONMENT` selects GitLab environment-scoped variables and must
match the environment declared in `assets.yml`. Only tool jobs declare that
GitLab environment, keeping secrets out of validation and rendering jobs.
## Kubernetes State
The `test-automation` namespace contains reusable platform state. The initial
manifests request one volume using the cluster's default StorageClass:
`tool-cache` is a long-lived, replaceable cache for downloaded vulnerability
databases, scanner rules, and package indexes.
Pipeline evidence is never authoritative on a PVC. GitLab artifacts are the
immutable record and receive an explicit retention policy. Databases that
require transactions or concurrent writers should use a dedicated StatefulSet
and PVC rather than the shared cache. Back up only state that cannot be
reconstructed from an upstream feed.
ZAP jobs are ephemeral and receive no persistent ZAP home by default. This
prevents sessions, authentication state, and target data from leaking between
projects. Only a future explicitly reviewed ZAP add-on cache should use
`tool-cache`.
## Job Isolation
Each GitLab CI job is already an ephemeral pod because `testserv` uses the
Kubernetes executor. Tool jobs receive the checked-out project, narrowly scoped
credentials, resource requests and limits, and the shared tool cache. Output is
written to the GitLab workspace and uploaded directly as pipeline artifacts.
NetworkPolicy should be added once target ranges and proxy requirements are known.
## Required Runner Configuration
The runner manager on `testserv` uses the `ci/gitlab-runner` ServiceAccount.
Configure its Helm values so `[runners.kubernetes].namespace` is
`test-automation`, and mount the `tool-cache` PVC at `/cache/tools`. The
cross-namespace RoleBinding in `platform/kubernetes/runner-rbac.yml` grants only
the pod, attach, log, Secret, Service, and event operations required by GitLab's
Kubernetes executor.
Bootstrap the namespace, storage, and RBAC once with an administrator context:
```bash
kubectl apply -k platform/kubernetes
```
CI tool pods do not need Kubernetes API credentials. The runner manager uses its
in-cluster identity to create and clean them up. Do not copy the admin kubeconfig
into GitLab CI.
## Delivery Sequence
1. Make inventory validation, schema validation, and report rendering mandatory.
2. Run Ansible in its GitLab Kubernetes-executor pod and normalize its JSON.
3. Add ZAP Automation Framework plans and a ZAP-to-common-schema adapter with
ASVS mappings.
4. Add SBOM generation through Ansible for Windows and Linux targets; preserve
CycloneDX as a raw artifact and normalize policy findings separately.
5. Add aggregate project reports and quality-gate policies after result semantics
are stable.
+70
View File
@@ -0,0 +1,70 @@
# Environment-Specific Secrets
GitLab CI/CD variables are the secret source of record. `assets.yml` contains
only non-secret project, host, and test-profile data.
## Environment Selection
Start a pipeline with `TARGET_ENVIRONMENT` set to the intended GitLab
environment scope, for example `test`, `acceptance`, or `production`. The value
must exactly match `all.vars.test_project.environment` in `assets.yml`.
Tool jobs declare:
```yaml
environment:
name: "$TARGET_ENVIRONMENT"
action: verify
```
GitLab therefore injects variables matching that environment scope only into
tool jobs. Validation and report jobs do not declare an environment and should
not receive these credentials.
Configure each secret under **Settings > CI/CD > Variables** with:
- an exact environment scope such as `test` or `production`;
- **Protected** enabled for protected environments;
- **Masked** or **Masked and hidden** where the value format permits it;
- **File** type for keys, certificates, and structured variable files.
Do not enable `CI_DEBUG_TRACE` in pipelines that receive secrets.
## Ansible Variables
Create `ANSIBLE_SECRET_VARS` as an environment-scoped **File** variable. Its
contents are an Ansible YAML or JSON variables file, for example:
```yaml
ansible_user: "DOMAIN\\automation-user"
ansible_password: "replace-in-gitlab"
ansible_become_password: "replace-in-gitlab"
vcenter_username: "automation@vsphere.local"
vcenter_password: "replace-in-gitlab"
```
The `test:ansible` job checks that the variable resolves to a file and exports
its temporary path as `ANSIBLE_VARS_FILE`. The methodology runner passes it with
`--extra-vars @<file>` without printing the contents or putting values in the
process command line.
For SSH key authentication, add `ANSIBLE_PRIVATE_KEY_FILE` as a separate
environment-scoped **File** variable. The methodology runner passes that path through
`--private-key` when present.
The current job assumes one credential set per test environment. Environments
with distinct Windows, Linux, network, or hypervisor credentials should be split
into separate tool jobs, each referencing its own scoped File variable.
## ZAP Variables
ZAP authentication will use individually masked variables referenced by its
Automation Framework plan, such as `ZAP_USERNAME`, `ZAP_PASSWORD`, or
`ZAP_AUTH_HEADER_VALUE`. Define only those required by the selected application.
The ZAP adapter and authentication plan are intentionally not enabled yet.
## Rotation
Rotate a secret by replacing the value in each GitLab environment scope. No
repository change is required. Existing artifacts contain normalized findings,
not the GitLab variable files, and the pipeline never uploads secret paths.
-136
View File
@@ -1,136 +0,0 @@
# inventory.ini — Target hosts for IEC 62443-3-3 SL2 compliance validation
#
# Each platform type has its own group with the connection variables
# required by the matching example playbook in playbooks/examples/.
#
# Store secrets in Ansible Vault:
# ansible-vault encrypt_string 'MyP@ss' --name ansible_password
#
# Run a specific platform:
# ansible-playbook -i inventory.ini playbooks/examples/windows_server.yml
# ansible-playbook -i inventory.ini playbooks/examples/cisco_switch.yml
#
# Run all Linux assets:
# ansible-playbook -i inventory.ini playbooks/site.yml --limit linux_vms -K
# ── Local control-node self-test ─────────────────────────────────────────────
[all]
localhost ansible_connection=local
# ── Linux VMs / Servers (SSH — native Ansible) ───────────────────────────────
# Example: playbooks/examples/linux_vm.yml
[linux_vms]
# linux-vm-01.example.com ansible_user=auditor
# linux-vm-02.example.com ansible_user=auditor ansible_become=yes
# ── Windows Servers (WinRM) ───────────────────────────────────────────────────
# Example: playbooks/examples/windows_server.yml
# Preferred transport: kerberos (domain) or ntlm (workgroup/local admin)
[windows_servers]
# win-srv-01.example.com
# win-srv-02.example.com
[windows_servers:vars]
ansible_connection=winrm
ansible_winrm_transport=ntlm
ansible_winrm_server_cert_validation=ignore
ansible_port=5985
# ansible_user=DOMAIN\auditor
# ansible_password="{{ vault_win_password }}"
# ── Windows Clients / Workstations (WinRM) ────────────────────────────────────
# Example: playbooks/examples/windows_client.yml
[windows_clients]
# win-ws-01.example.com
# win-ws-02.example.com
[windows_clients:vars]
ansible_connection=winrm
ansible_winrm_transport=ntlm
ansible_winrm_server_cert_validation=ignore
ansible_port=5985
# ansible_user=DOMAIN\auditor
# ansible_password="{{ vault_win_password }}"
# ── MS SQL Servers (WinRM to Windows host; SQL queried via PowerShell) ────────
# Example: playbooks/examples/mssql_server.yml
[mssql_servers]
# sql-srv-01.example.com mssql_instance=MSSQLSERVER
# sql-srv-02.example.com mssql_instance=NAMED_INSTANCE
[mssql_servers:vars]
ansible_connection=winrm
ansible_winrm_transport=ntlm
ansible_winrm_server_cert_validation=ignore
ansible_port=5985
# ansible_user=DOMAIN\auditor
# ansible_password="{{ vault_win_password }}"
# ── VMware vSphere ESXi Hosts (vSphere API via vCenter — no SSH) ──────────────
# Example: playbooks/examples/vmware_vsphere.yml
# The inventory host IS the ESXi hostname. Connection goes via vCenter API.
[vmware_esxi]
# esxi-01.example.com
# esxi-02.example.com
[vmware_esxi:vars]
ansible_connection=local
vcenter_hostname=vcenter.example.com
vcenter_username=audit@vsphere.local
# vcenter_password="{{ vault_vcenter_password }}"
vmware_validate_certs=false
# ── Hyper-V Clusters (WinRM to cluster node) ──────────────────────────────────
# Example: playbooks/examples/hyperv_cluster.yml
[hyperv_hosts]
# hv-node-01.example.com
# hv-node-02.example.com
[hyperv_hosts:vars]
ansible_connection=winrm
ansible_winrm_transport=ntlm
ansible_winrm_server_cert_validation=ignore
ansible_port=5985
# ansible_user=DOMAIN\auditor
# ansible_password="{{ vault_win_password }}"
# ── Cisco Switches (IOS / IOS-XE — SSH via network_cli) ──────────────────────
# Example: playbooks/examples/cisco_switch.yml
[cisco_switches]
# sw-core-01.example.com
# sw-acc-01.example.com
[cisco_switches:vars]
ansible_connection=ansible.netcommon.network_cli
ansible_network_os=cisco.ios.ios
ansible_become=yes
ansible_become_method=enable
# ansible_user=audit
# ansible_password="{{ vault_ios_password }}"
# ansible_become_password="{{ vault_ios_enable }}"
# ── Cisco Firewalls (ASA — SSH via network_cli) ───────────────────────────────
# Example: playbooks/examples/cisco_firewall.yml
[cisco_firewalls]
# asa-fw-01.example.com
# asa-fw-02.example.com
[cisco_firewalls:vars]
ansible_connection=ansible.netcommon.network_cli
ansible_network_os=cisco.asa.asa
ansible_become=yes
ansible_become_method=enable
# ansible_user=audit
# ansible_password="{{ vault_asa_password }}"
# ansible_become_password="{{ vault_asa_enable }}"
# ── Convenience group: all ICS/OT assets (excludes localhost) ────────────────
[ics_assets:children]
linux_vms
windows_servers
windows_clients
mssql_servers
vmware_esxi
hyperv_hosts
cisco_switches
cisco_firewalls
-21
View File
@@ -1,21 +0,0 @@
---
title: list of testing types and tools
state: draft
date: 20260921
---
## List of tools
|#|Testing Lane|Current State|Cadence Signal|Evidence / Owner Confidence|Purpose / Descriptor|Automation Likelihood|Rationale|Typical Pipeline Stage|
|---|---|---|---|---|---|---|---|---|
|1|SAST|Strong|Every code change|Pipeline results, supported|Identifies coding flaws, insecure patterns and implementation weaknesses in source code.|**Very High**|Mature tools with deterministic execution and immediate developer feedback.|Commit / Pull Request|
|2|SCA / SBOM|Strong|Build + regular monitoring|SBOM, vulnerability records, supported|Detects vulnerable dependencies, license issues and component supply-chain risks.|**Very High**|Fully automatable through build integration and continuous monitoring.|Build + Continuous Monitoring|
|3|Component / API Testing|Partial|After successful Stage 1|Coverage and test results, partial|Verifies service contracts, interfaces, business logic and API behavior.|**High**|Easily automated when interfaces are stable and testable.|CI / Integration|
|4|IAST|Decision Needed|Not defined|Scope, tool and owner to confirm|Runtime analysis during test execution to identify security weaknesses with application context.|**High**|Generally automated once tooling and deployment model are established.|Integration / Pre-production|
|5|DAST / Runtime Scan (ASVS 5)|Gap / Partial|Nightly / Release Target|Scan report, owner to confirm|Detects externally observable vulnerabilities in deployed applications.|**High**|Automated scanning is straightforward, but tuning and triage require human involvement.|Nightly / Release Validation|
|6|DAST - Destructive Pen Test|Gap|Release / Major Change|Security assessment report|Validates resilience against aggressive attack scenarios that may disrupt service.|**Low**|Requires controlled environments, expert judgment and risk management.|Pre-release / Special Campaign|
|7|Performance / Load / Fuzz Testing|Gap|Nightly + Daily Target|Trend analysis, thresholds, owner to confirm|Measures scalability, robustness and resistance to malformed inputs.|**High**|Modern load and fuzz frameworks automate execution and trending effectively.|Nightly / Continuous Validation|
|8|Integration / Regression Testing|Gap / Partial|Daily + SIT|Test suite results, shared ownership|Verifies system interactions and prevents previously corrected defects from reappearing.|**Very High**|Core CI/CD practice with strong automation support.|CI / SIT|
|9|Operational Vulnerability Scan|Gap / Partial|Regular Operations|Rapid7 / OBOM Scanning?|Assesses deployed environments, hosts, middleware and infrastructure exposure.|**High**|Scanning can be fully automated, remediation remains operationally driven.|Operational / Continuous Monitoring|
|10|FAT / SAT|Partial|Per Project / On-site|Project package, scope to confirm|Confirms contractual and operational acceptance criteria before handover.|**Low-Medium**|Some execution can be automated, but customer validation is largely manual.|Project Gate|
|11|Hardening Benchmark|Partial|Release (& Operational?)|Benchmark reports|Validates compliance with secure configuration standards and baselines.|**High**|CIS, DISA STIG and platform baseline checks are highly automatable.|Release + Operations|
+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,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
@@ -7,10 +7,10 @@
# Collections : cisco.asa, ansible.netcommon (installed in ansible-node image)
# Python pkg : paramiko (installed in ansible-node image)
#
# Inventory group : [cisco_firewalls] (see inventory.ini)
# Inventory group : cisco_firewalls (see assets.yml)
#
# Run:
# ansible-playbook -i inventory.ini playbooks/examples/cisco_firewall.yml
# 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.
@@ -7,10 +7,10 @@
# Collections : cisco.ios, ansible.netcommon (installed in ansible-node image)
# Python pkg : paramiko, netmiko (installed in ansible-node image)
#
# Inventory group : [cisco_switches] (see inventory.ini)
# Inventory group : cisco_switches (see assets.yml)
#
# Run:
# ansible-playbook -i inventory.ini playbooks/examples/cisco_switch.yml
# 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.
@@ -7,10 +7,10 @@
# Collections : ansible.windows
# Python pkg : pywinrm
#
# Inventory group : [hyperv_hosts] (see inventory.ini)
# Inventory group : hyperv_hosts (see assets.yml)
#
# Run:
# ansible-playbook -i inventory.ini playbooks/examples/hyperv_cluster.yml
# 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)
@@ -6,11 +6,11 @@
# Connection : SSH — native Ansible, no extra collection required
# Privilege : become: yes (sudo) for /etc/shadow, audit rules, sysctl
#
# Inventory group : [linux_vms] (see inventory.ini)
# Inventory group : linux_vms (see assets.yml)
#
# Run:
# ansible-playbook -i inventory.ini playbooks/examples/linux_vm.yml -K
# ansible-playbook -i inventory.ini playbooks/examples/linux_vm.yml \
# 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):
@@ -9,13 +9,13 @@
# Collections : ansible.windows
# Python pkg : pywinrm
#
# Inventory group : [mssql_servers] (see inventory.ini)
# 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 inventory.ini playbooks/examples/mssql_server.yml
# ansible-playbook -i assets.yml methodologies/ansible/playbooks/examples/mssql_server.yml
#
# Prerequisites on target:
# - SQLPS or SqlServer PowerShell module (Invoke-Sqlcmd)
@@ -9,14 +9,14 @@
# Collections : community.vmware (installed in ansible-node image)
# Python pkg : pyvmomi (installed in ansible-node image)
#
# Inventory group : [vmware_esxi] (see inventory.ini)
# 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 inventory.ini playbooks/examples/vmware_vsphere.yml
# ansible-playbook -i assets.yml methodologies/ansible/playbooks/examples/vmware_vsphere.yml
#
# Read-only vCenter role needed (minimum permissions):
# Host → Configuration → Security Profile → View
@@ -7,10 +7,10 @@
# Collections : ansible.windows
# Python pkg : pywinrm
#
# Inventory group : [windows_clients] (see inventory.ini)
# Inventory group : windows_clients (see assets.yml)
#
# Run:
# ansible-playbook -i inventory.ini playbooks/examples/windows_client.yml
# ansible-playbook -i assets.yml methodologies/ansible/playbooks/examples/windows_client.yml
#
# Notes:
# - Operator HMI workstations often run as local accounts (not domain-joined).
@@ -8,10 +8,10 @@
# Python pkg : pywinrm (installed in ansible-node image)
# Privilege : No become required — WinRM user needs local admin rights
#
# Inventory group : [windows_servers] (see inventory.ini)
# Inventory group : windows_servers (see assets.yml)
#
# Run:
# ansible-playbook -i inventory.ini playbooks/examples/windows_server.yml
# ansible-playbook -i assets.yml methodologies/ansible/playbooks/examples/windows_server.yml
#
# WinRM quick-enable on target (run as Administrator):
# winrm quickconfig -q
@@ -8,7 +8,7 @@
# 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 reports/<inventory_hostname>-<date>.json
# 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".
@@ -58,5 +58,5 @@
- name: "REPORT: Write JSON to local file"
ansible.builtin.copy:
content: "{{ __report | to_nice_json(indent=2) }}"
dest: "./reports/{{ inventory_hostname }}-{{ ansible_date_time.date }}.json"
dest: "{{ report_dir }}/{{ inventory_hostname }}-{{ ansible_date_time.date }}.json"
delegate_to: localhost
@@ -9,8 +9,8 @@
# 4. Invokes library/report.yml to aggregate test_results[] and write JSON
#
# Usage:
# ansible-playbook -i inventory.ini playbooks/site.yml --limit <host> -K
# ./run.sh --limit <host> -K
# 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:
@@ -21,14 +21,14 @@
# ignore_errors: yes
#
# Variables:
# report_dir: Where JSON reports land (default: ./reports, created locally)
# 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: "./reports"
report_dir: "./artifacts/raw/ansible"
pre_tasks:
- name: "Ensure report directory exists"
+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())
+15
View File
@@ -0,0 +1,15 @@
# OWASP ZAP Methodology
ZAP performs web application tests from ephemeral GitLab Kubernetes-executor pods. Targets come from the `web_applications` group in root `assets.yml`; authentication values come from environment-scoped GitLab variables.
The OWASP ASVS source material used to develop the finding-to-control mapping is retained under `reference/OWASP_ASVS`.
`run.sh` executes ZAP baseline scanning and a focused TLS preflight, because ZAP
does not enumerate all protocol and cipher weaknesses. `scripts/normalize.py`
maps both evidence sources to OWASP ASVS 5 controls using `asvs-mapping.json`.
Run with:
```bash
./methodologies/zap/run.sh https://target.example
```
+10
View File
@@ -0,0 +1,10 @@
{
"10020": ["3.4.6"],
"10021": ["3.2.1"],
"10035": ["3.4.1"],
"10036": ["13.2.1"],
"10038": ["3.4.3"],
"TLS-OLD-PROTOCOL": ["12.1.1"],
"TLS-SELF-SIGNED": ["12.2.2"],
"TLS-WEAK-CIPHER": ["12.1.1"]
}
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -euo pipefail
TARGET_URL="${1:?Usage: run.sh https://target}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPOSITORY_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
ARTIFACT_ROOT="${ARTIFACT_ROOT:-$REPOSITORY_DIR/artifacts}"
RAW_DIR="$ARTIFACT_ROOT/raw/zaproxy"
NORMALIZED_DIR="$ARTIFACT_ROOT/normalized"
mkdir -p "$RAW_DIR" "$NORMALIZED_DIR"
zap-baseline.py -t "$TARGET_URL" -J "$RAW_DIR/zap.json" -I
python3 "$SCRIPT_DIR/scripts/tls-probe.py" "$TARGET_URL" "$RAW_DIR/tls.json"
if [ "${NORMALIZE_ZAP:-true}" = "true" ]; then
python3 "$SCRIPT_DIR/scripts/normalize.py" \
"$RAW_DIR/zap.json" \
"$RAW_DIR/tls.json" \
"$NORMALIZED_DIR/zaproxy-demo-web.json" \
--target "$TARGET_URL" \
--mapping "$SCRIPT_DIR/asvs-mapping.json" \
--schema "$REPOSITORY_DIR/schemas/test-report.schema.json"
fi
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""Normalize ZAP baseline alerts and TLS preflight findings with ASVS mappings."""
import argparse
import json
import os
from datetime import datetime, timezone
from pathlib import Path
from jsonschema import Draft202012Validator, FormatChecker
RISK = {"0": "info", "1": "low", "2": "medium", "3": "high", "4": "critical"}
def standards(mapping: dict[str, list[str]], finding_id: str) -> list[dict[str, str]]:
return [
{"framework": "OWASP ASVS", "version": "5.0", "control": control}
for control in mapping.get(finding_id, [])
]
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("zap_json", type=Path)
parser.add_argument("tls_json", type=Path)
parser.add_argument("output", type=Path)
parser.add_argument("--target", required=True)
parser.add_argument("--mapping", type=Path, default=Path("methodologies/zap/asvs-mapping.json"))
parser.add_argument("--schema", type=Path, default=Path("schemas/test-report.schema.json"))
args = parser.parse_args()
zap = json.loads(args.zap_json.read_text(encoding="utf-8"))
tls = json.loads(args.tls_json.read_text(encoding="utf-8"))
mapping = json.loads(args.mapping.read_text(encoding="utf-8"))
results = []
for site in zap.get("site", []):
for alert in site.get("alerts", []):
finding_id = str(alert.get("pluginid", alert.get("alertRef", "ZAP-UNKNOWN")))
instances = alert.get("instances", [])
observed = "; ".join(str(item.get("uri", "")) for item in instances[:5])
results.append(
{
"id": f"ZAP-{finding_id}",
"title": str(alert.get("alert", "ZAP finding")),
"description": str(alert.get("desc", "")),
"status": "failed",
"severity": RISK.get(str(alert.get("riskcode", "0")), "info"),
"category": "Web application security",
"expected": "No ZAP alert",
"observed": observed or str(alert.get("evidence", "")),
"remediation": str(alert.get("solution", "Review and remediate the finding.")),
"standards": standards(mapping, finding_id),
"evidence": [{"type": "text", "name": "ZAP alert", "value": observed or str(alert)}],
}
)
for finding in tls.get("findings", []):
finding_id = str(finding["id"])
results.append(
{
"id": finding_id,
"title": str(finding["title"]),
"description": str(finding.get("description", "")),
"status": "failed",
"severity": str(finding.get("severity", "medium")),
"category": "TLS configuration",
"expected": "Current TLS protocol, cipher, and certificate configuration",
"observed": str(finding.get("evidence", ""))[-2000:],
"remediation": str(finding.get("remediation", "")),
"standards": standards(mapping, finding_id),
"evidence": [{"type": "text", "name": "TLS preflight", "value": str(finding.get("evidence", ""))[-2000:]}],
}
)
counts = dict.fromkeys(("passed", "failed", "errors", "skipped", "review"), 0)
counts["failed"] = len(results)
now = datetime.now(timezone.utc).isoformat()
report = {
"schema_version": "1.0.0",
"run": {
"id": os.environ.get("CI_PIPELINE_ID", now),
"started_at": now,
"source": "gitlab" if os.environ.get("CI") else "local",
"pipeline_url": os.environ.get("CI_PIPELINE_URL", ""),
"commit_sha": os.environ.get("CI_COMMIT_SHA", ""),
},
"project": {
"id": os.environ.get("TEST_PROJECT_ID", "demo-ubuntu-weak"),
"name": os.environ.get("TEST_PROJECT_NAME", "Ubuntu Weak Target Demonstration"),
"environment": os.environ.get("TEST_ENVIRONMENT", "test"),
"customer": os.environ.get("TEST_CUSTOMER", "Internal"),
"location": os.environ.get("TEST_LOCATION", "testserv"),
},
"tool": {"id": "zaproxy", "name": "OWASP ZAP with TLS preflight", "adapter_version": "1.0.0"},
"target": {"id": "demo-web", "type": "web_application", "address": args.target, "groups": ["web_applications"]},
"summary": {"total": len(results), **counts, "score": 0},
"results": results,
"raw_artifacts": [str(args.zap_json), str(args.tls_json)],
}
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(results)} web findings")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Collect focused TLS evidence that ZAP baseline does not enumerate."""
import argparse
import json
import subprocess
from pathlib import Path
from urllib.parse import urlparse
def openssl_handshake(host: str, port: int, option: str, cipher: str | None = None) -> tuple[bool, str]:
command = ["openssl", "s_client", "-connect", f"{host}:{port}", "-servername", host, option, "-brief"]
if cipher:
command.extend(["-cipher", cipher])
result = subprocess.run(command, input="", text=True, capture_output=True, timeout=20, check=False)
evidence = (result.stdout + result.stderr).strip()
return result.returncode == 0 and "Protocol version" in evidence, evidence[-2000:]
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("url")
parser.add_argument("output", type=Path)
args = parser.parse_args()
parsed = urlparse(args.url)
host = parsed.hostname
port = parsed.port or 443
if not host:
parser.error("URL must include a hostname")
findings = []
for option, label in (("-tls1", "TLS 1.0"), ("-tls1_1", "TLS 1.1")):
accepted, evidence = openssl_handshake(host, port, option, "AES128-SHA:@SECLEVEL=0")
if accepted:
findings.append(
{
"id": "TLS-OLD-PROTOCOL",
"title": f"Server accepts {label}",
"severity": "high",
"description": "The endpoint accepts an obsolete TLS protocol.",
"remediation": "Allow only TLS 1.2 and TLS 1.3.",
"evidence": evidence,
}
)
weak_cipher, cipher_evidence = openssl_handshake(host, port, "-tls1_2", "AES128-SHA:@SECLEVEL=0")
if weak_cipher:
findings.append(
{
"id": "TLS-WEAK-CIPHER",
"title": "Server accepts TLS_RSA_WITH_AES_128_CBC_SHA",
"severity": "medium",
"description": "The endpoint accepts a legacy RSA/CBC cipher suite.",
"remediation": "Use forward-secret AEAD cipher suites.",
"evidence": cipher_evidence,
}
)
verification = subprocess.run(
[
"openssl",
"s_client",
"-connect",
f"{host}:{port}",
"-servername",
host,
"-verify_return_error",
"-brief",
],
input="",
text=True,
capture_output=True,
timeout=20,
check=False,
)
verification_evidence = (verification.stdout + verification.stderr).strip()
if verification.returncode != 0 and "certificate verify failed" in verification_evidence.lower():
findings.append(
{
"id": "TLS-SELF-SIGNED",
"title": "TLS certificate is not publicly trusted",
"severity": "medium",
"description": "Default certificate verification rejected the endpoint certificate.",
"remediation": "Install a certificate issued by a trusted CA for the environment.",
"evidence": verification_evidence[-2000:],
}
)
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps({"target": args.url, "findings": findings}, indent=2) + "\n", encoding="utf-8")
print(f"Collected {len(findings)} TLS findings")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+23
View File
@@ -0,0 +1,23 @@
# Merge this fragment into the existing testserv GitLab Runner Helm values.
# Keep the runner manager in namespace "ci"; only CI job pods move.
runners:
config: |
[[runners]]
name = "testserv-k3s-runner"
url = "https://gitlab.com/"
executor = "kubernetes"
[runners.kubernetes]
namespace = "test-automation"
image = "alpine:3.20"
helper_image = "registry.gitlab.com/gitlab-org/gitlab-runner/gitlab-runner-helper:x86_64-latest"
privileged = false
poll_timeout = 600
cpu_request = "250m"
memory_request = "256Mi"
cpu_limit = "2"
memory_limit = "2Gi"
[[runners.kubernetes.volumes.pvc]]
name = "tool-cache"
mount_path = "/cache/tools"
+6
View File
@@ -0,0 +1,6 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- namespace.yml
- runner-rbac.yml
- storage.yml
+6
View File
@@ -0,0 +1,6 @@
apiVersion: v1
kind: Namespace
metadata:
name: test-automation
labels:
app.kubernetes.io/part-of: test-automation
+41
View File
@@ -0,0 +1,41 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: gitlab-runner-executor
namespace: test-automation
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["create", "delete", "get", "list", "watch"]
- apiGroups: [""]
resources: ["pods/attach"]
verbs: ["create", "delete", "get", "patch"]
- apiGroups: [""]
resources: ["pods/log"]
verbs: ["get", "list"]
- apiGroups: [""]
resources: ["secrets"]
verbs: ["create", "delete", "get", "update"]
- apiGroups: [""]
resources: ["serviceaccounts"]
verbs: ["get"]
- apiGroups: [""]
resources: ["services"]
verbs: ["create", "delete", "get"]
- apiGroups: [""]
resources: ["events"]
verbs: ["list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: gitlab-runner-executor
namespace: test-automation
subjects:
- kind: ServiceAccount
name: gitlab-runner
namespace: ci
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: gitlab-runner-executor
+14
View File
@@ -0,0 +1,14 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: tool-cache
namespace: test-automation
labels:
app.kubernetes.io/part-of: test-automation
app.kubernetes.io/component: tool-cache
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 20Gi
-185
View File
@@ -1,185 +0,0 @@
#!/usr/bin/env python3
"""
render_report.py — Render IEC 62443-3-3 SL2 compliance JSON to terminal or Markdown.
Zero dependencies beyond Python 3 stdlib (json, sys, datetime).
Output formats:
terminal (default) — Unicode box-drawing report with:
- Executive summary (total/passed/failed/compliance rate)
- Per-category results grouped by FR section
- Failure details with expected/actual/remediation for each
md — GitHub-flavored Markdown with:
- Metadata table
- Summary table
- Results table with icons
- Per-failure sections with remediation instructions
Usage:
python3 reports/render_report.py <report.json>
python3 reports/render_report.py <report.json> --format md
python3 reports/render_report.py <report.json> --format md > REPORT.md
Icon mapping:
passed == true → ✅
passed == false → ❌
passed == "review" → 🔍
passed == "skipped" → ⏭️
Severity mapping:
critical → 🔴, high → 🟠, medium → 🟡, low → 🟢
"""
import json
import sys
from datetime import datetime
ICONS = {
True: "✅",
False: "❌",
"review": "🔍",
"skipped": "⏭️",
}
SEVERITY_COLORS = {
"critical": "🔴",
"high": "🟠",
"medium": "🟡",
"low": "🟢",
}
def pass_icon(v):
if isinstance(v, bool):
return ICONS[v]
return ICONS.get(v, "❓")
def severity_icon(s):
return SEVERITY_COLORS.get(s, "⚪")
def render_terminal(report):
"""Rich terminal box-drawing report."""
meta = report["meta"]
summary = report["summary"]
results = report["results"]
failures = report.get("failures", [])
total = summary["total"]
passed = summary["passed"]
failed = summary["failed"]
skipped = summary.get("skipped", 0)
rate = (passed / total * 100) if total > 0 else 0
# Header
print("╔══════════════════════════════════════════════════════════════════════════╗")
print("║ IEC 62443-3-3 SECURITY LEVEL 2 — COMPLIANCE REPORT ║")
print("╠══════════════════════════════════════════════════════════════════════════╣")
print(f"║ Target: {meta['target']:<56s}║")
print(f"║ Standard: {meta['standard']:<56s}║")
print(f"║ Level: {meta['security_level']:<56s}║")
print(f"║ Timestamp: {meta['timestamp']:<56s}║")
print(f"║ Executed: {meta['executed_by']:<56s}║")
print("╠══════════════════════════════════════════════════════════════════════════╣")
print("║ EXECUTIVE SUMMARY ║")
print("╠══════════════════════════════════════════════════════════════════════════╣")
print(f"║ TOTAL: {total:<4d} ✅ PASS: {passed:<4d} ❌ FAIL: {failed:<4d} 🔍 REVIEW: {skipped:<4d} ║")
print(f"║ COMPLIANCE RATE: {rate:.1f}% ║")
print("╠══════════════════════════════════════════════════════════════════════════╣")
print("║ RESULTS BY TEST CASE ║")
print("╠══════════════════════════════════════════════════════════════════════════╣")
# By category
current_cat = None
for r in results:
if r["category"] != current_cat:
current_cat = r["category"]
print(f"║ ║")
print(f"║ ▸ {current_cat:<68s}║")
print(f"║ ║")
icon = pass_icon(r["passed"])
sev = severity_icon(r["severity"])
print(f"║ {sev} [{r['test_id']}] {icon} {r['description'][:60]:<60s}║")
# Failures detail
print("╠══════════════════════════════════════════════════════════════════════════╣")
print("║ FAILURE DETAILS ║")
print("╠══════════════════════════════════════════════════════════════════════════╣")
if failures:
for f in failures:
print(f"║ ║")
print(f"║ ❌ [{f['test_id']}] {f['description'][:56]:<56s}║")
print(f"║ Severity: {f['severity']:<52s}║")
print(f"║ Expected: {f['expected'][:52]:<52s}║")
print(f"║ Actual: {f['actual'][:52]:<52s}║")
print(f"║ Remediation: {f['remediation'][:52]:<52s}║")
else:
print("║ ✅ ALL CONTROLS PASSED ║")
print("╚══════════════════════════════════════════════════════════════════════════╝")
def render_markdown(report):
"""GitHub-flavored markdown report."""
meta = report["meta"]
summary = report["summary"]
results = report["results"]
failures = report.get("failures", [])
total = summary["total"]
passed = summary["passed"]
failed = summary["failed"]
rate = (passed / total * 100) if total > 0 else 0
print(f"# IEC 62443-3-3 SL2 Compliance Report")
print()
print(f"| Field | Value |")
print(f"|-------|-------|")
print(f"| Target | `{meta['target']}` |")
print(f"| Standard | {meta['standard']} |")
print(f"| Security Level | **{meta['security_level']}** |")
print(f"| Timestamp | {meta['timestamp']} |")
print(f"| Executed by | {meta['executed_by']} |")
print()
print(f"## Summary")
print()
print(f"| Total | Passed | Failed | Review | Compliance Rate |")
print(f"|-------|--------|--------|--------|-----------------|")
print(f"| {total} | {passed} | {failed} | {summary.get('skipped', 0)} | **{rate:.1f}%** |")
print()
print(f"## Results")
print()
print(f"| | ID | Requirement | Description | Expected | Actual | Severity |")
print(f"|---|----|-------------|-------------|----------|--------|----------|")
for r in results:
icon = pass_icon(r["passed"])
sev = severity_icon(r["severity"]) + " " + r["severity"]
print(f"| {icon} | {r['test_id']} | {r['requirement']} | {r['description']} | {r['expected']} | {r['actual']} | {sev} |")
if failures:
print()
print(f"## Failures ({len(failures)})")
print()
for f in failures:
print(f"### ❌ {f['test_id']}: {f['description']}")
print()
print(f"- **Severity:** {f['severity']}")
print(f"- **Expected:** {f['expected']}")
print(f"- **Actual:** {f['actual']}")
print(f"- **Remediation:** {f['remediation']}")
print()
if __name__ == "__main__":
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} <report.json> [--format md|terminal]", file=sys.stderr)
sys.exit(1)
path = sys.argv[1]
fmt = "terminal"
if len(sys.argv) > 2 and sys.argv[2] == "--format":
fmt = sys.argv[3] if len(sys.argv) > 3 else "terminal"
with open(path) as f:
report = json.load(f)
if fmt == "md":
render_markdown(report)
else:
render_terminal(report)
-66
View File
@@ -1,66 +0,0 @@
{{- /*
report.gohtml — IEC 62443-3-3 SL2 Compliance Report Template
Rendered directly by gomplate (https://gomplate.ca) — no custom Go binary
required. The JSON report is loaded as the root context, so fields are
accessed with plain dot notation (e.g. .meta.target).
Only gomplate's built-in functions (math.*, strings.*) are used, plus two
in-line sub-templates (passIcon/severityIcon) defined below.
Usage:
gomplate --context .=reports/<hostname>-<date>.json --file reports/report.gohtml
To customize: copy this file, modify, and point --file at the copy.
*/ -}}
{{- define "passIcon" -}}
{{- if eq . true }}✅ PASS{{ else if eq . false }}❌ FAIL{{ else }}❓ MANUAL{{ end -}}
{{- end -}}
{{- define "severityIcon" -}}
{{- if eq . "critical" }}🔴{{ else if eq . "high" }}🟠{{ else if eq . "medium" }}🟡{{ else if eq . "low" }}🟢{{ else }}⚪{{ end -}}
{{- end -}}
╔══════════════════════════════════════════════════════════════════════════╗
║ IEC 62443-3-3 SECURITY LEVEL 2 — COMPLIANCE REPORT ║
╠══════════════════════════════════════════════════════════════════════════╣
║ Target: {{ printf "%-56s" .meta.target }}║
║ Standard: {{ printf "%-56s" .meta.standard }}║
║ Level: {{ printf "%-56s" .meta.security_level }}║
║ Timestamp: {{ printf "%-56s" .meta.timestamp }}║
║ Executed by: {{ printf "%-55s" .meta.executed_by }}║
╠══════════════════════════════════════════════════════════════════════════╣
║ EXECUTIVE SUMMARY ║
╠══════════════════════════════════════════════════════════════════════════╣
║ ║
║ TOTAL PASSED FAILED REVIEW COMPLIANCE ║
║ ───── ────── ────── ────── ────────── ║
║ {{ printf "%-8d" .summary.total }} {{ printf "%-9d" .summary.passed }} {{ printf "%-9d" .summary.failed }} {{ printf "%-9d" .summary.skipped }} {{ if gt .summary.total 0 }}{{ printf "%.1f%%" (mul (div .summary.passed .summary.total) 100) }}{{ else }}N/A{{ end }}
║ ║
╠══════════════════════════════════════════════════════════════════════════╣
║ RESULTS BY REQUIREMENT ║
╠══════════════════════════════════════════════════════════════════════════╣
║ ║
{{- range $i, $r := .results }}
║ {{ template "severityIcon" $r.severity }} [{{ $r.test_id }}] {{ template "passIcon" $r.passed }} {{ $r.description }}
║ Requirement: {{ $r.requirement }}
║ Expected: {{ $r.expected }}
║ Actual: {{ $r.actual }}
{{- if not $r.passed }}
║ Fix: {{ $r.remediation }}
{{- end }}
║ ║
{{- end }}
╠══════════════════════════════════════════════════════════════════════════╣
║ FAILURE DETAIL ║
╠══════════════════════════════════════════════════════════════════════════╣
{{- $failures := .failures }}
{{- if $failures }}
{{- range $i, $f := $failures }}
║ ❌ {{ $f.test_id }} — {{ $f.description }}
║ Severity: {{ $f.severity | strings.Title }}
║ Remediation: {{ $f.remediation }}
║ ║
{{- end }}
{{- else }}
║ ✅ ALL CONTROLS PASSED ║
{{- end }}
╚══════════════════════════════════════════════════════════════════════════╝
+3
View File
@@ -0,0 +1,3 @@
PyYAML==6.0.2
jsonschema==4.25.1
fpdf2==2.8.4
-82
View File
@@ -1,82 +0,0 @@
#!/usr/bin/env bash
#
# run.sh — End-to-end IEC 62443-3-3 SL2 compliance test runner
#
# Orchestrates three phases:
# 1. Run ansible-playbook against the inventory (all FR suites)
# 2. Find the latest JSON report in reports/
# 3. Render it with Python (or Go, or fallback Python one-liner)
#
# Usage:
# ./run.sh # runs against all hosts
# ./run.sh --limit plc-rack01 -K # single host, ask sudo password
# ./run.sh --limit localhost -K # test locally
#
# Environment:
# INVENTORY — path to inventory file (default: ./inventory.ini)
# LIMIT — ansible --limit pattern (default: all)
#
# All extra arguments are forwarded to ansible-playbook:
# ./run.sh -vvv --limit localhost
#
# Output:
# reports/<hostname>-<date>.json — raw JSON test data
# stdout — formatted report (terminal or md)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
INVENTORY="${INVENTORY:-$SCRIPT_DIR/inventory.ini}"
LIMIT="${LIMIT:-all}"
echo "=== IEC 62443-3-3 SL2 Compliance Validation ==="
echo ""
# ── Phase 1: Run Ansible tests ──────────────────────────────────
echo "[1/3] Running compliance tests..."
ansible-playbook -i "$INVENTORY" "$SCRIPT_DIR/playbooks/site.yml" \
--limit "$LIMIT" \
"$@"
# ── Phase 2: Find latest report ─────────────────────────────────
REPORT_DIR="$SCRIPT_DIR/reports"
LATEST_JSON=$(ls -t "$REPORT_DIR"/*.json 2>/dev/null | head -1)
if [ -z "$LATEST_JSON" ]; then
echo ""
echo "✗ No JSON report generated. Check Ansible output above."
exit 1
fi
echo ""
echo "[2/3] Latest report: $(basename "$LATEST_JSON")"
# ── Phase 3: Render with gomplate ───────────────────────────────
echo "[3/3] Rendering report with gomplate..."
echo ""
if ! gomplate --context ".=$LATEST_JSON" --file "$REPORT_DIR/report.gohtml" 2>/dev/null; then
# Fallback: if gomplate isn't available, just cat the JSON
echo "---"
echo "(gomplate not available; showing raw JSON summary)"
python3 -c "
import json, sys
with open('$LATEST_JSON') as f:
r = json.load(f)
s = r['summary']
print(f'Total: {s[\"total\"]} | Passed: {s[\"passed\"]} | Failed: {s[\"failed\"]} | Rate: {s[\"passed\"]/s[\"total\"]*100:.1f}%')
print()
for t in r['results']:
icon = '✅' if t['passed'] == True else ('❌' if t['passed'] == False else '🔍')
print(f' {icon} [{t[\"test_id\"]}] {t[\"description\"]}')
print()
print('Failures:')
for f in r['failures']:
print(f' ❌ {f[\"test_id\"]}: {f[\"description\"]} (Severity: {f[\"severity\"]})')
print(f' Expected: {f[\"expected\"]}')
print(f' Actual: {f[\"actual\"]}')
print(f' Fix: {f[\"remediation\"]}')
" 2>/dev/null || cat "$LATEST_JSON"
fi
echo ""
echo "=== Done ==="
+124
View File
@@ -0,0 +1,124 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.invalid/schemas/test-report.schema.json",
"title": "Normalized Test Automation Report",
"type": "object",
"additionalProperties": false,
"required": ["schema_version", "run", "project", "tool", "target", "summary", "results"],
"properties": {
"schema_version": { "const": "1.0.0" },
"run": {
"type": "object",
"additionalProperties": false,
"required": ["id", "started_at", "source"],
"properties": {
"id": { "type": "string", "minLength": 1 },
"started_at": { "type": "string", "format": "date-time" },
"source": { "type": "string", "enum": ["gitlab", "local"] },
"pipeline_url": { "type": "string" },
"commit_sha": { "type": "string" }
}
},
"project": {
"type": "object",
"additionalProperties": false,
"required": ["id", "name", "environment"],
"properties": {
"id": { "type": "string", "minLength": 1 },
"name": { "type": "string", "minLength": 1 },
"environment": { "type": "string", "minLength": 1 },
"customer": { "type": "string" },
"location": { "type": "string" }
}
},
"tool": {
"type": "object",
"additionalProperties": false,
"required": ["id", "name"],
"properties": {
"id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]*$" },
"name": { "type": "string", "minLength": 1 },
"version": { "type": "string" },
"adapter_version": { "type": "string" }
}
},
"target": {
"type": "object",
"additionalProperties": false,
"required": ["id", "type"],
"properties": {
"id": { "type": "string", "minLength": 1 },
"type": { "type": "string", "minLength": 1 },
"address": { "type": "string" },
"groups": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }
}
},
"summary": {
"type": "object",
"additionalProperties": false,
"required": ["total", "passed", "failed", "errors", "skipped", "review"],
"properties": {
"total": { "type": "integer", "minimum": 0 },
"passed": { "type": "integer", "minimum": 0 },
"failed": { "type": "integer", "minimum": 0 },
"errors": { "type": "integer", "minimum": 0 },
"skipped": { "type": "integer", "minimum": 0 },
"review": { "type": "integer", "minimum": 0 },
"score": { "type": "number", "minimum": 0, "maximum": 100 }
}
},
"results": {
"type": "array",
"items": { "$ref": "#/$defs/result" }
},
"raw_artifacts": {
"type": "array",
"items": { "type": "string" },
"uniqueItems": true
}
},
"$defs": {
"result": {
"type": "object",
"additionalProperties": false,
"required": ["id", "title", "status", "severity"],
"properties": {
"id": { "type": "string", "minLength": 1 },
"title": { "type": "string", "minLength": 1 },
"description": { "type": "string" },
"status": { "type": "string", "enum": ["passed", "failed", "error", "skipped", "review"] },
"severity": { "type": "string", "enum": ["info", "low", "medium", "high", "critical"] },
"category": { "type": "string" },
"expected": { "type": "string" },
"observed": { "type": "string" },
"remediation": { "type": "string" },
"standards": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["framework", "control"],
"properties": {
"framework": { "type": "string" },
"version": { "type": "string" },
"control": { "type": "string" }
}
}
},
"evidence": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["type", "value"],
"properties": {
"type": { "type": "string", "enum": ["text", "file", "url"] },
"name": { "type": "string" },
"value": { "type": "string" }
}
}
}
}
}
}
}
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Combine normalized tool reports into one schema-valid project report."""
import argparse
import json
from pathlib import Path
from jsonschema import Draft202012Validator, FormatChecker
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("inputs", nargs="+", type=Path)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--schema", type=Path, default=Path("schemas/test-report.schema.json"))
args = parser.parse_args()
reports = [json.loads(path.read_text(encoding="utf-8")) for path in args.inputs]
if not reports:
parser.error("at least one normalized report is required")
project = reports[0]["project"]
run = reports[0]["run"]
results = []
raw_artifacts = []
for report in reports:
if report["project"]["id"] != project["id"]:
parser.error("all reports must belong to the same project")
tool = report["tool"]
for result in report["results"]:
combined = dict(result)
combined["id"] = f"{tool['id']}:{result['id']}"
combined["category"] = f"{tool['name']} | {result.get('category', '')}".rstrip(" |")
results.append(combined)
raw_artifacts.extend(report.get("raw_artifacts", []))
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"]
aggregate = {
"schema_version": "1.0.0",
"run": run,
"project": project,
"tool": {"id": "combined", "name": "Combined test methodologies", "adapter_version": "1.0.0"},
"target": {"id": "project-scope", "type": "test_environment", "groups": []},
"summary": {
"total": len(results),
**counts,
"score": round(counts["passed"] / scored * 100, 2) if scored else 0,
},
"results": results,
"raw_artifacts": sorted(set(raw_artifacts)),
}
schema = json.loads(args.schema.read_text(encoding="utf-8"))
Draft202012Validator(schema, format_checker=FormatChecker()).validate(aggregate)
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(aggregate, indent=2) + "\n", encoding="utf-8")
print(f"Aggregated {len(reports)} reports with {len(results)} results")
return 0
if __name__ == "__main__":
raise SystemExit(main())
-87
View File
@@ -1,87 +0,0 @@
#!/usr/bin/env bash
# ───────────────────────────────────────────────────────────
# build-ansible.sh — Build the Ansible Control Node Image
#
# Produces a Docker image with Ansible + all collections
# for Windows, Cisco, VMware, MSSQL, and Linux targets.
#
# Can be deployed on:
# • Docker Swarm / Kubernetes (native)
# • Alpine Docker Host on QEMU (docker pull + run)
# • Any Linux with Docker
#
# Usage:
# ./scripts/build-ansible.sh # local build
# ./scripts/build-ansible.sh --push # build + push to registry
# REGISTRY=my-registry ./scripts/build-ansible.sh --push
# ───────────────────────────────────────────────────────────
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
IMAGE_NAME="${IMAGE_NAME:-ansible-node}"
DOCKERFILE="${DOCKERFILE:-Dockerfile.ansible}"
REGISTRY="${REGISTRY:-}"
TAG="${TAG:-latest}"
# ── Colour helpers ──────────────────────────────────────────
RED='\033[0;31m'; GREEN='\033[0;32m'; BLUE='\033[0;34m'
BOLD='\033[1m'; NC='\033[0m'
info() { echo -e "${BLUE}[*]${NC} $*"; }
ok() { echo -e "${GREEN}[✓]${NC} $*"; }
err() { echo -e "${RED}[✗]${NC} $*"; }
# ── Build ───────────────────────────────────────────────────
echo ""
echo -e "${BOLD}══════════════════════════════════════════════${NC}"
echo -e "${BOLD} Ansible Control Node — Docker Image Builder${NC}"
echo -e "${BOLD}══════════════════════════════════════════════${NC}"
echo ""
FULL_IMAGE="${REGISTRY:+${REGISTRY}/}${IMAGE_NAME}:${TAG}"
info "Building: ${FULL_IMAGE}"
docker build \
-t "$FULL_IMAGE" \
-f "$PROJECT_DIR/${DOCKERFILE}" \
"$PROJECT_DIR"
ok "Image built: ${FULL_IMAGE}"
# ── Size report ─────────────────────────────────────────────
echo ""
info "Image layers:"
docker history "$FULL_IMAGE" --human --no-trunc | head -6
echo ""
IMAGE_SIZE=$(docker image inspect "$FULL_IMAGE" --format='{{.Size}}' | \
awk '{printf "%.0f MB", $1/1024/1024}')
ok "Total image size: ${IMAGE_SIZE}"
# ── Optional: push ──────────────────────────────────────────
if [[ "${1:-}" == "--push" ]]; then
if [ -z "$REGISTRY" ]; then
err "Set REGISTRY env var to push (e.g., REGISTRY=my-registry)"
exit 1
fi
info "Pushing: ${FULL_IMAGE}"
docker push "$FULL_IMAGE"
ok "Pushed: ${FULL_IMAGE}"
fi
# ── Usage hint ──────────────────────────────────────────────
echo ""
echo -e "${BOLD}Usage examples:${NC}"
echo ""
echo " # Run locally with mounted playbooks:"
echo " docker run --rm \\"
echo " -v \$(pwd)/playbooks:/ansible/playbooks \\"
echo " -v \$(pwd)/inventory.ini:/ansible/inventory/inventory.ini \\"
echo " ${FULL_IMAGE} site.yml"
echo ""
echo " # Pull into Alpine Docker Host:"
echo " sshpass -p ansible ssh -p 2222 ansible@localhost \\"
echo " docker pull ${FULL_IMAGE}"
echo ""
echo " # Shell into image:"
echo " docker run --rm -it ${FULL_IMAGE} --help"
echo ""
-155
View File
@@ -1,155 +0,0 @@
#!/usr/bin/env bash
# ───────────────────────────────────────────────────────────
# build-qemu.sh — Dockerfile → Bootable QEMU Disk
#
# Pipeline:
# 1. Build the Docker image (ansible-node)
# 2. Export the container rootfs as a tarball
# 3. Extract kernel + initramfs for direct -kernel boot
# 4. Create an ext4 disk image, label ANSIBLE_ROOT
# 5. Populate with rootfs contents
# 6. Optionally convert raw → qcow2 (if qemu-img available)
#
# Output (written to ./output/):
# ansible-node.qcow2 (or .raw) — root filesystem disk
# vmlinuz-virt — Linux kernel
# initramfs-virt — initramfs
#
# Prerequisites:
# docker, sudo, mkfs.ext4, optional: qemu-img
#
# Usage:
# ./scripts/build-qemu.sh # default 2GB
# DISK_SIZE_MB=4096 ./scripts/build-qemu.sh # custom size
# ───────────────────────────────────────────────────────────
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
OUTPUT_DIR="$PROJECT_DIR/output"
IMAGE_NAME="${IMAGE_NAME:-alpine-docker-host}"
DOCKERFILE="${DOCKERFILE:-Dockerfile.alpine-host}"
DISK_SIZE_MB="${DISK_SIZE_MB:-2048}"
# ── Colour helpers ──────────────────────────────────────────
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
BLUE='\033[0;34m'; BOLD='\033[1m'; NC='\033[0m'
info() { echo -e "${BLUE}[*]${NC} $*"; }
ok() { echo -e "${GREEN}[✓]${NC} $*"; }
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
err() { echo -e "${RED}[✗]${NC} $*"; }
# ── Preflight checks ────────────────────────────────────────
for cmd in docker sudo mkfs.ext4; do
if ! command -v "$cmd" &>/dev/null; then
err "Missing required tool: $cmd"
exit 1
fi
done
# ── Banner ──────────────────────────────────────────────────
echo ""
echo -e "${BOLD}══════════════════════════════════════════════${NC}"
echo -e "${BOLD} Ansible Control Node — QEMU Image Builder${NC}"
echo -e "${BOLD}══════════════════════════════════════════════${NC}"
echo ""
info "Image name: ${IMAGE_NAME}"
info "Disk size: ${DISK_SIZE_MB}MB"
info "Output dir: ${OUTPUT_DIR}"
echo ""
mkdir -p "$OUTPUT_DIR"
# ── Step 1: Build Docker image ──────────────────────────────
info "Step 1/6: Building Docker image '${IMAGE_NAME}' (${DOCKERFILE})..."
docker build \
-t "$IMAGE_NAME" \
-f "$PROJECT_DIR/${DOCKERFILE}" \
"$PROJECT_DIR"
ok "Docker image built"
# ── Step 2: Export rootfs ───────────────────────────────────
info "Step 2/6: Exporting container rootfs..."
ROOTFS_TAR="$PROJECT_DIR/.ansible-node-rootfs.tar"
CID=$(docker create "$IMAGE_NAME")
docker export "$CID" -o "$ROOTFS_TAR"
docker rm "$CID" >/dev/null
ROOTFS_SIZE=$(du -sh "$ROOTFS_TAR" | cut -f1)
ok "Rootfs exported (${ROOTFS_SIZE})"
# ── Step 3: Extract kernel + initramfs ──────────────────────
info "Step 3/6: Extracting kernel and initramfs..."
TMP_BOOT="$(mktemp -d)"
tar -xf "$ROOTFS_TAR" -C "$TMP_BOOT" boot/ 2>/dev/null
# Find kernel/initramfs (handle different naming patterns)
KERNEL_SRC=$(find "$TMP_BOOT/boot" -name 'vmlinuz-*' 2>/dev/null | head -1)
INITRD_SRC=$(find "$TMP_BOOT/boot" -name 'initramfs-*' 2>/dev/null | head -1)
if [ -z "$KERNEL_SRC" ] || [ -z "$INITRD_SRC" ]; then
err "Could not find kernel/initramfs in rootfs."
err "Expected files in /boot/ from linux-virt package."
ls -la "$TMP_BOOT/boot/" 2>/dev/null || echo "(no /boot directory)"
rm -rf "$TMP_BOOT" "$ROOTFS_TAR"
exit 1
fi
KERNEL_NAME=$(basename "$KERNEL_SRC")
INITRD_NAME=$(basename "$INITRD_SRC")
cp "$KERNEL_SRC" "$OUTPUT_DIR/vmlinuz-virt"
cp "$INITRD_SRC" "$OUTPUT_DIR/initramfs-virt"
rm -rf "$TMP_BOOT"
ok "Kernel: ${KERNEL_NAME}"
ok "Initrd: ${INITRD_NAME}"
# ── Step 4: Create raw disk image ───────────────────────────
info "Step 4/6: Creating disk image (${DISK_SIZE_MB}MB)..."
RAW_DISK="$OUTPUT_DIR/ansible-node.raw"
dd if=/dev/zero of="$RAW_DISK" bs=1M count="$DISK_SIZE_MB" status=progress 2>/dev/null
mkfs.ext4 -q -L ANSIBLE_ROOT "$RAW_DISK"
ok "ext4 filesystem created (label: ANSIBLE_ROOT)"
# ── Step 5: Mount and populate rootfs ───────────────────────
info "Step 5/6: Populating root filesystem..."
MNT="$(mktemp -d)"
sudo mount -o loop "$RAW_DISK" "$MNT"
sudo tar -xf "$ROOTFS_TAR" -C "$MNT"
sudo umount "$MNT"
rmdir "$MNT"
ok "Rootfs written to disk"
# ── Step 6: Convert to qcow2 (optional) ─────────────────────
info "Step 6/6: Finalizing..."
if command -v qemu-img &>/dev/null; then
QCOW2_DISK="$OUTPUT_DIR/ansible-node.qcow2"
qemu-img convert -f raw -O qcow2 "$RAW_DISK" "$QCOW2_DISK"
rm "$RAW_DISK"
ok "Converted to qcow2: ansible-node.qcow2"
FINAL_DISK="$QCOW2_DISK"
FINAL_FMT="qcow2"
else
warn "qemu-img not found — keeping raw image"
ok "Raw image: ansible-node.raw"
FINAL_DISK="$RAW_DISK"
FINAL_FMT="raw"
fi
# ── Cleanup ─────────────────────────────────────────────────
rm -f "$ROOTFS_TAR"
# ── Summary ─────────────────────────────────────────────────
echo ""
echo -e "${BOLD}══════════════════════════════════════════════${NC}"
echo -e "${GREEN}${BOLD} Build complete!${NC}"
echo -e "${BOLD}══════════════════════════════════════════════${NC}"
echo ""
echo -e " Disk: ${BOLD}${FINAL_DISK}${NC} (${FINAL_FMT})"
echo -e " Kernel: ${BOLD}${OUTPUT_DIR}/vmlinuz-virt${NC}"
echo -e " Initrd: ${BOLD}${OUTPUT_DIR}/initramfs-virt${NC}"
echo ""
echo -e " Launch: ${BOLD}./scripts/run-qemu.sh${NC}"
echo ""
ls -lh "$OUTPUT_DIR/"
echo ""
-13
View File
@@ -1,13 +0,0 @@
#!/bin/sh
# ───────────────────────────────────────────────────────────
# Entrypoint: web UI by default, ansible-playbook if arguments given
#
# docker run -p 8080:8080 ansible-node → web UI
# docker run ansible-node site.yml -i inventory → ansible-playbook
# ───────────────────────────────────────────────────────────
if [ $# -eq 0 ]; then
echo "Starting web UI on http://0.0.0.0:8080"
exec python3 /usr/local/bin/container-webui.py
else
exec ansible-playbook "$@"
fi
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Validate assets.yml and expose its metadata to CI jobs."""
import argparse
import json
from pathlib import Path
from typing import Any
import yaml
REQUIRED_PROJECT_FIELDS = ("id", "name", "environment")
def load_inventory(path: Path) -> dict[str, Any]:
with path.open(encoding="utf-8") as inventory_file:
inventory = yaml.safe_load(inventory_file)
if not isinstance(inventory, dict) or not isinstance(inventory.get("all"), dict):
raise ValueError("inventory must contain an 'all' mapping")
project = inventory["all"].get("vars", {}).get("test_project")
if not isinstance(project, dict):
raise ValueError("all.vars.test_project must be a mapping")
missing = [field for field in REQUIRED_PROJECT_FIELDS if not project.get(field)]
if missing:
raise ValueError(f"test_project is missing required values: {', '.join(missing)}")
children = inventory["all"].get("children", {})
if not isinstance(children, dict):
raise ValueError("all.children must be a mapping")
return inventory
def asset_matrix(inventory: dict[str, Any]) -> list[dict[str, Any]]:
assets = []
seen = set()
for group_name, group in inventory["all"].get("children", {}).items():
if not isinstance(group, dict):
raise ValueError(f"group '{group_name}' must be a mapping")
hosts = group.get("hosts", {})
if not isinstance(hosts, dict):
raise ValueError(f"group '{group_name}'.hosts must be a mapping")
for asset_id, host_vars in hosts.items():
if asset_id in seen:
raise ValueError(f"asset '{asset_id}' is declared more than once")
seen.add(asset_id)
variables = host_vars or {}
if not isinstance(variables, dict):
raise ValueError(f"asset '{asset_id}' variables must be a mapping")
assets.append(
{
"id": asset_id,
"group": group_name,
"address": variables.get("ansible_host", variables.get("target_url", asset_id)),
"asset_type": variables.get("asset_type", group_name.rstrip("s")),
"test_profiles": variables.get("test_profiles", []),
}
)
return assets
def dotenv_value(value: Any) -> str:
return str(value).replace("\n", " ").replace("\r", " ")
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("inventory", type=Path)
parser.add_argument("--expected-environment")
parser.add_argument("--dotenv", type=Path, required=True)
parser.add_argument("--matrix", type=Path, required=True)
args = parser.parse_args()
try:
inventory = load_inventory(args.inventory)
assets = asset_matrix(inventory)
except (OSError, ValueError, yaml.YAMLError) as error:
parser.error(str(error))
project = inventory["all"]["vars"]["test_project"]
if args.expected_environment and project["environment"] != args.expected_environment:
parser.error(
"assets.yml environment "
f"'{project['environment']}' does not match TARGET_ENVIRONMENT "
f"'{args.expected_environment}'"
)
groups = sorted({asset["group"] for asset in assets})
dotenv = {
"TEST_PROJECT_ID": project["id"],
"TEST_PROJECT_NAME": project["name"],
"TEST_ENVIRONMENT": project["environment"],
"TEST_CUSTOMER": project.get("customer", ""),
"TEST_LOCATION": project.get("location", ""),
"ASSET_COUNT": len(assets),
"ASSET_GROUPS": ",".join(groups),
}
args.dotenv.parent.mkdir(parents=True, exist_ok=True)
args.matrix.parent.mkdir(parents=True, exist_ok=True)
args.dotenv.write_text(
"".join(f"{key}={dotenv_value(value)}\n" for key, value in dotenv.items()),
encoding="utf-8",
)
args.matrix.write_text(json.dumps({"assets": assets}, indent=2) + "\n", encoding="utf-8")
print(f"Validated {len(assets)} assets for project '{project['name']}'")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env python3
"""Render a normalized test report as Markdown, HTML, and PDF."""
import argparse
import html
import json
from pathlib import Path
from fpdf import FPDF
def pdf_text(value: object) -> str:
return str(value).encode("latin-1", errors="replace").decode("latin-1")
def markdown(report: dict) -> str:
project = report["project"]
tool = report["tool"]
target = report["target"]
summary = report["summary"]
lines = [
f"# {project['name']} Test Report",
"",
"| Field | Value |",
"|---|---|",
f"| Project ID | {project['id']} |",
f"| Environment | {project['environment']} |",
f"| Tool | {tool['name']} |",
f"| Target | {target['id']} |",
f"| Run | {report['run']['id']} |",
f"| Started | {report['run']['started_at']} |",
"",
"## Summary",
"",
"| Total | Passed | Failed | Errors | Skipped | Review | Score |",
"|---:|---:|---:|---:|---:|---:|---:|",
f"| {summary['total']} | {summary['passed']} | {summary['failed']} | {summary['errors']} | {summary['skipped']} | {summary['review']} | {summary.get('score', 0):.2f}% |",
"",
"## Results",
"",
"| Status | Severity | ID | Title |",
"|---|---|---|---|",
]
for result in report["results"]:
title = str(result["title"]).replace("|", "\\|")
lines.append(f"| {result['status']} | {result['severity']} | {result['id']} | {title} |")
return "\n".join(lines) + "\n"
def html_document(markdown_text: str, report: dict) -> str:
rows = "".join(
"<tr>"
f"<td>{html.escape(result['status'])}</td>"
f"<td>{html.escape(result['severity'])}</td>"
f"<td>{html.escape(result['id'])}</td>"
f"<td>{html.escape(result['title'])}</td>"
"</tr>"
for result in report["results"]
)
summary = report["summary"]
return f"""<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>{html.escape(report['project']['name'])} test report</title>
<style>body{{font:14px sans-serif;max-width:1100px;margin:40px auto;color:#17202a}}table{{border-collapse:collapse;width:100%}}th,td{{border:1px solid #ccd1d1;padding:8px;text-align:left}}th{{background:#eaecee}}.summary{{display:flex;gap:24px;margin:24px 0}}.summary strong{{font-size:24px;display:block}}</style></head>
<body><h1>{html.escape(report['project']['name'])} Test Report</h1>
<p>{html.escape(report['tool']['name'])} against {html.escape(report['target']['id'])} at {html.escape(report['run']['started_at'])}</p>
<div class="summary"><span><strong>{summary['total']}</strong>Total</span><span><strong>{summary['passed']}</strong>Passed</span><span><strong>{summary['failed']}</strong>Failed</span><span><strong>{summary.get('score', 0):.2f}%</strong>Score</span></div>
<table><thead><tr><th>Status</th><th>Severity</th><th>ID</th><th>Title</th></tr></thead><tbody>{rows}</tbody></table>
<details><summary>Markdown source</summary><pre>{html.escape(markdown_text)}</pre></details></body></html>"""
def pdf_document(report: dict, output: Path) -> None:
pdf = FPDF()
pdf.set_auto_page_break(auto=True, margin=15)
pdf.add_page()
pdf.set_font("Helvetica", "B", 18)
pdf.multi_cell(0, 10, pdf_text(f"{report['project']['name']} Test Report"), new_x="LMARGIN", new_y="NEXT")
pdf.set_font("Helvetica", size=10)
pdf.multi_cell(
0,
6,
pdf_text(f"Tool: {report['tool']['name']}\nTarget: {report['target']['id']}\nStarted: {report['run']['started_at']}"),
new_x="LMARGIN",
new_y="NEXT",
)
summary = report["summary"]
pdf.ln(3)
pdf.set_font("Helvetica", "B", 12)
pdf.cell(0, 8, f"Total {summary['total']} Passed {summary['passed']} Failed {summary['failed']} Score {summary.get('score', 0):.2f}%", new_x="LMARGIN", new_y="NEXT")
pdf.set_font("Helvetica", size=9)
for result in report["results"]:
pdf.multi_cell(
0,
5,
pdf_text(f"[{result['status'].upper()}] [{result['severity']}] {result['id']}: {result['title']}"),
new_x="LMARGIN",
new_y="NEXT",
)
pdf.output(output)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("input", type=Path)
parser.add_argument("--output-dir", type=Path, required=True)
args = parser.parse_args()
report = json.loads(args.input.read_text(encoding="utf-8"))
args.output_dir.mkdir(parents=True, exist_ok=True)
stem = f"{report['tool']['id']}-{report['target']['id']}"
markdown_text = markdown(report)
(args.output_dir / f"{stem}.md").write_text(markdown_text, encoding="utf-8")
(args.output_dir / f"{stem}.html").write_text(html_document(markdown_text, report), encoding="utf-8")
pdf_document(report, args.output_dir / f"{stem}.pdf")
print(f"Rendered Markdown, HTML, and PDF reports in {args.output_dir}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
-176
View File
@@ -1,176 +0,0 @@
#!/usr/bin/env bash
# ───────────────────────────────────────────────────────────
# run-qemu.sh — Launch the Ansible Control Node VM
#
# Boots the minimal Alpine Linux VM with:
# • Machine: pc-q35-10.0
# • TTY: ttyS0, xterm-256color (serial console)
# • Keyboard: PS/2 (atkbd via QEMU default)
# • Network: user-mode NAT with port forwards:
# localhost:2222 → VM:22 (SSH)
# localhost:8080 → VM:8080 (custom services)
# • Disk: virtio-blk, read from output/
# • Init: OpenRC (no systemd)
#
# Usage:
# ./scripts/run-qemu.sh # serial console (default)
# ./scripts/run-qemu.sh --gui # graphical (GTK) window
# ./scripts/run-qemu.sh --vnc :0 # VNC on display :0
# ./scripts/run-qemu.sh --debug # verbose kernel boot
#
# Environment:
# QEMU_MEMORY — RAM size (default: 1024M)
# QEMU_SMP — CPU count (default: 2)
# SSH_PORT — host port for SSH forward (default: 2222)
#
# Access the VM:
# ssh -p 2222 ansible@localhost # password: ansible
# ssh -p 2222 root@localhost # password: ansible
#
# Stop the VM:
# Press Ctrl-A then X (in -nographic mode)
# Or: sudo shutdown -h now (inside VM)
# ───────────────────────────────────────────────────────────
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
OUTPUT_DIR="${OUTPUT_DIR:-$SCRIPT_DIR/../output}"
# ── Configuration (env-overridable) ─────────────────────────
QEMU_BIN="${QEMU_BIN:-qemu-system-x86_64}"
QEMU_MACHINE="${QEMU_MACHINE:-pc-q35-10.0}"
QEMU_MEMORY="${QEMU_MEMORY:-1024M}"
QEMU_SMP="${QEMU_SMP:-2}"
SSH_PORT="${SSH_PORT:-2222}"
EXTRA_PORT="${EXTRA_PORT:-8090}"
# ── Colour helpers ──────────────────────────────────────────
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
BLUE='\033[0;34m'; BOLD='\033[1m'; NC='\033[0m'
info() { echo -e "${BLUE}[*]${NC} $*"; }
ok() { echo -e "${GREEN}[✓]${NC} $*"; }
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
# ── Find build artifacts ────────────────────────────────────
# Prefer qcow2 over raw
if [ -f "$OUTPUT_DIR/ansible-node.qcow2" ]; then
DISK="$OUTPUT_DIR/ansible-node.qcow2"
DISK_FMT="qcow2"
elif [ -f "$OUTPUT_DIR/ansible-node.raw" ]; then
DISK="$OUTPUT_DIR/ansible-node.raw"
DISK_FMT="raw"
else
echo -e "${RED}[✗]${NC} No disk image found in ${OUTPUT_DIR}"
echo " Run ./scripts/build-qemu.sh first."
exit 1
fi
KERNEL="$OUTPUT_DIR/vmlinuz-virt"
INITRD="$OUTPUT_DIR/initramfs-virt"
for f in "$DISK" "$KERNEL" "$INITRD"; do
if [ ! -f "$f" ]; then
echo -e "${RED}[✗]${NC} Missing: $f"
exit 1
fi
done
# ── Parse arguments ─────────────────────────────────────────
DISPLAY_MODE="nographic"
KERNEL_APPEND="root=/dev/vda console=ttyS0 TERM=xterm-256color quiet modules=virtio_blk,ext4 rootflags=rw"
EXTRA_QEMU_ARGS=()
while [[ $# -gt 0 ]]; do
case "$1" in
--gui|-g)
DISPLAY_MODE="gtk"
;;
--vnc)
DISPLAY_MODE="vnc"
VNC_DISPLAY="${2:-:0}"
shift
;;
--debug)
# Remove 'quiet', add verbose/delay for debugging
KERNEL_APPEND="${KERNEL_APPEND// quiet/} debug_init rootdelay=3"
;;
--help|-h)
echo "Usage: $0 [--gui|--vnc :N|--debug] [extra qemu args...]"
echo ""
echo "Modes:"
echo " (default) Serial console (-nographic), Ctrl-A X to exit"
echo " --gui Graphical GTK window with keyboard support"
echo " --vnc :N VNC server on display N"
echo " --debug Verbose kernel boot messages"
echo ""
echo "Environment:"
echo " QEMU_MEMORY=1024M RAM size"
echo " QEMU_SMP=2 CPU count"
echo " SSH_PORT=2222 Host SSH port"
exit 0
;;
*)
EXTRA_QEMU_ARGS+=("$1")
;;
esac
shift
done
# ── Display mode flags ──────────────────────────────────────
case "$DISPLAY_MODE" in
nographic)
DISPLAY_FLAG="-nographic"
;;
gtk)
DISPLAY_FLAG="-display gtk"
# QEMU's GTK display includes keyboard support via PS/2 emulation
# which covers the CONFIG_KEYBOARD_ATKBD kernel requirement
;;
vnc)
DISPLAY_FLAG="-vnc ${VNC_DISPLAY} -vga virtio"
KERNEL_APPEND="$KERNEL_APPEND video=1024x768"
;;
esac
# ── Launch ──────────────────────────────────────────────────
echo ""
echo -e "${BOLD}══════════════════════════════════════════════${NC}"
echo -e "${BOLD} Ansible Control Node${NC}"
echo -e "${BOLD}══════════════════════════════════════════════${NC}"
echo ""
echo -e " Machine: ${GREEN}${QEMU_MACHINE}${NC}"
echo -e " Memory: ${GREEN}${QEMU_MEMORY}${NC}"
echo -e " CPUs: ${GREEN}${QEMU_SMP}${NC}"
echo -e " Disk: ${GREEN}${DISK_FMT}:${DISK}${NC}"
echo -e " Display: ${GREEN}${DISPLAY_MODE}${NC}"
echo -e " Kernel: ${KERNEL_APPEND}"
echo ""
echo -e " SSH: ${YELLOW}ssh -p ${SSH_PORT} ansible@localhost${NC}"
echo -e " Password: ${YELLOW}ansible${NC}"
echo ""
echo -e " ${BOLD}Ctrl-A X${NC} to quit (serial mode)"
echo -e " ${BOLD}Ctrl-C${NC} to force-quit (any mode)"
echo ""
# Build netdev string, skipping extra port if already in use
NETDEV_FORWARDS="hostfwd=tcp::${SSH_PORT}-:22"
if ! ss -tlnp 2>/dev/null | grep -q ":${EXTRA_PORT} "; then
NETDEV_FORWARDS="${NETDEV_FORWARDS},hostfwd=tcp::${EXTRA_PORT}-:8080"
else
warn "Port ${EXTRA_PORT} already in use — skipping extra forward"
fi
# shellcheck disable=SC2086
exec "$QEMU_BIN" \
-machine "$QEMU_MACHINE" \
-m "$QEMU_MEMORY" \
-smp "$QEMU_SMP" \
-enable-kvm \
-kernel "$KERNEL" \
-initrd "$INITRD" \
-append "$KERNEL_APPEND" \
-drive "file=$DISK,if=virtio,format=$DISK_FMT" \
-netdev "user,id=net0,${NETDEV_FORWARDS}" \
-device virtio-net,netdev=net0 \
$DISPLAY_FLAG \
"${EXTRA_QEMU_ARGS[@]}"
-174
View File
@@ -1,174 +0,0 @@
#!/bin/sh
# ───────────────────────────────────────────────────────────
# tty-menu.sh — Serial Console Menu for the Alpine Docker Host
#
# Launched by agetty on ttyS0. The user interacts directly
# with the Ansible container from the serial console.
#
# Options:
# 1-4 Run playbooks (docker run ansible-node)
# 5 Download reports as tar.gz
# 6 View latest report summary
# 7 Shell into Ansible container
# 8 Shell on Docker host
# 0 Shutdown VM
# ───────────────────────────────────────────────────────────
PLAYBOOKS_DIR="/ansible/playbooks"
REPORTS_DIR="/ansible/reports"
INVENTORY="/ansible/inventory/inventory.ini"
IMAGE="ansible-node"
WEB_PORT="8080"
# ── Colour helpers ────────────────────────────────────────
GREEN='\033[0;32m'
BLUE='\033[0;34m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
clear
while true; do
# Determine IP for web UI hint
IP=$(ip -4 addr show scope global 2>/dev/null | \
grep -oP '(?<=inet\s)\d+(\.\d+){3}' | head -1)
[ -z "$IP" ] && IP="(no network)"
echo ""
echo -e "${GREEN}${BOLD}╔══════════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}${BOLD}║ IEC 62443-3-3 SL2 Compliance Validator ║${NC}"
echo -e "${GREEN}${BOLD}╠══════════════════════════════════════════════════╣${NC}"
echo -e "${GREEN}${BOLD}║${NC} Web UI: ${CYAN}http://${IP}:${WEB_PORT}${GREEN} ${NC}${GREEN}${BOLD}║${NC}"
echo -e "${GREEN}${BOLD}╠══════════════════════════════════════════════════╣${NC}"
echo -e "${GREEN}${BOLD}║${NC} ${GREEN}${BOLD}║${NC}"
echo -e "${GREEN}${BOLD}║${NC} ${BOLD}1)${NC} Run all tests (site.yml) ${GREEN}${BOLD}║${NC}"
echo -e "${GREEN}${BOLD}║${NC} ${BOLD}2)${NC} Run FR1 — Auth & Identification ${GREEN}${BOLD}║${NC}"
echo -e "${GREEN}${BOLD}║${NC} ${BOLD}3)${NC} Run FR2 — Use Control ${GREEN}${BOLD}║${NC}"
echo -e "${GREEN}${BOLD}║${NC} ${BOLD}4)${NC} Run FR5 — Restricted Data Flow ${GREEN}${BOLD}║${NC}"
echo -e "${GREEN}${BOLD}║${NC} ${GREEN}${BOLD}║${NC}"
echo -e "${GREEN}${BOLD}║${NC} ${BOLD}5)${NC} Create reports archive (tar.gz) ${GREEN}${BOLD}║${NC}"
echo -e "${GREEN}${BOLD}║${NC} ${BOLD}6)${NC} View latest report summary ${GREEN}${BOLD}║${NC}"
echo -e "${GREEN}${BOLD}║${NC} ${GREEN}${BOLD}║${NC}"
echo -e "${GREEN}${BOLD}║${NC} ${BOLD}7)${NC} Shell — Ansible container ${GREEN}${BOLD}║${NC}"
echo -e "${GREEN}${BOLD}║${NC} ${BOLD}8)${NC} Shell — Docker host ${GREEN}${BOLD}║${NC}"
echo -e "${GREEN}${BOLD}║${NC} ${GREEN}${BOLD}║${NC}"
echo -e "${GREEN}${BOLD}║${NC} ${BOLD}0)${NC} Shutdown VM ${GREEN}${BOLD}║${NC}"
echo -e "${GREEN}${BOLD}╚══════════════════════════════════════════════════╝${NC}"
echo ""
printf " Choice [0-8]: "
read -r CHOICE
docker_run() {
local pb="$1"; shift
echo ""
echo -e "${YELLOW}Running: ${pb}${NC}"
echo "═══════════════════════════════════════════"
docker run --rm -it \
-v "${PLAYBOOKS_DIR}:/ansible/playbooks:ro" \
-v "${REPORTS_DIR}:/ansible/reports" \
-v "$(dirname "${INVENTORY}"):/ansible/inventory:ro" \
"${IMAGE}" \
"/ansible/playbooks/${pb}" \
-i /ansible/inventory/inventory.ini "$@"
echo ""
echo -e "${GREEN}Done. Reports: ${REPORTS_DIR}${NC}"
echo "Press Enter to continue..."
read -r _
}
case "$CHOICE" in
1) docker_run "site.yml" ;;
2) docker_run "suites/fr1_auth.yml" ;;
3) docker_run "suites/fr2_use_control.yml" ;;
4) docker_run "suites/fr5_data_flow.yml" ;;
5)
echo ""
echo -e "${YELLOW}Available reports:${NC}"
ls -lh "${REPORTS_DIR}"/*.json 2>/dev/null || echo " No reports yet."
echo ""
printf " Enter filename (or Enter for all as tar.gz): "
read -r FN
if [ -n "$FN" ]; then
OUT="/tmp/${FN}"
cp "${REPORTS_DIR}/${FN}" "$OUT" 2>/dev/null && \
echo -e " Written: ${GREEN}${OUT}${NC}" || \
echo -e "${RED} Not found: ${FN}${NC}"
else
OUTF="/tmp/reports-$(date +%Y%m%d-%H%M).tar.gz"
cd "${REPORTS_DIR}" && tar czf "$OUTF" *.json 2>/dev/null && \
echo -e " Created: ${GREEN}${OUTF}${NC} ($(du -sh "$OUTF" | cut -f1))"
fi
echo " Transfer via: scp ansible@<host>:/tmp/reports-*.tar.gz ."
echo ""
echo "Press Enter to continue..."
read -r _
;;
6)
LATEST=$(ls -t "${REPORTS_DIR}"/*.json 2>/dev/null | head -1)
if [ -z "$LATEST" ]; then
echo -e "${RED} No reports yet.${NC}"
else
echo ""
echo -e "${YELLOW}Latest: $(basename "$LATEST")${NC}"
echo "═══════════════════════════════════════════"
python3 -c "
import json
with open('$LATEST') as f:
r = json.load(f)
s = r['summary']
print(f'Total: {s[\"total\"]} | Passed: {s[\"passed\"]} | Failed: {s[\"failed\"]}')
print(f'Compliance rate: {s[\"passed\"]/s[\"total\"]*100:.1f}%')
print()
for t in r['results']:
icon = '\u2705' if t['passed'] == True else ('\u274c' if t['passed'] == False else '\U0001f50d')
print(f' {icon} [{t[\"test_id\"]}] {t[\"description\"]}')
print()
if r.get('failures'):
print('Failures:')
for f in r['failures']:
print(f' \u274c {f[\"test_id\"]}: {f[\"description\"]}')
print(f' Expected: {f[\"expected\"]}')
print(f' Actual: {f[\"actual\"]}')
print(f' Fix: {f[\"remediation\"]}')
" 2>/dev/null || echo " Error reading report"
fi
echo ""
echo "Press Enter to continue..."
read -r _
;;
7)
echo ""
echo -e "${YELLOW}Ansible container shell (type 'exit' to return)${NC}"
echo "═══════════════════════════════════════════"
docker run --rm -it \
-v "${PLAYBOOKS_DIR}:/ansible/playbooks:ro" \
-v "${REPORTS_DIR}:/ansible/reports" \
-v "$(dirname "${INVENTORY}"):/ansible/inventory:ro" \
"${IMAGE}" /bin/bash 2>/dev/null || \
docker run --rm -it \
-v "${PLAYBOOKS_DIR}:/ansible/playbooks:ro" \
-v "${REPORTS_DIR}:/ansible/reports" \
-v "$(dirname "${INVENTORY}"):/ansible/inventory:ro" \
"${IMAGE}" /bin/sh
;;
8)
echo ""
echo -e "${YELLOW}Host shell (type 'exit' to return to menu)${NC}"
echo "═══════════════════════════════════════════"
/bin/bash 2>/dev/null || /bin/sh
;;
0)
echo ""
echo -e "${RED}Shutting down...${NC}"
sudo poweroff 2>/dev/null || poweroff
exit 0
;;
*)
echo -e "${RED}Invalid choice${NC}"
sleep 1
;;
esac
done
-138
View File
@@ -1,138 +0,0 @@
## Testing tool overview
A set of tools to be selected and orchestrated to supply the full test coverage from initial software project to final delivery of projects to customers.
### End to en overview of testing tools
An overview of the testing types, their current adoption and whom in BEUMER are responsible for their operation
#### OSSRA
- State: not adopted
- Owner: TBD
- Tool-name: MITRE HipCheck
- Description: Open Source Software Risk Assessment is done as a part of evaluating supply chain risks from dependencies to OSS. The overall project risk can stem from reliance on an immature, abandoned or badly maintained project. Tools Such as HipCheck can be used to quickly assess risks from project dependencies and single out which dependencies require further analysis or in some cases outright disqualify source projects by policy.
#### SAST
- State: Imlemented, but analyzed for possible replacement
- Owner: P&T
- Tool-name: SonarQube
- Description:
#### SCA / SBOM
- State: Partially Implemented
- Owner: P&T
- Tool-name: bespoke tooling, Trivy, DependencyTrack
- Description:
#### Component / API
- State: Planned
- Owner: SW tests
- Tool-name: TBD
- Description: Component and API testing verifies that services, interfaces and integrations behave correctly at the contract level, including authentication, authorization, input validation, error handling and response integrity. This is important for exposing breaking changes between dependent components and validating expected behavior before release.
#### IaCST
- State: Planned
- Owner: SW tests
- Tool-name: TBD
- Description: Infrastructure-as-Code security testing validates IaC templates, deployment definitions and configuration baselines for insecure defaults, unsafe settings, drift and policy violations before systems are built. It reduces the risk of introducing cloud or on-prem misconfigurations during provisioning.
#### IAST
- State: Planned
- Owner: SW tests
- Tool-name: TBD
- Description: Interactive Application Security Testing combines dynamic execution with source-level insight to detect vulnerabilities in running applications, including unsafe data flows, injection points and authentication weaknesses. It is particularly useful for validating real application behavior in a test environment.
#### DAST / runtime scan (ASVS 5)
- State: Planned
- Owner: SW tests
- Tool-name: ZAP / ASVS-aligned scanning tooling
- Description: Dynamic application security testing exercises the application at runtime to detect misconfigurations, authentication weaknesses, insecure session handling and web application vulnerabilities. When aligned to ASVS 5, it provides evidence that key security requirements are being met in deployed or near-production environments.
#### DAST - Destructive Pen Test
- State: Planned
- Owner: SW tests
- Tool-name: External security test partner / approved tooling
- Description: Destructive penetration testing goes beyond routine vulnerability scanning to validate exploitability and system resilience against realistic attack paths. This type of testing is typically conducted in controlled environments with clear scope, approval and rollback procedures.
#### Performance / load / Fuzz
- State: Planned
- Owner: SW tests
- Tool-name: TBD
- Description: Performance, load and fuzz testing measure stability, throughput, response time and resilience under stress, sustained load and malformed or unexpected input. This helps uncover bottlenecks, resource exhaustion issues and reliability failures before deployment.
#### Integration / Regression
- State: Planned
- Owner: SW tests
- Tool-name: Ansible + test automation frameworks
- Description: Integration and regression testing confirm that components work together as expected across interfaces and operational flows, while also verifying that new changes do not break previously working behavior. This is a core part of release confidence for system-level changes.
#### Operational Vulnerability Scan
- State: Planned
- Owner: SW tests
- Tool-name: TBD
- Description: Operational vulnerability scanning focuses on live system components such as hosts, services, containers, appliances and network devices to identify known issues that require remediation or compensating controls. It supports continuous assurance that deployed environments remain within acceptable risk levels.
#### Hardening Benchmark
- State: Planned
- Owner: SW tests
- Tool-name: CIS benchmarks / platform-specific hardening baselines
- Description: Hardening benchmark testing validates that deployed systems, operating systems and infrastructure components match approved security baselines. This reduces the attack surface and ensures configuration settings align with internal standards and regulatory expectations.
#### FAT / SAT
- State: Planned
- Owner: SW tests
- Tool-name: Site acceptance and factory acceptance test automation
- Description: Factory Acceptance Testing and Site Acceptance Testing confirm that systems meet the agreed specification and operational requirements before handover and customer acceptance. These tests validate readiness, functionality and integration in realistic deployment conditions.
#### OBOM / Asset Inventory Management
- State: Planned
- Owner: SW tests
- Tool-name: TBD
- Description: An operational bill of materials and asset inventory tracks software, firmware, hardware, configurations and dependencies across the deployed environment. This is essential for asset visibility, vulnerability prioritization, lifecycle management and change control.
### Selected tooling
#### Orchestration
- GitLab CI/CD
- Ansible
#### Connectivity
- TBD Proxying services
#### Standards
- IEC 62443-4-2
- Mandatory in BEUMER. All components must provide SL-C 2 or be deployed in a context wherein countermeasures are in place to supply the gap.
- IEC 62443-3-3
- Mandatory in BEUMER. SL-T 2 is the mandatory level for the systems supplied to BEUMERs customers.
- ASVS 5.0
- Possibly a supporting standard in the sense that it can provide testable requirements for i.e. strong cryptography. Community-supploed tests have been developed that may be useful in providing ASVS assessment automation, and a mapping with some requirements from IEC62443 is possible.
- CIS hardening benchmarks
- CIS provides a large set of hardening benchmarks as well as build-kits. Hardening benchmarks are available for commong infrastructure elements such as VMWare vSphere, Hyper-V, Windows, Cisco devices, Major Linux Distributions.
#### Testing
- Ansible - can be used both to orchestrate other testing tools, or it can execute a testing suite directly using python frameworks such as PyTest.
- ZaProxy - can be used to automate a "passive" DAST whereing the the tool connects to a remote api and detects misconfiguration from non-exploitive communication patterns. Projects exist that pair an older version of ASVS to ZaProxy scanning metrics, which could be updated to provide evidence for ASVS 5 compliance.
#### Report Generation
- gomplate
- pandoc
+27
View File
@@ -0,0 +1,27 @@
FROM ubuntu:24.04
ARG DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends nginx openssh-server openssl python3 \
&& rm -rf /var/lib/apt/lists/* \
&& mkdir -p /run/sshd /etc/nginx/tls \
&& useradd --create-home --shell /bin/bash auditor \
&& echo 'auditor:DemoPassword1!' | chpasswd \
&& printf '%s\n' \
'PasswordAuthentication yes' \
'PermitRootLogin no' \
>> /etc/ssh/sshd_config \
&& openssl req -x509 -newkey rsa:2048 -nodes -days 30 \
-subj '/CN=demo-target' \
-keyout /etc/nginx/tls/server.key \
-out /etc/nginx/tls/server.crt
COPY targets/ubuntu-weak/nginx.conf /etc/nginx/sites-enabled/default
COPY targets/ubuntu-weak/index.html /var/www/html/index.html
COPY targets/ubuntu-weak/entrypoint.sh /usr/local/bin/demo-entrypoint
RUN chmod 0755 /usr/local/bin/demo-entrypoint
EXPOSE 22 443
ENTRYPOINT ["/usr/local/bin/demo-entrypoint"]
+9
View File
@@ -0,0 +1,9 @@
# Intentionally Weak Ubuntu Target
This image exists only to demonstrate the test pipeline. It runs SSH and nginx
with password authentication, a self-signed certificate, obsolete TLS protocol
configuration, a CBC cipher, and missing browser security headers.
Default demonstration credentials are `auditor` / `DemoPassword1!`. Override
the password with `TARGET_PASSWORD`. Never expose this image outside an isolated
test network.
+25
View File
@@ -0,0 +1,25 @@
---
all:
vars:
test_project:
id: "demo-ubuntu-weak"
name: "Ubuntu Weak Target Demonstration"
environment: "test"
customer: "Internal"
location: "testserv"
children:
linux_vms:
hosts:
demo-target:
ansible_host: demo-target
ansible_user: auditor
ansible_connection: paramiko
ansible_password: "{{ lookup('env', 'DEMO_SSH_PASSWORD') }}"
asset_type: linux_vm
test_profiles: [demo, iec62443]
web_applications:
hosts:
demo-web:
target_url: https://demo-target
asset_type: web_application
test_profiles: [zap-baseline, asvs]
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
set -euo pipefail
if [ -n "${TARGET_PASSWORD:-}" ]; then
echo "auditor:$TARGET_PASSWORD" | chpasswd
fi
/usr/sbin/sshd
exec nginx -g 'daemon off;'
+5
View File
@@ -0,0 +1,5 @@
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Weak Demo Target</title></head>
<body><h1>Test automation target</h1><p>Intentionally insecure. Never deploy outside a test network.</p></body>
</html>
@@ -0,0 +1,77 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: demo-target
namespace: test-automation
labels:
app: demo-target
spec:
replicas: 1
selector:
matchLabels:
app: demo-target
template:
metadata:
labels:
app: demo-target
spec:
containers:
- name: ubuntu
image: ubuntu:24.04
imagePullPolicy: IfNotPresent
command: ["/bin/bash", "-c"]
args:
- |
set -e
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y --no-install-recommends nginx openssh-server openssl python3
mkdir -p /run/sshd /etc/nginx/tls
id auditor >/dev/null 2>&1 || useradd --create-home --shell /bin/bash auditor
echo 'auditor:DemoPassword1!' | chpasswd
printf '\nPasswordAuthentication yes\nPermitRootLogin no\n' >> /etc/ssh/sshd_config
openssl req -x509 -newkey rsa:2048 -nodes -days 30 -subj '/CN=demo-target' -keyout /etc/nginx/tls/server.key -out /etc/nginx/tls/server.crt
cp /config/nginx.conf /etc/nginx/sites-enabled/default
cp /config/index.html /var/www/html/index.html
/usr/sbin/sshd
exec nginx -g 'daemon off;'
ports:
- name: ssh
containerPort: 22
- name: https
containerPort: 443
readinessProbe:
tcpSocket:
port: https
initialDelaySeconds: 5
periodSeconds: 3
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: "1"
memory: 512Mi
volumeMounts:
- name: config
mountPath: /config
volumes:
- name: config
configMap:
name: demo-target-config
---
apiVersion: v1
kind: Service
metadata:
name: demo-target
namespace: test-automation
spec:
selector:
app: demo-target
ports:
- name: ssh
port: 22
targetPort: ssh
- name: https
port: 443
targetPort: https
+12
View File
@@ -0,0 +1,12 @@
server {
listen 443 ssl default_server;
server_name _;
ssl_certificate /etc/nginx/tls/server.crt;
ssl_certificate_key /etc/nginx/tls/server.key;
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
ssl_ciphers 'AES128-SHA:@SECLEVEL=0';
root /var/www/html;
index index.html;
}
-282
View File
@@ -1,282 +0,0 @@
#!/usr/bin/env python3
"""IEC 62443-3-3 Compliance Tester — Minimal Web UI.
Zero dependencies beyond Python 3 stdlib.
Serves on :8080, executes playbooks via docker, serves reports."""
import http.server
import json
import os
import subprocess
import glob
import urllib.parse
import shutil
from pathlib import Path
PLAYBOOKS_DIR = "/ansible/playbooks"
REPORTS_DIR = "/ansible/reports"
INVENTORY = "/ansible/inventory/inventory.ini"
ANSIBLE_IMAGE = "ansible-node"
# ── HTML template (inline) ─────────────────────────────────
HTML = r"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>IEC 62443-3-3 Compliance Tester</title>
<style>
:root{{--bg:#1a1a2e;--fg:#e0e0e0;--accent:#00d4aa;--err:#ff6b6b;
--card:#16213e;--border:#0f3460;--btn:#0f3460;--btn-hover:#1a508b}}
*{{box-sizing:border-box;margin:0;padding:0}}
body{{font:14px/1.6 system-ui,sans-serif;background:var(--bg);color:var(--fg);
max-width:960px;margin:0 auto;padding:20px}}
h1{{color:var(--accent);margin-bottom:8px}}
h2{{color:var(--accent);margin:24px 0 12px;font-size:1.1em}}
.card{{background:var(--card);border:1px solid var(--border);
border-radius:8px;padding:16px;margin-bottom:16px}}
.grid{{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px}}
.btn{{display:block;width:100%;padding:12px 16px;border:none;border-radius:6px;
background:var(--btn);color:var(--fg);font-size:14px;cursor:pointer;
text-align:left;transition:background .2s}}
.btn:hover{{background:var(--btn-hover)}}
.btn.run{{color:var(--accent);font-weight:bold}}
.output{{background:#000;color:#0f0;padding:12px;border-radius:6px;
font:12px monospace;white-space:pre-wrap;max-height:420px;overflow:auto;
margin-top:12px;display:none}}
.output.visible{{display:block}}
.badge{{display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;
margin-left:8px;opacity:.7}}
.report-list{{list-style:none}}
.report-list li{{padding:8px 0;border-bottom:1px solid var(--border)}}
.report-list a{{color:var(--accent);text-decoration:none;margin-right:12px}}
.report-list a:hover{{text-decoration:underline}}
.status{{font-size:12px;opacity:.7;margin-top:16px}}
</style>
</head>
<body>
<h1>⚡ IEC 62443-3-3 SL2</h1>
<p>Industrial control system security compliance validation</p>
<h2>▶ Run Tests</h2>
<div class="card">
<div class="grid" id="playbook-list">
{playbook_buttons}
</div>
<pre class="output" id="output">Select a playbook to run...</pre>
</div>
<h2>📋 Reports</h2>
<div class="card">
<ul class="report-list" id="report-list">
{report_items}
</ul>
</div>
<div class="status" id="status">{status}</div>
<script>
async function runPlaybook(name) {{
const out = document.getElementById("output");
out.classList.add("visible");
out.textContent = "Starting " + name + "...\\n";
document.getElementById("status").textContent = "Running " + name + "...";
try {{
const res = await fetch("/api/run", {{
method: "POST",
headers: {{"Content-Type": "application/x-www-form-urlencoded"}},
body: "playbook=" + encodeURIComponent(name) +
"&limit=" + encodeURIComponent(document.getElementById("limit")?.value || "all")
}});
const data = await res.json();
out.textContent = data.output || data.error || "No output";
document.getElementById("status").textContent =
data.ok ? "✓ " + name + " completed" : "✗ " + name + " failed";
// Refresh report list
const rr = await fetch("/api/reports");
const reports = await rr.json();
let items = "";
reports.forEach(r => {{
items += `<li>${{r.name}} <span class="badge">${{r.size}}</span>
<a href="${{r.json_url}}">JSON</a></li>`;
}});
document.getElementById("report-list").innerHTML =
items || "<li>No reports yet</li>";
}} catch(e) {{
out.textContent += "\\nError: " + e;
document.getElementById("status").textContent = "Connection lost";
}}
}}
</script>
</body>
</html>"""
class Handler(http.server.BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
pass
def _send(self, code, ct, body):
self.send_response(code)
self.send_header("Content-Type", ct)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Cache-Control", "no-cache")
self.end_headers()
self.wfile.write(body if isinstance(body, bytes) else body.encode())
def _json(self, code, obj):
self._send(code, "application/json", json.dumps(obj, indent=2))
def do_GET(self):
p = urllib.parse.urlparse(self.path)
if p.path == "/" or p.path == "/index.html":
self._serve_index()
elif p.path == "/api/reports":
self._api_reports()
elif p.path.startswith("/reports/"):
self._serve_file(REPORTS_DIR, p.path[9:])
else:
self._send(404, "text/plain", "Not found")
def do_POST(self):
p = urllib.parse.urlparse(self.path)
if p.path == "/api/run":
cl = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(cl).decode()
qs = urllib.parse.parse_qs(body)
playbook = qs.get("playbook", [""])[0]
limit = qs.get("limit", ["all"])[0]
self._run_playbook(playbook, limit)
else:
self._send(405, "text/plain", "Method not allowed")
def _serve_index(self):
# List playbooks
pbs = sorted(
[f.name for f in Path(PLAYBOOKS_DIR).rglob("*.yml")
if not f.name.startswith(".")],
key=lambda x: (x != "site.yml", x)
)
buttons = ""
for p in pbs:
label = p.replace(".yml", "").replace("_", " ").title()
if p == "site.yml":
label = "🚀 Run All Tests"
buttons += (f'<button class="btn run" '
f'onclick="runPlaybook(\'{p}\')">{label}</button>\n')
# List reports
try:
reps = sorted(
Path(REPORTS_DIR).glob("*.json"),
key=lambda f: f.stat().st_mtime, reverse=True
)[:20]
items = ""
for r in reps:
try:
sz = r.stat().st_size
szs = f"{sz/1024:.0f}KB"
except Exception:
szs = "?"
items += (f'<li>{r.name} <span class="badge">{szs}</span> '
f'<a href="/reports/{r.name}">Download</a></li>\n')
except Exception:
items = "<li>No reports yet</li>"
html = HTML.format(
playbook_buttons=buttons or "<p>No playbooks found</p>",
report_items=items or "<li>No reports yet</li>",
status="Ready"
)
self._send(200, "text/html", html)
def _api_reports(self):
try:
reps = sorted(
Path(REPORTS_DIR).glob("*.json"),
key=lambda f: f.stat().st_mtime, reverse=True
)[:20]
result = []
for r in reps:
sz = r.stat().st_size
szs = f"{sz/1024:.0f}KB"
result.append({
"name": r.name,
"size": szs,
"json_url": f"/reports/{r.name}",
})
self._json(200, result)
except Exception as e:
self._json(500, {"error": str(e)})
def _serve_file(self, base, name):
name = os.path.basename(name)
fpath = os.path.join(base, name)
if not os.path.isfile(fpath):
self._send(404, "text/plain", "File not found")
return
ct = "application/json" if name.endswith(".json") else "application/octet-stream"
self.send_response(200)
self.send_header("Content-Type", ct)
self.send_header("Content-Disposition",
f'attachment; filename="{name}"')
self.end_headers()
with open(fpath, "rb") as f:
shutil.copyfileobj(f, self.wfile)
def _run_playbook(self, playbook, limit):
playbook = os.path.basename(playbook)
if not playbook or ".." in playbook:
self._json(400, {"error": "Invalid playbook name"})
return
pb_path = None
for f in Path(PLAYBOOKS_DIR).rglob(playbook):
pb_path = str(f)
break
if not pb_path:
self._json(404, {"error": f"Playbook not found: {playbook}"})
return
rel = os.path.relpath(pb_path, PLAYBOOKS_DIR)
cmd = [
"docker", "run", "--rm",
"-v", f"{PLAYBOOKS_DIR}:/ansible/playbooks:ro",
"-v", f"{REPORTS_DIR}:/ansible/reports",
"-v", f"{os.path.dirname(INVENTORY)}:/ansible/inventory:ro",
ANSIBLE_IMAGE,
f"/ansible/playbooks/{rel}",
"-i", "/ansible/inventory/inventory.ini",
]
if limit and limit != "all":
cmd += ["--limit", limit]
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=300
)
output = result.stdout + "\n" + result.stderr
self._json(200, {
"ok": result.returncode == 0,
"exit_code": result.returncode,
"output": output[-50000:]
})
except subprocess.TimeoutExpired:
self._json(500, {"error": "Playbook timed out after 5 min"})
except Exception as e:
self._json(500, {"error": str(e)})
if __name__ == "__main__":
os.makedirs(PLAYBOOKS_DIR, exist_ok=True)
os.makedirs(REPORTS_DIR, exist_ok=True)
print("Listening on http://0.0.0.0:8080")
httpd = http.server.HTTPServer(("0.0.0.0", 8080), Handler)
try:
httpd.serve_forever()
except KeyboardInterrupt:
pass
-559
View File
@@ -1,559 +0,0 @@
#!/usr/bin/env python3
"""IEC 62443-3-3 Compliance Tester — Container Web UI.
Runs inside the ansible-node Docker image. Serves on :8080.
Features: run playbooks, export results as JSON / Markdown / PDF.
Dependencies: fpdf2 (pure Python PDF), render_report.py (bundled), Python 3 stdlib.
"""
import http.server
import json
import os
import subprocess
import urllib.parse
import shutil
import io
import time
from pathlib import Path
try:
from fpdf import FPDF
HAS_FPDF = True
except ImportError:
HAS_FPDF = False
PLAYBOOKS_DIR = "/ansible/playbooks"
REPORTS_DIR = "/ansible/reports"
INVENTORY = "/ansible/inventory/inventory.ini"
RENDER_MD = "/ansible/reports/render_report.py"
BIND = ("0.0.0.0", 8080)
# ── HTML template ──────────────────────────────────────────
HTML = r"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>IEC 62443-3-3 SL2 — Compliance Tester</title>
<style>
:root{{--bg:#0d1117;--fg:#c9d1d9;--accent:#58a6ff;--green:#3fb950;
--red:#f85149;--card:#161b22;--border:#30363d;--btn:#21262d;--btn-hover:#30363d}}
*{{box-sizing:border-box;margin:0;padding:0}}
body{{font:14px/1.6 -apple-system,BlinkMacSystemFont,sans-serif;background:var(--bg);
color:var(--fg);max-width:1024px;margin:0 auto;padding:24px}}
h1{{color:var(--accent);font-size:22px;margin-bottom:4px}}
h2{{color:var(--accent);font-size:15px;margin:24px 0 10px;text-transform:uppercase;letter-spacing:.5px}}
.card{{background:var(--card);border:1px solid var(--border);border-radius:8px;padding:16px;margin-bottom:16px}}
.grid{{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:10px}}
.btn{{display:block;width:100%;padding:10px 14px;border:1px solid var(--border);border-radius:6px;
background:var(--btn);color:var(--fg);font-size:13px;cursor:pointer;text-align:left;transition:all .15s}}
.btn:hover{{background:var(--btn-hover);border-color:var(--accent)}}
.btn.run{{color:var(--green);font-weight:600}}
.output{{background:#0d1117;border:1px solid var(--border);border-radius:6px;
padding:14px;font:12px/ui-monospace,monospace;white-space:pre-wrap;
max-height:420px;overflow:auto;margin-top:12px;display:none;color:#7ee787}}
.output.visible{{display:block}}
.reports table{{width:100%;border-collapse:collapse;font-size:13px}}
.reports th{{text-align:left;padding:8px 12px;border-bottom:1px solid var(--border);color:var(--accent)}}
.reports td{{padding:8px 12px;border-bottom:1px solid var(--border)}}
.reports a{{color:var(--accent);text-decoration:none;margin-right:8px;font-size:12px;
padding:3px 8px;border:1px solid var(--border);border-radius:4px}}
.reports a:hover{{border-color:var(--accent);background:var(--btn-hover)}}
.status{{font-size:12px;color:#8b949e;margin-top:16px;display:flex;align-items:center;gap:8px}}
.status-dot{{width:8px;height:8px;border-radius:50%;display:inline-block}}
.status-dot.idle{{background:var(--green)}}
.status-dot.running{{background:#d29922;animation:pulse 1s infinite}}
@keyframes pulse{{50%{{opacity:.4}}}}
.rate{{font-size:28px;font-weight:700;color:var(--green)}}
.rate.low{{color:var(--red)}}
.summary-grid{{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-top:12px}}
.summary-item{{text-align:center;padding:12px;border-radius:6px;background:var(--btn)}}
.summary-item .num{{font-size:24px;font-weight:700}}
.summary-item .label{{font-size:11px;color:#8b949e;margin-top:4px}}
.summary-item.pass .num{{color:var(--green)}}
.summary-item.fail .num{{color:var(--red)}}
</style>
</head>
<body>
<h1>⚡ IEC 62443-3-3 SL2</h1>
<p style="color:#8b949e;font-size:13px">Industrial control system security compliance validation</p>
<h2>▶ Run Tests</h2>
<div class="card">
<div class="grid">{playbook_buttons}</div>
<pre class="output" id="output">Select a playbook to run…</pre>
</div>
<div id="summary-section" style="display:none">
<h2>📊 Summary</h2>
<div class="card">
<div class="summary-grid" id="summary-grid"></div>
<div style="text-align:center;margin-top:12px">
<span class="rate" id="compliance-rate"></span>
<span style="color:#8b949e;font-size:12px;margin-left:8px">compliance rate</span>
</div>
</div>
</div>
<h2>📋 Reports</h2>
<div class="card reports">
<table>
<thead><tr><th>Report</th><th>Size</th><th style="text-align:right">Download</th></tr></thead>
<tbody id="report-list">{report_rows}</tbody>
</table>
</div>
<div class="status">
<span class="status-dot idle" id="status-dot"></span>
<span id="status-text">Ready</span>
</div>
<script>
async function runPlaybook(name) {{
const out = document.getElementById("output");
const dot = document.getElementById("status-dot");
const txt = document.getElementById("status-text");
out.classList.add("visible");
out.textContent = "▸ Starting " + name + "…\\n";
dot.className = "status-dot running";
txt.textContent = "Running " + name + "…";
try {{
const res = await fetch("/api/run", {{
method: "POST",
headers: {{"Content-Type": "application/x-www-form-urlencoded"}},
body: "playbook=" + encodeURIComponent(name)
}});
const data = await res.json();
out.textContent = data.output || data.error || "No output";
dot.className = "status-dot idle";
txt.textContent = data.ok ? "✓ " + name + " — passed" : "✗ " + name + " — issues found";
// Refresh summary & reports if run succeeded
if (data.latest_report) {{
loadSummary(data.latest_report);
}}
loadReports();
}} catch(e) {{
out.textContent += "\\n✗ Error: " + e;
dot.className = "status-dot idle";
txt.textContent = "Error running " + name;
}}
}}
async function loadSummary(reportFile) {{
try {{
const res = await fetch("/api/summary?file=" + encodeURIComponent(reportFile));
const s = await res.json();
document.getElementById("summary-section").style.display = "block";
document.getElementById("summary-grid").innerHTML =
`<div class="summary-item pass"><div class="num">${{s.passed}}</div><div class="label">Passed</div></div>
<div class="summary-item fail"><div class="num">${{s.failed}}</div><div class="label">Failed</div></div>
<div class="summary-item"><div class="num">${{s.total}}</div><div class="label">Total</div></div>
<div class="summary-item"><div class="num">${{s.skipped || 0}}</div><div class="label">Skipped</div></div>`;
const rate = document.getElementById("compliance-rate");
rate.textContent = (s.passed/s.total*100).toFixed(1) + "%";
rate.className = "rate" + (s.passed/s.total < 0.8 ? " low" : "");
}} catch(e) {{}}
}}
async function loadReports() {{
try {{
const res = await fetch("/api/reports");
const reports = await res.json();
let rows = "";
reports.forEach(r => {{
rows += `<tr>
<td>${{r.name}}</td>
<td style="color:#8b949e">${{r.size}}</td>
<td style="text-align:right">
<a href="/api/reports/${{r.name}}">JSON</a>
<a href="/api/reports/${{r.name}}/md">MD</a>
<a href="/api/reports/${{r.name}}/pdf">PDF</a>
</td></tr>`;
}});
document.getElementById("report-list").innerHTML =
rows || `<tr><td colspan="3" style="color:#8b949e">No reports yet</td></tr>`;
}} catch(e) {{}}
}}
</script>
</body>
</html>"""
# ── PDF Generator ──────────────────────────────────────────
def generate_pdf(json_path: str) -> bytes:
"""Generate a clean PDF report from a test-results JSON file."""
with open(json_path) as f:
data = json.load(f)
meta = data.get("meta", {})
summary = data.get("summary", {})
results = data.get("results", [])
failures= data.get("failures", [])
pdf = FPDF()
pdf.set_auto_page_break(True, 20)
pdf.add_page()
# ── Cover / Header ──────────────────────────────────
pdf.set_font("Helvetica", "B", 22)
pdf.set_text_color(0, 74, 173)
pdf.cell(0, 12, "IEC 62443-3-3 SL2", new_x="LMARGIN", new_y="NEXT")
pdf.set_font("Helvetica", "", 14)
pdf.set_text_color(100, 100, 100)
pdf.cell(0, 8, "Compliance Validation Report", new_x="LMARGIN", new_y="NEXT")
pdf.ln(6)
# Meta info
pdf.set_font("Helvetica", "", 10)
pdf.set_text_color(80, 80, 80)
for label, key in [("Target:", "target"), ("Date:", "timestamp"),
("Standard:", "standard"), ("Security Level:", "security_level")]:
val = meta.get(key, "—")
pdf.cell(35, 6, label)
pdf.set_text_color(40, 40, 40)
pdf.cell(0, 6, str(val), new_x="LMARGIN", new_y="NEXT")
pdf.set_text_color(80, 80, 80)
pdf.ln(8)
# ── Summary box ─────────────────────────────────────
total = summary.get("total", 0)
passed = summary.get("passed", 0)
failed = summary.get("failed", 0)
rate = (passed / total * 100) if total > 0 else 0
pdf.set_fill_color(240, 248, 255)
pdf.rect(10, pdf.get_y(), 190, 22, style="F")
pdf.set_xy(14, pdf.get_y() + 4)
pdf.set_font("Helvetica", "B", 12)
pdf.set_text_color(0, 74, 173)
pdf.cell(50, 6, f"Passed: {passed}")
pdf.set_text_color(180, 40, 40)
pdf.cell(50, 6, f"Failed: {failed}")
pdf.set_text_color(40, 40, 40)
pdf.cell(50, 6, f"Total: {total}")
pdf.set_text_color(0, 120, 0)
pdf.cell(40, 6, f"Rate: {rate:.1f}%")
pdf.ln(26)
# ── Results by category ─────────────────────────────
by_cat = {}
for r in results:
cat = r.get("category", "Uncategorized")
by_cat.setdefault(cat, []).append(r)
for cat, items in by_cat.items():
# Category header
pdf.set_font("Helvetica", "B", 11)
pdf.set_text_color(0, 74, 173)
pdf.cell(0, 8, cat, new_x="LMARGIN", new_y="NEXT")
# Column headers
pdf.set_font("Helvetica", "B", 8)
pdf.set_fill_color(230, 235, 245)
pdf.set_text_color(60, 60, 60)
cols = [("Test ID", 22), ("Description", 72), ("Status", 18),
("Severity", 22), ("Expected", 56)]
for label, w in cols:
pdf.cell(w, 6, label, fill=True)
pdf.ln()
# Results
for r in items:
pid = r.get("test_id", "?")
desc = r.get("description", "")[:65]
p = r.get("passed")
sev = r.get("severity", "low")
exp = r.get("expected", "")[:45]
icon = "PASS" if p is True else ("FAIL" if p is False else "REVIEW")
pdf.set_font("Helvetica", "", 8)
if p is False:
pdf.set_text_color(180, 40, 40)
elif p is True:
pdf.set_text_color(0, 100, 0)
else:
pdf.set_text_color(180, 130, 0)
pdf.cell(22, 5, pid)
pdf.set_text_color(40, 40, 40)
pdf.cell(72, 5, desc)
pdf.set_text_color(180 if p is False else (0, 100, 0) if p is True else (180, 130, 0))
pdf.cell(18, 5, icon)
pdf.set_text_color(100, 100, 100)
pdf.cell(22, 5, sev.upper() if p is False else sev)
pdf.set_text_color(40, 40, 40)
pdf.cell(56, 5, exp)
pdf.ln()
pdf.ln(4)
# ── Failure details ──────────────────────────────────
if failures:
pdf.add_page()
pdf.set_font("Helvetica", "B", 14)
pdf.set_text_color(180, 40, 40)
pdf.cell(0, 10, "Failure Details & Remediation", new_x="LMARGIN", new_y="NEXT")
pdf.ln(4)
for f in failures:
pdf.set_font("Helvetica", "B", 10)
pdf.set_text_color(180, 40, 40)
pdf.cell(0, 7, f"[{f.get('test_id', '?')}] {f.get('description', '')}",
new_x="LMARGIN", new_y="NEXT")
pdf.set_font("Helvetica", "", 9)
pdf.set_text_color(80, 80, 80)
pdf.cell(0, 5, f" Expected: {f.get('expected', '—')}",
new_x="LMARGIN", new_y="NEXT")
pdf.cell(0, 5, f" Actual: {f.get('actual', '—')}",
new_x="LMARGIN", new_y="NEXT")
pdf.cell(0, 5, f" Remediation: {f.get('remediation', '—')}",
new_x="LMARGIN", new_y="NEXT")
pdf.set_draw_color(220, 220, 220)
pdf.line(10, pdf.get_y() + 2, 200, pdf.get_y() + 2)
pdf.ln(6)
return pdf.output()
# ── HTTP Handler ───────────────────────────────────────────
class Handler(http.server.BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
pass
def _send(self, code, ct, body):
self.send_response(code)
self.send_header("Content-Type", ct)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Cache-Control", "no-cache")
self.end_headers()
self.wfile.write(body if isinstance(body, bytes) else body.encode())
def _json(self, code, obj):
self._send(code, "application/json", json.dumps(obj, indent=2))
def _list_playbooks(self):
pbs = sorted(
[f.name for f in Path(PLAYBOOKS_DIR).rglob("*.yml")
if not f.name.startswith(".")],
key=lambda x: (x != "site.yml", x)
)
return pbs
def _list_reports(self):
try:
return sorted(
Path(REPORTS_DIR).glob("*.json"),
key=lambda f: f.stat().st_mtime, reverse=True
)[:50]
except Exception:
return []
# ── Routing ────────────────────────────────────────
def do_GET(self):
p = urllib.parse.urlparse(self.path)
path = p.path
if path == "/":
self._serve_index()
elif path == "/api/reports":
self._api_reports()
elif path == "/api/summary":
qs = urllib.parse.parse_qs(p.query)
fn = qs.get("file", [""])[0]
self._api_summary(fn)
elif path.startswith("/api/reports/"):
rest = path[len("/api/reports/"):]
if rest.endswith("/md"):
self._serve_markdown(rest[:-3])
elif rest.endswith("/pdf"):
self._serve_pdf(rest[:-4])
else:
self._serve_json(rest)
else:
self._send(404, "text/plain", "Not found")
def do_POST(self):
p = urllib.parse.urlparse(self.path)
if p.path == "/api/run":
cl = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(cl).decode()
qs = urllib.parse.parse_qs(body)
playbook = qs.get("playbook", [""])[0]
self._run_playbook(playbook)
else:
self._send(405, "text/plain", "Method not allowed")
# ── Pages ──────────────────────────────────────────
def _serve_index(self):
pbs = self._list_playbooks()
buttons = ""
for p in pbs:
label = p.replace(".yml", "").replace("_", " ").title()
if p == "site.yml":
label = "🚀 Run All Tests"
buttons += (f'<button class="btn run" '
f'onclick="runPlaybook(\'{p}\')">{label}</button>\n')
reps = self._list_reports()
rows = ""
for r in reps:
name = r.name
try:
sz = r.stat().st_size
szs = f"{sz/1024:.0f} KB"
except Exception:
szs = "?"
rows += (
f'<tr><td>{name}</td><td style="color:#8b949e">{szs}</td>'
f'<td style="text-align:right">'
f'<a href="/api/reports/{name}">JSON</a>'
f'<a href="/api/reports/{name}/md">MD</a>'
f'<a href="/api/reports/{name}/pdf">PDF</a>'
f'</td></tr>\n'
)
self._send(200, "text/html", HTML.format(
playbook_buttons=buttons or "<p style='color:#8b949e'>No playbooks found in /ansible/playbooks</p>",
report_rows=rows or '<tr><td colspan="3" style="color:#8b949e">No reports yet — run a test</td></tr>',
))
# ── API ────────────────────────────────────────────
def _api_reports(self):
result = []
for r in self._list_reports():
sz = r.stat().st_size
szs = f"{sz/1024:.0f} KB"
result.append({
"name": r.name,
"size": szs,
})
self._json(200, result)
def _api_summary(self, filename):
fpath = os.path.join(REPORTS_DIR, os.path.basename(filename))
if not os.path.isfile(fpath):
self._json(404, {"error": "Report not found"})
return
try:
with open(fpath) as f:
data = json.load(f)
s = data.get("summary", {})
self._json(200, {
"total": s.get("total", 0),
"passed": s.get("passed", 0),
"failed": s.get("failed", 0),
"skipped": s.get("skipped", 0),
})
except Exception as e:
self._json(500, {"error": str(e)})
# ── File serving ───────────────────────────────────
def _serve_json(self, name):
name = os.path.basename(name)
fpath = os.path.join(REPORTS_DIR, name)
if not os.path.isfile(fpath):
self._send(404, "text/plain", "File not found"); return
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Disposition", f'attachment; filename="{name}"')
self.end_headers()
with open(fpath, "rb") as f:
shutil.copyfileobj(f, self.wfile)
def _serve_markdown(self, name):
name = os.path.basename(name)
fpath = os.path.join(REPORTS_DIR, name)
if not os.path.isfile(fpath):
self._send(404, "text/plain", "File not found"); return
out = io.StringIO()
try:
# Use bundled render_report.py for markdown conversion
r = subprocess.run(
["python3", RENDER_MD, fpath, "--format", "md"],
capture_output=True, text=True, timeout=30, cwd=REPORTS_DIR
)
md = r.stdout or f"# Error converting report\n\n{r.stderr}"
except Exception:
md = f"# Error\n\nCould not convert {name} to Markdown"
self.send_response(200)
self.send_header("Content-Type", "text/markdown; charset=utf-8")
self.send_header("Content-Disposition",
f'attachment; filename="{name.replace(".json", ".md")}"')
self.end_headers()
self.wfile.write(md.encode())
def _serve_pdf(self, name):
if not HAS_FPDF:
self._send(500, "text/plain", "PDF support not installed (missing fpdf2)")
return
name = os.path.basename(name)
fpath = os.path.join(REPORTS_DIR, name)
if not os.path.isfile(fpath):
self._send(404, "text/plain", "File not found"); return
try:
pdf_bytes = generate_pdf(fpath)
except Exception as e:
self._send(500, "text/plain", f"PDF generation failed: {e}")
return
self.send_response(200)
self.send_header("Content-Type", "application/pdf")
self.send_header("Content-Disposition",
f'attachment; filename="{name.replace(".json", ".pdf")}"')
self.send_header("Content-Length", str(len(pdf_bytes)))
self.end_headers()
self.wfile.write(pdf_bytes)
# ── Run playbook ───────────────────────────────────
def _run_playbook(self, playbook):
playbook = os.path.basename(playbook)
if not playbook or ".." in playbook:
self._json(400, {"error": "Invalid playbook name"}); return
pb_path = None
for f in Path(PLAYBOOKS_DIR).rglob(playbook):
pb_path = str(f); break
if not pb_path:
self._json(404, {"error": f"Not found: {playbook}"}); return
rel = os.path.relpath(pb_path, PLAYBOOKS_DIR)
cmd = [
"ansible-playbook",
f"/ansible/playbooks/{rel}",
"-i", INVENTORY,
]
try:
result = subprocess.run(cmd, capture_output=True, text=True,
timeout=300, cwd="/ansible")
output = (result.stdout + "\n" + result.stderr)[-80000:]
# Find latest report
latest = ""
reps = sorted(Path(REPORTS_DIR).glob("*.json"),
key=lambda f: f.stat().st_mtime, reverse=True)
if reps:
latest = reps[0].name
self._json(200, {
"ok": result.returncode == 0,
"exit_code": result.returncode,
"output": output,
"latest_report": latest,
})
except subprocess.TimeoutExpired:
self._json(500, {"error": "Timed out after 5 minutes"})
except Exception as e:
self._json(500, {"error": str(e)})
if __name__ == "__main__":
os.makedirs(PLAYBOOKS_DIR, exist_ok=True)
os.makedirs(REPORTS_DIR, exist_ok=True)
print(f"Listening on http://{BIND[0]}:{BIND[1]}")
httpd = http.server.HTTPServer(BIND, Handler)
try:
httpd.serve_forever()
except KeyboardInterrupt:
pass