mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-06-02 19:11:34 +02:00
59f8f42d80
- 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>
2143 lines
105 KiB
TypeScript
Executable File
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>
|
|
)
|
|
}
|