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