build 4.6 Demo

This commit is contained in:
DaStra3003
2026-08-19 08:04:59 +02:00
commit ff8f1ca989
123 changed files with 17696 additions and 0 deletions
+614
View File
@@ -0,0 +1,614 @@
"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"
// 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<string, unknown>
related_packets: number[]
related_streams: number[]
timestamp_first_seen?: number | null
timestamp_last_seen?: number | null
}
interface Summary {
total: number
by_severity: Record<string, number>
by_category: Record<string, number>
highlights: Finding[]
top: Finding[]
}
interface AnalysisResult {
meta?: Record<string, unknown>
summary: Summary
findings: Finding[]
}
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<string, string> = {
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<string, string> = {
critical: "CRITICAL",
high: "HIGH",
medium: "MEDIUM",
low: "LOW",
info: "INFO",
}
function Pill({ severity }: { severity: string }) {
return (
<span className={`inline-block rounded px-2 py-0.5 text-xs font-semibold ${SEV_STYLE[severity] || SEV_STYLE.info}`}>
{SEV_LABEL[severity] || severity.toUpperCase()}
</span>
)
}
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",
])
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<AnalysisResult | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [status, setStatus] = useState<DeepStatus | null>(null)
const [cloud, setCloud] = useState<CloudResults | null>(null)
const [analyzing, setAnalyzing] = useState(false)
const [selUrls, setSelUrls] = useState<Set<number>>(new Set())
const [selHashes, setSelHashes] = useState<Set<number>>(new Set())
const fetchCloud = async () => {
if (!fileId) return
try {
const res = await fetch(getApiUrl(`get_cloud_results/${fileId}`), {
cache: "no-store",
credentials: "include",
})
if (res.ok) setCloud(await res.json())
} catch { /* Optional enrichment data does not block the finding view. */ }
}
useEffect(() => {
if (!fileId) { setError("No capture ID was provided."); setLoading(false); return }
const load = async () => {
try {
const res = await fetch(getApiUrl(`get_findings/${fileId}`), {
cache: "no-store",
credentials: "include",
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
setData(await res.json())
} catch (e) {
setError(`Findings could not be loaded: ${e instanceof Error ? e.message : String(e)}`)
} finally {
setLoading(false)
}
await fetchCloud()
try {
const sres = await fetch(getApiUrl(`get_analysis_status/${fileId}`), {
cache: "no-store",
credentials: "include",
})
if (sres.ok) {
const s: DeepStatus = await sres.json()
setStatus(s)
if (s.state === "running") setAnalyzing(true)
}
} catch { /* Status is optional until enrichment starts. */ }
}
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<typeof setInterval> | null = null
const poll = async () => {
try {
const res = await fetch(getApiUrl(`get_analysis_status/${fileId}`), {
cache: "no-store",
credentials: "include",
})
if (res.ok) {
const s: DeepStatus = await res.json()
setStatus(s)
if (s.state === "done" || s.state === "error") setAnalyzing(false)
}
} catch { /* Retry on the next polling interval. */ }
await fetchCloud()
}
poll()
timer = setInterval(poll, 3000)
return () => { if (timer) clearInterval(timer) }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fileId, analyzing])
const startAnalysis = async () => {
if (!fileId || (selUrls.size + selHashes.size) === 0) return
try {
const response = await fetch(getApiUrl(`start_analysis/${fileId}`), {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url_ids: Array.from(selUrls), hash_ids: Array.from(selHashes) }),
cache: "no-store",
})
if (!response.ok) throw new Error(`HTTP ${response.status}`)
setAnalyzing(true)
} catch (e) {
setError(`Enrichment could not be started: ${e instanceof Error ? e.message : String(e)}`)
}
}
const toggleId = (set: Set<number>, setter: (s: Set<number>) => 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<number>) => 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 <CenterCard title="Loading analysis…" desc="Retrieving findings." />
}
if (error) {
return <CenterCard title="Error" desc={error} />
}
const summary = data?.summary
const findings = data?.findings ?? []
const isEmpty = findings.length === 0
return (
<div className="min-h-screen p-4 bg-background">
<div className="max-w-7xl mx-auto space-y-6">
<Card>
<CardHeader>
<div className="flex justify-between items-start mb-4 flex-wrap gap-2">
<Button variant="outline" onClick={() => router.push("/devices")}>
<ArrowLeft className="mr-2 h-4 w-4" /> Back to devices
</Button>
<div className="flex gap-2">
<Button
onClick={startAnalysis}
disabled={analyzing || (selUrls.size + selHashes.size) === 0}
>
{analyzing
? "Enrichment running…"
: `Start enrichment (${selUrls.size + selHashes.size})`}
</Button>
<Button variant="outline" onClick={() => download("md")}>
<FileText className="mr-2 h-4 w-4" /> .md
</Button>
<Button variant="outline" onClick={() => download("txt")}>
<FileCode className="mr-2 h-4 w-4" /> .txt
</Button>
<Button variant="outline" onClick={() => download("json")}>
<FileJson className="mr-2 h-4 w-4" /> .json
</Button>
</div>
</div>
<CardTitle className="text-3xl flex items-center gap-2">
<ShieldAlert className="h-7 w-7" /> Security findings
</CardTitle>
<CardDescription>
Network-capture evaluation by IoT PreTester 4.6 Demo.
</CardDescription>
</CardHeader>
<CardContent>
{/* Status of optional URL and hash enrichment. */}
{status && status.state !== "pending" && (
<div className={`mb-6 rounded-lg border p-4 ${
status.state === "done" ? "border-green-300 bg-green-50"
: status.state === "error" ? "border-red-300 bg-red-50"
: "border-blue-300 bg-blue-50"}`}>
<div className="flex items-center justify-between gap-2 flex-wrap">
<span className="font-medium">
{status.state === "running" && "Artifact enrichment is running…"}
{status.state === "done" && "✅ Artifact enrichment completed"}
{status.state === "error" && "⚠️ Artifact enrichment failed"}
</span>
<span className="text-sm text-muted-foreground">
URLs {status.url_done}/{status.url_total} · Hashes {status.hash_done}/{status.hash_total}
{status.cracked > 0 && ` · ${status.cracked} cracked`}
</span>
</div>
{status.state === "running" && status.progress_total > 0 && (
<div className="mt-2 h-2 w-full rounded bg-blue-100 overflow-hidden">
<div className="h-full bg-blue-500 transition-all" style={{ width: `${status.percent}%` }} />
</div>
)}
{(status.state === "done" || status.state === "running") && (
<p className="mt-2 text-sm text-muted-foreground">
Details are available under “Cloud URLs and hashes”.
</p>
)}
</div>
)}
{/* Severity summary */}
{summary && (
<div className="flex flex-wrap gap-3 mb-6">
{SEV_ORDER.map((sev) => (
<div key={sev} className="flex items-center gap-2">
<Pill severity={sev} />
<span className="text-sm text-muted-foreground">
{summary.by_severity?.[sev] ?? 0}
</span>
</div>
))}
<div className="text-sm text-muted-foreground ml-auto">
Total: <span className="font-semibold">{summary.total}</span>
</div>
</div>
)}
{/* Prioritized highlights */}
{summary && summary.highlights?.length > 0 && (
<div className="mb-6 rounded-lg border border-red-300 bg-red-50 p-4">
<h3 className="font-semibold mb-2 text-red-800">⚠️ Prioritized highlights</h3>
<ul className="space-y-1">
{summary.highlights.map((f) => (
<li key={f.finding_id} className="text-sm">
<Pill severity={f.severity} /> <span className="ml-1">{f.title}</span>
<span className="text-muted-foreground"> — {affectedLine(f)}</span>
</li>
))}
</ul>
</div>
)}
{isEmpty ? (
<div className="text-center py-12 text-muted-foreground">
No security-relevant findings were detected, or analysis is still running.
<br />Reload the page if the capture has just been submitted.
</div>
) : (
<div className="space-y-4">
{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 (
<Card key={f.finding_id} className="border-l-4" style={{ borderLeftColor: sevBorder(f.severity) }}>
<CardContent className="p-4 space-y-2">
<div className="flex items-center justify-between gap-2 flex-wrap">
<h4 className="font-semibold">{i + 1}. {f.title}</h4>
<div className="flex items-center gap-2">
<Pill severity={f.severity} />
<span className="text-xs text-muted-foreground">Confidence: {f.confidence}</span>
</div>
</div>
{/* Primary artifact */}
{artifact && (
<div className="rounded-md border border-amber-300 bg-amber-50 dark:bg-amber-950/30 p-2">
<div className="text-xs font-medium text-amber-700 dark:text-amber-400">🔎 Observed artifact</div>
<code className="text-sm break-all">{artifact}</code>
</div>
)}
{/* Packet-level sources */}
{sources.length > 0 && (
<div className="rounded-md border bg-muted/40 p-2">
<div className="text-xs font-medium mb-1">📍 Capture sources</div>
<ul className="space-y-0.5">
{sources.map((ref, idx) => (
<li key={idx} className="text-xs font-mono text-muted-foreground break-all">
{fmtSource(ref)}
</li>
))}
{pktTotal > sources.length && (
<li className="text-xs text-muted-foreground">
… and {pktTotal - sources.length} more packets
</li>
)}
</ul>
</div>
)}
<p className="text-sm"><span className="font-medium">Risk:</span> {f.risk_description}</p>
<p className="text-sm"><span className="font-medium">Affected:</span> {affectedLine(f)}</p>
<p className="text-sm"><span className="font-medium">Recommendation:</span> {f.recommendation}</p>
<div className="text-xs text-muted-foreground">
Category: {f.category} · Detector: {f.detector}
{captureFile && <> · Capture: {captureFile}</>}
</div>
{f.evidence && Object.keys(f.evidence).filter((k) => !EVIDENCE_SKIP.has(k)).length > 0 && (
<details className="text-xs">
<summary className="cursor-pointer text-muted-foreground">Additional evidence</summary>
<pre className="mt-1 whitespace-pre-wrap bg-muted p-2 rounded">
{JSON.stringify(
Object.fromEntries(Object.entries(f.evidence).filter(([k]) => !EVIDENCE_SKIP.has(k))),
null, 2)}
</pre>
</details>
)}
</CardContent>
</Card>
)
})}
</div>
)}
{cloud && (cloud.hashes.length > 0 || cloud.urls.length > 0) && (
<h2 className="text-2xl font-bold mt-10 mb-3">Extracted artifacts</h2>
)}
{/* Hash artifacts */}
{cloud && cloud.hashes.length > 0 && (
<details className="mt-2 group">
<summary className="flex items-center justify-between gap-2 mb-3 cursor-pointer list-none">
<h3 className="text-xl font-semibold flex items-center gap-2">
<ChevronRight className="h-5 w-5 transition-transform group-open:rotate-90" />
Hashes ({cloud.hashes.length})
</h3>
<label className="text-sm flex items-center gap-1 cursor-pointer text-muted-foreground"
onClick={(e) => e.stopPropagation()}>
<input type="checkbox" disabled={analyzing}
checked={cloud.hashes.every((h) => selHashes.has(h.id))}
onChange={(e) => setAll(cloud.hashes.map((h) => h.id), setSelHashes, e.target.checked)} />
select all
</label>
</summary>
<div className="space-y-3">
{cloud.hashes.map((h) => (
<Card key={h.id} className="border-l-4"
style={{ borderLeftColor: h.cracked ? "#dc2626" : "#9ca3af" }}>
<CardContent className="p-4 space-y-2">
<div className="flex items-center justify-between gap-2 flex-wrap">
<h4 className="font-semibold flex items-center gap-2 min-w-0">
<input type="checkbox" disabled={analyzing}
checked={selHashes.has(h.id)}
onChange={() => toggleId(selHashes, setSelHashes, h.id)} />
<code className="font-mono text-sm break-all">{h.hash_value}</code>
</h4>
<div className="shrink-0">
{h.cracked
? <span className="rounded px-2 py-0.5 text-xs font-semibold bg-red-600 text-white">CRACKED</span>
: h.analyzed
? <span className="rounded px-2 py-0.5 text-xs font-semibold bg-gray-400 text-white">not cracked</span>
: (analyzing && selHashes.has(h.id))
? <span className="rounded px-2 py-0.5 text-xs font-semibold bg-blue-400 text-white">running…</span>
: <span className="rounded px-2 py-0.5 text-xs font-semibold bg-gray-200 text-gray-700">not analyzed</span>}
</div>
</div>
{h.hash_type && h.hash_type.length > 0 && (
<p className="text-sm"><span className="font-medium">Type:</span> {h.hash_type.join(", ")}</p>
)}
{h.cracked && (
<p className="text-sm">
<span className="font-medium">Plaintext:</span>{" "}
<code className="bg-red-100 px-1 rounded">{h.cracked_value}</code>
{h.crack_time ? ` (${h.crack_time})` : ""}
</p>
)}
</CardContent>
</Card>
))}
</div>
</details>
)}
{/* Endpoint artifacts */}
{cloud && cloud.urls.length > 0 && (
<details className="mt-3 group">
<summary className="flex items-center justify-between gap-2 mb-3 cursor-pointer list-none">
<h3 className="text-xl font-semibold flex items-center gap-2">
<ChevronRight className="h-5 w-5 transition-transform group-open:rotate-90" />
<Globe className="h-5 w-5" /> Cloud URLs and endpoints ({cloud.urls.length})
</h3>
<label className="text-sm flex items-center gap-1 cursor-pointer text-muted-foreground"
onClick={(e) => e.stopPropagation()}>
<input type="checkbox" disabled={analyzing}
checked={cloud.urls.every((u) => selUrls.has(u.id))}
onChange={(e) => setAll(cloud.urls.map((u) => u.id), setSelUrls, e.target.checked)} />
select all
</label>
</summary>
<div className="space-y-3">
{cloud.urls.map((u) => (
<Card key={u.id} className="border-l-4" style={{ borderLeftColor: "#60a5fa" }}>
<CardContent className="p-4 space-y-2">
<div className="flex items-center justify-between gap-2 flex-wrap">
<h4 className="font-semibold flex items-center gap-2 min-w-0">
<input type="checkbox" disabled={analyzing}
checked={selUrls.has(u.id)}
onChange={() => toggleId(selUrls, setSelUrls, u.id)} />
<code className="font-mono text-sm break-all">{u.url}</code>
</h4>
<div className="shrink-0">
{u.analyzed
? <span className="rounded px-2 py-0.5 text-xs font-semibold bg-green-600 text-white">analyzed</span>
: (analyzing && selUrls.has(u.id))
? <span className="rounded px-2 py-0.5 text-xs font-semibold bg-blue-400 text-white">running…</span>
: <span className="rounded px-2 py-0.5 text-xs font-semibold bg-gray-200 text-gray-700">not analyzed</span>}
</div>
</div>
{u.analyzed && (
<>
<p className="text-sm"><span className="font-medium">Open ports:</span> {u.ports && u.ports.length > 0 ? u.ports.join(", ") : "—"}</p>
{u.tls_versions && u.tls_versions.length > 0 && <p className="text-sm"><span className="font-medium">TLS:</span> {u.tls_versions.join(", ")}</p>}
{u.cipher_suites && u.cipher_suites.length > 0 && <p className="text-sm"><span className="font-medium">Cipher:</span> {u.cipher_suites.join(", ")}</p>}
</>
)}
</CardContent>
</Card>
))}
</div>
</details>
)}
</CardContent>
</Card>
</div>
</div>
)
}
function sevBorder(sev: string): string {
return ({
critical: "#dc2626", high: "#f97316", medium: "#facc15", low: "#60a5fa", info: "#9ca3af",
} as Record<string, string>)[sev] || "#9ca3af"
}
function CenterCard({ title, desc }: { title: string; desc: string }) {
return (
<div className="min-h-screen bg-background p-4 flex items-center justify-center">
<Card className="w-96">
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription>{desc}</CardDescription>
</CardHeader>
</Card>
</div>
)
}
export default function FindingsPage() {
return (
<ProtectedRoute>
<Suspense fallback={<CenterCard title="Loading…" desc="Please wait." />}>
<FindingsContent />
</Suspense>
</ProtectedRoute>
)
}