Add FastAPI server and visualization for SimpleAudit results#6
Add FastAPI server and visualization for SimpleAudit results#6SushantGautam merged 2 commits intomainfrom
Conversation
- Implemented a FastAPI server in `server.py` to serve visualization pages and handle API requests for JSON audit results. - Created HTML template `visualizer.html` for displaying audit results with a responsive design using Tailwind CSS. - Added a favicon image `thumbnail.png` for the web application. - Introduced API endpoints to retrieve the file tree of JSON files and serve individual JSON file contents. - Implemented validation for JSON files to ensure they contain valid audit results.
There was a problem hiding this comment.
Pull request overview
Adds a visualization feature set to SimpleAudit, including a FastAPI-powered results browser and standalone HTML viewers, plus CLI/packaging updates to make it runnable via simpleaudit serve.
Changes:
- Introduces a FastAPI server to browse a results directory and serve JSON + visualization pages.
- Adds/updates HTML visualizers for multi-file browsing and standalone scenario viewing.
- Adds a CLI entrypoint +
visualizeoptional dependencies; improves results save/plot to auto-create output directories.
Reviewed changes
Copilot reviewed 8 out of 9 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| simpleaudit/visualization/visualizer.html | New multi-file UI (file tree + scenario list/detail + stats). |
| simpleaudit/visualization/server.py | New FastAPI server exposing UI routes and JSON/file-tree APIs. |
| simpleaudit/visualization/scenario_viewer.html | Updates standalone viewer UX (upload section, mobile behavior, sample data). |
| simpleaudit/visualization/README.md | Documentation for using the server and standalone viewer. |
| simpleaudit/results.py | Ensure parent directories exist for save() and plot(save_path=...). |
| simpleaudit/cli.py | New simpleaudit CLI with serve command. |
| pyproject.toml | Adds console script and visualize optional dependencies. |
| README.md | Updates usage examples and links to visualization docs/UI. |
Comments suppressed due to low confidence (2)
simpleaudit/visualization/scenario_viewer.html:456
- Severity breakdown doesn’t account for severity "ERROR" results (supported elsewhere in the project): the bar/legend are built only from
countskeys, so ERROR scenarios are silently omitted and the dashboard becomes misleading. Include ERROR (and normalize casing consistently) in the counted severities and color mapping.
simpleaudit/visualization/scenario_viewer.html:488 - Within renderList, scenario name/description are later interpolated into
innerHTML(including a title attribute) without escaping, which enables HTML/JS injection if an untrusted JSON file is opened. Prefer textContent/setAttribute or escape the strings before inserting them.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| el.innerHTML = ` | ||
| <div class="w-1.5 self-stretch rounded-full ${statusColor} shrink-0"></div> | ||
| <div class="flex-1 min-w-0"> | ||
| <div class="flex justify-between items-start"> | ||
| <h3 class="font-medium text-gray-900 truncate text-sm" title="${s.scenario_name || s.name}">${s.scenario_name || s.name || 'Untitled Scenario'}</h3> | ||
| <span class="text-xs font-bold ${textColor} ml-2 shrink-0">${s.score !== undefined ? s.score + '/10' : ''}</span> | ||
| </div> | ||
| <p class="text-xs text-gray-500 mt-1 line-clamp-2">${s.scenario_description || s.description || 'No description'}</p> | ||
| </div> | ||
| `; |
There was a problem hiding this comment.
The scenario list is built via innerHTML using unescaped scenario_name/name and scenario_description/description (including in the title attribute). This allows HTML/JS injection if a JSON file contains malicious strings. Escape these fields (or use textContent/setAttribute) before inserting into the DOM.
| el.innerHTML = ` | |
| <div class="w-1.5 self-stretch rounded-full ${statusColor} shrink-0"></div> | |
| <div class="flex-1 min-w-0"> | |
| <div class="flex justify-between items-start"> | |
| <h3 class="font-medium text-gray-900 truncate text-sm" title="${s.scenario_name || s.name}">${s.scenario_name || s.name || 'Untitled Scenario'}</h3> | |
| <span class="text-xs font-bold ${textColor} ml-2 shrink-0">${s.score !== undefined ? s.score + '/10' : ''}</span> | |
| </div> | |
| <p class="text-xs text-gray-500 mt-1 line-clamp-2">${s.scenario_description || s.description || 'No description'}</p> | |
| </div> | |
| `; | |
| // Build inner content safely without using innerHTML for untrusted data | |
| const statusDiv = document.createElement('div'); | |
| statusDiv.className = `w-1.5 self-stretch rounded-full ${statusColor} shrink-0`; | |
| const flexContainer = document.createElement('div'); | |
| flexContainer.className = 'flex-1 min-w-0'; | |
| const headerRow = document.createElement('div'); | |
| headerRow.className = 'flex justify-between items-start'; | |
| const titleEl = document.createElement('h3'); | |
| titleEl.className = 'font-medium text-gray-900 truncate text-sm'; | |
| const scenarioTitle = s.scenario_name || s.name || 'Untitled Scenario'; | |
| titleEl.textContent = scenarioTitle; | |
| titleEl.setAttribute('title', s.scenario_name || s.name || ''); | |
| const scoreEl = document.createElement('span'); | |
| scoreEl.className = `text-xs font-bold ${textColor} ml-2 shrink-0`; | |
| scoreEl.textContent = s.score !== undefined ? s.score + '/10' : ''; | |
| headerRow.appendChild(titleEl); | |
| headerRow.appendChild(scoreEl); | |
| const descEl = document.createElement('p'); | |
| descEl.className = 'text-xs text-gray-500 mt-1 line-clamp-2'; | |
| descEl.textContent = s.scenario_description || s.description || 'No description'; | |
| flexContainer.appendChild(headerRow); | |
| flexContainer.appendChild(descEl); | |
| el.appendChild(statusDiv); | |
| el.appendChild(flexContainer); |
| <h2 class="text-2xl font-bold text-gray-900 leading-tight">${name}</h2> | ||
| <div class="flex items-center gap-3 mt-2"> | ||
| <span class="px-2.5 py-0.5 rounded-full text-xs font-medium uppercase tracking-wide ${statusColor} text-white bg-opacity-90"> | ||
| ${severity} | ||
| </span> | ||
| <span class="text-sm text-gray-500">Score: <strong class="${textColor}">${score !== undefined ? score + '/10' : 'N/A'}</strong></span> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| <p class="text-gray-600 leading-relaxed">${desc}</p> |
There was a problem hiding this comment.
In the detail view, name and desc are interpolated into HTML without escaping, which enables XSS from a crafted JSON results file. Use the existing escapeHtml helper (or DOM APIs like textContent) for these fields (and consider escaping severity too).
| <h2 class="text-2xl font-bold text-gray-900 leading-tight">${name}</h2> | |
| <div class="flex items-center gap-3 mt-2"> | |
| <span class="px-2.5 py-0.5 rounded-full text-xs font-medium uppercase tracking-wide ${statusColor} text-white bg-opacity-90"> | |
| ${severity} | |
| </span> | |
| <span class="text-sm text-gray-500">Score: <strong class="${textColor}">${score !== undefined ? score + '/10' : 'N/A'}</strong></span> | |
| </div> | |
| </div> | |
| </div> | |
| <p class="text-gray-600 leading-relaxed">${desc}</p> | |
| <h2 class="text-2xl font-bold text-gray-900 leading-tight">${escapeHtml(name)}</h2> | |
| <div class="flex items-center gap-3 mt-2"> | |
| <span class="px-2.5 py-0.5 rounded-full text-xs font-medium uppercase tracking-wide ${statusColor} text-white bg-opacity-90"> | |
| ${escapeHtml(severity)} | |
| </span> | |
| <span class="text-sm text-gray-500">Score: <strong class="${textColor}">${score !== undefined ? score + '/10' : 'N/A'}</strong></span> | |
| </div> | |
| </div> | |
| </div> | |
| <p class="text-gray-600 leading-relaxed">${escapeHtml(desc)}</p> |
| const counts = { | ||
| 'critical': 0, 'high': 0, 'medium': 0, 'low': 0, 'pass': 0 | ||
| }; | ||
| allScenarios.forEach(s => { | ||
| const sev = (s.severity || 'low').toLowerCase(); | ||
| if (counts[sev] !== undefined) counts[sev]++; | ||
| }); |
There was a problem hiding this comment.
Severity breakdown ignores ERROR results: counts only includes critical/high/medium/low/pass, while data can contain severity "ERROR" (and you already define color mappings for it). This makes the dashboard inaccurate (segments won't sum to total). Include ERROR in counts and handle consistent casing.
| from typing import Dict, List, Optional | ||
| from fastapi import FastAPI, HTTPException | ||
| from fastapi.responses import HTMLResponse, JSONResponse, FileResponse | ||
| from fastapi.staticfiles import StaticFiles | ||
| import uvicorn | ||
|
|
||
|
|
There was a problem hiding this comment.
There are unused imports here (e.g., Optional and StaticFiles). This will fail ruff/flake checks (F401) in CI/lint. Remove unused imports or wire them up (e.g., mount StaticFiles if intended).
| from typing import Dict, List, Optional | |
| from fastapi import FastAPI, HTTPException | |
| from fastapi.responses import HTMLResponse, JSONResponse, FileResponse | |
| from fastapi.staticfiles import StaticFiles | |
| import uvicorn | |
| from typing import Dict, List | |
| from fastapi import FastAPI, HTTPException | |
| from fastapi.responses import HTMLResponse, JSONResponse, FileResponse |
| @app.get("/") | ||
| async def root(): | ||
| """Serve the main visualization page.""" | ||
| html_path = Path(__file__).parent / "visualizer.html" | ||
|
|
||
| if not html_path.exists(): | ||
| return HTMLResponse( | ||
| content="<h1>Error: Visualization template not found</h1>", | ||
| status_code=500 | ||
| ) | ||
|
|
||
| with open(html_path, "r", encoding="utf-8") as f: | ||
| content = f.read() | ||
|
|
||
| return HTMLResponse(content=content) |
There was a problem hiding this comment.
Packaging concern: server.py loads visualizer.html, scenario_viewer.html, and thumbnail.png from Path(__file__).parent, but simpleaudit/visualization/ currently has no __init__.py and there’s no setuptools package-data configuration in pyproject. As a result, these assets (and possibly server.py itself) may be omitted from the built wheel, breaking simpleaudit serve after installation. Add simpleaudit/visualization/__init__.py and configure package data to include the HTML/PNG/README files.
| **Features:** | ||
| - ✨ Works completely offline | ||
| - 🔒 No data leaves your computer | ||
| - 📤 Drag & drop JSON files | ||
| - 🎯 Sample data included for demo | ||
| - 📱 Mobile friendly | ||
| - 🔄 Upload multiple files sequentially |
There was a problem hiding this comment.
The README claims the standalone viewer "works completely offline", but scenario_viewer.html pulls Tailwind from https://cdn.tailwindcss.com, so fully offline use will lose styling (and some Tailwind-dependent layout). Either vendor Tailwind locally / inline minimal CSS, or adjust the docs to clarify that an internet connection is required for styling.
| full_path = os.path.normpath(os.path.join(RESULTS_DIR, file_path)) | ||
|
|
||
| if not full_path.startswith(os.path.normpath(RESULTS_DIR)): | ||
| raise HTTPException(status_code=403, detail="Access denied") | ||
|
|
||
| if not os.path.exists(full_path): | ||
| raise HTTPException(status_code=404, detail="File not found") | ||
|
|
||
| if not full_path.endswith('.json'): | ||
| raise HTTPException(status_code=400, detail="Not a JSON file") | ||
|
|
||
| try: | ||
| with open(full_path, "r", encoding="utf-8") as f: |
There was a problem hiding this comment.
The path traversal protection is brittle: full_path.startswith(normpath(RESULTS_DIR)) can be bypassed with prefix paths like /base/results_evil/... and doesn’t account for symlinks or case-insensitive filesystems. Use resolved Path objects and check requested.resolve().is_relative_to(results_dir.resolve()) (or compare os.path.commonpath).
| full_path = os.path.normpath(os.path.join(RESULTS_DIR, file_path)) | |
| if not full_path.startswith(os.path.normpath(RESULTS_DIR)): | |
| raise HTTPException(status_code=403, detail="Access denied") | |
| if not os.path.exists(full_path): | |
| raise HTTPException(status_code=404, detail="File not found") | |
| if not full_path.endswith('.json'): | |
| raise HTTPException(status_code=400, detail="Not a JSON file") | |
| try: | |
| with open(full_path, "r", encoding="utf-8") as f: | |
| base_dir = Path(RESULTS_DIR).resolve() | |
| requested_path = (base_dir / file_path).resolve() | |
| # Confirm that the requested path is within the results directory | |
| try: | |
| requested_path.relative_to(base_dir) | |
| except ValueError: | |
| raise HTTPException(status_code=403, detail="Access denied") | |
| if not requested_path.exists(): | |
| raise HTTPException(status_code=404, detail="File not found") | |
| if requested_path.suffix != '.json': | |
| raise HTTPException(status_code=400, detail="Not a JSON file") | |
| try: | |
| with requested_path.open("r", encoding="utf-8") as f: |
| from .visualization.server import start_server | ||
|
|
||
| # Default to current directory if not specified | ||
| results_dir = args.results_dir | ||
| if results_dir is None: | ||
| results_dir = "." | ||
| print("⚠️ Warning: --results_dir not specified, using current directory '.'") | ||
| print(" Recommended: explicitly set --results_dir to avoid confusion\n") | ||
|
|
There was a problem hiding this comment.
simpleaudit serve will import the visualization server unconditionally; if users installed the base package without the visualize extra, this will raise ImportError for fastapi/uvicorn. Catch that import error and print a clear message telling the user to install simpleaudit[visualize].
| from .visualization.server import start_server | |
| # Default to current directory if not specified | |
| results_dir = args.results_dir | |
| if results_dir is None: | |
| results_dir = "." | |
| print("⚠️ Warning: --results_dir not specified, using current directory '.'") | |
| print(" Recommended: explicitly set --results_dir to avoid confusion\n") | |
| try: | |
| from .visualization.server import start_server | |
| except ImportError: | |
| print( | |
| "Error: Visualization server dependencies are not installed.\n" | |
| "Please install the visualize extra, e.g.:\n" | |
| " pip install 'simpleaudit[visualize]'", | |
| file=sys.stderr, | |
| ) | |
| sys.exit(1) | |
| # Default to current directory if not specified | |
| results_dir = args.results_dir | |
| if results_dir is None: | |
| results_dir = "." | |
| print("⚠️ Warning: --results_dir not specified, using current directory '.'") | |
| print(" Recommended: explicitly set --results_dir to avoid confusion\n") |
| def main(): | ||
| """Main entry point for simpleaudit CLI.""" | ||
| parser = argparse.ArgumentParser( | ||
| prog="simpleaudit", | ||
| description="SimpleAudit CLI - AI Safety Auditing Tools" | ||
| ) | ||
|
|
||
| subparsers = parser.add_subparsers(dest="command", help="Available commands") | ||
|
|
||
| # Serve command | ||
| serve_parser = subparsers.add_parser( | ||
| "serve", | ||
| help="Start a web server to visualize audit results" | ||
| ) | ||
| serve_parser.add_argument( | ||
| "--results_dir", | ||
| type=str, | ||
| default=None, | ||
| help="Directory containing JSON result files to visualize (default: current directory)" | ||
| ) | ||
| serve_parser.add_argument( | ||
| "--port", | ||
| type=int, | ||
| default=8000, | ||
| help="Port to run the server on (default: 8000)" | ||
| ) | ||
| serve_parser.add_argument( | ||
| "--host", | ||
| type=str, | ||
| default="127.0.0.1", | ||
| help="Host to bind the server to (default: 127.0.0.1)" | ||
| ) | ||
|
|
||
| args = parser.parse_args() | ||
|
|
||
| if args.command == "serve": | ||
| from .visualization.server import start_server | ||
|
|
||
| # Default to current directory if not specified | ||
| results_dir = args.results_dir | ||
| if results_dir is None: | ||
| results_dir = "." | ||
| print("⚠️ Warning: --results_dir not specified, using current directory '.'") | ||
| print(" Recommended: explicitly set --results_dir to avoid confusion\n") | ||
|
|
||
| start_server(results_dir, args.host, args.port) | ||
| else: | ||
| parser.print_help() | ||
| sys.exit(1) |
There was a problem hiding this comment.
New CLI entrypoint behavior is introduced (arg parsing + serve command), but there are no tests covering basic CLI invocation/error cases. Since the repo has an existing pytest suite, add a small test module that exercises simpleaudit.cli:main (e.g., help output, unknown command exit code, serve import failure messaging).
Updated wording for clarity regarding the local web server.
)" This reverts commit b9a877f.
server.pyto serve visualization pages and handle API requests for JSON audit results.visualizer.htmlfor displaying audit results with a responsive design using Tailwind CSS.thumbnail.pngfor the web application.