"use client" import { getApiUrl } from "@/config/api" import { Suspense, useEffect, useState } from "react" import { useRouter, useSearchParams } from "next/navigation" import { ArrowLeft, FileJson, FileText, FileCode, ShieldAlert, Globe, ChevronRight } from "lucide-react" import { ProtectedRoute } from "@/components/protected-route" import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" import { InventoryPanel } from "@/components/inventory-panel" import { apiFetch, apiErrorMessage } from "@/lib/api" import { getLogger } from "@/lib/logger" const log = getLogger("findings") // API types mirror the backend finding schema. interface Finding { finding_id: string title: string category: string severity: "critical" | "high" | "medium" | "low" | "info" confidence: "high" | "medium" | "low" detector?: string affected_device?: string | null source_ip?: string | null destination_ip?: string | null protocol?: string | null port?: number | null risk_description: string recommendation: string evidence: Record related_packets: number[] related_streams: number[] timestamp_first_seen?: number | null timestamp_last_seen?: number | null } interface Summary { total: number by_severity: Record by_category: Record highlights: Finding[] top: Finding[] device_related?: number other_hosts?: number } interface AnalysisResult { meta?: Record summary: Summary findings: Finding[] // Complete observation inventory (hosts, endpoints, TLS, DNS, coverage). inventory?: Record // Present while the capture analysis has not produced a report yet. status?: "analyzing" | "failed" | "empty" | "ready" capture_status?: string message?: string // Failure reason recorded by the backend and whether a per-capture log exists. error?: string | null log_available?: boolean } interface AnalysisLog { available: boolean log: string path?: string message?: string } interface DeepStatus { state: "pending" | "running" | "done" | "error" url_total: number url_done: number hash_total: number hash_done: number cracked: number progress_done: number progress_total: number percent: number message?: string } interface CloudUrl { id: number url: string analyzed: boolean ports?: number[] tls_versions?: string[] cipher_suites?: string[] } interface CloudHash { id: number hash_value: string analyzed: boolean cracked: boolean cracked_value?: string | null crack_time?: string | null hash_type?: string[] } interface CloudResults { urls: CloudUrl[] hashes: CloudHash[] } const SEV_ORDER = ["critical", "high", "medium", "low", "info"] as const const SEV_STYLE: Record = { critical: "bg-red-600 text-white", high: "bg-orange-500 text-white", medium: "bg-yellow-400 text-black", low: "bg-blue-400 text-white", info: "bg-gray-400 text-white", } const SEV_LABEL: Record = { critical: "CRITICAL", high: "HIGH", medium: "MEDIUM", low: "LOW", info: "INFO", } function Pill({ severity }: { severity: string }) { return ( {SEV_LABEL[severity] || severity.toUpperCase()} ) } function affectedLine(f: Finding): string { const parts: string[] = [] if (f.source_ip) parts.push(`Source ${f.source_ip}`) if (f.destination_ip) parts.push(`Destination ${f.destination_ip}`) if (f.protocol) parts.push(f.protocol + (f.port ? `:${f.port}` : "")) const ev = f.evidence || {} for (const k of ["host", "registered_domain", "query_name", "target"]) { if (ev[k]) { parts.push(String(ev[k])); break } } return parts.join(", ") || "—" } // Evidence rendered in dedicated sections is excluded from raw details. const EVIDENCE_SKIP = new Set([ "dedup_key", "sources", "artifact", "capture_file", "source_packet_count", "occurrences", "device_related", ]) type SourceRef = { packet?: number; stream?: number; timestamp?: number src_ip?: string; dst_ip?: string; protocol?: string; port?: number capture?: string; note?: string } function fmtTs(ts?: number): string { if (ts === undefined || ts === null) return "" try { return new Date(ts * 1000).toISOString().replace("T", " ").replace(".000Z", " UTC") } catch { return String(ts) } } function artifactOf(f: Finding): string | null { const art = (f.evidence || {})["artifact"] as { label?: string; value?: string } | string | undefined if (!art) return null if (typeof art === "string") return art if (art.value) return art.label ? `${art.label}: ${art.value}` : art.value return null } function sourcesOf(f: Finding): SourceRef[] { const s = (f.evidence || {})["sources"] return Array.isArray(s) ? (s as SourceRef[]) : [] } function fmtSource(ref: SourceRef): string { const parts: string[] = [] if (ref.packet !== undefined) parts.push(`Packet #${ref.packet}`) if (ref.stream !== undefined) parts.push(`Stream ${ref.stream}`) if (ref.timestamp !== undefined) parts.push(fmtTs(ref.timestamp)) if (ref.src_ip || ref.dst_ip) parts.push(`${ref.src_ip ?? "?"} → ${ref.dst_ip ?? "?"}`) if (ref.protocol) parts.push(ref.protocol + (ref.port ? `:${ref.port}` : "")) if (ref.capture) parts.push(`Capture ${ref.capture}`) if (ref.note) parts.push(ref.note) return parts.join(" · ") || "—" } function FindingsContent() { const router = useRouter() const searchParams = useSearchParams() const fileId = searchParams.get("fileId") const [data, setData] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [status, setStatus] = useState(null) const [cloud, setCloud] = useState(null) const [analyzing, setAnalyzing] = useState(false) const [captureRunning, setCaptureRunning] = useState(false) const [selUrls, setSelUrls] = useState>(new Set()) const [selHashes, setSelHashes] = useState>(new Set()) const [backendLog, setBackendLog] = useState(null) const [logLoading, setLogLoading] = useState(false) const fetchCloud = async () => { if (!fileId) return try { setCloud(await apiFetch(`get_cloud_results/${fileId}`)) } catch (e) { log.debug(`Cloud results unavailable: ${apiErrorMessage(e)}`) } } const fetchBackendLog = async () => { if (!fileId) return setLogLoading(true) try { setBackendLog(await apiFetch(`get_analysis_log/${fileId}?lines=400`)) } catch (e) { setBackendLog({ available: false, log: "", message: apiErrorMessage(e) }) } finally { setLogLoading(false) } } useEffect(() => { if (!fileId) { setError("No capture ID was provided."); setLoading(false); return } const load = async () => { try { const result = await apiFetch(`get_findings/${fileId}`) setData(result) setCaptureRunning(result.status === "analyzing") log.info(`Capture ${fileId}: status=${result.status} findings=${result.findings?.length ?? 0}`, result.error ? { error: result.error } : undefined) if (result.status === "failed") fetchBackendLog() } catch (e) { log.error(`Findings for capture ${fileId} could not be loaded: ${apiErrorMessage(e)}`) setError(`Findings could not be loaded: ${apiErrorMessage(e)}`) } finally { setLoading(false) } await fetchCloud() try { const s = await apiFetch(`get_analysis_status/${fileId}`) setStatus(s) if (s.state === "running") setAnalyzing(true) } catch (e) { log.debug(`Enrichment status unavailable: ${apiErrorMessage(e)}`) } } load() // eslint-disable-next-line react-hooks/exhaustive-deps }, [fileId]) // Refresh enrichment results after the user returns to this tab. useEffect(() => { if (!fileId) return const onFocus = () => { fetchCloud() } window.addEventListener("focus", onFocus) document.addEventListener("visibilitychange", onFocus) return () => { window.removeEventListener("focus", onFocus) document.removeEventListener("visibilitychange", onFocus) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [fileId]) // Poll only while enrichment is running. useEffect(() => { if (!fileId || !analyzing) return let timer: ReturnType | null = null const poll = async () => { try { const s = await apiFetch(`get_analysis_status/${fileId}`) setStatus(s) if (s.state === "done" || s.state === "error") { setAnalyzing(false) log.info(`Enrichment for capture ${fileId} finished: ${s.state} (${s.message})`) } } catch (e) { log.warn(`Enrichment status poll failed: ${apiErrorMessage(e)}`) } await fetchCloud() } poll() timer = setInterval(poll, 3000) return () => { if (timer) clearInterval(timer) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [fileId, analyzing]) // Poll while the capture analysis itself is still running. useEffect(() => { if (!fileId || !captureRunning) return let timer: ReturnType | null = null const poll = async () => { try { const result = await apiFetch(`get_findings/${fileId}`) setData(result) if (result.status !== "analyzing") { setCaptureRunning(false) log.info(`Capture ${fileId} analysis finished: ${result.status}`, result.error ? { error: result.error } : undefined) if (result.status === "failed") fetchBackendLog() await fetchCloud() } } catch (e) { log.warn(`Capture status poll failed: ${apiErrorMessage(e)}`) } } timer = setInterval(poll, 5000) return () => { if (timer) clearInterval(timer) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [fileId, captureRunning]) const startAnalysis = async () => { if (!fileId || (selUrls.size + selHashes.size) === 0) return try { await apiFetch(`start_analysis/${fileId}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url_ids: Array.from(selUrls), hash_ids: Array.from(selHashes) }), }) log.info(`Enrichment started for capture ${fileId}: ${selUrls.size} URLs, ${selHashes.size} hashes`) setAnalyzing(true) } catch (e) { log.error(`Enrichment could not be started: ${apiErrorMessage(e)}`) setError(`Enrichment could not be started: ${apiErrorMessage(e)}`) } } const toggleId = (set: Set, setter: (s: Set) => void, id: number) => { const next = new Set(set) if (next.has(id)) next.delete(id); else next.add(id) setter(next) } const setAll = (ids: number[], setter: (s: Set) => void, on: boolean) => setter(on ? new Set(ids) : new Set()) const download = (fmt: "md" | "txt" | "json") => { if (!fileId) return window.open(getApiUrl(`get_report/${fileId}/${fmt}`), "_blank") } if (loading) { return } if (error) { return } const summary = data?.summary const findings = data?.findings ?? [] const isEmpty = findings.length === 0 return (
Security findings Network-capture evaluation by IoT PreTester 4.6 Demo.
{/* Status of the capture analysis itself. */} {data?.status && data.status !== "ready" && (

{data.status === "analyzing" && "⏳ The capture is being analyzed…"} {data.status === "failed" && "⚠️ The capture analysis failed"} {data.status === "empty" && "No security analysis is available for this capture."}

{data.status === "analyzing" ? "Large captures take a moment. This page refreshes automatically." : data.message}

{data.status === "failed" && (
{data.error && (
Error
{data.error}
)}
{backendLog?.available && backendLog.path && ( Backend file: {backendLog.path} )}
{backendLog && ( backendLog.available ? (
{backendLog.log || "(empty log)"}
) : (

{backendLog.message || "No analysis log is available."}

) )}

Container log: docker compose logs backend · rotating file: /app/logs/backend.log · client log: window.__iotpDumpLogs() in the browser console.

)}
)} {/* A partial pass must be visible as such. */} {Boolean(data?.meta?.truncated) && (

⚠️ Incomplete analysis

{String(data?.meta?.truncation_reason ?? "Configured analysis limits were reached.")}

)} {/* Status of optional URL and hash enrichment. */} {status && status.state !== "pending" && (
{status.state === "running" && "Artifact enrichment is running…"} {status.state === "done" && "✅ Artifact enrichment completed"} {status.state === "error" && "⚠️ Artifact enrichment failed"} URLs {status.url_done}/{status.url_total} · Hashes {status.hash_done}/{status.hash_total} {status.cracked > 0 && ` · ${status.cracked} cracked`}
{status.state === "running" && status.progress_total > 0 && (
)} {(status.state === "done" || status.state === "running") && (

Details are available under “Cloud URLs and hashes”.

)}
)} {/* Severity summary */} {summary && (
{SEV_ORDER.map((sev) => (
{summary.by_severity?.[sev] ?? 0}
))}
Total: {summary.total}
)} {/* Device under test */} {Boolean(data?.meta?.device_ip) && (
Device under test:{" "} {String(data?.meta?.device_ip)} {Array.isArray(data?.meta?.device_macs) && (data?.meta?.device_macs as string[]).length > 0 && ( <> (MAC {(data?.meta?.device_macs as string[]).join(", ")}) )} {" "}· device-related findings: {summary?.device_related ?? 0} · other hosts: {summary?.other_hosts ?? 0}
)} {/* Prioritized highlights */} {summary && summary.highlights?.length > 0 && (

⚠️ Prioritized highlights

    {summary.highlights.map((f) => (
  • {f.title} — {affectedLine(f)}
  • ))}
)} {isEmpty ? (
No security-relevant findings were detected, or analysis is still running.
Reload the page if the capture has just been submitted.
) : (
{findings.map((f, i) => { const artifact = artifactOf(f) const sources = sourcesOf(f) const pktTotal = typeof f.evidence?.source_packet_count === "number" ? (f.evidence.source_packet_count as number) : 0 const captureFile = typeof f.evidence?.capture_file === "string" ? (f.evidence.capture_file as string) : null return (

{i + 1}. {f.title} {f.evidence?.device_related === true && ( DEVICE )}

Confidence: {f.confidence}
{/* Primary artifact */} {artifact && (
🔎 Observed artifact
{artifact}
)} {/* Packet-level sources */} {sources.length > 0 && (
📍 Capture sources
    {sources.map((ref, idx) => (
  • {fmtSource(ref)}
  • ))} {pktTotal > sources.length && (
  • … and {pktTotal - sources.length} more packets
  • )}
)}

Risk: {f.risk_description}

Affected: {affectedLine(f)}

Recommendation: {f.recommendation}

Category: {f.category} · Detector: {f.detector} {captureFile && <> · Capture: {captureFile}}
{f.evidence && Object.keys(f.evidence).filter((k) => !EVIDENCE_SKIP.has(k)).length > 0 && (
Additional evidence
                            {JSON.stringify(
                              Object.fromEntries(Object.entries(f.evidence).filter(([k]) => !EVIDENCE_SKIP.has(k))),
                              null, 2)}
                          
)}
) })}
)} {data?.status === "ready" && } {cloud && (cloud.hashes.length > 0 || cloud.urls.length > 0) && (

Extracted artifacts

)} {/* Hash artifacts */} {cloud && cloud.hashes.length > 0 && (

Hashes ({cloud.hashes.length})

{cloud.hashes.map((h) => (

toggleId(selHashes, setSelHashes, h.id)} /> {h.hash_value}

{h.cracked ? CRACKED : h.analyzed ? not cracked : (analyzing && selHashes.has(h.id)) ? running… : not analyzed}
{h.hash_type && h.hash_type.length > 0 && (

Type: {h.hash_type.join(", ")}

)} {h.cracked && (

Plaintext:{" "} {h.cracked_value} {h.crack_time ? ` (${h.crack_time})` : ""}

)}
))}
)} {/* Endpoint artifacts */} {cloud && cloud.urls.length > 0 && (

Cloud URLs and endpoints ({cloud.urls.length})

{cloud.urls.map((u) => (

toggleId(selUrls, setSelUrls, u.id)} /> {u.url}

{u.analyzed ? analyzed : (analyzing && selUrls.has(u.id)) ? running… : not analyzed}
{u.analyzed && ( <>

Open ports: {u.ports && u.ports.length > 0 ? u.ports.join(", ") : "—"}

{u.tls_versions && u.tls_versions.length > 0 &&

TLS: {u.tls_versions.join(", ")}

} {u.cipher_suites && u.cipher_suites.length > 0 &&

Cipher: {u.cipher_suites.join(", ")}

} )}
))}
)}
) } function sevBorder(sev: string): string { return ({ critical: "#dc2626", high: "#f97316", medium: "#facc15", low: "#60a5fa", info: "#9ca3af", } as Record)[sev] || "#9ca3af" } function CenterCard({ title, desc }: { title: string; desc: string }) { return (
{title} {desc}
) } export default function FindingsPage() { return ( }> ) }