Files
CyberSecurityUP 59f8f42d80 NeuroSploit v3.2.4 - MD Agent Orchestrator Overhaul + Claude 4.6 + SmartRouter Failover
- MD Agent system restructured: real HTTP exploitation, retry with exponential backoff, reduced concurrency (2 parallel, 2s stagger)
- Claude 4.6 model support (Opus/Sonnet) with corrected API version headers
- SmartRouter true failover with provider preference cascade
- WAFResult attribute error fix in autonomous_agent.py
- CVSS data sanitization for all vulnerability database saves
- AI recon JSON parsing robustness improvements
- rebuild.sh simplified from 714 to 196 lines
- Frontend: removed unused routes, simplified Auto Pentest page
- Agent grid: reduced max tests per agent (8→5), condensed recon prompts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 20:25:01 -03:00

2143 lines
105 KiB
TypeScript
Executable File

import { useState, useEffect, useRef, useCallback, useMemo } 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, Clock, Search, Activity, Terminal, Crosshair
} from 'lucide-react'
import { PieChart, Pie, Cell, Tooltip as RechartsTooltip, ResponsiveContainer } from 'recharts'
import { agentApi, reportsApi, promptsApi, cliAgentApi } from '../services/api'
import type { AgentStatus, AgentFinding, AgentLog, Prompt, ToolExecution, ContainerStatus } from '../types'
import VulnAgentGrid from '../components/VulnAgentGrid'
// ─── Constants ────────────────────────────────────────────────────────────────
const PHASES = [
{ key: 'recon', label: 'Reconnaissance', icon: Globe, range: [0, 20] as const },
{ key: 'agents', label: 'Agent Grid (108 agents)', icon: Layers, range: [20, 85] as const },
{ key: 'final', label: 'Finalization', icon: Shield, range: [85, 100] as const },
]
const STREAMS = [
{ key: 'recon', label: 'Recon', icon: Globe, color: 'blue', activeUntil: 20 },
{ key: 'agents', label: 'Agent Grid', icon: Brain, color: 'purple', activeUntil: 85 },
{ key: 'final', label: 'Report', icon: Wrench, color: 'orange', activeUntil: 100 },
] 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' },
}
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',
}
const SEVERITY_CHART_COLORS: Record<string, string> = {
critical: '#ef4444', high: '#f97316', medium: '#eab308', low: '#3b82f6', info: '#6b7280',
}
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 LOG_FILTERS = [
{ key: 'all', label: 'All', color: '' },
{ key: 'recon', label: 'Recon', color: 'text-blue-400' },
{ key: 'agents', label: 'Agents', color: 'text-green-400' },
{ key: 'judge', label: 'Validation', color: 'text-amber-300' },
{ key: 'final', label: 'Final', color: 'text-cyan-400' },
{ key: 'error', label: 'Errors', color: 'text-red-400' },
]
const SESSIONS_KEY = 'neurosploit_autopentest_sessions'
const LEGACY_SESSION_KEY = 'neurosploit_autopentest_session'
const POLL_INTERVAL = 1500
const POLL_INTERVAL_ERROR = 5000
const TOAST_DURATION = 5000
const MAX_TOASTS = 5
// ─── Types ────────────────────────────────────────────────────────────────────
interface SavedSession {
agentId: string
target: string
startedAt: string
status: 'running' | 'completed' | 'error' | 'stopped'
}
interface Toast {
id: string
message: string
severity: string
timestamp: number
}
// ─── Utility Functions ────────────────────────────────────────────────────────
function phaseFromProgress(progress: number): number {
if (progress < 20) return 0
if (progress < 85) return 1
return 2
}
function formatElapsed(totalSeconds: number): string {
const h = Math.floor(totalSeconds / 3600)
const m = Math.floor((totalSeconds % 3600) / 60)
const s = totalSeconds % 60
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
}
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'
if (message.startsWith('[CONTAINER]')) return 'text-cyan-300'
if (message.startsWith('[CLI-AGENT]')) return 'text-pink-400'
if (message.startsWith('[PHASE]')) return 'text-yellow-400'
if (message.startsWith('[PHASE FAIL]')) return 'text-red-400'
if (message.startsWith('[BANNER]')) return 'text-teal-400'
if (message.startsWith('[WAF]')) return 'text-amber-400'
if (message.startsWith('[PLAYBOOK]')) return 'text-indigo-400'
if (message.startsWith('[SITE ANALYZER]')) return 'text-emerald-400'
if (message.startsWith('[MD-AGENTS]')) return 'text-cyan-300'
if (message.startsWith('[AGENT GRID]')) return 'text-green-400'
if (message.startsWith('[PHASE 1]')) return 'text-blue-300'
if (message.startsWith('[PHASE 2]')) return 'text-purple-300'
if (message.startsWith('[PHASE 3]')) return 'text-yellow-300'
if (message.startsWith('[RECON]')) return 'text-blue-400'
if (message.startsWith('[CVE]')) return 'text-red-300'
if (message.startsWith('[CHAIN]')) return 'text-orange-300'
if (message.startsWith('[JUDGE]')) return 'text-amber-300'
if (message.includes('Starting (real HTTP)')) return 'text-green-300'
return ''
}
function matchLogFilter(log: AgentLog, filter: string): boolean {
if (filter === 'all') return true
if (filter === 'stream1') return log.message.startsWith('[STREAM 1]')
if (filter === 'stream2') return log.message.startsWith('[STREAM 2]')
if (filter === 'stream3') return log.message.startsWith('[STREAM 3]')
if (filter === 'deep') return log.message.startsWith('[DEEP]')
if (filter === 'container') return log.message.startsWith('[CONTAINER]')
if (filter === 'cli_agent') return log.message.startsWith('[CLI-AGENT]')
if (filter === 'error') return log.level === 'error' || log.level === 'warning'
return true
}
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 }
}
// ─── Sub-Components ───────────────────────────────────────────────────────────
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 LiveStatsDashboard({ status, elapsedSeconds, toolExecutions }: {
status: AgentStatus; elapsedSeconds: number; toolExecutions: ToolExecution[]
}) {
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
<div className="bg-dark-800 border border-dark-700 rounded-xl p-4">
<div className="flex items-center gap-2 mb-1">
<Clock className="w-4 h-4 text-blue-400" />
<span className="text-[10px] text-dark-400 uppercase font-semibold tracking-wider">Elapsed</span>
</div>
<span className="text-2xl font-mono text-white tabular-nums">{formatElapsed(elapsedSeconds)}</span>
</div>
<div className="bg-dark-800 border border-dark-700 rounded-xl p-4">
<div className="flex items-center gap-2 mb-1">
<Bug className="w-4 h-4 text-red-400" />
<span className="text-[10px] text-dark-400 uppercase font-semibold tracking-wider">Findings</span>
</div>
<div className="flex items-baseline gap-2">
<span className="text-2xl font-mono text-white">{status.findings_count}</span>
{(status.rejected_findings_count ?? 0) > 0 && (
<span className="text-xs text-dark-500">+{status.rejected_findings_count} rej</span>
)}
</div>
</div>
<div className="bg-dark-800 border border-dark-700 rounded-xl p-4">
<div className="flex items-center gap-2 mb-1">
<Terminal className="w-4 h-4 text-cyan-400" />
<span className="text-[10px] text-dark-400 uppercase font-semibold tracking-wider">Tools Run</span>
</div>
<span className="text-2xl font-mono text-white">{toolExecutions.length}</span>
</div>
<div className="bg-dark-800 border border-dark-700 rounded-xl p-4">
<div className="flex items-center gap-2 mb-1">
<Activity className="w-4 h-4 text-green-400" />
<span className="text-[10px] text-dark-400 uppercase font-semibold tracking-wider">Progress</span>
</div>
<div className="flex items-baseline gap-2">
<span className="text-2xl font-mono text-white">{status.progress}%</span>
<span className="text-xs text-dark-500 truncate">{status.phase || 'Init'}</span>
</div>
</div>
</div>
)
}
function ToolExecutionRow({ exec, expanded, onToggle }: {
exec: ToolExecution; expanded: boolean; onToggle: () => void
}) {
const hasExpandable = !!(exec.stdout_preview || exec.stderr_preview || exec.reason)
return (
<div className="border-b border-dark-800 last:border-0">
<button
onClick={hasExpandable ? onToggle : undefined}
className={`w-full grid grid-cols-[50px_70px_1fr_50px_65px_55px_20px] sm:grid-cols-[60px_80px_1fr_50px_70px_60px_24px] gap-2 items-center px-2 py-2 text-xs transition-colors ${
hasExpandable ? 'hover:bg-dark-700/50 cursor-pointer' : 'cursor-default'
}`}
>
<span className="font-mono text-dark-500 truncate">{exec.task_id?.slice(0, 6) || '---'}</span>
<span className="text-cyan-400 font-medium truncate">{exec.tool}</span>
<span className="text-dark-300 truncate text-left" title={exec.command}>{exec.command}</span>
<span className={`font-bold text-center ${exec.exit_code === 0 ? 'text-green-400' : exec.exit_code === -1 ? 'text-yellow-400' : exec.exit_code !== null ? 'text-red-400' : 'text-dark-500'}`}>
{exec.exit_code === null || exec.exit_code === undefined ? '...' : exec.exit_code === -1 ? 'ERR' : exec.exit_code}
</span>
<span className="text-dark-400 text-right">{exec.duration != null && exec.duration > 0 ? `${exec.duration.toFixed(1)}s` : exec.exit_code === -1 ? 'N/A' : '---'}</span>
<span className="text-dark-300 text-center">{exec.findings_count ?? 0}</span>
<span className="text-dark-500">
{hasExpandable ? (expanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />) : null}
</span>
</button>
{expanded && (
<div className="px-3 pb-3 space-y-2 bg-dark-900/50 border-t border-dark-800">
{exec.reason && (
<p className="text-xs text-dark-400 pt-2"><span className="text-dark-500 font-medium">Reason: </span>{exec.reason}</p>
)}
{exec.stdout_preview && (
<div>
<p className="text-[10px] font-medium text-dark-500 mb-1 uppercase tracking-wider">stdout</p>
<pre className="bg-dark-950 rounded p-2 text-xs text-green-400/80 max-h-[200px] overflow-y-auto whitespace-pre-wrap font-mono">{exec.stdout_preview}</pre>
</div>
)}
{exec.stderr_preview && (
<div>
<p className="text-[10px] font-medium text-dark-500 mb-1 uppercase tracking-wider">stderr</p>
<pre className="bg-dark-950 rounded p-2 text-xs text-red-400/70 max-h-[200px] overflow-y-auto whitespace-pre-wrap font-mono">{exec.stderr_preview}</pre>
</div>
)}
{exec.container_name && (
<p className="text-[10px] text-dark-500">Container: <span className="font-mono text-dark-400">{exec.container_name}</span></p>
)}
</div>
)}
</div>
)
}
function SeverityMiniChart({ sevCounts }: { sevCounts: Record<string, number> }) {
const data = ['critical', 'high', 'medium', 'low', 'info']
.filter(s => (sevCounts[s] || 0) > 0)
.map(s => ({ name: s, value: sevCounts[s] || 0 }))
if (data.length === 0) return null
return (
<div className="w-20 h-20 flex-shrink-0">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie data={data} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={32} innerRadius={16} strokeWidth={0}>
{data.map(entry => (
<Cell key={entry.name} fill={SEVERITY_CHART_COLORS[entry.name]} />
))}
</Pie>
<RechartsTooltip
contentStyle={{ backgroundColor: '#1a1a2e', border: '1px solid #334155', borderRadius: '8px', fontSize: '11px', padding: '4px 8px' }}
itemStyle={{ color: '#e2e8f0' }}
formatter={(value: number, name: string) => [`${value}`, name.charAt(0).toUpperCase() + name.slice(1)]}
/>
</PieChart>
</ResponsiveContainer>
</div>
)
}
function LogViewer({ logs, logFilter, setLogFilter, logSearch, setLogSearch, logsEndRef }: {
logs: AgentLog[]; logFilter: string; setLogFilter: (f: string) => void
logSearch: string; setLogSearch: (s: string) => void; logsEndRef: React.RefObject<HTMLDivElement>
}) {
const filteredLogs = useMemo(() =>
logs.filter(log => {
if (!matchLogFilter(log, logFilter)) return false
if (logSearch && !log.message.toLowerCase().includes(logSearch.toLowerCase())) return false
return true
}), [logs, logFilter, logSearch]
)
return (
<div className="bg-dark-900 rounded-xl overflow-hidden">
<div className="flex items-center gap-1.5 p-2 border-b border-dark-700 flex-wrap">
{LOG_FILTERS.map(f => (
<button
key={f.key}
onClick={() => setLogFilter(f.key)}
className={`px-2 py-0.5 rounded text-[10px] font-medium transition-colors ${
logFilter === f.key ? 'bg-dark-700 text-white' : `text-dark-500 hover:text-dark-300 ${f.color}`
}`}
>
{f.label}
</button>
))}
<div className="flex-1 min-w-0" />
<div className="relative">
<Search className="w-3 h-3 absolute left-2 top-1/2 -translate-y-1/2 text-dark-500 pointer-events-none" />
<input
type="text"
value={logSearch}
onChange={e => setLogSearch(e.target.value)}
placeholder="Search..."
className="pl-6 pr-2 py-1 bg-dark-800 border border-dark-700 rounded text-xs text-white placeholder-dark-500 focus:outline-none focus:border-dark-500 w-32 sm:w-40"
/>
</div>
<span className="text-[10px] text-dark-600 tabular-nums">{filteredLogs.length}/{logs.length}</span>
</div>
<div className="p-3 max-h-[400px] overflow-y-auto font-mono text-xs space-y-px">
{filteredLogs.length === 0 ? (
<p className="text-dark-500 text-center py-4">
{logs.length === 0 ? 'Waiting for logs...' : 'No logs match filter'}
</p>
) : (
filteredLogs.map((log, i) => (
<div key={i} className="flex gap-2 py-0.5 hover:bg-dark-800/30 rounded px-1 -mx-1">
<span className="text-dark-600 flex-shrink-0 text-[10px] tabular-nums">{log.time?.slice(11, 19) || ''}</span>
<span className={`flex-shrink-0 uppercase w-10 text-[10px] ${
log.level === 'error' ? 'text-red-400' :
log.level === 'warning' ? 'text-yellow-400' :
log.level === 'success' ? 'text-green-400' :
log.level === 'info' ? 'text-blue-400' : 'text-dark-500'
}`}>{log.level}</span>
<span className={`break-all ${logMessageColor(log.message) || (log.source === 'llm' ? 'text-purple-400' : 'text-dark-300')}`}>
{log.message}
</span>
</div>
))
)}
<div ref={logsEndRef} />
</div>
</div>
)
}
function ToastContainer({ toasts, onDismiss }: { toasts: Toast[]; onDismiss: (id: string) => void }) {
if (toasts.length === 0) return null
return (
<div className="fixed top-4 right-4 z-50 space-y-2 max-w-sm pointer-events-none">
{toasts.map(toast => (
<div
key={toast.id}
style={{ animation: 'fadeSlideIn 0.3s ease-out' }}
className={`flex items-center gap-2 px-4 py-3 rounded-xl border shadow-2xl pointer-events-auto ${
toast.severity === 'critical' ? 'bg-red-950/90 border-red-500/40 text-red-300' :
toast.severity === 'high' ? 'bg-orange-950/90 border-orange-500/40 text-orange-300' :
toast.severity === 'medium' ? 'bg-yellow-950/90 border-yellow-500/40 text-yellow-300' :
toast.severity === 'completed' ? 'bg-green-950/90 border-green-500/40 text-green-300' :
toast.severity === 'error' ? 'bg-red-950/90 border-red-500/40 text-red-300' :
'bg-dark-800/95 border-dark-600 text-dark-300'
}`}
>
{toast.severity === 'completed' ? (
<CheckCircle2 className="w-4 h-4 flex-shrink-0" />
) : toast.severity === 'error' || toast.severity === 'critical' ? (
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
) : (
<Bug className="w-4 h-4 flex-shrink-0" />
)}
<span className="text-sm flex-1 line-clamp-2">{toast.message}</span>
<button onClick={() => onDismiss(toast.id)} className="text-dark-500 hover:text-white flex-shrink-0">
<X className="w-3 h-3" />
</button>
</div>
))}
</div>
)
}
// ─── Main Component ───────────────────────────────────────────────────────────
export default function AutoPentestPage() {
const navigate = useNavigate()
// Form state
const [target, setTarget] = useState('')
const [multiTarget, setMultiTarget] = useState(false)
const [targets, setTargets] = useState('')
const [subdomainDiscovery, setSubdomainDiscovery] = useState(false)
const [enableKaliSandbox, setEnableKaliSandbox] = 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('')
const [savedPrompts, setSavedPrompts] = useState<Prompt[]>([])
const [selectedPromptIds, setSelectedPromptIds] = useState<string[]>([])
const [showSavedPrompts, setShowSavedPrompts] = useState(false)
// Model selection
const [availableModels, setAvailableModels] = useState<Array<{ provider_id: string; provider_name: string; default_model: string; tier: number; available_models: string[] }>>([])
const [selectedProvider, setSelectedProvider] = useState('anthropic')
const [selectedModel, setSelectedModel] = useState('claude-sonnet-4-20250514')
// MD Agent selection
const [availableMdAgents, setAvailableMdAgents] = useState<Array<{ name: string; display_name: string; category: string }>>([])
const [selectedMdAgents, setSelectedMdAgents] = useState<string[]>([])
const [showAgentSelector, setShowAgentSelector] = useState(false)
// CLI Agent mode
const [testMode, setTestMode] = useState<'auto_pentest' | 'cli_agent' | 'full_llm_pentest'>('auto_pentest')
const [cliProviders, setCliProviders] = useState<Array<{ id: string; name: string; connected: boolean; account_label?: string; source?: string }>>([])
const [cliEnabled, setCliEnabled] = useState(false)
const [selectedCliProvider, setSelectedCliProvider] = useState('')
const [methodologies, setMethodologies] = useState<Array<{ name: string; path: string; size_human: string; is_default: boolean }>>([])
const [selectedMethodology, setSelectedMethodology] = useState('')
const [enableCliPhase, setEnableCliPhase] = useState(false) // Checkbox in auto_pentest mode
// Learning stats (TP/FP per vuln type)
const [learningStats, setLearningStats] = useState<Record<string, { tp: number; fp: number }>>({})
// History
const [showHistory, setShowHistory] = useState(false)
const [history, setHistory] = useState<Array<any>>([])
const [historyLoading, setHistoryLoading] = useState(false)
// Triple-check
const [tripleCheckScanId, setTripleCheckScanId] = useState<string | null>(null)
const [tripleCheckProvider, setTripleCheckProvider] = useState('')
const [tripleCheckModel, setTripleCheckModel] = useState('')
// Multi-session state
const [sessions, setSessions] = useState<SavedSession[]>([])
const [activeSessionIdx, setActiveSessionIdx] = useState(-1)
const [maxConcurrent, setMaxConcurrent] = useState(5)
const [statusCache, setStatusCache] = useState<Record<string, AgentStatus>>({})
const [error, setError] = useState<string | null>(null)
// Derived from active session
const activeSession = activeSessionIdx >= 0 && activeSessionIdx < sessions.length ? sessions[activeSessionIdx] : null
const agentId = activeSession?.agentId ?? null
const isRunning = activeSession?.status === 'running'
const status = agentId ? statusCache[agentId] ?? null : null
// Live stats
const [elapsedSeconds, setElapsedSeconds] = useState(0)
// UI state
const [activeTab, setActiveTab] = useState<'findings' | 'logs' | 'agents'>('findings')
const [expandedFinding, setExpandedFinding] = useState<string | null>(null)
const [expandedTool, setExpandedTool] = useState<string | null>(null)
const [findingsFilter, setFindingsFilter] = useState<'confirmed' | 'rejected' | 'all'>('all')
const [logFilter, setLogFilter] = useState('all')
const [logSearch, setLogSearch] = useState('')
// Toast notifications
const [toasts, setToasts] = useState<Toast[]>([])
// Finding animations
const [newFindingIds, setNewFindingIds] = useState<Set<string>>(new Set())
// Connection state
const [connectionLost, setConnectionLost] = useState(false)
// Logs & Report
const [logs, setLogs] = useState<AgentLog[]>([])
const [generatingReport, setGeneratingReport] = useState(false)
const [reportId, setReportId] = useState<string | null>(null)
// Refs
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const logsEndRef = useRef<HTMLDivElement>(null)
const failCountRef = useRef<Record<string, number>>({})
const seenFindingIdsRef = useRef<Set<string>>(new Set())
const prevPhaseRef = useRef<string | null>(null)
const prevStatusRef = useRef<string | null>(null)
const newFindingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// ─── Toast Helper ─────────────────────────────────────────────────────────
const addToast = useCallback((message: string, severity: string = 'info') => {
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
setToasts(prev => [...prev.slice(-(MAX_TOASTS - 1)), { id, message, severity, timestamp: Date.now() }])
setTimeout(() => setToasts(prev => prev.filter(t => t.id !== id)), TOAST_DURATION)
}, [])
const dismissToast = useCallback((id: string) => {
setToasts(prev => prev.filter(t => t.id !== id))
}, [])
// ─── Mount: restore sessions + load prompts + models ───────────────────────
useEffect(() => {
// Migrate legacy single-session key
try {
const legacy = localStorage.getItem(LEGACY_SESSION_KEY)
if (legacy) {
const old = JSON.parse(legacy)
const migrated: SavedSession = {
agentId: old.agentId,
target: old.target,
startedAt: old.startedAt,
status: 'running',
}
setSessions([migrated])
setActiveSessionIdx(0)
localStorage.removeItem(LEGACY_SESSION_KEY)
localStorage.setItem(SESSIONS_KEY, JSON.stringify([migrated]))
}
} catch { /* ignore */ }
// Restore multi-session array
try {
const saved = localStorage.getItem(SESSIONS_KEY)
if (saved) {
const parsed: SavedSession[] = JSON.parse(saved)
setSessions(parsed)
const runningIdx = parsed.findIndex(s => s.status === 'running')
setActiveSessionIdx(runningIdx >= 0 ? runningIdx : parsed.length > 0 ? 0 : -1)
}
} catch { /* ignore */ }
// Fetch backend limits
agentApi.listActive().then(data => {
setMaxConcurrent(data.max_concurrent)
}).catch(() => {})
promptsApi.list().then(p => setSavedPrompts(p || [])).catch(() => {})
// Fetch available LLM providers/models
fetch('/api/v1/providers/available-models')
.then(r => r.json())
.then(data => setAvailableModels(data.models || []))
.catch(() => {})
// Fetch available MD agents
fetch('/api/v1/agent/md-agents')
.then(r => r.json())
.then(data => setAvailableMdAgents(data.agents || []))
.catch(() => {})
// Fetch learning stats (TP/FP counts per vuln type)
fetch('/api/v1/scans/vulnerabilities/learning/stats')
.then(r => r.json())
.then(data => {
if (data.vuln_types) {
const stats: Record<string, { tp: number; fp: number }> = {}
for (const [vt, info] of Object.entries(data.vuln_types as Record<string, any>)) {
stats[vt] = { tp: info.true_positives || 0, fp: info.false_positives || 0 }
}
setLearningStats(stats)
}
})
.catch(() => {})
// Fetch CLI agent providers and methodologies
cliAgentApi.getProviders()
.then(data => {
setCliProviders(data.providers || [])
setCliEnabled(data.enabled || false)
const connected = (data.providers || []).find((p: any) => p.connected)
if (connected) setSelectedCliProvider(connected.id)
})
.catch(() => {})
cliAgentApi.getMethodologies()
.then(data => {
setMethodologies(data.methodologies || [])
const def = (data.methodologies || []).find((m: any) => m.is_default)
if (def) setSelectedMethodology(def.path)
})
.catch(() => {})
// Restore statusCache for ALL saved sessions on mount
try {
const saved = localStorage.getItem(SESSIONS_KEY)
if (saved) {
const parsed: SavedSession[] = JSON.parse(saved)
for (const sess of parsed) {
agentApi.getStatus(sess.agentId)
.then(s => {
setStatusCache(prev => ({ ...prev, [sess.agentId]: s }))
if (s.status !== sess.status && (s.status === 'completed' || s.status === 'error' || s.status === 'stopped')) {
setSessions(prev => prev.map(p =>
p.agentId === sess.agentId ? { ...p, status: s.status as SavedSession['status'] } : p
))
}
})
.catch(() => {})
}
}
} catch { /* ignore */ }
}, [])
// Persist sessions to localStorage
useEffect(() => {
if (sessions.length > 0) {
localStorage.setItem(SESSIONS_KEY, JSON.stringify(sessions))
} else {
localStorage.removeItem(SESSIONS_KEY)
}
}, [sessions])
// ─── Elapsed Time Ticker ──────────────────────────────────────────────────
useEffect(() => {
if (!status?.started_at) return
const startTime = new Date(status.started_at).getTime()
if (isRunning) {
// Live ticker while scan is active
const tick = () => setElapsedSeconds(Math.floor((Date.now() - startTime) / 1000))
tick()
const id = setInterval(tick, 1000)
return () => clearInterval(id)
} else {
// Completed/stopped/error — compute final duration from timestamps
const endTime = status.completed_at
? new Date(status.completed_at).getTime()
: Date.now()
setElapsedSeconds(Math.max(0, Math.floor((endTime - startTime) / 1000)))
}
}, [isRunning, status?.started_at, status?.completed_at])
// ─── Polling — ALL running sessions + active session logs ─────────────────
useEffect(() => {
const runningSessions = sessions.filter(s => s.status === 'running')
const needsPoll = runningSessions.length > 0 || agentId
if (!needsPoll) return
const poll = async () => {
// Poll all running sessions
for (const sess of runningSessions) {
try {
const s = await agentApi.getStatus(sess.agentId)
setStatusCache(prev => ({ ...prev, [sess.agentId]: s }))
failCountRef.current[sess.agentId] = 0
if (s.status === 'completed' || s.status === 'error' || s.status === 'stopped') {
setSessions(prev => prev.map(p =>
p.agentId === sess.agentId
? { ...p, status: s.status as SavedSession['status'] }
: p
))
// Toast for completion of active session
if (sess.agentId === agentId) {
if (s.status === 'completed') addToast(`Pentest complete! ${s.findings_count} findings`, 'completed')
else if (s.status === 'error') addToast('Pentest failed', 'error')
else if (s.status === 'stopped') addToast('Pentest stopped', 'info')
}
}
} catch (err: any) {
const httpStatus = err?.response?.status
const fails = (failCountRef.current[sess.agentId] || 0) + 1
failCountRef.current[sess.agentId] = fails
if (httpStatus === 404 || fails >= 3) {
setSessions(prev => prev.map(p =>
p.agentId === sess.agentId ? { ...p, status: 'stopped' } : p
))
}
}
}
// Fetch status + logs for active session
if (agentId) {
try {
const s = await agentApi.getStatus(agentId)
setStatusCache(prev => ({ ...prev, [agentId]: s }))
// Connection restored
if (connectionLost) setConnectionLost(false)
// Phase change detection
if (prevPhaseRef.current && s.phase && s.phase !== prevPhaseRef.current) {
addToast(`Phase: ${s.phase}`, 'info')
}
prevPhaseRef.current = s.phase || null
// Status transition detection
if (prevStatusRef.current === 'running' && (s.status === 'completed' || s.status === 'error' || s.status === 'stopped')) {
// Handled above in the running sessions loop
}
prevStatusRef.current = s.status
// New finding detection
const currentIds = new Set((s.findings || []).map((f: AgentFinding) => f.id))
if (seenFindingIdsRef.current.size > 0) {
const newIds = [...currentIds].filter(id => !seenFindingIdsRef.current.has(id))
if (newIds.length > 0) {
newIds.forEach(id => {
const f = s.findings?.find((x: AgentFinding) => x.id === id)
if (f) addToast(`${f.severity.toUpperCase()}: ${f.title}`, f.severity)
})
setNewFindingIds(new Set(newIds))
if (newFindingTimerRef.current) clearTimeout(newFindingTimerRef.current)
newFindingTimerRef.current = setTimeout(() => setNewFindingIds(new Set()), 3000)
}
}
seenFindingIdsRef.current = currentIds
} catch {
const fails = (failCountRef.current[agentId] || 0) + 1
failCountRef.current[agentId] = fails
if (fails >= 3) setConnectionLost(true)
}
try {
const logData = await agentApi.getLogs(agentId, 300)
setLogs(logData.logs || [])
} catch { /* ignore */ }
}
}
poll()
const interval = connectionLost ? POLL_INTERVAL_ERROR : POLL_INTERVAL
pollRef.current = setInterval(poll, interval)
return () => { if (pollRef.current) clearInterval(pollRef.current) }
}, [sessions, agentId, connectionLost, addToast])
// Auto-scroll logs disabled — user controls scroll position
// ─── History ──────────────────────────────────────────────────────────────
const fetchHistory = useCallback(async () => {
setHistoryLoading(true)
try {
const data = await agentApi.getHistory(1, 50)
setHistory(data.history || [])
} catch { /* ignore */ }
setHistoryLoading(false)
}, [])
// ─── Triple-Check ─────────────────────────────────────────────────────────
const handleTripleCheck = async (scanId: string) => {
try {
const resp = await agentApi.tripleCheck(scanId, tripleCheckProvider || undefined, tripleCheckModel || undefined)
const newSession: SavedSession = {
agentId: resp.agent_id,
target: `Triple-Check: ${scanId.slice(0, 8)}`,
startedAt: new Date().toISOString(),
status: 'running',
}
const updated = [...sessions, newSession]
setSessions(updated)
setActiveSessionIdx(updated.length - 1)
localStorage.setItem(SESSIONS_KEY, JSON.stringify(updated))
setTripleCheckScanId(null)
addToast('Triple-check started', 'info')
} catch (e: any) {
setError(e.response?.data?.detail || 'Triple-check failed')
}
}
// ─── Start / Stop / Clear ──────────────────────────────────────────────────
const handleStart = async () => {
const primaryTarget = target.trim()
if (!primaryTarget) return
const runningCount = sessions.filter(s => s.status === 'running').length
if (runningCount >= maxConcurrent) {
setError(`Maximum ${maxConcurrent} concurrent scans reached. Stop one first.`)
return
}
setError(null)
setLogs([])
setReportId(null)
seenFindingIdsRef.current = new Set()
prevPhaseRef.current = null
prevStatusRef.current = null
try {
const targetList = multiTarget
? targets.split('\n').map(t => t.trim()).filter(Boolean)
: undefined
const isCliMode = testMode === 'cli_agent'
const resp = await agentApi.autoPentest(primaryTarget, {
mode: testMode,
subdomain_discovery: subdomainDiscovery,
targets: targetList,
auth_type: authType || undefined,
auth_value: authValue || undefined,
prompt: customPrompt.trim() || undefined,
enable_kali_sandbox: isCliMode ? true : (enableKaliSandbox || undefined),
custom_prompt_ids: selectedPromptIds.length > 0 ? selectedPromptIds : undefined,
preferred_provider: selectedProvider || undefined,
preferred_model: selectedModel || undefined,
enable_cli_agent: isCliMode || enableCliPhase || undefined,
cli_agent_provider: (isCliMode || enableCliPhase) ? (selectedCliProvider || undefined) : undefined,
methodology_file: (isCliMode || enableCliPhase) ? (selectedMethodology || undefined) : undefined,
selected_md_agents: selectedMdAgents.length > 0 ? selectedMdAgents : undefined,
})
const newSession: SavedSession = {
agentId: resp.agent_id,
target: primaryTarget,
startedAt: new Date().toISOString(),
status: 'running',
}
setSessions(prev => {
const updated = [...prev, newSession]
setActiveSessionIdx(updated.length - 1)
return updated
})
setTarget('')
addToast('Auto pentest started', 'info')
} catch (err: any) {
if (err?.response?.status === 429) {
setError(err.response.data.detail)
} else {
setError(err?.response?.data?.detail || err?.message || 'Failed to start pentest')
}
}
}
const handleStop = async () => {
if (!agentId) return
try {
await agentApi.stop(agentId)
setSessions(prev => prev.map(s =>
s.agentId === agentId ? { ...s, status: 'stopped' as const } : s
))
} catch { /* ignore */ }
}
const handleClearSession = (agentIdToClear?: string) => {
const idToRemove = agentIdToClear || agentId
if (idToRemove) {
setSessions(prev => prev.filter(s => s.agentId !== idToRemove))
if (activeSession?.agentId === idToRemove) {
setActiveSessionIdx(-1)
}
} else {
setSessions([])
setActiveSessionIdx(-1)
}
setLogs([])
setError(null)
setReportId(null)
setElapsedSeconds(0)
setNewFindingIds(new Set())
setConnectionLost(false)
seenFindingIdsRef.current = new Set()
prevPhaseRef.current = null
prevStatusRef.current = null
}
// ─── AI Report ─────────────────────────────────────────────────────────────
const handleGenerateAiReport = useCallback(async (reportProvider?: string, reportModel?: string) => {
if (!status?.scan_id) return
setGeneratingReport(true)
try {
const report = await reportsApi.generateAiReport({
scan_id: status.scan_id,
title: `AI Pentest Report - ${activeSession?.target || target}`,
preferred_provider: reportProvider || selectedProvider || undefined,
preferred_model: reportModel || selectedModel || undefined,
})
setReportId(report.id)
addToast('AI Report generated', 'completed')
} catch (err: any) {
setError(err?.response?.data?.detail || 'Failed to generate AI report')
} finally {
setGeneratingReport(false)
}
}, [status?.scan_id, activeSession?.target, target, selectedProvider, selectedModel, addToast])
// ─── Derived State ──────────────────────────────────────────────────────────
const currentPhaseIdx = status ? phaseFromProgress(status.progress) : -1
const findings = status?.findings || []
const rejectedFindings = status?.rejected_findings || []
const allFindings = useMemo(() => [...findings, ...rejectedFindings], [findings, rejectedFindings])
const displayFindings = findingsFilter === 'confirmed' ? findings
: findingsFilter === 'rejected' ? rejectedFindings : allFindings
const sevCounts = useMemo(() =>
findings.reduce((acc, f) => { acc[f.severity] = (acc[f.severity] || 0) + 1; return acc }, {} as Record<string, number>),
[findings]
)
const toolExecutions: ToolExecution[] = status?.tool_executions || []
const containerStatus: ContainerStatus | undefined = status?.container_status
// ─── Render ─────────────────────────────────────────────────────────────────
return (
<div className="min-h-screen flex flex-col items-center py-8 sm:py-12 px-3 sm:px-4">
{/* Inline keyframes */}
<style>{`
@keyframes fadeSlideIn { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: translateY(0); } }
@keyframes glowPulse { 0%, 100% { opacity: 0.4; } 50% { opacity: 0.8; } }
`}</style>
{/* Toast Notifications */}
<ToastContainer toasts={toasts} onDismiss={dismissToast} />
{/* Connection Lost Banner */}
{connectionLost && (
<div className="w-full max-w-4xl mb-3 p-3 bg-yellow-500/10 border border-yellow-500/20 rounded-xl flex items-center gap-2" style={{ animation: 'fadeSlideIn 0.3s ease-out' }}>
<AlertTriangle className="w-4 h-4 text-yellow-400 flex-shrink-0" />
<span className="text-yellow-400 text-sm flex-1">Connection issues retrying...</span>
<Loader2 className="w-4 h-4 text-yellow-400 animate-spin flex-shrink-0" />
</div>
)}
{/* Header */}
<div className="text-center mb-8 sm: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 mx-auto text-sm">
One-click comprehensive penetration test. 100 vulnerability types, AI-powered analysis, full report.
</p>
<button
onClick={() => { setShowHistory(!showHistory); if (!showHistory) fetchHistory() }}
className="mt-3 inline-flex items-center gap-2 px-4 py-2 bg-dark-800 border border-dark-600 rounded-lg text-sm text-dark-300 hover:text-white hover:border-dark-500 transition-all"
>
<ScrollText className="w-4 h-4" />
{showHistory ? 'Hide History' : 'Test History'}
</button>
</div>
{/* History Panel */}
{showHistory && (
<div className="w-full max-w-4xl mb-6 bg-dark-800 border border-dark-700 rounded-xl p-4" style={{ animation: 'fadeSlideIn 0.3s ease-out' }}>
<div className="flex items-center justify-between mb-3">
<h3 className="text-white font-semibold text-sm">Past Pentest Runs</h3>
<button onClick={() => setShowHistory(false)} className="text-dark-400 hover:text-white transition-colors">
<X className="w-4 h-4" />
</button>
</div>
{historyLoading ? (
<div className="flex justify-center py-6"><Loader2 className="w-6 h-6 animate-spin text-dark-400" /></div>
) : history.length === 0 ? (
<p className="text-dark-500 text-sm text-center py-4">No completed scans yet</p>
) : (
<div className="space-y-2 max-h-80 overflow-y-auto">
{history.map((h: any) => (
<div key={h.scan_id} className="flex items-center justify-between px-3 py-2 bg-dark-900 rounded-lg border border-dark-700 hover:border-dark-600 transition-all">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={`text-xs px-1.5 py-0.5 rounded font-medium ${
h.status === 'completed' ? 'bg-green-500/20 text-green-400' :
h.status === 'stopped' ? 'bg-yellow-500/20 text-yellow-400' :
'bg-red-500/20 text-red-400'
}`}>{h.status}</span>
<span className="text-white text-sm truncate">{h.target}</span>
</div>
<div className="flex items-center gap-3 mt-1 text-xs text-dark-400">
<span>{h.findings_count} findings</span>
{h.critical_count > 0 && <span className="text-red-400">{h.critical_count}C</span>}
{h.high_count > 0 && <span className="text-orange-400">{h.high_count}H</span>}
{h.medium_count > 0 && <span className="text-yellow-400">{h.medium_count}M</span>}
<span>{h.endpoints_count} endpoints</span>
{h.duration_seconds && <span>{Math.round(h.duration_seconds / 60)}min</span>}
<span>{new Date(h.created_at).toLocaleDateString()}</span>
</div>
</div>
<div className="flex items-center gap-2 ml-3 flex-shrink-0">
<button
onClick={() => navigate(`/scan/${h.scan_id}`)}
className="px-2 py-1 text-xs bg-blue-500/20 text-blue-400 rounded hover:bg-blue-500/30 transition-colors"
>View</button>
<button
onClick={() => setTripleCheckScanId(h.scan_id)}
className="px-2 py-1 text-xs bg-purple-500/20 text-purple-400 rounded hover:bg-purple-500/30 transition-colors"
>Triple-Check</button>
<button
onClick={() => { setTarget(h.target); setShowHistory(false) }}
className="px-2 py-1 text-xs bg-green-500/20 text-green-400 rounded hover:bg-green-500/30 transition-colors"
>Re-run</button>
</div>
</div>
))}
</div>
)}
</div>
)}
{/* Triple-Check Modal */}
{tripleCheckScanId && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => setTripleCheckScanId(null)}>
<div className="bg-dark-800 border border-dark-700 rounded-xl p-6 w-96 max-w-[90vw]" onClick={e => e.stopPropagation()} style={{ animation: 'fadeSlideIn 0.3s ease-out' }}>
<h3 className="text-white font-semibold mb-4">Triple-Check Findings</h3>
<p className="text-dark-400 text-sm mb-4">
Re-validate all findings from this scan using a different AI model.
</p>
<div className="space-y-3 mb-4">
<div>
<label className="text-xs text-dark-400 mb-1 block">Provider</label>
<select
value={tripleCheckProvider}
onChange={e => { setTripleCheckProvider(e.target.value); setTripleCheckModel('') }}
className="w-full px-3 py-2 bg-dark-900 border border-dark-600 rounded-lg text-white text-sm"
>
<option value="">Auto (different from original)</option>
{availableModels.map(p => (
<option key={p.provider_id} value={p.provider_id}>{p.provider_name}</option>
))}
</select>
</div>
<div>
<label className="text-xs text-dark-400 mb-1 block">Model</label>
<select
value={tripleCheckModel}
onChange={e => setTripleCheckModel(e.target.value)}
className="w-full px-3 py-2 bg-dark-900 border border-dark-600 rounded-lg text-white text-sm"
>
<option value="">Auto</option>
{(tripleCheckProvider
? availableModels.find(p => p.provider_id === tripleCheckProvider)?.available_models || []
: [...new Set(availableModels.flatMap(p => p.available_models))]
).map(m => (
<option key={m} value={m}>{m}</option>
))}
</select>
</div>
</div>
<div className="flex gap-3">
<button
onClick={() => setTripleCheckScanId(null)}
className="flex-1 px-4 py-2 bg-dark-700 text-dark-300 rounded-lg hover:bg-dark-600 text-sm transition-colors"
>Cancel</button>
<button
onClick={() => handleTripleCheck(tripleCheckScanId!)}
className="flex-1 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-500 text-sm font-medium transition-colors"
>Start Triple-Check</button>
</div>
</div>
</div>
)}
{/* Multi-session tabs */}
{sessions.length > 0 && (
<div className="w-full max-w-4xl flex items-center gap-2 mb-4 overflow-x-auto pb-1">
{sessions.map((sess, idx) => {
const isActive = idx === activeSessionIdx
const sessStatus = statusCache[sess.agentId]
const progress = sessStatus?.progress ?? 0
return (
<button
key={sess.agentId}
onClick={() => { setActiveSessionIdx(idx); setLogs([]); setReportId(null); seenFindingIdsRef.current = new Set(); prevPhaseRef.current = null }}
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium transition-all whitespace-nowrap ${
isActive
? 'bg-green-500/20 text-green-400 border border-green-500/30'
: 'bg-dark-800 text-dark-400 border border-dark-700 hover:border-dark-600'
}`}
>
{sess.status === 'running' && <Loader2 className="w-3 h-3 animate-spin text-blue-400" />}
{sess.status === 'completed' && <CheckCircle2 className="w-3 h-3 text-green-400" />}
{sess.status === 'error' && <AlertTriangle className="w-3 h-3 text-red-400" />}
{sess.status === 'stopped' && <X className="w-3 h-3 text-dark-400" />}
<span className="truncate max-w-[120px]">{sess.target}</span>
<span className={`text-[10px] tabular-nums ${
sess.status === 'running' ? 'text-blue-400' :
sess.status === 'completed' ? 'text-green-400' :
sess.status === 'error' ? 'text-red-400' : 'text-dark-400'
}`}>{progress}%</span>
<span
onClick={(e) => { e.stopPropagation(); handleClearSession(sess.agentId) }}
className="ml-1 text-dark-500 hover:text-red-400 cursor-pointer"
>
<X className="w-3 h-3" />
</span>
</button>
)
})}
<span className="text-dark-500 text-[10px] ml-auto whitespace-nowrap tabular-nums">
{sessions.filter(s => s.status === 'running').length}/{maxConcurrent} slots
</span>
</div>
)}
{/* ═══ START FORM ═══ */}
{!agentId && (
<div className="w-full max-w-2xl bg-dark-800 border border-dark-700 rounded-2xl p-6 sm: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"
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 transition-colors"
/>
</div>
{/* Test Mode Toggle */}
<div className="flex items-center gap-1 mb-4 p-1 bg-dark-900 rounded-lg w-fit">
<button
onClick={() => setTestMode('auto_pentest')}
disabled={isRunning}
className={`px-4 py-2 rounded-md text-sm font-medium transition-all ${
testMode === 'auto_pentest'
? 'bg-green-600 text-white shadow-lg'
: 'text-dark-400 hover:text-white hover:bg-dark-700'
} disabled:opacity-50`}
>
<Rocket className="w-4 h-4 inline mr-1.5" />
Auto Pentest
</button>
<button
onClick={() => setTestMode('cli_agent')}
disabled={isRunning}
className={`px-4 py-2 rounded-md text-sm font-medium transition-all ${
testMode === 'cli_agent'
? 'bg-purple-600 text-white shadow-lg'
: 'text-dark-400 hover:text-white hover:bg-dark-700'
} disabled:opacity-50`}
title={!cliEnabled ? 'Set ENABLE_CLI_AGENT=true in .env' : 'Run AI CLI (Claude/Gemini/Codex) inside Kali container'}
>
<Terminal className="w-4 h-4 inline mr-1.5" />
CLI Agent
{!cliEnabled && <span className="ml-1 text-xs text-dark-500">(disabled)</span>}
</button>
<button
onClick={() => setTestMode('full_llm_pentest')}
disabled={isRunning}
className={`px-4 py-2 rounded-md text-sm font-medium transition-all ${
testMode === 'full_llm_pentest'
? 'bg-red-600 text-white shadow-lg'
: 'text-dark-400 hover:text-white hover:bg-dark-700'
} disabled:opacity-50`}
title="Full AI-driven pentest — LLM plans and executes every test"
>
<Crosshair className="w-4 h-4 inline mr-1.5" />
Full LLM Pentest
</button>
</div>
{/* CLI Agent Options (shown in CLI mode) */}
{testMode === 'cli_agent' && (
<div className="mb-6 p-4 bg-dark-900/50 border border-purple-500/30 rounded-xl">
<h4 className="text-sm font-medium text-purple-400 mb-3 flex items-center gap-2">
<Terminal className="w-4 h-4" />
CLI Agent Configuration
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-dark-400 mb-1">CLI Provider</label>
<select
value={selectedCliProvider}
onChange={e => setSelectedCliProvider(e.target.value)}
disabled={isRunning}
className="w-full px-3 py-2 bg-dark-900 border border-dark-600 rounded-lg text-sm text-white focus:outline-none focus:border-purple-500 disabled:opacity-50"
>
<option value="">Select provider...</option>
{cliProviders.map(p => (
<option key={p.id} value={p.id} disabled={!p.connected}>
{p.name} {p.connected ? `(${p.source})` : '(not connected)'}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-dark-400 mb-1">Methodology</label>
<select
value={selectedMethodology}
onChange={e => setSelectedMethodology(e.target.value)}
disabled={isRunning}
className="w-full px-3 py-2 bg-dark-900 border border-dark-600 rounded-lg text-sm text-white focus:outline-none focus:border-purple-500 disabled:opacity-50"
>
{methodologies.map(m => (
<option key={m.path} value={m.path}>
{m.name} ({m.size_human}){m.is_default ? ' [default]' : ''}
</option>
))}
</select>
</div>
</div>
{!cliEnabled && (
<p className="mt-2 text-xs text-yellow-500">
Set ENABLE_CLI_AGENT=true in .env to enable CLI Agent mode
</p>
)}
</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>
<label className="flex items-center gap-2 cursor-pointer" title="Enable Kali Linux sandbox for AI-driven tool execution and 0-day research">
<input
type="checkbox"
checked={testMode === 'cli_agent' ? true : enableKaliSandbox}
onChange={e => setEnableKaliSandbox(e.target.checked)}
disabled={isRunning || testMode === 'cli_agent'}
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">Kali Sandbox + AI Researcher</span>
</label>
{testMode === 'auto_pentest' && cliProviders.some(p => p.connected) && (
<label className="flex items-center gap-2 cursor-pointer" title="Also run CLI Agent as additional phase inside Auto Pentest">
<input
type="checkbox"
checked={enableCliPhase}
onChange={e => setEnableCliPhase(e.target.checked)}
disabled={isRunning || !cliEnabled}
className="w-4 h-4 rounded bg-dark-900 border-dark-600 text-purple-500 focus:ring-purple-500"
/>
<span className="text-sm text-dark-300">
CLI Agent Phase
{!cliEnabled && <span className="text-dark-500 ml-1">(enable in .env)</span>}
</span>
</label>
)}
</div>
{/* CLI Agent Phase Options (shown when checkbox enabled in auto_pentest mode) */}
{testMode === 'auto_pentest' && enableCliPhase && (
<div className="mb-6 p-3 bg-dark-900/50 border border-purple-500/20 rounded-lg">
<div className="flex gap-3">
<div className="flex-1">
<label className="block text-xs font-medium text-dark-400 mb-1">CLI Provider</label>
<select
value={selectedCliProvider}
onChange={e => setSelectedCliProvider(e.target.value)}
disabled={isRunning}
className="w-full px-3 py-1.5 bg-dark-900 border border-dark-600 rounded text-sm text-white focus:outline-none focus:border-purple-500 disabled:opacity-50"
>
<option value="">Auto</option>
{cliProviders.filter(p => p.connected).map(p => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div>
<div className="flex-1">
<label className="block text-xs font-medium text-dark-400 mb-1">Methodology</label>
<select
value={selectedMethodology}
onChange={e => setSelectedMethodology(e.target.value)}
disabled={isRunning}
className="w-full px-3 py-1.5 bg-dark-900 border border-dark-600 rounded text-sm text-white focus:outline-none focus:border-purple-500 disabled:opacity-50"
>
{methodologies.map(m => (
<option key={m.path} value={m.path}>{m.name}{m.is_default ? ' [default]' : ''}</option>
))}
</select>
</div>
</div>
</div>
)}
{/* MD Agent Selection */}
{availableMdAgents.length > 0 && (
<div className="mb-6">
<button
type="button"
onClick={() => setShowAgentSelector(!showAgentSelector)}
className="flex items-center gap-2 text-sm text-dark-300 hover:text-white transition-colors mb-2"
>
{showAgentSelector ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
<Brain className="w-4 h-4" />
AI Agents ({selectedMdAgents.length > 0 ? `${selectedMdAgents.length} selected` : `All ${availableMdAgents.length} agents`})
</button>
{showAgentSelector && (
<div className="p-3 bg-dark-900/50 border border-cyan-500/20 rounded-lg">
<p className="text-xs text-dark-400 mb-2">
Select which AI agents run after recon. Empty = all {availableMdAgents.length} offensive agents.
</p>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{availableMdAgents.map(agent => {
const isSelected = selectedMdAgents.includes(agent.name)
const catColor = agent.category === 'offensive' ? 'cyan'
: agent.category === 'analysis' ? 'yellow'
: agent.category === 'defensive' ? 'blue' : 'gray'
const agentKey = agent.name.replace(/\.md$/, '').replace(/_/g, '_')
const ls = learningStats[agentKey]
return (
<label
key={agent.name}
className={`flex items-center gap-2 p-2 rounded-lg cursor-pointer border transition-colors ${
isSelected
? `bg-${catColor}-500/15 border-${catColor}-500/40 text-${catColor}-400`
: 'bg-dark-800 border-dark-700 text-dark-400 hover:border-dark-500'
}`}
>
<input
type="checkbox"
checked={isSelected}
onChange={() => {
setSelectedMdAgents(prev =>
isSelected ? prev.filter(n => n !== agent.name) : [...prev, agent.name]
)
}}
disabled={isRunning}
className="w-3.5 h-3.5 rounded bg-dark-900 border-dark-600 text-cyan-500 focus:ring-cyan-500"
/>
<div className="min-w-0 flex-1">
<span className="text-xs font-medium block truncate">{agent.display_name}</span>
<div className="flex items-center gap-1.5">
<span className="text-[10px] text-dark-500 capitalize">{agent.category}</span>
{ls && (ls.tp > 0 || ls.fp > 0) && (
<span className="text-[9px] font-mono">
{ls.tp > 0 && <span className="text-green-400">{ls.tp}TP</span>}
{ls.tp > 0 && ls.fp > 0 && <span className="text-dark-600">/</span>}
{ls.fp > 0 && <span className="text-red-400">{ls.fp}FP</span>}
</span>
)}
</div>
</div>
</label>
)
})}
</div>
{selectedMdAgents.length > 0 && (
<button
type="button"
onClick={() => setSelectedMdAgents([])}
className="mt-2 text-xs text-dark-500 hover:text-dark-300 transition-colors"
>
Clear selection (use all agents)
</button>
)}
</div>
)}
</div>
)}
{/* LLM Provider / Model Selection */}
<div className="mb-6 flex flex-col sm:flex-row gap-3 sm:gap-4">
<div className="flex-1">
<label className="block text-xs font-medium text-dark-400 mb-1">LLM Provider</label>
<select
value={selectedProvider}
onChange={e => {
setSelectedProvider(e.target.value)
const m = availableModels.find(m => m.provider_id === e.target.value)
if (m) setSelectedModel(m.default_model)
else if (e.target.value === 'anthropic') setSelectedModel('claude-sonnet-4-20250514')
else setSelectedModel('')
}}
disabled={isRunning}
className="w-full px-3 py-2 bg-dark-900 border border-dark-600 rounded-lg text-sm text-white focus:outline-none focus:border-green-500 disabled:opacity-50 transition-colors"
>
<option value="">Auto (best available)</option>
<option value="anthropic">Anthropic (Claude API)</option>
<option value="claude_code">Claude Code (OAuth)</option>
<option value="openai">OpenAI</option>
<option value="gemini">Gemini</option>
<option value="openrouter">OpenRouter</option>
{availableModels.filter(m => !['anthropic','claude_code','openai','gemini','openrouter'].includes(m.provider_id)).map(m => (
<option key={m.provider_id} value={m.provider_id}>
{m.provider_name} (Tier {m.tier})
</option>
))}
</select>
</div>
<div className="flex-1">
<label className="block text-xs font-medium text-dark-400 mb-1">Model</label>
<select
value={selectedModel}
onChange={e => setSelectedModel(e.target.value)}
disabled={isRunning}
className="w-full px-3 py-2 bg-dark-900 border border-dark-600 rounded-lg text-sm text-white focus:outline-none focus:border-green-500 disabled:opacity-50 transition-colors"
>
<option value="">Auto (default)</option>
{selectedProvider === 'anthropic' || selectedProvider === 'claude_code' || selectedProvider === '' ? (
<>
<option value="claude-opus-4-20250514">Claude Opus 4</option>
<option value="claude-sonnet-4-20250514">Claude Sonnet 4</option>
<option value="claude-sonnet-4-5-20250929">Claude Sonnet 4.5</option>
<option value="claude-haiku-4-5-20251001">Claude Haiku 4.5</option>
</>
) : null}
{(selectedProvider && availableModels.find(m => m.provider_id === selectedProvider)?.available_models || [])
.filter(m => !m.startsWith('claude-'))
.map(model => (
<option key={model} value={model}>{model}</option>
))
}
</select>
</div>
</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 transition-colors"
/>
</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 transition-colors"
>
<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 transition-colors"
/>
)}
</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 transition-colors"
/>
<p className="mt-1 text-xs text-dark-500">
Guide the AI agent with additional context, focus areas, or specific instructions.
</p>
</div>
)}
</div>
{/* Saved Custom Prompts (multi-select) */}
{savedPrompts.length > 0 && (
<div className="mb-6">
<button
onClick={() => setShowSavedPrompts(!showSavedPrompts)}
disabled={isRunning}
className="flex items-center gap-2 text-sm text-dark-400 hover:text-white transition-colors disabled:opacity-50"
>
<ScrollText className="w-4 h-4" />
<span>Saved Prompts ({selectedPromptIds.length} selected)</span>
{showSavedPrompts ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
{showSavedPrompts && (
<div className="mt-3 pl-6 space-y-2 max-h-48 overflow-y-auto">
{savedPrompts.map(p => (
<label key={p.id} className="flex items-start gap-2 cursor-pointer p-2 rounded-lg hover:bg-dark-900/50 transition-colors">
<input
type="checkbox"
checked={selectedPromptIds.includes(p.id)}
onChange={e => {
if (e.target.checked) {
setSelectedPromptIds(prev => [...prev, p.id])
} else {
setSelectedPromptIds(prev => prev.filter(id => id !== p.id))
}
}}
disabled={isRunning}
className="w-4 h-4 mt-0.5 rounded bg-dark-900 border-dark-600 text-primary-500 focus:ring-primary-500"
/>
<div>
<span className="text-sm text-white">{p.name}</span>
{p.category && (
<span className="ml-2 px-1.5 py-0.5 text-xs rounded bg-primary-500/20 text-primary-400">{p.category}</span>
)}
{p.description && (
<p className="text-xs text-dark-400 mt-0.5">{p.description}</p>
)}
</div>
</label>
))}
</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 flex-shrink-0" />
<span className="text-red-400 text-sm">{error}</span>
</div>
)}
{/* Start Button */}
<button
onClick={handleStart}
disabled={!target.trim() || sessions.filter(s => s.status === 'running').length >= maxConcurrent}
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-4 sm:p-6 mb-4">
<div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-3 min-w-0">
<div className={`w-3 h-3 rounded-full flex-shrink-0 ${
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 truncate">
{isRunning ? 'Pentest Running' :
status?.status === 'completed' ? 'Pentest Complete' :
status?.status === 'error' ? 'Pentest Failed' : 'Pentest Stopped'}
</h3>
<span className="text-dark-400 text-sm truncate max-w-[200px] sm:max-w-[300px] hidden sm:inline">{activeSession?.target}</span>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{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={() => { setActiveSessionIdx(-1); setLogs([]); setReportId(null); setElapsedSeconds(0) }}
className="px-4 py-1.5 bg-green-500/20 hover:bg-green-500/30 text-green-400 rounded-lg text-sm transition-colors flex items-center gap-1.5"
>
<Rocket className="w-4 h-4" /> New Scan
</button>
<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" /> Remove
</button>
</>
)}
</div>
</div>
</div>
{/* Live Stats Dashboard */}
{status && (
<LiveStatsDashboard
status={status}
elapsedSeconds={elapsedSeconds}
toolExecutions={toolExecutions}
/>
)}
{/* Progress Panel */}
{status && (
<div className="bg-dark-800 border border-dark-700 rounded-2xl p-4 sm:p-6 mb-4">
<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 tabular-nums font-mono">{status.progress}%</span>
</div>
{/* Enhanced Progress Bar */}
<div className="relative w-full bg-dark-900 rounded-full h-3 mb-4 overflow-hidden">
<div className="absolute top-0 left-1/2 w-px h-full bg-dark-700 z-10" />
<div className="absolute top-0 left-3/4 w-px h-full bg-dark-700 z-10" />
<div
className="h-full rounded-full transition-all duration-700 ease-out relative"
style={{ width: `${status.progress}%`, background: 'linear-gradient(90deg, #22c55e, #16a34a)' }}
>
{isRunning && (
<div
className="absolute right-0 top-0 h-full w-6 rounded-full"
style={{ background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.25))', animation: 'glowPulse 1.5s ease-in-out infinite' }}
/>
)}
</div>
</div>
{/* Phase Indicators */}
<div className="grid grid-cols-1 sm: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' || status.status === 'stopped'
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 text-dark-500 flex-shrink-0" />}
<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] tabular-nums ${
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>
)}
{/* Container Telemetry */}
{containerStatus && (
<div className="bg-dark-800 border border-dark-700 rounded-2xl p-4 sm:p-6 mb-4">
<div className="flex items-center justify-between mb-4 flex-wrap gap-2">
<div className="flex items-center gap-3">
<Terminal className="w-5 h-5 text-cyan-400" />
<h3 className="text-white font-semibold text-sm">Container Telemetry</h3>
<span className={`flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-bold ${
containerStatus.online
? 'bg-green-500/20 text-green-400 border border-green-500/40'
: 'bg-red-500/20 text-red-400 border border-red-500/40'
}`}>
<span className={`w-2 h-2 rounded-full ${containerStatus.online ? 'bg-green-400 animate-pulse' : 'bg-red-400'}`} />
{containerStatus.online ? 'ONLINE' : 'OFFLINE'}
</span>
</div>
<div className="flex items-center gap-3 text-xs text-dark-400 font-mono">
{containerStatus.container_id && <span>ID: {containerStatus.container_id.slice(0, 12)}</span>}
{containerStatus.container_name && <span className="hidden sm:inline">{containerStatus.container_name}</span>}
</div>
</div>
{toolExecutions.length > 0 ? (
<div className="space-y-0 max-h-[350px] overflow-y-auto rounded-lg border border-dark-700 bg-dark-900/50">
<div className="grid grid-cols-[50px_70px_1fr_50px_65px_55px_20px] sm:grid-cols-[60px_80px_1fr_50px_70px_60px_24px] gap-2 text-[10px] text-dark-500 font-semibold uppercase tracking-wider px-2 py-2 border-b border-dark-700 bg-dark-900 sticky top-0">
<span>Task</span>
<span>Tool</span>
<span>Command</span>
<span className="text-center">Exit</span>
<span className="text-right">Duration</span>
<span className="text-center">Finds</span>
<span />
</div>
{toolExecutions.map((exec, i) => (
<ToolExecutionRow
key={exec.task_id || i}
exec={exec}
expanded={expandedTool === (exec.task_id || String(i))}
onToggle={() => setExpandedTool(expandedTool === (exec.task_id || String(i)) ? null : (exec.task_id || String(i)))}
/>
))}
</div>
) : (
<div className="text-center text-dark-500 text-sm py-6">
{isRunning ? (
<span className="flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
Waiting for tool executions...
</span>
) : 'No tool executions recorded'}
</div>
)}
{toolExecutions.length > 0 && (() => {
const last = toolExecutions[toolExecutions.length - 1]
return (
<div className="mt-3 pt-3 border-t border-dark-700 flex items-center gap-2 text-xs flex-wrap">
<span className="text-dark-500">Last:</span>
<span className="text-cyan-400 font-medium">{last.tool}</span>
<span className={`font-bold ${last.exit_code === 0 ? 'text-green-400' : last.exit_code === -1 ? 'text-yellow-400' : 'text-red-400'}`}>
{last.exit_code === -1 ? 'container error' : `exit:${last.exit_code}`}
</span>
<span className="text-dark-400">{last.duration != null && last.duration > 0 ? `${last.duration.toFixed(1)}s` : ''}</span>
{last.findings_count > 0 && <span className="text-red-400">{last.findings_count} findings</span>}
</div>
)
})()}
</div>
)}
{/* ═══ Findings / Agents / Logs Tabs ═══ */}
<div className="bg-dark-800 border border-dark-700 rounded-2xl p-4 sm:p-6 mb-4">
{/* Tab bar */}
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
<div className="flex items-center gap-3 flex-wrap">
<h3 className="text-white font-semibold text-sm">
{activeTab === 'findings' ? `Findings (${findings.length})` :
activeTab === 'agents' ? 'Vulnerability Agents' : 'Activity Log'}
</h3>
{activeTab === 'findings' && (
<div className="flex gap-1.5 items-center">
{['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-[10px] font-bold tabular-nums`}>
{count}
</span>
)
})}
<SeverityMiniChart sevCounts={sevCounts} />
</div>
)}
</div>
<div className="flex gap-1.5 flex-wrap">
<button
onClick={() => setActiveTab('findings')}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors flex items-center gap-1.5 ${
activeTab === 'findings' ? 'bg-primary-500/20 text-primary-400 border border-primary-500/30' : 'bg-dark-700 text-dark-400 hover:text-white border border-transparent'
}`}
>
<Bug className="w-3 h-3" />Findings
{findings.length > 0 && <span className="text-[10px] opacity-70">({findings.length})</span>}
</button>
{rejectedFindings.length > 0 && (
<button
onClick={() => { setActiveTab('findings'); setFindingsFilter(findingsFilter === 'rejected' ? 'all' : 'rejected') }}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors flex items-center gap-1.5 ${
findingsFilter === 'rejected' && activeTab === 'findings'
? 'bg-orange-500/20 text-orange-400 border border-orange-500/30'
: 'bg-dark-700 text-orange-400/60 hover:text-orange-400 border border-transparent'
}`}
>
<AlertTriangle className="w-3 h-3" />Rejected ({rejectedFindings.length})
</button>
)}
<button
onClick={() => setActiveTab('agents')}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors flex items-center gap-1.5 ${
activeTab === 'agents' ? 'bg-primary-500/20 text-primary-400 border border-primary-500/30' : 'bg-dark-700 text-dark-400 hover:text-white border border-transparent'
}`}
>
<Shield className="w-3 h-3" />Agents
</button>
<button
onClick={() => setActiveTab('logs')}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors flex items-center gap-1.5 ${
activeTab === 'logs' ? 'bg-primary-500/20 text-primary-400 border border-primary-500/30' : 'bg-dark-700 text-dark-400 hover:text-white border border-transparent'
}`}
>
<ScrollText className="w-3 h-3" />Log
{logs.length > 0 && <span className="text-[10px] opacity-70">({logs.length})</span>}
</button>
</div>
</div>
{/* Agents Grid View */}
{activeTab === 'agents' && agentId && (
<VulnAgentGrid agentId={agentId} isRunning={isRunning} />
)}
{/* Findings Filter Tabs */}
{activeTab === 'findings' && allFindings.length > 0 && rejectedFindings.length > 0 && (
<div className="flex gap-1 mb-3">
{(['all', 'confirmed', 'rejected'] as const).map(f => (
<button
key={f}
onClick={() => setFindingsFilter(f)}
className={`px-2.5 py-1 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 */}
{activeTab === 'findings' && (
displayFindings.length > 0 ? (
<div className="space-y-2 max-h-[500px] overflow-y-auto pr-1">
{displayFindings.map((f: AgentFinding) => {
const isNew = newFindingIds.has(f.id)
return (
<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 transition-all duration-300 ${
isNew ? 'ring-2 ring-green-500/30' : ''
}`}
style={isNew ? { animation: 'fadeSlideIn 0.5s ease-out' } : undefined}
>
<button
onClick={() => setExpandedFinding(expandedFinding === f.id ? null : f.id)}
className="w-full flex items-center gap-2 sm: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-[10px] 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-[10px] 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-[10px] font-semibold px-1.5 py-0.5 rounded-full border flex-shrink-0 tabular-nums ${CONFIDENCE_STYLES[conf.color]}`}>
{conf.score}
</span>
)
})()}
<span className="text-dark-500 text-[10px] flex-shrink-0 hidden sm:inline">{f.vulnerability_type}</span>
{expandedFinding === f.id ? <ChevronUp className="w-4 h-4 text-dark-400 flex-shrink-0" /> : <ChevronDown className="w-4 h-4 text-dark-400 flex-shrink-0" />}
</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-1 sm: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, 400)}{f.description.length > 400 ? '...' : ''}</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, 300)}</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, 400)}</span>
</div>
)}
{f.poc_code && (
<div>
<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 font-mono">{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 tabular-nums ${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 tabular-nums ${
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">
<CheckCircle2 className="w-3 h-3 text-green-400 flex-shrink-0 mt-0.5" />
<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">
<Shield className="w-3 h-3 text-blue-400 flex-shrink-0 mt-0.5" />
<span>Controls: <span className="text-dark-300">{f.negative_controls}</span></span>
</p>
)}
</div>
)
})()}
</div>
)}
</div>
)
})}
</div>
) : (
<div className="flex items-center justify-center py-8 text-dark-500">
{isRunning ? (
<span className="flex items-center gap-2">
<Loader2 className="w-5 h-5 animate-spin" />
Scanning in progress... Findings will appear as discovered.
</span>
) : (
'No findings'
)}
</div>
)
)}
{/* Activity Log */}
{activeTab === 'logs' && (
<LogViewer
logs={logs}
logFilter={logFilter}
setLogFilter={setLogFilter}
logSearch={logSearch}
setLogSearch={setLogSearch}
logsEndRef={logsEndRef}
/>
)}
</div>
{/* Completion / Stopped Actions */}
{(status?.status === 'completed' || status?.status === 'stopped') && (
<div className={`bg-dark-800 border ${status.status === 'completed' ? 'border-green-500/30' : 'border-yellow-500/30'} rounded-2xl p-4 sm:p-6 mb-4`} style={{ animation: 'fadeSlideIn 0.5s ease-out' }}>
<div className="flex items-center gap-3 mb-4">
{status.status === 'completed' ? (
<CheckCircle2 className="w-6 h-6 text-green-500" />
) : (
<AlertTriangle className="w-6 h-6 text-yellow-500" />
)}
<h3 className={`${status.status === 'completed' ? 'text-green-400' : 'text-yellow-400'} font-semibold text-lg`}>
{status.status === 'completed' ? 'Pentest Complete' : 'Pentest Stopped'}
</h3>
</div>
<div className="flex items-center gap-4 mb-4 flex-wrap">
<p className="text-dark-400 text-sm">
{status.status === 'completed'
? `Found ${findings.length} vulnerabilities across ${activeSession?.target || target}.`
: `Stopped at ${status.progress}% — found ${findings.length} finding${findings.length !== 1 ? 's' : ''}.`}
</p>
{elapsedSeconds > 0 && (
<span className="text-dark-500 text-xs font-mono">Duration: {formatElapsed(elapsedSeconds)}</span>
)}
</div>
{/* 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 flex-shrink-0" />
<div>
<p className="text-purple-400 font-medium text-sm">Generating AI Report...</p>
<p className="text-dark-400 text-xs">Analyzing findings and writing 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"
title={selectedProvider ? `Using: ${selectedProvider}/${selectedModel || 'auto'}` : 'Using: auto'}
>
<Sparkles className="w-4 h-4" /> Generate AI Report
{selectedProvider && <span className="text-xs opacity-70">({selectedProvider})</span>}
</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-4 sm:p-6" style={{ animation: 'fadeSlideIn 0.5s ease-out' }}>
<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>
)
}