build 4.6 Demo
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user