mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-02-12 14:02:45 +00:00
930 lines
42 KiB
TypeScript
930 lines
42 KiB
TypeScript
import { useState, useEffect, useRef, useCallback } from 'react'
|
|
import { useNavigate } from 'react-router-dom'
|
|
import {
|
|
Rocket, Shield, ChevronDown, ChevronUp, Loader2,
|
|
AlertTriangle, CheckCircle2, Globe, Lock, Bug, MessageSquare,
|
|
FileText, ScrollText, X, ExternalLink, Download, Sparkles, Trash2,
|
|
Brain, Wrench, Layers
|
|
} from 'lucide-react'
|
|
import { agentApi, reportsApi } from '../services/api'
|
|
import type { AgentStatus, AgentFinding, AgentLog } from '../types'
|
|
|
|
const PHASES = [
|
|
{ key: 'parallel', label: 'Parallel Streams', icon: Layers, range: [0, 50] as const },
|
|
{ key: 'deep', label: 'Deep Analysis', icon: Brain, range: [50, 75] as const },
|
|
{ key: 'final', label: 'Finalization', icon: Shield, range: [75, 100] as const },
|
|
]
|
|
|
|
const STREAMS = [
|
|
{ key: 'recon', label: 'Recon', icon: Globe, color: 'blue', activeUntil: 25 },
|
|
{ key: 'junior', label: 'Junior AI', icon: Brain, color: 'purple', activeUntil: 35 },
|
|
{ key: 'tools', label: 'Tools', icon: Wrench, color: 'orange', activeUntil: 50 },
|
|
] as const
|
|
|
|
const STREAM_COLORS: Record<string, { bg: string; text: string; border: string; pulse: string }> = {
|
|
blue: { bg: 'bg-blue-500/20', text: 'text-blue-400', border: 'border-blue-500/40', pulse: 'bg-blue-400' },
|
|
purple: { bg: 'bg-purple-500/20', text: 'text-purple-400', border: 'border-purple-500/40', pulse: 'bg-purple-400' },
|
|
orange: { bg: 'bg-orange-500/20', text: 'text-orange-400', border: 'border-orange-500/40', pulse: 'bg-orange-400' },
|
|
}
|
|
|
|
function phaseFromProgress(progress: number): number {
|
|
if (progress < 50) return 0
|
|
if (progress < 75) return 1
|
|
return 2
|
|
}
|
|
|
|
function StreamBadge({ stream, progress, isRunning }: {
|
|
stream: typeof STREAMS[number]
|
|
progress: number
|
|
isRunning: boolean
|
|
}) {
|
|
const active = isRunning && progress < stream.activeUntil
|
|
const done = progress >= stream.activeUntil
|
|
const colors = STREAM_COLORS[stream.color]
|
|
const Icon = stream.icon
|
|
|
|
return (
|
|
<div
|
|
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full border text-xs font-medium transition-all duration-300 ${
|
|
active
|
|
? `${colors.bg} ${colors.text} ${colors.border}`
|
|
: done
|
|
? 'bg-dark-700/50 text-dark-400 border-dark-600'
|
|
: 'bg-dark-900 text-dark-500 border-dark-700'
|
|
}`}
|
|
>
|
|
{active && (
|
|
<span className="relative flex h-2 w-2">
|
|
<span className={`animate-ping absolute inline-flex h-full w-full rounded-full ${colors.pulse} opacity-75`} />
|
|
<span className={`relative inline-flex rounded-full h-2 w-2 ${colors.pulse}`} />
|
|
</span>
|
|
)}
|
|
{done && <CheckCircle2 className="w-3 h-3 text-green-500" />}
|
|
{!active && !done && <Icon className="w-3 h-3" />}
|
|
<span>{stream.label}</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function logMessageColor(message: string): string {
|
|
if (message.startsWith('[STREAM 1]')) return 'text-blue-400'
|
|
if (message.startsWith('[STREAM 2]')) return 'text-purple-400'
|
|
if (message.startsWith('[STREAM 3]')) return 'text-orange-400'
|
|
if (message.startsWith('[TOOL]')) return 'text-orange-300'
|
|
if (message.startsWith('[DEEP]')) return 'text-cyan-400'
|
|
if (message.startsWith('[FINAL]')) return 'text-green-400'
|
|
return ''
|
|
}
|
|
|
|
const SEVERITY_COLORS: Record<string, string> = {
|
|
critical: 'bg-red-500',
|
|
high: 'bg-orange-500',
|
|
medium: 'bg-yellow-500',
|
|
low: 'bg-blue-500',
|
|
info: 'bg-gray-500',
|
|
}
|
|
|
|
const SEVERITY_BORDER: Record<string, string> = {
|
|
critical: 'border-red-500/40',
|
|
high: 'border-orange-500/40',
|
|
medium: 'border-yellow-500/40',
|
|
low: 'border-blue-500/40',
|
|
info: 'border-gray-500/40',
|
|
}
|
|
|
|
// Resolve a confidence score from the various possible sources on a finding.
|
|
function getConfidenceDisplay(finding: { confidence_score?: number; confidence?: string }): { score: number; color: string; label: string } | null {
|
|
let score: number | null = null
|
|
|
|
if (typeof finding.confidence_score === 'number') {
|
|
score = finding.confidence_score
|
|
} else if (finding.confidence) {
|
|
const parsed = Number(finding.confidence)
|
|
if (!isNaN(parsed)) {
|
|
score = parsed
|
|
} else {
|
|
const map: Record<string, number> = { high: 90, medium: 60, low: 30 }
|
|
score = map[finding.confidence.toLowerCase()] ?? null
|
|
}
|
|
}
|
|
|
|
if (score === null) return null
|
|
|
|
const color = score >= 90 ? 'green' : score >= 60 ? 'yellow' : 'red'
|
|
const label = score >= 90 ? 'Confirmed' : score >= 60 ? 'Likely' : 'Low'
|
|
return { score, color, label }
|
|
}
|
|
|
|
const CONFIDENCE_STYLES: Record<string, string> = {
|
|
green: 'bg-green-500/15 text-green-400 border-green-500/30',
|
|
yellow: 'bg-yellow-500/15 text-yellow-400 border-yellow-500/30',
|
|
red: 'bg-red-500/15 text-red-400 border-red-500/30',
|
|
}
|
|
|
|
const SESSION_KEY = 'neurosploit_autopentest_session'
|
|
|
|
interface SavedSession {
|
|
agentId: string
|
|
target: string
|
|
startedAt: string
|
|
}
|
|
|
|
export default function AutoPentestPage() {
|
|
const navigate = useNavigate()
|
|
const [target, setTarget] = useState('')
|
|
const [multiTarget, setMultiTarget] = useState(false)
|
|
const [targets, setTargets] = useState('')
|
|
const [subdomainDiscovery, setSubdomainDiscovery] = useState(false)
|
|
const [showAuth, setShowAuth] = useState(false)
|
|
const [authType, setAuthType] = useState<string>('')
|
|
const [authValue, setAuthValue] = useState('')
|
|
const [showPrompt, setShowPrompt] = useState(false)
|
|
const [customPrompt, setCustomPrompt] = useState('')
|
|
|
|
// Running state
|
|
const [isRunning, setIsRunning] = useState(false)
|
|
const [agentId, setAgentId] = useState<string | null>(null)
|
|
const [status, setStatus] = useState<AgentStatus | null>(null)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
// Live findings & logs
|
|
const [showFindings, setShowFindings] = useState(true)
|
|
const [showLogs, setShowLogs] = useState(false)
|
|
const [logs, setLogs] = useState<AgentLog[]>([])
|
|
const [expandedFinding, setExpandedFinding] = useState<string | null>(null)
|
|
|
|
// Report generation
|
|
const [generatingReport, setGeneratingReport] = useState(false)
|
|
const [reportId, setReportId] = useState<string | null>(null)
|
|
|
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
|
const logsEndRef = useRef<HTMLDivElement>(null)
|
|
|
|
// Restore session on mount
|
|
useEffect(() => {
|
|
try {
|
|
const saved = localStorage.getItem(SESSION_KEY)
|
|
if (saved) {
|
|
const session: SavedSession = JSON.parse(saved)
|
|
setAgentId(session.agentId)
|
|
setTarget(session.target)
|
|
setIsRunning(true)
|
|
}
|
|
} catch {
|
|
// ignore parse errors
|
|
}
|
|
}, [])
|
|
|
|
// Save session when agentId changes
|
|
useEffect(() => {
|
|
if (agentId && isRunning) {
|
|
const session: SavedSession = {
|
|
agentId,
|
|
target: target || '',
|
|
startedAt: new Date().toISOString(),
|
|
}
|
|
localStorage.setItem(SESSION_KEY, JSON.stringify(session))
|
|
}
|
|
}, [agentId, isRunning, target])
|
|
|
|
// Poll agent status + logs
|
|
useEffect(() => {
|
|
if (!agentId) return
|
|
|
|
const poll = async () => {
|
|
try {
|
|
const s = await agentApi.getStatus(agentId)
|
|
setStatus(s)
|
|
if (s.status === 'completed' || s.status === 'error' || s.status === 'stopped') {
|
|
setIsRunning(false)
|
|
if (pollRef.current) clearInterval(pollRef.current)
|
|
}
|
|
} catch {
|
|
// ignore polling errors
|
|
}
|
|
|
|
// Fetch recent logs
|
|
try {
|
|
const logData = await agentApi.getLogs(agentId, 200)
|
|
setLogs(logData.logs || [])
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
poll()
|
|
pollRef.current = setInterval(poll, 3000)
|
|
return () => { if (pollRef.current) clearInterval(pollRef.current) }
|
|
}, [agentId])
|
|
|
|
// Auto-scroll logs
|
|
useEffect(() => {
|
|
if (showLogs && logsEndRef.current) {
|
|
logsEndRef.current.scrollIntoView({ behavior: 'smooth' })
|
|
}
|
|
}, [logs, showLogs])
|
|
|
|
const handleStart = async () => {
|
|
const primaryTarget = target.trim()
|
|
if (!primaryTarget) return
|
|
|
|
setError(null)
|
|
setIsRunning(true)
|
|
setStatus(null)
|
|
setLogs([])
|
|
setReportId(null)
|
|
|
|
try {
|
|
const targetList = multiTarget
|
|
? targets.split('\n').map(t => t.trim()).filter(Boolean)
|
|
: undefined
|
|
|
|
const resp = await agentApi.autoPentest(primaryTarget, {
|
|
subdomain_discovery: subdomainDiscovery,
|
|
targets: targetList,
|
|
auth_type: authType || undefined,
|
|
auth_value: authValue || undefined,
|
|
prompt: customPrompt.trim() || undefined,
|
|
})
|
|
setAgentId(resp.agent_id)
|
|
} catch (err: any) {
|
|
setError(err?.response?.data?.detail || err?.message || 'Failed to start pentest')
|
|
setIsRunning(false)
|
|
}
|
|
}
|
|
|
|
const handleStop = async () => {
|
|
if (!agentId) return
|
|
try {
|
|
await agentApi.stop(agentId)
|
|
setIsRunning(false)
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
const handleClearSession = () => {
|
|
localStorage.removeItem(SESSION_KEY)
|
|
setAgentId(null)
|
|
setStatus(null)
|
|
setLogs([])
|
|
setIsRunning(false)
|
|
setError(null)
|
|
setReportId(null)
|
|
setTarget('')
|
|
}
|
|
|
|
const handleGenerateAiReport = useCallback(async () => {
|
|
if (!status?.scan_id) return
|
|
setGeneratingReport(true)
|
|
try {
|
|
const report = await reportsApi.generateAiReport({
|
|
scan_id: status.scan_id,
|
|
title: `AI Pentest Report - ${target}`,
|
|
})
|
|
setReportId(report.id)
|
|
} catch (err: any) {
|
|
setError(err?.response?.data?.detail || 'Failed to generate AI report')
|
|
} finally {
|
|
setGeneratingReport(false)
|
|
}
|
|
}, [status?.scan_id, target])
|
|
|
|
const currentPhaseIdx = status ? phaseFromProgress(status.progress) : -1
|
|
const findings = status?.findings || []
|
|
const rejectedFindings = status?.rejected_findings || []
|
|
const allFindings = [...findings, ...rejectedFindings]
|
|
const [findingsFilter, setFindingsFilter] = useState<'confirmed' | 'rejected' | 'all'>('all')
|
|
const displayFindings = findingsFilter === 'confirmed' ? findings
|
|
: findingsFilter === 'rejected' ? rejectedFindings
|
|
: allFindings
|
|
const sevCounts = findings.reduce(
|
|
(acc, f) => {
|
|
acc[f.severity] = (acc[f.severity] || 0) + 1
|
|
return acc
|
|
},
|
|
{} as Record<string, number>
|
|
)
|
|
|
|
return (
|
|
<div className="min-h-screen flex flex-col items-center py-12 px-4">
|
|
{/* Header */}
|
|
<div className="text-center mb-10">
|
|
<div className="inline-flex items-center justify-center w-16 h-16 bg-green-500/20 rounded-2xl mb-4">
|
|
<Rocket className="w-8 h-8 text-green-400" />
|
|
</div>
|
|
<h1 className="text-3xl font-bold text-white mb-2">Auto Pentest</h1>
|
|
<p className="text-dark-400 max-w-md">
|
|
One-click comprehensive penetration test. 100 vulnerability types, AI-powered analysis, full report.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Main Card - only show config when no session is active */}
|
|
{!agentId && (
|
|
<div className="w-full max-w-2xl bg-dark-800 border border-dark-700 rounded-2xl p-8">
|
|
{/* URL Input */}
|
|
<div className="mb-6">
|
|
<label className="block text-sm font-medium text-dark-300 mb-2">Target URL</label>
|
|
<input
|
|
type="url"
|
|
value={target}
|
|
onChange={e => setTarget(e.target.value)}
|
|
placeholder="https://example.com"
|
|
disabled={isRunning}
|
|
className="w-full px-4 py-4 bg-dark-900 border border-dark-600 rounded-xl text-white text-lg placeholder-dark-500 focus:outline-none focus:border-green-500 focus:ring-1 focus:ring-green-500 disabled:opacity-50"
|
|
/>
|
|
</div>
|
|
|
|
{/* Toggles */}
|
|
<div className="flex flex-wrap gap-4 mb-6">
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={subdomainDiscovery}
|
|
onChange={e => setSubdomainDiscovery(e.target.checked)}
|
|
disabled={isRunning}
|
|
className="w-4 h-4 rounded bg-dark-900 border-dark-600 text-green-500 focus:ring-green-500"
|
|
/>
|
|
<span className="text-sm text-dark-300">Subdomain Discovery</span>
|
|
</label>
|
|
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={multiTarget}
|
|
onChange={e => setMultiTarget(e.target.checked)}
|
|
disabled={isRunning}
|
|
className="w-4 h-4 rounded bg-dark-900 border-dark-600 text-green-500 focus:ring-green-500"
|
|
/>
|
|
<span className="text-sm text-dark-300">Multiple Targets</span>
|
|
</label>
|
|
</div>
|
|
|
|
{/* Multi-target textarea */}
|
|
{multiTarget && (
|
|
<div className="mb-6">
|
|
<label className="block text-sm font-medium text-dark-300 mb-2">Additional Targets (one per line)</label>
|
|
<textarea
|
|
value={targets}
|
|
onChange={e => setTargets(e.target.value)}
|
|
rows={4}
|
|
disabled={isRunning}
|
|
placeholder={"https://api.example.com\nhttps://admin.example.com"}
|
|
className="w-full px-4 py-3 bg-dark-900 border border-dark-600 rounded-xl text-white placeholder-dark-500 focus:outline-none focus:border-green-500 disabled:opacity-50"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Auth Section (collapsible) */}
|
|
<div className="mb-6">
|
|
<button
|
|
onClick={() => setShowAuth(!showAuth)}
|
|
disabled={isRunning}
|
|
className="flex items-center gap-2 text-sm text-dark-400 hover:text-white transition-colors disabled:opacity-50"
|
|
>
|
|
<Lock className="w-4 h-4" />
|
|
<span>Authentication (Optional)</span>
|
|
{showAuth ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
|
</button>
|
|
|
|
{showAuth && (
|
|
<div className="mt-3 space-y-3 pl-6">
|
|
<select
|
|
value={authType}
|
|
onChange={e => setAuthType(e.target.value)}
|
|
disabled={isRunning}
|
|
className="w-full px-3 py-2 bg-dark-900 border border-dark-600 rounded-lg text-white text-sm focus:outline-none focus:border-green-500"
|
|
>
|
|
<option value="">No Authentication</option>
|
|
<option value="bearer">Bearer Token</option>
|
|
<option value="cookie">Cookie</option>
|
|
<option value="basic">Basic Auth (user:pass)</option>
|
|
<option value="header">Custom Header (Name:Value)</option>
|
|
</select>
|
|
{authType && (
|
|
<input
|
|
type="text"
|
|
value={authValue}
|
|
onChange={e => setAuthValue(e.target.value)}
|
|
disabled={isRunning}
|
|
placeholder={
|
|
authType === 'bearer' ? 'eyJhbGciOiJIUzI1NiIs...' :
|
|
authType === 'cookie' ? 'session=abc123; token=xyz' :
|
|
authType === 'basic' ? 'admin:password123' :
|
|
'X-API-Key:your-api-key'
|
|
}
|
|
className="w-full px-3 py-2 bg-dark-900 border border-dark-600 rounded-lg text-white text-sm placeholder-dark-500 focus:outline-none focus:border-green-500"
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Custom AI Prompt (collapsible) */}
|
|
<div className="mb-6">
|
|
<button
|
|
onClick={() => setShowPrompt(!showPrompt)}
|
|
disabled={isRunning}
|
|
className="flex items-center gap-2 text-sm text-dark-400 hover:text-white transition-colors disabled:opacity-50"
|
|
>
|
|
<MessageSquare className="w-4 h-4" />
|
|
<span>Custom AI Prompt (Optional)</span>
|
|
{showPrompt ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
|
</button>
|
|
|
|
{showPrompt && (
|
|
<div className="mt-3 pl-6">
|
|
<textarea
|
|
value={customPrompt}
|
|
onChange={e => setCustomPrompt(e.target.value)}
|
|
rows={4}
|
|
disabled={isRunning}
|
|
placeholder={"Focus on authentication bypass and IDOR vulnerabilities.\nThe app uses JWT tokens in localStorage.\nAdmin panel at /admin requires role=admin cookie."}
|
|
className="w-full px-4 py-3 bg-dark-900 border border-dark-600 rounded-xl text-white text-sm placeholder-dark-500 focus:outline-none focus:border-green-500 disabled:opacity-50"
|
|
/>
|
|
<p className="mt-1 text-xs text-dark-500">
|
|
Guide the AI agent with additional context, focus areas, or specific instructions.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Error */}
|
|
{error && (
|
|
<div className="mb-6 p-3 bg-red-500/10 border border-red-500/20 rounded-lg flex items-center gap-2">
|
|
<AlertTriangle className="w-5 h-5 text-red-400" />
|
|
<span className="text-red-400 text-sm">{error}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Start Button */}
|
|
<button
|
|
onClick={handleStart}
|
|
disabled={!target.trim()}
|
|
className="w-full py-4 bg-green-500 hover:bg-green-600 disabled:bg-dark-600 disabled:text-dark-400 text-white font-bold text-lg rounded-xl transition-colors flex items-center justify-center gap-3"
|
|
>
|
|
<Rocket className="w-6 h-6" />
|
|
START PENTEST
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Active Session View */}
|
|
{agentId && (
|
|
<div className="w-full max-w-4xl">
|
|
{/* Session Header */}
|
|
<div className="bg-dark-800 border border-dark-700 rounded-2xl p-6 mb-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className={`w-3 h-3 rounded-full ${isRunning ? 'bg-green-500 animate-pulse' : status?.status === 'completed' ? 'bg-green-500' : status?.status === 'error' ? 'bg-red-500' : 'bg-gray-500'}`} />
|
|
<h3 className="text-white font-semibold">
|
|
{isRunning ? 'Pentest Running' : status?.status === 'completed' ? 'Pentest Complete' : status?.status === 'error' ? 'Pentest Failed' : 'Pentest Stopped'}
|
|
</h3>
|
|
<span className="text-dark-400 text-sm">{target}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{isRunning && (
|
|
<button
|
|
onClick={handleStop}
|
|
className="px-4 py-1.5 bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded-lg text-sm transition-colors flex items-center gap-1.5"
|
|
>
|
|
<X className="w-4 h-4" /> Stop
|
|
</button>
|
|
)}
|
|
{!isRunning && (
|
|
<button
|
|
onClick={handleClearSession}
|
|
className="px-4 py-1.5 bg-dark-700 hover:bg-dark-600 text-dark-300 rounded-lg text-sm transition-colors flex items-center gap-1.5"
|
|
>
|
|
<Trash2 className="w-4 h-4" /> Clear Session
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Progress Bar */}
|
|
{status && (
|
|
<>
|
|
<div className="flex items-center justify-between text-sm mb-2">
|
|
<span className="text-dark-400">{status.phase || 'Initializing...'}</span>
|
|
<span className="text-dark-400">{status.progress}%</span>
|
|
</div>
|
|
<div className="w-full bg-dark-900 rounded-full h-2 mb-4">
|
|
<div
|
|
className="bg-green-500 h-2 rounded-full transition-all duration-500"
|
|
style={{ width: `${status.progress}%` }}
|
|
/>
|
|
</div>
|
|
|
|
{/* Phase Indicators */}
|
|
<div className="grid grid-cols-3 gap-3">
|
|
{PHASES.map((phase, idx) => {
|
|
const Icon = phase.icon
|
|
const isActive = idx === currentPhaseIdx && isRunning
|
|
const isDone = idx < currentPhaseIdx || status.status === 'completed'
|
|
|
|
return (
|
|
<div
|
|
key={phase.key}
|
|
className={`rounded-xl p-3 border transition-all duration-300 ${
|
|
isActive ? 'bg-green-500/10 border-green-500/30' :
|
|
isDone ? 'bg-dark-700/50 border-dark-600' :
|
|
'bg-dark-900 border-dark-700'
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-2 mb-1">
|
|
{isActive ? (
|
|
<Loader2 className="w-4 h-4 animate-spin text-green-400 flex-shrink-0" />
|
|
) : isDone ? (
|
|
<CheckCircle2 className="w-4 h-4 text-green-500 flex-shrink-0" />
|
|
) : (
|
|
<Icon className={`w-4 h-4 flex-shrink-0 ${isActive ? 'text-green-400' : 'text-dark-500'}`} />
|
|
)}
|
|
<span className={`text-xs font-medium ${
|
|
isActive ? 'text-green-400' :
|
|
isDone ? 'text-dark-300' :
|
|
'text-dark-500'
|
|
}`}>
|
|
{phase.label}
|
|
</span>
|
|
<span className={`ml-auto text-[10px] ${
|
|
isActive ? 'text-green-500/60' :
|
|
isDone ? 'text-dark-500' :
|
|
'text-dark-600'
|
|
}`}>
|
|
{phase.range[0]}-{phase.range[1]}%
|
|
</span>
|
|
</div>
|
|
|
|
{/* Stream badges for the parallel phase */}
|
|
{idx === 0 && (
|
|
<div className="flex flex-wrap gap-1.5 mt-2">
|
|
{STREAMS.map(stream => (
|
|
<StreamBadge
|
|
key={stream.key}
|
|
stream={stream}
|
|
progress={status.progress}
|
|
isRunning={isRunning}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Severity Summary + Tabs */}
|
|
{findings.length > 0 && (
|
|
<div className="bg-dark-800 border border-dark-700 rounded-2xl p-6 mb-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-3">
|
|
<h3 className="text-white font-semibold">Findings ({findings.length})</h3>
|
|
<div className="flex gap-2">
|
|
{['critical', 'high', 'medium', 'low', 'info'].map(sev => {
|
|
const count = sevCounts[sev] || 0
|
|
if (count === 0) return null
|
|
return (
|
|
<span key={sev} className={`${SEVERITY_COLORS[sev]} text-white px-2 py-0.5 rounded-full text-xs font-bold`}>
|
|
{count}
|
|
</span>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => { setShowFindings(true); setShowLogs(false) }}
|
|
className={`px-3 py-1 rounded-lg text-xs transition-colors ${showFindings ? 'bg-primary-500 text-white' : 'bg-dark-700 text-dark-400 hover:text-white'}`}
|
|
>
|
|
<Bug className="w-3 h-3 inline mr-1" />Findings ({findings.length})
|
|
</button>
|
|
{rejectedFindings.length > 0 && (
|
|
<button
|
|
onClick={() => { setShowFindings(true); setShowLogs(false); setFindingsFilter(findingsFilter === 'rejected' ? 'all' : 'rejected') }}
|
|
className={`px-3 py-1 rounded-lg text-xs transition-colors ${findingsFilter === 'rejected' && showFindings ? 'bg-orange-500/30 text-orange-400' : 'bg-dark-700 text-orange-400/60 hover:text-orange-400'}`}
|
|
>
|
|
<AlertTriangle className="w-3 h-3 inline mr-1" />Rejected ({rejectedFindings.length})
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => { setShowLogs(true); setShowFindings(false) }}
|
|
className={`px-3 py-1 rounded-lg text-xs transition-colors ${showLogs ? 'bg-primary-500 text-white' : 'bg-dark-700 text-dark-400 hover:text-white'}`}
|
|
>
|
|
<ScrollText className="w-3 h-3 inline mr-1" />Activity Log
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Findings Filter Tabs */}
|
|
{showFindings && allFindings.length > 0 && rejectedFindings.length > 0 && (
|
|
<div className="flex gap-1 mb-2">
|
|
{(['all', 'confirmed', 'rejected'] as const).map(f => (
|
|
<button
|
|
key={f}
|
|
onClick={() => setFindingsFilter(f)}
|
|
className={`px-2 py-0.5 rounded-full text-xs transition-colors ${
|
|
findingsFilter === f ? 'bg-primary-500/20 text-primary-400' : 'text-dark-500 hover:text-dark-300'
|
|
}`}
|
|
>
|
|
{f === 'all' ? `All (${allFindings.length})` : f === 'confirmed' ? `Confirmed (${findings.length})` : `Rejected (${rejectedFindings.length})`}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Findings List */}
|
|
{showFindings && (
|
|
<div className="space-y-2 max-h-[500px] overflow-y-auto pr-1">
|
|
{displayFindings.map((f: AgentFinding) => (
|
|
<div
|
|
key={f.id}
|
|
className={`border ${f.ai_status === 'rejected' ? 'border-orange-500/30 opacity-70' : (SEVERITY_BORDER[f.severity] || 'border-dark-600')} rounded-xl bg-dark-900 overflow-hidden`}
|
|
>
|
|
<button
|
|
onClick={() => setExpandedFinding(expandedFinding === f.id ? null : f.id)}
|
|
className="w-full flex items-center gap-3 p-3 text-left hover:bg-dark-800/50 transition-colors"
|
|
>
|
|
<span className={`${SEVERITY_COLORS[f.severity]} text-white px-2 py-0.5 rounded text-xs font-bold uppercase flex-shrink-0`}>
|
|
{f.severity}
|
|
</span>
|
|
<span className={`text-sm flex-1 truncate ${f.ai_status === 'rejected' ? 'text-dark-400' : 'text-white'}`}>{f.title}</span>
|
|
{f.ai_status === 'rejected' && (
|
|
<span className="text-xs px-1.5 py-0.5 rounded-full bg-orange-500/20 text-orange-400 flex-shrink-0">Rejected</span>
|
|
)}
|
|
{(() => {
|
|
const conf = getConfidenceDisplay(f)
|
|
if (!conf) return null
|
|
return (
|
|
<span className={`text-xs font-semibold px-1.5 py-0.5 rounded-full border flex-shrink-0 ${CONFIDENCE_STYLES[conf.color]}`}>
|
|
{conf.score}/100
|
|
</span>
|
|
)
|
|
})()}
|
|
<span className="text-dark-500 text-xs flex-shrink-0">{f.vulnerability_type}</span>
|
|
{expandedFinding === f.id ? <ChevronUp className="w-4 h-4 text-dark-400" /> : <ChevronDown className="w-4 h-4 text-dark-400" />}
|
|
</button>
|
|
|
|
{expandedFinding === f.id && (
|
|
<div className="px-3 pb-3 space-y-2 border-t border-dark-700 pt-2">
|
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
|
{f.affected_endpoint && (
|
|
<div>
|
|
<span className="text-dark-500">Endpoint: </span>
|
|
<span className="text-dark-300 break-all">{f.affected_endpoint}</span>
|
|
</div>
|
|
)}
|
|
{f.parameter && (
|
|
<div>
|
|
<span className="text-dark-500">Parameter: </span>
|
|
<span className="text-dark-300">{f.parameter}</span>
|
|
</div>
|
|
)}
|
|
{f.cwe_id && (
|
|
<div>
|
|
<span className="text-dark-500">CWE: </span>
|
|
<span className="text-dark-300">{f.cwe_id}</span>
|
|
</div>
|
|
)}
|
|
{f.cvss_score > 0 && (
|
|
<div>
|
|
<span className="text-dark-500">CVSS: </span>
|
|
<span className="text-dark-300">{f.cvss_score}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{f.description && (
|
|
<p className="text-dark-400 text-xs">{f.description.substring(0, 300)}{f.description.length > 300 ? '...' : ''}</p>
|
|
)}
|
|
{f.payload && (
|
|
<div className="bg-dark-800 rounded-lg p-2">
|
|
<span className="text-dark-500 text-xs">Payload: </span>
|
|
<code className="text-green-400 text-xs break-all">{f.payload.substring(0, 200)}</code>
|
|
</div>
|
|
)}
|
|
{f.evidence && (
|
|
<div className="bg-dark-800 rounded-lg p-2">
|
|
<span className="text-dark-500 text-xs">Evidence: </span>
|
|
<span className="text-dark-300 text-xs">{f.evidence.substring(0, 300)}</span>
|
|
</div>
|
|
)}
|
|
{f.poc_code && (
|
|
<div className="mt-2">
|
|
<p className="text-xs font-medium text-dark-400 mb-1">PoC Code</p>
|
|
<pre className="p-2 bg-dark-950 rounded text-xs text-green-400 overflow-x-auto max-h-[300px] overflow-y-auto whitespace-pre-wrap">{f.poc_code}</pre>
|
|
</div>
|
|
)}
|
|
{f.ai_status === 'rejected' && f.rejection_reason && (
|
|
<div className="bg-orange-500/10 border border-orange-500/20 rounded-lg p-2">
|
|
<span className="text-orange-400 text-xs font-medium">Rejection: </span>
|
|
<span className="text-orange-300/80 text-xs">{f.rejection_reason}</span>
|
|
</div>
|
|
)}
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className={`text-xs ${f.ai_status === 'rejected' ? 'text-orange-400' : f.ai_verified ? 'text-green-400' : 'text-dark-500'}`}>
|
|
{f.ai_status === 'rejected' ? 'AI Rejected' : f.ai_verified ? 'AI Verified' : 'Tool Detected'}
|
|
</span>
|
|
{(() => {
|
|
const conf = getConfidenceDisplay(f)
|
|
if (!conf) return null
|
|
return (
|
|
<span className={`text-xs font-semibold px-1.5 py-0.5 rounded-full border ${CONFIDENCE_STYLES[conf.color]}`}>
|
|
Confidence: {conf.score}/100 ({conf.label})
|
|
</span>
|
|
)
|
|
})()}
|
|
</div>
|
|
{(() => {
|
|
const hasBreakdown = f.confidence_breakdown && Object.keys(f.confidence_breakdown).length > 0
|
|
const hasProof = !!f.proof_of_execution
|
|
const hasControls = !!f.negative_controls
|
|
if (!hasBreakdown && !hasProof && !hasControls) return null
|
|
return (
|
|
<div className="bg-dark-800 rounded-lg p-2 space-y-1">
|
|
{hasBreakdown && (
|
|
<div className="grid grid-cols-2 gap-x-4 gap-y-0.5 text-xs text-dark-400">
|
|
{Object.entries(f.confidence_breakdown!).map(([key, val]) => (
|
|
<div key={key} className="flex justify-between">
|
|
<span className="capitalize">{key.replace(/_/g, ' ')}</span>
|
|
<span className={`font-mono font-medium ${
|
|
Number(val) > 0 ? 'text-green-400' : Number(val) < 0 ? 'text-red-400' : 'text-dark-500'
|
|
}`}>
|
|
{Number(val) > 0 ? '+' : ''}{val}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
{hasProof && (
|
|
<p className="text-xs text-dark-400 flex items-start gap-1">
|
|
<span className="text-green-400">✓</span>
|
|
<span>Proof: <span className="text-dark-300">{f.proof_of_execution}</span></span>
|
|
</p>
|
|
)}
|
|
{hasControls && (
|
|
<p className="text-xs text-dark-400 flex items-start gap-1">
|
|
<span className="text-blue-400">■</span>
|
|
<span>Controls: <span className="text-dark-300">{f.negative_controls}</span></span>
|
|
</p>
|
|
)}
|
|
</div>
|
|
)
|
|
})()}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Activity Log */}
|
|
{showLogs && (
|
|
<div className="bg-dark-900 rounded-xl p-3 max-h-[500px] overflow-y-auto font-mono text-xs">
|
|
{logs.length === 0 ? (
|
|
<p className="text-dark-500 text-center py-4">No logs yet...</p>
|
|
) : (
|
|
logs.map((log, i) => (
|
|
<div key={i} className="flex gap-2 py-0.5">
|
|
<span className="text-dark-600 flex-shrink-0">{log.time || ''}</span>
|
|
<span className={`flex-shrink-0 uppercase w-12 ${
|
|
log.level === 'error' ? 'text-red-400' :
|
|
log.level === 'warning' ? 'text-yellow-400' :
|
|
log.level === 'info' ? 'text-blue-400' :
|
|
'text-dark-500'
|
|
}`}>{log.level}</span>
|
|
<span className={logMessageColor(log.message) || (log.source === 'llm' ? 'text-purple-400' : 'text-dark-300')}>
|
|
{log.message}
|
|
</span>
|
|
</div>
|
|
))
|
|
)}
|
|
<div ref={logsEndRef} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* No findings yet but running */}
|
|
{findings.length === 0 && isRunning && (
|
|
<div className="bg-dark-800 border border-dark-700 rounded-2xl p-6 mb-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="text-white font-semibold">Activity</h3>
|
|
<button
|
|
onClick={() => setShowLogs(!showLogs)}
|
|
className={`px-3 py-1 rounded-lg text-xs transition-colors ${showLogs ? 'bg-primary-500 text-white' : 'bg-dark-700 text-dark-400 hover:text-white'}`}
|
|
>
|
|
<ScrollText className="w-3 h-3 inline mr-1" />{showLogs ? 'Hide' : 'Show'} Logs
|
|
</button>
|
|
</div>
|
|
{showLogs ? (
|
|
<div className="bg-dark-900 rounded-xl p-3 max-h-[400px] overflow-y-auto font-mono text-xs">
|
|
{logs.length === 0 ? (
|
|
<div className="flex items-center justify-center py-4 text-dark-500">
|
|
<Loader2 className="w-4 h-4 animate-spin mr-2" /> Waiting for logs...
|
|
</div>
|
|
) : (
|
|
logs.map((log, i) => (
|
|
<div key={i} className="flex gap-2 py-0.5">
|
|
<span className="text-dark-600 flex-shrink-0">{log.time || ''}</span>
|
|
<span className={`flex-shrink-0 uppercase w-12 ${
|
|
log.level === 'error' ? 'text-red-400' :
|
|
log.level === 'warning' ? 'text-yellow-400' :
|
|
log.level === 'info' ? 'text-blue-400' :
|
|
'text-dark-500'
|
|
}`}>{log.level}</span>
|
|
<span className={logMessageColor(log.message) || (log.source === 'llm' ? 'text-purple-400' : 'text-dark-300')}>
|
|
{log.message}
|
|
</span>
|
|
</div>
|
|
))
|
|
)}
|
|
<div ref={logsEndRef} />
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center justify-center py-6 text-dark-500">
|
|
<Loader2 className="w-5 h-5 animate-spin mr-2" />
|
|
Scanning in progress... Findings will appear here as they are discovered.
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Completion Actions */}
|
|
{status?.status === 'completed' && (
|
|
<div className="bg-dark-800 border border-green-500/30 rounded-2xl p-6 mb-6">
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<CheckCircle2 className="w-6 h-6 text-green-500" />
|
|
<h3 className="text-green-400 font-semibold text-lg">Pentest Complete</h3>
|
|
</div>
|
|
<p className="text-dark-400 mb-4">
|
|
Found {findings.length} vulnerabilities across {target}.
|
|
</p>
|
|
|
|
{/* Report Generation */}
|
|
{generatingReport && (
|
|
<div className="mb-4 p-4 bg-purple-500/10 border border-purple-500/20 rounded-xl flex items-center gap-3">
|
|
<Loader2 className="w-5 h-5 text-purple-400 animate-spin" />
|
|
<div>
|
|
<p className="text-purple-400 font-medium text-sm">Generating AI Report...</p>
|
|
<p className="text-dark-400 text-xs">The AI is analyzing findings and writing the executive summary.</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex flex-wrap gap-3">
|
|
<button
|
|
onClick={() => navigate(`/agent/${agentId}`)}
|
|
className="px-5 py-2 bg-primary-500 hover:bg-primary-600 text-white rounded-lg transition-colors flex items-center gap-2 text-sm"
|
|
>
|
|
<ExternalLink className="w-4 h-4" /> View Full Results
|
|
</button>
|
|
|
|
{!reportId ? (
|
|
<button
|
|
onClick={handleGenerateAiReport}
|
|
disabled={generatingReport || !status.scan_id}
|
|
className="px-5 py-2 bg-purple-500 hover:bg-purple-600 disabled:opacity-50 text-white rounded-lg transition-colors flex items-center gap-2 text-sm"
|
|
>
|
|
<Sparkles className="w-4 h-4" /> Generate AI Report
|
|
</button>
|
|
) : (
|
|
<>
|
|
<a
|
|
href={reportsApi.getViewUrl(reportId)}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="px-5 py-2 bg-green-500 hover:bg-green-600 text-white rounded-lg transition-colors flex items-center gap-2 text-sm"
|
|
>
|
|
<FileText className="w-4 h-4" /> View Report
|
|
</a>
|
|
<a
|
|
href={reportsApi.getDownloadZipUrl(reportId)}
|
|
className="px-5 py-2 bg-dark-700 hover:bg-dark-600 text-white rounded-lg transition-colors flex items-center gap-2 text-sm"
|
|
>
|
|
<Download className="w-4 h-4" /> Download ZIP
|
|
</a>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Error state */}
|
|
{status?.status === 'error' && (
|
|
<div className="bg-dark-800 border border-red-500/30 rounded-2xl p-6">
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<AlertTriangle className="w-6 h-6 text-red-400" />
|
|
<h3 className="text-red-400 font-semibold">Pentest Failed</h3>
|
|
</div>
|
|
<p className="text-dark-400">{status.error || 'An unexpected error occurred.'}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|