750 lines
32 KiB
TypeScript
750 lines
32 KiB
TypeScript
"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<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[]
|
|
device_related?: number
|
|
other_hosts?: number
|
|
}
|
|
|
|
interface AnalysisResult {
|
|
meta?: Record<string, unknown>
|
|
summary: Summary
|
|
findings: Finding[]
|
|
// Complete observation inventory (hosts, endpoints, TLS, DNS, coverage).
|
|
inventory?: Record<string, unknown>
|
|
// 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<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",
|
|
"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<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 [captureRunning, setCaptureRunning] = useState(false)
|
|
const [selUrls, setSelUrls] = useState<Set<number>>(new Set())
|
|
const [selHashes, setSelHashes] = useState<Set<number>>(new Set())
|
|
const [backendLog, setBackendLog] = useState<AnalysisLog | null>(null)
|
|
const [logLoading, setLogLoading] = useState(false)
|
|
|
|
const fetchCloud = async () => {
|
|
if (!fileId) return
|
|
try {
|
|
setCloud(await apiFetch<CloudResults>(`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<AnalysisLog>(`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<AnalysisResult>(`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<DeepStatus>(`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<typeof setInterval> | null = null
|
|
const poll = async () => {
|
|
try {
|
|
const s = await apiFetch<DeepStatus>(`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<typeof setInterval> | null = null
|
|
const poll = async () => {
|
|
try {
|
|
const result = await apiFetch<AnalysisResult>(`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<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 the capture analysis itself. */}
|
|
{data?.status && data.status !== "ready" && (
|
|
<div className={`mb-6 rounded-lg border p-4 ${
|
|
data.status === "analyzing" ? "border-blue-300 bg-blue-50"
|
|
: data.status === "failed" ? "border-red-300 bg-red-50"
|
|
: "border-gray-300 bg-gray-50"
|
|
}`}>
|
|
<p className="font-medium">
|
|
{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."}
|
|
</p>
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|
{data.status === "analyzing"
|
|
? "Large captures take a moment. This page refreshes automatically."
|
|
: data.message}
|
|
</p>
|
|
{data.status === "failed" && (
|
|
<div className="mt-3 space-y-2">
|
|
{data.error && (
|
|
<div className="rounded-md border border-red-200 bg-white/70 p-2">
|
|
<div className="text-xs font-medium text-red-800">Error</div>
|
|
<code className="text-xs break-all whitespace-pre-wrap">{data.error}</code>
|
|
</div>
|
|
)}
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<Button size="sm" variant="outline" onClick={fetchBackendLog} disabled={logLoading}>
|
|
{logLoading ? "Loading log…" : backendLog ? "Refresh analysis log" : "Show analysis log"}
|
|
</Button>
|
|
{backendLog?.available && backendLog.path && (
|
|
<span className="text-xs text-muted-foreground">Backend file: {backendLog.path}</span>
|
|
)}
|
|
</div>
|
|
{backendLog && (
|
|
backendLog.available ? (
|
|
<pre className="max-h-96 overflow-auto rounded-md bg-slate-900 p-3 text-[11px] leading-snug text-slate-100 whitespace-pre-wrap break-all">{backendLog.log || "(empty log)"}</pre>
|
|
) : (
|
|
<p className="text-xs text-muted-foreground">{backendLog.message || "No analysis log is available."}</p>
|
|
)
|
|
)}
|
|
<p className="text-xs text-muted-foreground">
|
|
Container log: <code>docker compose logs backend</code> · rotating file: <code>/app/logs/backend.log</code> ·
|
|
client log: <code>window.__iotpDumpLogs()</code> in the browser console.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* A partial pass must be visible as such. */}
|
|
{Boolean(data?.meta?.truncated) && (
|
|
<div className="mb-6 rounded-lg border border-amber-300 bg-amber-50 p-4">
|
|
<p className="font-medium">⚠️ Incomplete analysis</p>
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|
{String(data?.meta?.truncation_reason ?? "Configured analysis limits were reached.")}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* 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>
|
|
)}
|
|
|
|
{/* Device under test */}
|
|
{Boolean(data?.meta?.device_ip) && (
|
|
<div className="mb-6 rounded-lg border border-emerald-300 bg-emerald-50 dark:bg-emerald-950/30 p-4 text-sm">
|
|
<span className="font-medium">Device under test:</span>{" "}
|
|
<code>{String(data?.meta?.device_ip)}</code>
|
|
{Array.isArray(data?.meta?.device_macs) && (data?.meta?.device_macs as string[]).length > 0 && (
|
|
<> (MAC {(data?.meta?.device_macs as string[]).join(", ")})</>
|
|
)}
|
|
<span className="text-muted-foreground">
|
|
{" "}· device-related findings: {summary?.device_related ?? 0} · other hosts: {summary?.other_hosts ?? 0}
|
|
</span>
|
|
</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}
|
|
{f.evidence?.device_related === true && (
|
|
<span className="ml-2 rounded bg-emerald-600 px-1.5 py-0.5 text-[10px] font-semibold text-white align-middle">DEVICE</span>
|
|
)}
|
|
</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>
|
|
)}
|
|
|
|
{data?.status === "ready" && <InventoryPanel inventory={data?.inventory} />}
|
|
|
|
{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>
|
|
)
|
|
}
|