CYBER-0 initial concept ready
This commit is contained in:
+5
-3
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
@@ -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 []
|
||||
@@ -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
@@ -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]
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
@@ -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|
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
+2
-2
@@ -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.
|
||||
+2
-2
@@ -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.
|
||||
+2
-2
@@ -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)
|
||||
+3
-3
@@ -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):
|
||||
+2
-2
@@ -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)
|
||||
+2
-2
@@ -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
|
||||
+2
-2
@@ -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).
|
||||
+2
-2
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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())
|
||||
@@ -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
|
||||
```
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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"
|
||||
@@ -0,0 +1,6 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- namespace.yml
|
||||
- runner-rbac.yml
|
||||
- storage.yml
|
||||
@@ -0,0 +1,6 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: test-automation
|
||||
labels:
|
||||
app.kubernetes.io/part-of: test-automation
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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 }}
|
||||
╚══════════════════════════════════════════════════════════════════════════╝
|
||||
@@ -0,0 +1,3 @@
|
||||
PyYAML==6.0.2
|
||||
jsonschema==4.25.1
|
||||
fpdf2==2.8.4
|
||||
@@ -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 ==="
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
@@ -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 ""
|
||||
@@ -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 ""
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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[@]}"
|
||||
@@ -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
|
||||
Binary file not shown.
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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]
|
||||
@@ -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;'
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user