From edb6cde069c04c9f8f4b95b9c856c725dc64ca7c Mon Sep 17 00:00:00 2001 From: Ole Valente Date: Tue, 22 Sep 2026 00:00:45 +0200 Subject: [PATCH] CYBER-0 initial concept ready --- .gitignore | 8 +- .gitlab-ci.yml | 289 ++++++ .gitmodules | 4 +- Dockerfile.alpine-host | 132 --- Dockerfile.ansible | 144 --- README.md | 837 ++---------------- assets.yml | 85 ++ docs/architecture.md | 96 ++ docs/secrets.md | 70 ++ inventory.ini | 136 --- list_of_total_set_of_testing_tools.md | 21 - methodologies/ansible/Dockerfile | 77 ++ methodologies/ansible/README.md | 25 + .../ansible/fixtures}/sample-output.json | 0 .../ansible/playbooks/demo_target.yml | 92 ++ .../playbooks}/examples/cisco_firewall.yml | 4 +- .../playbooks}/examples/cisco_switch.yml | 4 +- .../playbooks}/examples/hyperv_cluster.yml | 4 +- .../ansible/playbooks}/examples/linux_vm.yml | 6 +- .../playbooks}/examples/mssql_server.yml | 4 +- .../playbooks}/examples/vmware_vsphere.yml | 4 +- .../playbooks}/examples/windows_client.yml | 4 +- .../playbooks}/examples/windows_server.yml | 4 +- .../ansible/playbooks}/library/report.yml | 4 +- .../ansible/playbooks}/site.yml | 8 +- .../ansible/playbooks}/suites/fr1_auth.yml | 0 .../playbooks}/suites/fr2_use_control.yml | 0 .../playbooks}/suites/fr5_data_flow.yml | 0 .../playbooks}/templates/test_automated.yml | 0 .../playbooks}/templates/test_file_check.yml | 0 .../playbooks}/templates/test_hitl.yml | 0 .../templates/test_service_check.yml | 0 methodologies/ansible/run.sh | 72 ++ methodologies/ansible/scripts/build-image.sh | 27 + methodologies/ansible/scripts/normalize.py | 103 +++ methodologies/zap/README.md | 15 + methodologies/zap/asvs-mapping.json | 10 + .../zap/reference}/OWASP_ASVS | 0 methodologies/zap/run.sh | 24 + methodologies/zap/scripts/normalize.py | 111 +++ methodologies/zap/scripts/tls-probe.py | 96 ++ platform/gitlab-runner/values.example.yml | 23 + platform/kubernetes/kustomization.yml | 6 + platform/kubernetes/namespace.yml | 6 + platform/kubernetes/runner-rbac.yml | 41 + platform/kubernetes/storage.yml | 14 + reports/render_report.py | 185 ---- reports/report.gohtml | 66 -- requirements-ci.txt | 3 + run.sh | 82 -- schemas/test-report.schema.json | 124 +++ scripts/aggregate-reports.py | 66 ++ scripts/build-ansible.sh | 87 -- scripts/build-qemu.sh | 155 ---- scripts/entrypoint.sh | 13 - scripts/parse-assets.py | 114 +++ scripts/render-normalized.py | 118 +++ scripts/run-qemu.sh | 176 ---- scripts/tty-menu.sh | 174 ---- .../IEC_62443_CR_SR_verified_text.xlsx | Bin 99933 -> 0 bytes source_documents/testing_tools/overview.md | 138 --- targets/ubuntu-weak/Dockerfile | 27 + targets/ubuntu-weak/README.md | 9 + targets/ubuntu-weak/assets.yml | 25 + targets/ubuntu-weak/entrypoint.sh | 9 + targets/ubuntu-weak/index.html | 5 + targets/ubuntu-weak/kubernetes-runtime.yml | 77 ++ targets/ubuntu-weak/nginx.conf | 12 + webui/app.py | 282 ------ webui/container-app.py | 559 ------------ 70 files changed, 1976 insertions(+), 3140 deletions(-) create mode 100644 .gitlab-ci.yml delete mode 100644 Dockerfile.alpine-host delete mode 100644 Dockerfile.ansible create mode 100644 assets.yml create mode 100644 docs/architecture.md create mode 100644 docs/secrets.md delete mode 100644 inventory.ini delete mode 100644 list_of_total_set_of_testing_tools.md create mode 100644 methodologies/ansible/Dockerfile create mode 100644 methodologies/ansible/README.md rename {reports => methodologies/ansible/fixtures}/sample-output.json (100%) create mode 100644 methodologies/ansible/playbooks/demo_target.yml rename {playbooks => methodologies/ansible/playbooks}/examples/cisco_firewall.yml (98%) rename {playbooks => methodologies/ansible/playbooks}/examples/cisco_switch.yml (99%) rename {playbooks => methodologies/ansible/playbooks}/examples/hyperv_cluster.yml (98%) rename {playbooks => methodologies/ansible/playbooks}/examples/linux_vm.yml (97%) rename {playbooks => methodologies/ansible/playbooks}/examples/mssql_server.yml (98%) rename {playbooks => methodologies/ansible/playbooks}/examples/vmware_vsphere.yml (99%) rename {playbooks => methodologies/ansible/playbooks}/examples/windows_client.yml (98%) rename {playbooks => methodologies/ansible/playbooks}/examples/windows_server.yml (98%) rename {playbooks => methodologies/ansible/playbooks}/library/report.yml (95%) rename {playbooks => methodologies/ansible/playbooks}/site.yml (89%) rename {playbooks => methodologies/ansible/playbooks}/suites/fr1_auth.yml (100%) rename {playbooks => methodologies/ansible/playbooks}/suites/fr2_use_control.yml (100%) rename {playbooks => methodologies/ansible/playbooks}/suites/fr5_data_flow.yml (100%) rename {playbooks => methodologies/ansible/playbooks}/templates/test_automated.yml (100%) rename {playbooks => methodologies/ansible/playbooks}/templates/test_file_check.yml (100%) rename {playbooks => methodologies/ansible/playbooks}/templates/test_hitl.yml (100%) rename {playbooks => methodologies/ansible/playbooks}/templates/test_service_check.yml (100%) create mode 100644 methodologies/ansible/run.sh create mode 100644 methodologies/ansible/scripts/build-image.sh create mode 100644 methodologies/ansible/scripts/normalize.py create mode 100644 methodologies/zap/README.md create mode 100644 methodologies/zap/asvs-mapping.json rename {source_documents => methodologies/zap/reference}/OWASP_ASVS (100%) create mode 100644 methodologies/zap/run.sh create mode 100644 methodologies/zap/scripts/normalize.py create mode 100644 methodologies/zap/scripts/tls-probe.py create mode 100644 platform/gitlab-runner/values.example.yml create mode 100644 platform/kubernetes/kustomization.yml create mode 100644 platform/kubernetes/namespace.yml create mode 100644 platform/kubernetes/runner-rbac.yml create mode 100644 platform/kubernetes/storage.yml delete mode 100644 reports/render_report.py delete mode 100644 reports/report.gohtml create mode 100644 requirements-ci.txt delete mode 100644 run.sh create mode 100644 schemas/test-report.schema.json create mode 100644 scripts/aggregate-reports.py delete mode 100644 scripts/build-ansible.sh delete mode 100644 scripts/build-qemu.sh delete mode 100644 scripts/entrypoint.sh create mode 100644 scripts/parse-assets.py create mode 100644 scripts/render-normalized.py delete mode 100644 scripts/run-qemu.sh delete mode 100644 scripts/tty-menu.sh delete mode 100644 source_documents/IEC62443/IEC_62443_CR_SR_verified_text.xlsx delete mode 100644 source_documents/testing_tools/overview.md create mode 100644 targets/ubuntu-weak/Dockerfile create mode 100644 targets/ubuntu-weak/README.md create mode 100644 targets/ubuntu-weak/assets.yml create mode 100644 targets/ubuntu-weak/entrypoint.sh create mode 100644 targets/ubuntu-weak/index.html create mode 100644 targets/ubuntu-weak/kubernetes-runtime.yml create mode 100644 targets/ubuntu-weak/nginx.conf delete mode 100644 webui/app.py delete mode 100644 webui/container-app.py diff --git a/.gitignore b/.gitignore index d1a69b7..6cc2991 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..9722d4d --- /dev/null +++ b/.gitlab-ci.yml @@ -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/ \ No newline at end of file diff --git a/.gitmodules b/.gitmodules index d7e200e..54f8275 100644 --- a/.gitmodules +++ b/.gitmodules @@ -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 diff --git a/Dockerfile.alpine-host b/Dockerfile.alpine-host deleted file mode 100644 index 5e7bcc0..0000000 --- a/Dockerfile.alpine-host +++ /dev/null @@ -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://:8080 ║\e[0m\n\ -\e[1;34m║ SSH: ssh ansible@ -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"] diff --git a/Dockerfile.ansible b/Dockerfile.ansible deleted file mode 100644 index 5fac374..0000000 --- a/Dockerfile.ansible +++ /dev/null @@ -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 [] diff --git a/README.md b/README.md index 59ea3a7..32202bf 100644 --- a/README.md +++ b/README.md @@ -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
project and targets] --> P + V[Environment-scoped GitLab variables
credentials and keys] --> P + P --> R[GitLab Kubernetes Runner] + R --> K[k3s namespace: test-automation] + K --> C[(tool-cache PVC
downloaded databases)] + K --> AN[Ansible methodology pod] + K --> Z[ZAP methodology pod] + AN --> T[Linux, Windows, SQL,
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 .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/-.json - │ - ▼ - run.sh / render manually: - python3 reports/render_report.py reports/-.json - gomplate --context .=reports/-.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// +├── 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//`. -### 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=/ ./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/-.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`. diff --git a/assets.yml b/assets.yml new file mode 100644 index 0000000..03e2c4b --- /dev/null +++ b/assets.yml @@ -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] \ No newline at end of file diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..a524971 --- /dev/null +++ b/docs/architecture.md @@ -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//`. +- 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. diff --git a/docs/secrets.md b/docs/secrets.md new file mode 100644 index 0000000..2e89928 --- /dev/null +++ b/docs/secrets.md @@ -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 @` 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. diff --git a/inventory.ini b/inventory.ini deleted file mode 100644 index 624c8de..0000000 --- a/inventory.ini +++ /dev/null @@ -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 diff --git a/list_of_total_set_of_testing_tools.md b/list_of_total_set_of_testing_tools.md deleted file mode 100644 index 6d1c4ba..0000000 --- a/list_of_total_set_of_testing_tools.md +++ /dev/null @@ -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| diff --git a/methodologies/ansible/Dockerfile b/methodologies/ansible/Dockerfile new file mode 100644 index 0000000..9ddfcce --- /dev/null +++ b/methodologies/ansible/Dockerfile @@ -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"] diff --git a/methodologies/ansible/README.md b/methodologies/ansible/README.md new file mode 100644 index 0000000..0b4ece1 --- /dev/null +++ b/methodologies/ansible/README.md @@ -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 +``` diff --git a/reports/sample-output.json b/methodologies/ansible/fixtures/sample-output.json similarity index 100% rename from reports/sample-output.json rename to methodologies/ansible/fixtures/sample-output.json diff --git a/methodologies/ansible/playbooks/demo_target.yml b/methodologies/ansible/playbooks/demo_target.yml new file mode 100644 index 0000000..7a586e1 --- /dev/null +++ b/methodologies/ansible/playbooks/demo_target.yml @@ -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 \ No newline at end of file diff --git a/playbooks/examples/cisco_firewall.yml b/methodologies/ansible/playbooks/examples/cisco_firewall.yml similarity index 98% rename from playbooks/examples/cisco_firewall.yml rename to methodologies/ansible/playbooks/examples/cisco_firewall.yml index 0f4e833..3a314b7 100644 --- a/playbooks/examples/cisco_firewall.yml +++ b/methodologies/ansible/playbooks/examples/cisco_firewall.yml @@ -7,10 +7,10 @@ # Collections : cisco.asa, ansible.netcommon (installed in ansible-node image) # Python pkg : paramiko (installed in ansible-node image) # -# Inventory group : [cisco_firewalls] (see inventory.ini) +# Inventory group : cisco_firewalls (see assets.yml) # # Run: -# ansible-playbook -i inventory.ini playbooks/examples/cisco_firewall.yml +# ansible-playbook -i assets.yml methodologies/ansible/playbooks/examples/cisco_firewall.yml # # ASA-specific notes: # - asa_command returns stdout as a list, same as ios_command. diff --git a/playbooks/examples/cisco_switch.yml b/methodologies/ansible/playbooks/examples/cisco_switch.yml similarity index 99% rename from playbooks/examples/cisco_switch.yml rename to methodologies/ansible/playbooks/examples/cisco_switch.yml index 6251651..b71dc1c 100644 --- a/playbooks/examples/cisco_switch.yml +++ b/methodologies/ansible/playbooks/examples/cisco_switch.yml @@ -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. diff --git a/playbooks/examples/hyperv_cluster.yml b/methodologies/ansible/playbooks/examples/hyperv_cluster.yml similarity index 98% rename from playbooks/examples/hyperv_cluster.yml rename to methodologies/ansible/playbooks/examples/hyperv_cluster.yml index a1346a2..709d72d 100644 --- a/playbooks/examples/hyperv_cluster.yml +++ b/methodologies/ansible/playbooks/examples/hyperv_cluster.yml @@ -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) diff --git a/playbooks/examples/linux_vm.yml b/methodologies/ansible/playbooks/examples/linux_vm.yml similarity index 97% rename from playbooks/examples/linux_vm.yml rename to methodologies/ansible/playbooks/examples/linux_vm.yml index 4930f03..c23eab5 100644 --- a/playbooks/examples/linux_vm.yml +++ b/methodologies/ansible/playbooks/examples/linux_vm.yml @@ -6,11 +6,11 @@ # Connection : SSH — native Ansible, no extra collection required # Privilege : become: yes (sudo) for /etc/shadow, audit rules, sysctl # -# Inventory group : [linux_vms] (see inventory.ini) +# Inventory group : linux_vms (see assets.yml) # # Run: -# ansible-playbook -i inventory.ini playbooks/examples/linux_vm.yml -K -# ansible-playbook -i inventory.ini playbooks/examples/linux_vm.yml \ +# ansible-playbook -i assets.yml methodologies/ansible/playbooks/examples/linux_vm.yml -K +# ansible-playbook -i assets.yml methodologies/ansible/playbooks/examples/linux_vm.yml \ # --limit linux-vm-01.example.com -K # # What this covers (beyond the core fr1/fr2/fr5 suites): diff --git a/playbooks/examples/mssql_server.yml b/methodologies/ansible/playbooks/examples/mssql_server.yml similarity index 98% rename from playbooks/examples/mssql_server.yml rename to methodologies/ansible/playbooks/examples/mssql_server.yml index a1f0546..0d720ec 100644 --- a/playbooks/examples/mssql_server.yml +++ b/methodologies/ansible/playbooks/examples/mssql_server.yml @@ -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) diff --git a/playbooks/examples/vmware_vsphere.yml b/methodologies/ansible/playbooks/examples/vmware_vsphere.yml similarity index 99% rename from playbooks/examples/vmware_vsphere.yml rename to methodologies/ansible/playbooks/examples/vmware_vsphere.yml index 6490195..ed09478 100644 --- a/playbooks/examples/vmware_vsphere.yml +++ b/methodologies/ansible/playbooks/examples/vmware_vsphere.yml @@ -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 diff --git a/playbooks/examples/windows_client.yml b/methodologies/ansible/playbooks/examples/windows_client.yml similarity index 98% rename from playbooks/examples/windows_client.yml rename to methodologies/ansible/playbooks/examples/windows_client.yml index 9890ebf..2a9b407 100644 --- a/playbooks/examples/windows_client.yml +++ b/methodologies/ansible/playbooks/examples/windows_client.yml @@ -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). diff --git a/playbooks/examples/windows_server.yml b/methodologies/ansible/playbooks/examples/windows_server.yml similarity index 98% rename from playbooks/examples/windows_server.yml rename to methodologies/ansible/playbooks/examples/windows_server.yml index a6d8da6..f116ef9 100644 --- a/playbooks/examples/windows_server.yml +++ b/methodologies/ansible/playbooks/examples/windows_server.yml @@ -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 diff --git a/playbooks/library/report.yml b/methodologies/ansible/playbooks/library/report.yml similarity index 95% rename from playbooks/library/report.yml rename to methodologies/ansible/playbooks/library/report.yml index 969e09f..faa5e20 100644 --- a/playbooks/library/report.yml +++ b/methodologies/ansible/playbooks/library/report.yml @@ -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/-.json +# 3. Writes JSON to artifacts/raw/ansible/-.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 diff --git a/playbooks/site.yml b/methodologies/ansible/playbooks/site.yml similarity index 89% rename from playbooks/site.yml rename to methodologies/ansible/playbooks/site.yml index 7f8b38f..977b769 100644 --- a/playbooks/site.yml +++ b/methodologies/ansible/playbooks/site.yml @@ -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 -K -# ./run.sh --limit -K +# ansible-playbook -i assets.yml methodologies/ansible/playbooks/site.yml --limit -K +# ./methodologies/ansible/run.sh --limit -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" diff --git a/playbooks/suites/fr1_auth.yml b/methodologies/ansible/playbooks/suites/fr1_auth.yml similarity index 100% rename from playbooks/suites/fr1_auth.yml rename to methodologies/ansible/playbooks/suites/fr1_auth.yml diff --git a/playbooks/suites/fr2_use_control.yml b/methodologies/ansible/playbooks/suites/fr2_use_control.yml similarity index 100% rename from playbooks/suites/fr2_use_control.yml rename to methodologies/ansible/playbooks/suites/fr2_use_control.yml diff --git a/playbooks/suites/fr5_data_flow.yml b/methodologies/ansible/playbooks/suites/fr5_data_flow.yml similarity index 100% rename from playbooks/suites/fr5_data_flow.yml rename to methodologies/ansible/playbooks/suites/fr5_data_flow.yml diff --git a/playbooks/templates/test_automated.yml b/methodologies/ansible/playbooks/templates/test_automated.yml similarity index 100% rename from playbooks/templates/test_automated.yml rename to methodologies/ansible/playbooks/templates/test_automated.yml diff --git a/playbooks/templates/test_file_check.yml b/methodologies/ansible/playbooks/templates/test_file_check.yml similarity index 100% rename from playbooks/templates/test_file_check.yml rename to methodologies/ansible/playbooks/templates/test_file_check.yml diff --git a/playbooks/templates/test_hitl.yml b/methodologies/ansible/playbooks/templates/test_hitl.yml similarity index 100% rename from playbooks/templates/test_hitl.yml rename to methodologies/ansible/playbooks/templates/test_hitl.yml diff --git a/playbooks/templates/test_service_check.yml b/methodologies/ansible/playbooks/templates/test_service_check.yml similarity index 100% rename from playbooks/templates/test_service_check.yml rename to methodologies/ansible/playbooks/templates/test_service_check.yml diff --git a/methodologies/ansible/run.sh b/methodologies/ansible/run.sh new file mode 100644 index 0000000..150ea67 --- /dev/null +++ b/methodologies/ansible/run.sh @@ -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" diff --git a/methodologies/ansible/scripts/build-image.sh b/methodologies/ansible/scripts/build-image.sh new file mode 100644 index 0000000..66a1c9d --- /dev/null +++ b/methodologies/ansible/scripts/build-image.sh @@ -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" diff --git a/methodologies/ansible/scripts/normalize.py b/methodologies/ansible/scripts/normalize.py new file mode 100644 index 0000000..e6bc430 --- /dev/null +++ b/methodologies/ansible/scripts/normalize.py @@ -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()) \ No newline at end of file diff --git a/methodologies/zap/README.md b/methodologies/zap/README.md new file mode 100644 index 0000000..826c740 --- /dev/null +++ b/methodologies/zap/README.md @@ -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 +``` diff --git a/methodologies/zap/asvs-mapping.json b/methodologies/zap/asvs-mapping.json new file mode 100644 index 0000000..ba5f3e4 --- /dev/null +++ b/methodologies/zap/asvs-mapping.json @@ -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"] +} \ No newline at end of file diff --git a/source_documents/OWASP_ASVS b/methodologies/zap/reference/OWASP_ASVS similarity index 100% rename from source_documents/OWASP_ASVS rename to methodologies/zap/reference/OWASP_ASVS diff --git a/methodologies/zap/run.sh b/methodologies/zap/run.sh new file mode 100644 index 0000000..76ca085 --- /dev/null +++ b/methodologies/zap/run.sh @@ -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 \ No newline at end of file diff --git a/methodologies/zap/scripts/normalize.py b/methodologies/zap/scripts/normalize.py new file mode 100644 index 0000000..1960da5 --- /dev/null +++ b/methodologies/zap/scripts/normalize.py @@ -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()) \ No newline at end of file diff --git a/methodologies/zap/scripts/tls-probe.py b/methodologies/zap/scripts/tls-probe.py new file mode 100644 index 0000000..cb629ae --- /dev/null +++ b/methodologies/zap/scripts/tls-probe.py @@ -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()) \ No newline at end of file diff --git a/platform/gitlab-runner/values.example.yml b/platform/gitlab-runner/values.example.yml new file mode 100644 index 0000000..dbd4cc4 --- /dev/null +++ b/platform/gitlab-runner/values.example.yml @@ -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" diff --git a/platform/kubernetes/kustomization.yml b/platform/kubernetes/kustomization.yml new file mode 100644 index 0000000..7c10076 --- /dev/null +++ b/platform/kubernetes/kustomization.yml @@ -0,0 +1,6 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - namespace.yml + - runner-rbac.yml + - storage.yml \ No newline at end of file diff --git a/platform/kubernetes/namespace.yml b/platform/kubernetes/namespace.yml new file mode 100644 index 0000000..d369e81 --- /dev/null +++ b/platform/kubernetes/namespace.yml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: test-automation + labels: + app.kubernetes.io/part-of: test-automation \ No newline at end of file diff --git a/platform/kubernetes/runner-rbac.yml b/platform/kubernetes/runner-rbac.yml new file mode 100644 index 0000000..cbbdfdb --- /dev/null +++ b/platform/kubernetes/runner-rbac.yml @@ -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 \ No newline at end of file diff --git a/platform/kubernetes/storage.yml b/platform/kubernetes/storage.yml new file mode 100644 index 0000000..9e0d11f --- /dev/null +++ b/platform/kubernetes/storage.yml @@ -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 diff --git a/reports/render_report.py b/reports/render_report.py deleted file mode 100644 index 9c182ba..0000000 --- a/reports/render_report.py +++ /dev/null @@ -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 - python3 reports/render_report.py --format md - python3 reports/render_report.py --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]} [--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) diff --git a/reports/report.gohtml b/reports/report.gohtml deleted file mode 100644 index f93535a..0000000 --- a/reports/report.gohtml +++ /dev/null @@ -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/-.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 }} -╚══════════════════════════════════════════════════════════════════════════╝ diff --git a/requirements-ci.txt b/requirements-ci.txt new file mode 100644 index 0000000..cdf5068 --- /dev/null +++ b/requirements-ci.txt @@ -0,0 +1,3 @@ +PyYAML==6.0.2 +jsonschema==4.25.1 +fpdf2==2.8.4 \ No newline at end of file diff --git a/run.sh b/run.sh deleted file mode 100644 index 5514acf..0000000 --- a/run.sh +++ /dev/null @@ -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/-.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 ===" diff --git a/schemas/test-report.schema.json b/schemas/test-report.schema.json new file mode 100644 index 0000000..1c26b57 --- /dev/null +++ b/schemas/test-report.schema.json @@ -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" } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/scripts/aggregate-reports.py b/scripts/aggregate-reports.py new file mode 100644 index 0000000..aa75e38 --- /dev/null +++ b/scripts/aggregate-reports.py @@ -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()) \ No newline at end of file diff --git a/scripts/build-ansible.sh b/scripts/build-ansible.sh deleted file mode 100644 index c9f50a6..0000000 --- a/scripts/build-ansible.sh +++ /dev/null @@ -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 "" diff --git a/scripts/build-qemu.sh b/scripts/build-qemu.sh deleted file mode 100644 index bb191f2..0000000 --- a/scripts/build-qemu.sh +++ /dev/null @@ -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 "" diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh deleted file mode 100644 index 2bbaedf..0000000 --- a/scripts/entrypoint.sh +++ /dev/null @@ -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 diff --git a/scripts/parse-assets.py b/scripts/parse-assets.py new file mode 100644 index 0000000..fc894d4 --- /dev/null +++ b/scripts/parse-assets.py @@ -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()) \ No newline at end of file diff --git a/scripts/render-normalized.py b/scripts/render-normalized.py new file mode 100644 index 0000000..9f222b9 --- /dev/null +++ b/scripts/render-normalized.py @@ -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( + "" + f"{html.escape(result['status'])}" + f"{html.escape(result['severity'])}" + f"{html.escape(result['id'])}" + f"{html.escape(result['title'])}" + "" + for result in report["results"] + ) + summary = report["summary"] + return f""" +{html.escape(report['project']['name'])} test report + +

{html.escape(report['project']['name'])} Test Report

+

{html.escape(report['tool']['name'])} against {html.escape(report['target']['id'])} at {html.escape(report['run']['started_at'])}

+
{summary['total']}Total{summary['passed']}Passed{summary['failed']}Failed{summary.get('score', 0):.2f}%Score
+{rows}
StatusSeverityIDTitle
+
Markdown source
{html.escape(markdown_text)}
""" + + +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()) \ No newline at end of file diff --git a/scripts/run-qemu.sh b/scripts/run-qemu.sh deleted file mode 100644 index 42f312a..0000000 --- a/scripts/run-qemu.sh +++ /dev/null @@ -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[@]}" diff --git a/scripts/tty-menu.sh b/scripts/tty-menu.sh deleted file mode 100644 index 1f2d784..0000000 --- a/scripts/tty-menu.sh +++ /dev/null @@ -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@:/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 diff --git a/source_documents/IEC62443/IEC_62443_CR_SR_verified_text.xlsx b/source_documents/IEC62443/IEC_62443_CR_SR_verified_text.xlsx deleted file mode 100644 index 2a309577b1511e0a1afcab6d5e687ac1e9f4dfed..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 99933 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%0D3W~$x1oy$ED9iQFo3E#73o&)Ep{S)HP+E|k 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 diff --git a/source_documents/testing_tools/overview.md b/source_documents/testing_tools/overview.md deleted file mode 100644 index 5a71dee..0000000 --- a/source_documents/testing_tools/overview.md +++ /dev/null @@ -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 \ No newline at end of file diff --git a/targets/ubuntu-weak/Dockerfile b/targets/ubuntu-weak/Dockerfile new file mode 100644 index 0000000..de06f3b --- /dev/null +++ b/targets/ubuntu-weak/Dockerfile @@ -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"] \ No newline at end of file diff --git a/targets/ubuntu-weak/README.md b/targets/ubuntu-weak/README.md new file mode 100644 index 0000000..6611686 --- /dev/null +++ b/targets/ubuntu-weak/README.md @@ -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. diff --git a/targets/ubuntu-weak/assets.yml b/targets/ubuntu-weak/assets.yml new file mode 100644 index 0000000..b6f8596 --- /dev/null +++ b/targets/ubuntu-weak/assets.yml @@ -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] \ No newline at end of file diff --git a/targets/ubuntu-weak/entrypoint.sh b/targets/ubuntu-weak/entrypoint.sh new file mode 100644 index 0000000..836f8b1 --- /dev/null +++ b/targets/ubuntu-weak/entrypoint.sh @@ -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;' \ No newline at end of file diff --git a/targets/ubuntu-weak/index.html b/targets/ubuntu-weak/index.html new file mode 100644 index 0000000..b8ca66e --- /dev/null +++ b/targets/ubuntu-weak/index.html @@ -0,0 +1,5 @@ + + +Weak Demo Target +

Test automation target

Intentionally insecure. Never deploy outside a test network.

+ \ No newline at end of file diff --git a/targets/ubuntu-weak/kubernetes-runtime.yml b/targets/ubuntu-weak/kubernetes-runtime.yml new file mode 100644 index 0000000..3a99bef --- /dev/null +++ b/targets/ubuntu-weak/kubernetes-runtime.yml @@ -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 diff --git a/targets/ubuntu-weak/nginx.conf b/targets/ubuntu-weak/nginx.conf new file mode 100644 index 0000000..a4c7e9d --- /dev/null +++ b/targets/ubuntu-weak/nginx.conf @@ -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; +} \ No newline at end of file diff --git a/webui/app.py b/webui/app.py deleted file mode 100644 index 4ef6264..0000000 --- a/webui/app.py +++ /dev/null @@ -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""" - - - - -IEC 62443-3-3 Compliance Tester - - - - -

⚡ IEC 62443-3-3 SL2

-

Industrial control system security compliance validation

- -

▶ Run Tests

-
-
- {playbook_buttons} -
-
Select a playbook to run...
-
- -

📋 Reports

-
-
    - {report_items} -
-
- -
{status}
- - - -""" - - -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'\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'
  • {r.name} {szs} ' - f'Download
  • \n') - except Exception: - items = "
  • No reports yet
  • " - - html = HTML.format( - playbook_buttons=buttons or "

    No playbooks found

    ", - report_items=items or "
  • No reports yet
  • ", - 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 diff --git a/webui/container-app.py b/webui/container-app.py deleted file mode 100644 index 04e8bdf..0000000 --- a/webui/container-app.py +++ /dev/null @@ -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""" - - - - -IEC 62443-3-3 SL2 — Compliance Tester - - - - -

    ⚡ IEC 62443-3-3 SL2

    -

    Industrial control system security compliance validation

    - -

    ▶ Run Tests

    -
    -
    {playbook_buttons}
    -
    Select a playbook to run…
    -
    - - - -

    📋 Reports

    -
    - - - {report_rows} -
    ReportSizeDownload
    -
    - -
    - - Ready -
    - - - -""" - -# ── 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'\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'{name}{szs}' - f'' - f'JSON' - f'MD' - f'PDF' - f'\n' - ) - - self._send(200, "text/html", HTML.format( - playbook_buttons=buttons or "

    No playbooks found in /ansible/playbooks

    ", - report_rows=rows or 'No reports yet — run a test', - )) - - # ── 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