diff --git a/webui/app.py b/webui/app.py new file mode 100644 index 0000000..4ef6264 --- /dev/null +++ b/webui/app.py @@ -0,0 +1,282 @@ +#!/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

+
+ +
+ +
{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