""" viewer.py: a minimal, offline FastAPI app that turns the scanner into a one-command browser demo. It runs a scan once at startup (default: the offline ``stub`` target, no API key required), then serves: GET / on-brand landing page with the headline result GET /report the full, self-contained report.html GET /report.json machine-readable findings GET /model_card.md NIST AI RMF / ISO 42001 governance narrative GET /risk_register.csv GRC-ready risk register GET /healthz liveness probe Design goals: lean (FastAPI + the scanner's existing deps only), offline-first, and fully testable via ``starlette.testclient.TestClient`` without binding a server. Run it with: uvicorn llm_security_scanner.viewer:app --reload # or: llm-scan serve The landing page shares the report's identity: a dark-first enterprise security console (near-black slate, a cyan→emerald scanner-signal accent, monospace data, a severity colour system and a bento severity dashboard), so the demo and the report read as one product. """ from __future__ import annotations import os from functools import lru_cache from typing import Dict from fastapi import FastAPI, HTTPException from fastapi.responses import HTMLResponse, PlainTextResponse, Response from . import __version__ from .engine import Scanner from .governance import render_model_card, render_risk_register from .models import ScanResult from .providers import get_provider from .reporting import render_html_report, summary_table # The target the demo scans. Defaults to the offline stub so the viewer needs no # API key; override with LLM_SCAN_VIEWER_TARGET to point at a real provider. _TARGET = os.environ.get("LLM_SCAN_VIEWER_TARGET", "stub") @lru_cache(maxsize=1) def get_scan_result() -> ScanResult: """Run the scan once and memoize it for the life of the process. Cached so every request renders from a single, consistent result (and the landing page, report and downloads never disagree). """ provider = get_provider(_TARGET) return Scanner(provider, scanner_version=__version__).run() # --------------------------------------------------------------------------- # # Landing page # --------------------------------------------------------------------------- # _SEVERITY_HEX = { "CRITICAL": "#f43f5e", # rose-500 "HIGH": "#f97316", # orange-500 "MEDIUM": "#f59e0b", # amber-500 "LOW": "#eab308", # yellow-500 } def _result_gradient(result: ScanResult) -> str: """Build the CSS conic-gradient for the landing-page severity donut.""" sc = result.severity_counts() total = result.total_findings if not total: return "conic-gradient(rgb(var(--border)) 0deg 360deg)" stops = [] start = 0.0 for name in ("CRITICAL", "HIGH", "MEDIUM", "LOW"): count = sc[name] if not count: continue end = start + count / total * 360.0 stops.append(f"{_SEVERITY_HEX[name]} {start:.3f}deg {end:.3f}deg") start = end return f"conic-gradient({', '.join(stops)})" def _landing_html(result: ScanResult) -> str: sc = result.severity_counts() hs = result.highest_severity() pass_pct = round(result.pass_rate * 100) n_categories = len({o.probe.category for o in result.outcomes}) result_gradient = _result_gradient(result) # Severity accent + verdict driven by the worst finding. Dark-on-light text # for the amber/yellow flags, white for the red/orange ones. accent = _SEVERITY_HEX.get(hs.name, "#34d399") if hs else "#34d399" if hs and hs.value >= 4: verdict, verdict_bg, verdict_ink = "Release-blocking", "#f43f5e", "#fff" elif hs and hs.value >= 3: verdict, verdict_bg, verdict_ink = "Needs remediation", "#f97316", "#fff" else: verdict, verdict_bg, verdict_ink = "No blockers", "#34d399", "#08121a" # Headline icon: a warning triangle when there is high+ exposure, else a tick. if hs and hs.value >= 3: headline_icon = ( "" ) else: headline_icon = ( "" ) donut_empty = "
" if result.total_findings == 0 else "" # Severity stat tiles (bento) + distribution bars share the same numbers. total = result.total_findings or 1 tiles = "" bars = "" for name in ("CRITICAL", "HIGH", "MEDIUM", "LOW"): count = sc[name] pct = round(count / total * 100) if result.total_findings else 0 color = _SEVERITY_HEX[name] zero = "" if count else " zero" num_cls = " hit" if count else "" tiles += ( f'An extensible adversarial probe battery, prompt injection, jailbreaks, secret leakage, indirect/RAG injection, with a NIST AI RMF / ISO 42001 governance package generated from the same run.