Codex vulnhr plan

Context

La pagina Demo (/demo, admin-only) ha backend completo e frontend funzionante (ShowcasePlayer con timeline playback, speed controls, fullscreen, keyboard shortcuts). Ma gli scenari sono fittizi e la UX è povera (8-11 eventi, nessuna animazione, nessun diagramma chain).

Obiettivo: Demo che un admin BeDefended presenta in screen-share a un potenziale cliente. Lo scenario principale viene da un pentest REALE su VulnHR (app HR interna, ~93 vulnerabilità). VulnHR viene gestito da un pannello Start/Stop nella pagina Demo stessa. Il pentest gira internamente su AX42-U.

Infrastruttura: Due server Hetzner attivi — CCX23 (dashboard pubblica) e AX42-U (runner + Docker). Cloudflare davanti. Il runner (porta 8881 su AX42-U) espone POST /lab-jobs per eseguire comandi e GET /docker/containers per status.


PREREQUISITO UNA TANTUM (manuale, non nel codice)

Prima di poter usare il pannello Start/Stop, serve il setup iniziale su AX42-U:

# SSH su AX42-U ssh runner@

# 1. Clonare VulnHR (una volta sola) cd /opt/labs git clone https://github.com/sbbedefended/VulnHR.git vulnhr

# 2. Hosts entry (una volta sola) echo "127.0.0.1 vulnhr.test" >> /etc/hosts

# 3. Installare cloudflared e creare tunnel (una volta sola) cloudflared tunnel create demo-vulnhr # Creare config ~/.cloudflared/config-vulnhr.yml con ingress verso localhost:7331 cloudflared tunnel route dns demo-vulnhr demo-hr.bedefended.com

Dopo questo setup, tutto il resto (start/stop containers, start/stop tunnel) avviene dalla dashboard.


Task 1 — Backend: endpoint VulnHR lab control

File: dashboard/backend/app/routers/demo.py

Aggiungere 3 nuovi endpoint per gestire il lab VulnHR tramite il runner. Usano runner_client.create_lab_job() per eseguire comandi su AX42-U.

Aggiungere queste import in cima al file:

import time from app.services.runner_client import get_runner_client

Aggiungere questi endpoint DOPO preflight (riga 59):

# --------------------------------------------------------------------------- # VulnHR lab management # ---------------------------------------------------------------------------

VULNHR_LAB_DIR = "/opt/labs/vulnhr" VULNHR_CONTAINERS = ["hrportal-nginx", "hrportal-php", "hrportal-postgres", "hrportal-redis", "hrportal-ldap", "hrportal-n8n"] VULNHR_SETUP_COMMANDS = [ "docker compose exec -T php mkdir -p bootstrap/cache storage/logs storage/framework/sessions storage/framework/views storage/framework/cache", "docker compose exec -T php chmod -R 777 bootstrap/cache storage", "docker compose exec -T php composer install --no-interaction --no-security-blocking", "docker compose exec -T php php artisan key:generate --force", "docker compose exec -T php php artisan migrate --force", "docker compose exec -T php php artisan db:seed --force", "docker compose exec -T php php artisan storage:link --force", "docker compose exec -T php chmod -R 777 storage bootstrap/cache", ]

@router.get("/vulnhr/status") def vulnhr_status(current_user: CurrentUser): """Check if VulnHR containers are running.""" del current_user try: client = get_runner_client() containers = client.list_running_containers() running = [c for c in VULNHR_CONTAINERS if c in containers] all_up = len(running) == len(VULNHR_CONTAINERS) return { "status": "running" if all_up else ("partial" if running else "stopped"), "containers_up": len(running), "containers_total": len(VULNHR_CONTAINERS), "running": running, "target_url": "http://vulnhr.test:7331/", "public_url": "https://demo-hr.bedefended.com", } except Exception as exc: logger.error("VulnHR status check failed: %s", exc) return {"status": "error", "error": str(exc)}

@router.post("/vulnhr/start", status_code=201) def vulnhr_start(current_user: CurrentUser): """Start VulnHR containers + cloudflare tunnel via runner lab-job.""" del current_user client = get_runner_client()

 # Step 1: docker compose up
 up_job = client.create_lab_job({
     "lab_id": "vulnhr-demo",
     "operation": "start",
     "command": ["docker", "compose", "up", "-d"],
     "cwd": VULNHR_LAB_DIR,
     "env": {},
     "setup_commands": [],
 })

 # Wait for compose up to finish (max 60s)
 job_id = up_job["id"]
 for _ in range(60):
     time.sleep(1)
     job_info = client.get_lab_job(job_id)
     if job_info.get("status") in ("completed", "failed"):
         break

 if job_info.get("status") == "failed":
     raise HTTPException(status_code=500, detail="docker compose up failed")

 # Step 2: run setup commands sequentially
 setup_script = " && ".join(VULNHR_SETUP_COMMANDS)
 setup_job = client.create_lab_job({
     "lab_id": "vulnhr-demo-setup",
     "operation": "setup",
     "command": ["bash", "-c", setup_script],
     "cwd": VULNHR_LAB_DIR,
     "env": {},
     "setup_commands": [],
 })

 # Wait for setup (max 120s — composer install can be slow)
 setup_id = setup_job["id"]
 for _ in range(120):
     time.sleep(1)
     setup_info = client.get_lab_job(setup_id)
     if setup_info.get("status") in ("completed", "failed"):
         break

 # Step 3: start cloudflare tunnel (background, won't "complete")
 tunnel_job = client.create_lab_job({
     "lab_id": "vulnhr-demo-tunnel",
     "operation": "tunnel",
     "command": ["cloudflared", "tunnel", "--config", "/root/.cloudflared/config-vulnhr.yml", "run", "demo-vulnhr"],
     "cwd": "/root",
     "env": {},
     "setup_commands": [],
 })

 return {
     "status": "started",
     "compose_job_id": job_id,
     "setup_job_id": setup_id,
     "tunnel_job_id": tunnel_job["id"],
     "target_url": "http://vulnhr.test:7331/",
     "public_url": "https://demo-hr.bedefended.com",
 }

@router.post("/vulnhr/stop") def vulnhr_stop(current_user: CurrentUser): """Stop VulnHR containers + cloudflare tunnel.""" del current_user client = get_runner_client()

 # Stop tunnel (kill any running vulnhr tunnel jobs)
 # The tunnel runs as a lab-job, we can't enumerate them easily,
 # so we just run pkill
 try:
     client.create_lab_job({
         "lab_id": "vulnhr-demo-tunnel-stop",
         "operation": "stop-tunnel",
         "command": ["pkill", "-f", "config-vulnhr.yml"],
         "cwd": "/root",
         "env": {},
         "setup_commands": [],
     })
 except Exception:
     pass  # tunnel may not be running

 # Stop containers
 stop_job = client.create_lab_job({
     "lab_id": "vulnhr-demo-stop",
     "operation": "stop",
     "command": ["docker", "compose", "down"],
     "cwd": VULNHR_LAB_DIR,
     "env": {},
     "setup_commands": [],
 })

 return {
     "status": "stopping",
     "job_id": stop_job["id"],
 }

Task 2 — Frontend API: aggiungere hooks VulnHR

File: dashboard/frontend/src/api/demo.ts

DemoFinding (riga 5-13). Aggiungere dopo cwe:

export interface DemoFinding { id: string title: string severity: string cvss_score: number | null endpoint: string description: string cwe: string poc_curl?: string request_snippet?: string response_snippet?: string }

DemoChain (riga 23-28). Aggiungere dopo findings:

export interface DemoChain { id: string title: string severity: string findings: string[] mermaid_code?: string }

Aggiungere DOPO useDeleteDemoScenario (riga 111):

// --- Snapshot ---

export interface DemoSnapshotRequest { engagement_name: string name: string description: string highlight_finding_ids: string[] }

export function useSnapshotEngagement() { const queryClient = useQueryClient() return useMutation({ mutationFn: async (payload: DemoSnapshotRequest): Promise => { const { data } = await apiClient.post('/demo/snapshot', payload) return data }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['demo', 'scenarios'] }) }, }) }

// --- VulnHR Lab Control ---

export interface VulnHRStatus { status: 'running' | 'partial' | 'stopped' | 'error' containers_up?: number containers_total?: number running?: string[] target_url?: string public_url?: string error?: string }

export function useVulnHRStatus() { return useQuery({ queryKey: ['demo', 'vulnhr', 'status'], queryFn: async () => { const { data } = await apiClient.get('/demo/vulnhr/status') return data }, refetchInterval: 5000, // poll every 5s staleTime: 3000, }) }

export function useVulnHRStart() { const queryClient = useQueryClient() return useMutation({ mutationFn: async () => { const { data } = await apiClient.post('/demo/vulnhr/start') return data }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['demo', 'vulnhr', 'status'] }) }, }) }

export function useVulnHRStop() { const queryClient = useQueryClient() return useMutation({ mutationFn: async () => { const { data } = await apiClient.post('/demo/vulnhr/stop') return data }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['demo', 'vulnhr', 'status'] }) }, }) }


Task 3 — Schema backend: aggiungere campi evidence e mermaid

File: dashboard/backend/app/schemas.py

DemoFinding (riga 2167-2174). Aggiungere 3 campi DOPO cwe:

class DemoFinding(BaseModel): id: str title: str severity: str = "" cvss_score: float | None = None endpoint: str = "" description: str = "" cwe: str = "" poc_curl: str = "" request_snippet: str = "" response_snippet: str = ""

DemoChain (riga 2187-2191). Aggiungere mermaid_code DOPO findings:

class DemoChain(BaseModel): id: str title: str severity: str = "" findings: list[str] = Field(default_factory=list) mermaid_code: str = ""


Task 4 — Animazioni Tailwind

File: dashboard/frontend/tailwind.config.ts

Dentro keyframes (dopo shimmer a riga ~152, PRIMA della } che chiude keyframes), aggiungere:

'critical-drop': { '0%': { transform: 'scale(1)', boxShadow: '0 0 0 rgba(255, 51, 51, 0)' }, '15%': { transform: 'scale(1.03)', boxShadow: '0 0 40px rgba(255, 51, 51, 0.6), 0 0 80px rgba(255, 51, 51, 0.3)' }, '100%': { transform: 'scale(1)', boxShadow: '0 0 15px rgba(255, 51, 51, 0.15)' }, }, 'slide-in-left': { 'from': { opacity: '0', transform: 'translateX(-20px)' }, 'to': { opacity: '1', transform: 'translateX(0)' }, }, 'narrator-glow': { '0%, 100%': { borderColor: 'rgba(34, 211, 238, 0.3)' }, '50%': { borderColor: 'rgba(34, 211, 238, 0.6)' }, },

Dentro animation (dopo shimmer a riga ~162, PRIMA della } che chiude animation), aggiungere:

'critical-drop': 'critical-drop 0.8s cubic-bezier(0.34, 1.56, 0.64, 1) forwards', 'slide-in-left': 'slide-in-left 0.4s ease-out forwards', 'narrator-glow': 'narrator-glow 3s ease-in-out infinite',


Task 5 — Arricchire i 3 scenari seed JSON

File da modificare: - dashboard/data/demo-scenarios/quick-api-demo.json - dashboard/data/demo-scenarios/enterprise-webapp-demo.json - dashboard/data/demo-scenarios/burp-bidirectional-demo.json

Per ogni file, mantenere INTATTI tutti i campi top-level (id, name, description, target_display, target_url, tech_stack, total_duration_seconds, total_findings, severity_counts, phases, highlight_finding_ids, created_from, snapshot_date).

5a. Espandere timeline da ~10 a 45-55 eventi

Distribuire uniformemente su total_duration_seconds * 1000 ms. Usare 5 event_type: - PHASE — transizioni di fase (5 per scenario: discover, scan, test, verify, report) - FINDING — scoperta vulnerability. Il campo details DEVE contenere il finding ID (es. "FINDING-001 confirmed via UNION SELECT") - INFO — eventi informativi (tool output, progress, endpoint count) - WARN — segnali di allarme (WAF detected, rate limit, anomalia) - NARRATOR — NUOVO. Narrazione per il presenter. Tono descrittivo per il cliente, NON jargon tecnico. 6-8 per scenario. Esempi: - "L'AI ha analizzato 23 endpoint e identificato 3 pattern sospetti nei meccanismi di autenticazione." - "Qui l'intelligenza artificiale sta correlando automaticamente due vulnerabilità separate per costruire una catena di attacco completa." - "Il sistema ha verificato che questa vulnerabilità permette a un utente standard di accedere ai dati salariali di tutti i dipendenti."

Tipi di eventi intermedi (mix di tutti): - Dispatch agenti: "Dispatching 3 parallel agents for injection testing wave" - Tool invocations: "Running nuclei with 847 templates against 23 endpoints" - Progress: "18/23 endpoints scanned, 3 high-confidence signals promoted" - AI decisions: "[AI-DECISION] Choosing UNION-based over blind SQLi based on error reflection in response" - Discovery: "Found hidden /api/v1/admin/debug endpoint in JavaScript bundle" - Verification: "Replaying SQLi with different payload to confirm — not a false positive"

5b. Aggiungere evidence ai findings

Per ogni finding, aggiungere poc_curl, request_snippet, response_snippet con dati verosimili. Esempio per SQLi:

"poc_curl": "curl -s 'https://api.demo.local/api/v1/users?search=%27%20UNION%20SELECT%201,username,password,4,5%20FROM%20users--' -H 'Authorization: Bearer eyJhbG...'", "request_snippet": "GET /api/v1/users?search=' UNION SELECT 1,username,password,4,5 FROM users-- HTTP/1.1\nHost: api.demo.local\nAuthorization: Bearer eyJhbG...", "response_snippet": "HTTP/1.1 200 OK\nContent-Type: application/json\n\n{\"users\":[{\"id\":1,\"name\":\"admin\",\"email\":\"admin@demo.local\"}]}"

5c. Aggiungere mermaid_code alle chains

Per ogni chain, aggiungere il diagramma Mermaid. Usare graph LR con nodi colorati per severity: - Critical: fill:#dc2626,stroke:#991b1b,color:#fff - High: fill:#ea580c,stroke:#c2410c,color:#fff - Medium: fill:#eab308,stroke:#a16207,color:#fff

Esempio: "mermaid_code": "graph LR\n A[\"FINDING-001\nSQL Injection\"] -->|extracts credentials| B[\"FINDING-002\nWeak JWT Secret\"]\n B -->|forges admin token| C[\"FINDING-003\nIDOR on Profiles\"]\n style A fill:#dc2626,stroke:#991b1b,color:#fff\n style B fill:#dc2626,stroke:#991b1b,color:#fff\n style C fill:#ea580c,stroke:#c2410c,color:#fff"


Task 6 — Riscrivere DemoPage.tsx

File: dashboard/frontend/src/pages/DemoPage.tsx

Il file ha 708 righe. Le modifiche sono nel ShowcasePlayer (righe 141-489) e nel DemoPage (righe 491-707). ScenarioCard (righe 64-139) e le helper formatMs, severityColor, findTriggeredFinding (righe 37-62) restano invariate.

6.0 — Import aggiuntivi

Aggiungere in cima al file:

import { PieChart, Pie, Cell, ResponsiveContainer } from 'recharts' import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter' import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism' import { MermaidRenderer } from '../components/chains/MermaidRenderer' import { useEngagements } from '../api/engagements' import { BaseModal } from '../components/base'

Aggiungere useSnapshotEngagement, useVulnHRStatus, useVulnHRStart, useVulnHRStop all'import da ../api/demo.

Aggiungere Power, PowerOff, ExternalLink, Loader2 all'import lucide-react.

Rimuovere FlaskConical dall'import lucide (riga 6).

6.1 — Rimuovere costanti Live Demo

Eliminare LIVE_DEMO_LABS (righe 31-35).

6.2 — Aggiungere costanti

Dopo BURP_DEMO_ID (riga 30):

const SEVERITY_COLORS: Record = { Critical: '#dc2626', High: '#ea580c', Medium: '#eab308', Low: '#22c55e', Info: '#64748b', }

const EVENT_TYPE_CONFIG: Record = { PHASE: { icon: '◆', borderClass: 'border-accent-500/40', bgClass: 'bg-accent-500/10' }, FINDING: { icon: '⚠', borderClass: 'border-red-500/40', bgClass: 'bg-red-500/10' }, INFO: { icon: '●', borderClass: 'border-slate-500/30', bgClass: 'bg-slate-800/60' }, WARN: { icon: '△', borderClass: 'border-yellow-500/40', bgClass: 'bg-yellow-500/10' }, NARRATOR: { icon: '💬', borderClass: 'border-cyan-400/40', bgClass: 'bg-gradient-to-r from-cyan-500/10 to-purple-500/10' }, }

6.3 — Aggiungere useAnimatedCounter hook

Dopo le costanti, prima di ScenarioCard:

function useAnimatedCounter(target: number, duration = 800): number { const [value, setValue] = useState(0) const prevTarget = useRef(0) useEffect(() => { if (target === prevTarget.current) return prevTarget.current = target const start = performance.now() function tick(now: number) { const elapsed = now - start const progress = Math.min(elapsed / duration, 1) const eased = 1 - Math.pow(1 - progress, 3) setValue(Math.round(target * eased)) if (progress < 1) requestAnimationFrame(tick) } requestAnimationFrame(tick) }, [target, duration]) return value }

6.4 — ShowcasePlayer: aggiungere auto-scroll

Dentro ShowcasePlayer, dopo latestFinding (riga 221):

const timelineEndRef = useRef(null) const prevVisibleCount = useRef(0)

useEffect(() => { if (visibleEvents.length > prevVisibleCount.current) { prevVisibleCount.current = visibleEvents.length timelineEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) } }, [visibleEvents.length])

Piazzare

DOPO il .map() degli eventi (dopo riga 351).

6.5 — ShowcasePlayer: tracking Critical glow

Dopo il blocco auto-scroll:

const prevFindingIds = useRef(new Set()) const [freshCriticalIds, setFreshCriticalIds] = useState(new Set())

useEffect(() => { const newIds = new Set() for (const f of visibleFindings) { if (!prevFindingIds.current.has(f.id) && f.severity === 'Critical') { newIds.add(f.id) } } prevFindingIds.current = new Set(visibleFindings.map(f => f.id)) if (newIds.size > 0) { setFreshCriticalIds(newIds) const timer = setTimeout(() => setFreshCriticalIds(new Set()), 1500) return () => clearTimeout(timer) } }, [visibleFindings])

6.6 — ShowcasePlayer: donut counter

Dopo freshCriticalIds:

const donutData = Object.entries(severityCounts) .filter(([, value]) => value > 0) .map(([name, value]) => ({ name, value })) const animatedTotal = useAnimatedCounter(visibleFindings.length)

6.7 — ShowcasePlayer: riscrivere rendering eventi timeline

Sostituire il blocco scenario.timeline.map(event => ...) (righe 320-351) con:

{scenario.timeline.map(event => { const active = event.relative_time_ms <= positionMs const finding = event.event_type === 'FINDING' ? findTriggeredFinding(scenario, event.details) : null const config = EVENT_TYPE_CONFIG[event.event_type] || EVENT_TYPE_CONFIG.INFO const isNarrator = event.event_type === 'NARRATOR' const isCriticalFinding = finding && freshCriticalIds.has(finding.id)

return (

${event.relative_time_ms}-${event.action}} className={cn( 'rounded border px-4 py-3 transition-all', active ? ${config.borderClass} ${config.bgClass} text-white : 'border-white/10 bg-slate-950/40 text-slate-500', active && 'animate-slide-in-left', isNarrator && active && 'animate-narrator-glow', isCriticalFinding && 'animate-critical-drop', )} >
{formatMs(event.relative_time_ms)} {config.icon} {event.event_type} {!isNarrator && event.skill && ( {event.skill} )}
{isNarrator ? (
{event.details}
) : ( <>
{event.action}
{event.details}
</> )} {finding && (
{finding.id} · {finding.severity}
)}
) })}

6.8 — ShowcasePlayer: donut chart al posto dei severity counters

Sostituire la BaseCard con i 6 box severity (righe 403-418) con:


0 ? donutData : [{ name: 'Empty', value: 1 }]} cx="50%" cy="50%" innerRadius="60%" outerRadius="85%" dataKey="value" stroke="none" isAnimationActive={true} animationDuration={600} > {donutData.map((entry) => ( ))} {donutData.length === 0 && }
{animatedTotal}
findings
{(['Critical', 'High', 'Medium', 'Low'] as const).map(sev => (
{severityCounts[sev]} {sev}
))}

6.9 — ShowcasePlayer: evidence nel Spotlight

Nel Spotlight panel, DOPO il display del CWE (riga ~367):

{latestFinding.poc_curl && (

PoC
{latestFinding.poc_curl}

)} {latestFinding.request_snippet && (

Request
{latestFinding.request_snippet}

)} {latestFinding.response_snippet && (

Response
{latestFinding.response_snippet}

)}

6.10 — ShowcasePlayer: chains con Mermaid

Sostituire il rendering delle chains (righe 458-482):

{scenario.chains.map(chain => { const allVisible = chain.findings.every(id => visibleFindingIds.has(id)) return (

{chain.title}
{chain.severity}
{chain.findings.map(findingId => ( {findingId} ))}
{chain.mermaid_code && allVisible && (
)}
) })}

6.11 — DemoPage: sostituire Live Demo Mode con VulnHR Lab Control + snapshot wizard

Nel componente DemoPage (riga 491+):

Rimuovere state e hook: - selectedLabId (riga 495) - useDemoPreflight (riga 497)

Aggiungere state e hook: const [snapshotOpen, setSnapshotOpen] = useState(false) const { data: engagements } = useEngagements() const snapshot = useSnapshotEngagement() const { data: vulnhrStatus } = useVulnHRStatus() const vulnhrStart = useVulnHRStart() const vulnhrStop = useVulnHRStop()

Aggiungere bottone "Create from Engagement" nell'header (riga ~535):

setSnapshotOpen(true)}> Create from Engagement

Sostituire l'intera sezione Live Demo Mode (righe 612-703) con il pannello VulnHR Lab Control:

VulnHR Demo Lab

Start the VulnHR lab for live client demos. Exposes the HR portal on the public URL via Cloudflare Tunnel. Stop it when the demo is over.

Lab Status
{vulnhrStatus?.containers_up ?? 0}/{vulnhrStatus?.containers_total ?? 6} containers
{vulnhrStatus?.status ?? 'unknown'}
{vulnhrStatus?.status === 'running' && vulnhrStatus.public_url && ( {vulnhrStatus.public_url} )}
{vulnhrStatus?.status !== 'running' ? ( : } onClick={() => vulnhrStart.mutate()} loading={vulnhrStart.isPending} > {vulnhrStart.isPending ? 'Starting...' : 'Start VulnHR + Tunnel'} ) : ( } onClick={() => { if (window.confirm('Stop VulnHR lab and close the public tunnel?')) { vulnhrStop.mutate() } }} loading={vulnhrStop.isPending} > {vulnhrStop.isPending ? 'Stopping...' : 'Stop Lab'} )}
Credentials: a.rossi@meridian.local / Admin123! (Admin) · m.bianchi@meridian.local / Hr1234! (HR) · p.romano@meridian.local / Emp12345 (Employee)

Il layout grid (riga 575) resta xl:grid-cols-[1.2fr_0.8fr] — colonna sinistra per gli scenari, colonna destra per il pannello VulnHR.

La grid delle card scenari (riga 583) resta md:grid-cols-2 (2 colonne nella colonna sinistra).

Aggiungere modal snapshot alla fine del JSX di DemoPage:

{snapshotOpen && ( setSnapshotOpen(false)}>

{ e.preventDefault() const fd = new FormData(e.currentTarget) snapshot.mutate({ engagement_name: fd.get('engagement') as string, name: fd.get('name') as string, description: fd.get('description') as string, highlight_finding_ids: [], }, { onSuccess: () => setSnapshotOpen(false), }) }}>
Create Snapshot
)}


Riepilogo file da modificare

┌─────┬────────────────────────────────────────────────────────────┬─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ │ # │ File │ Cosa │ ├─────┼────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤ │ 1 │ dashboard/backend/app/routers/demo.py │ +3 endpoint VulnHR (status, start, stop) con runner lab-jobs │ ├─────┼────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤ │ 2 │ dashboard/backend/app/schemas.py │ +3 campi DemoFinding, +1 campo DemoChain │ ├─────┼────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤ │ 3 │ dashboard/frontend/src/api/demo.ts │ +3 campi DemoFinding, +1 DemoChain, +useSnapshotEngagement, +useVulnHRStatus/Start/Stop │ ├─────┼────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤ │ 4 │ dashboard/frontend/tailwind.config.ts │ +3 keyframes, +3 animation │ ├─────┼────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤ │ 5 │ dashboard/data/demo-scenarios/quick-api-demo.json │ Timeline 50 eventi con NARRATOR, +evidence, +mermaid │ ├─────┼────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤ │ 6 │ dashboard/data/demo-scenarios/enterprise-webapp-demo.json │ Timeline 50 eventi con NARRATOR, +evidence, +mermaid │ ├─────┼────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤ │ 7 │ dashboard/data/demo-scenarios/burp-bidirectional-demo.json │ Timeline 45 eventi con NARRATOR, +evidence, +mermaid │ ├─────┼────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤ │ 8 │ dashboard/frontend/src/pages/DemoPage.tsx │ ShowcasePlayer (auto-scroll, event styling, critical glow, donut, evidence, Mermaid) + DemoPage (VulnHR panel con Start/Stop, snapshot wizard, rimuovere old Live Demo) │ └─────┴────────────────────────────────────────────────────────────┴─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘


Verifica

  1. cd dashboard/frontend && npx tsc --noEmit — zero errori TypeScript
  2. Dashboard up → /demo come admin
  3. Pannello VulnHR: mostra "stopped" → click "Start VulnHR + Tunnel" → status diventa "running" → URL pubblico cliccabile → click "Stop Lab" → torna "stopped"
  4. Scenari seed: gallery visibile, aprire scenario → Play → auto-scroll, NARRATOR events in italic cyan, Critical glow, donut animato, PoC nel Spotlight, Mermaid nelle chain
  5. Snapshot: "Create from Engagement" → form → crea → nuovo scenario appare in gallery