Add files via upload

This commit is contained in:
Joas A Santos
2026-02-11 10:52:07 -03:00
committed by GitHub
parent aac5b8f365
commit e1ff8a8355
24 changed files with 9927 additions and 78 deletions
+123
View File
@@ -0,0 +1,123 @@
# NeuroSploit v3 - Kali Linux Security Sandbox
# Per-scan container with essential tools pre-installed + on-demand install support.
#
# Build:
# docker build -f docker/Dockerfile.kali -t neurosploit-kali:latest docker/
#
# Rebuild (no cache):
# docker build --no-cache -f docker/Dockerfile.kali -t neurosploit-kali:latest docker/
#
# Or via compose:
# docker compose -f docker/docker-compose.kali.yml build
#
# Design:
# - Pre-compile Go tools (nuclei, naabu, httpx, subfinder, katana, dnsx, ffuf,
# gobuster, dalfox, waybackurls, uncover) to avoid 60s+ go install per scan
# - Pre-install common apt tools (nikto, sqlmap, masscan, whatweb) for instant use
# - Include Go, Python, pip, git so on-demand tools can be compiled/installed
# - Full Kali apt repos available for on-demand apt-get install of any security tool
# ---- Stage 1: Pre-compile Go security tools ----
FROM golang:1.24-bookworm AS go-builder
RUN apt-get update && apt-get install -y --no-install-recommends \
git build-essential libpcap-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
# Pre-compile ProjectDiscovery suite + common Go tools
# Split into separate RUN layers for better Docker cache (if one fails, others cached)
RUN go install -v github.com/projectdiscovery/nuclei/v3/cmd/nuclei@latest
RUN go install -v github.com/projectdiscovery/naabu/v2/cmd/naabu@latest
RUN go install -v github.com/projectdiscovery/httpx/cmd/httpx@latest
RUN go install -v github.com/projectdiscovery/subfinder/v2/cmd/subfinder@latest
RUN go install -v github.com/projectdiscovery/katana/cmd/katana@latest
RUN go install -v github.com/projectdiscovery/dnsx/cmd/dnsx@latest
RUN go install -v github.com/projectdiscovery/uncover/cmd/uncover@latest
RUN go install -v github.com/ffuf/ffuf/v2@latest
RUN go install -v github.com/OJ/gobuster/v3@v3.7.0
RUN go install -v github.com/hahwul/dalfox/v2@latest
RUN go install -v github.com/tomnomnom/waybackurls@latest
# ---- Stage 2: Kali Linux runtime ----
FROM kalilinux/kali-rolling
LABEL maintainer="NeuroSploit Team"
LABEL description="NeuroSploit Kali Sandbox - Per-scan isolated tool execution"
LABEL neurosploit.version="3.0"
LABEL neurosploit.type="kali-sandbox"
ENV DEBIAN_FRONTEND=noninteractive
# Layer 1: Core system + build tools (rarely changes, cached)
RUN apt-get update && apt-get install -y --no-install-recommends \
bash \
curl \
wget \
git \
jq \
ca-certificates \
openssl \
dnsutils \
whois \
netcat-openbsd \
libpcap-dev \
python3 \
python3-pip \
golang-go \
build-essential \
&& rm -rf /var/lib/apt/lists/*
# Layer 2: Pre-install common security tools from Kali repos (saves ~30s on-demand each)
RUN apt-get update && apt-get install -y --no-install-recommends \
nmap \
nikto \
sqlmap \
masscan \
whatweb \
&& rm -rf /var/lib/apt/lists/*
# Copy ALL pre-compiled Go binaries from builder
COPY --from=go-builder /go/bin/nuclei /usr/local/bin/
COPY --from=go-builder /go/bin/naabu /usr/local/bin/
COPY --from=go-builder /go/bin/httpx /usr/local/bin/
COPY --from=go-builder /go/bin/subfinder /usr/local/bin/
COPY --from=go-builder /go/bin/katana /usr/local/bin/
COPY --from=go-builder /go/bin/dnsx /usr/local/bin/
COPY --from=go-builder /go/bin/uncover /usr/local/bin/
COPY --from=go-builder /go/bin/ffuf /usr/local/bin/
COPY --from=go-builder /go/bin/gobuster /usr/local/bin/
COPY --from=go-builder /go/bin/dalfox /usr/local/bin/
COPY --from=go-builder /go/bin/waybackurls /usr/local/bin/
# Go environment for on-demand tool compilation
ENV GOPATH=/root/go
ENV PATH="${PATH}:/root/go/bin"
# Create directories
RUN mkdir -p /opt/wordlists /opt/output /opt/templates /opt/nuclei-templates
# Download commonly used wordlists (|| true so build doesn't fail on network issues)
RUN wget -q https://raw.githubusercontent.com/danielmiessler/SecLists/master/Discovery/Web-Content/common.txt \
-O /opt/wordlists/common.txt 2>/dev/null || true && \
wget -q https://raw.githubusercontent.com/danielmiessler/SecLists/master/Discovery/Web-Content/directory-list-2.3-medium.txt \
-O /opt/wordlists/directory-list-medium.txt 2>/dev/null || true && \
wget -q https://raw.githubusercontent.com/danielmiessler/SecLists/master/Discovery/DNS/subdomains-top1million-5000.txt \
-O /opt/wordlists/subdomains-5000.txt 2>/dev/null || true && \
wget -q https://raw.githubusercontent.com/danielmiessler/SecLists/master/Passwords/Common-Credentials/10-million-password-list-top-1000.txt \
-O /opt/wordlists/passwords-top1000.txt 2>/dev/null || true
# Update Nuclei templates
RUN nuclei -update-templates -silent 2>/dev/null || true
# Health check script
RUN printf '#!/bin/bash\nnuclei -version > /dev/null 2>&1 && naabu -version > /dev/null 2>&1 && echo "OK"\n' \
> /opt/healthcheck.sh && chmod +x /opt/healthcheck.sh
HEALTHCHECK --interval=60s --timeout=10s --retries=3 \
CMD /opt/healthcheck.sh
WORKDIR /opt/output
ENTRYPOINT ["/bin/bash", "-c"]
+98
View File
@@ -0,0 +1,98 @@
# NeuroSploit v3 - Security Sandbox Container
# Kali-based container with real penetration testing tools
# Provides Nuclei, Naabu, and other ProjectDiscovery tools via isolated execution
FROM golang:1.24-bookworm AS go-builder
RUN apt-get update && apt-get install -y --no-install-recommends git build-essential && \
rm -rf /var/lib/apt/lists/*
WORKDIR /build
# Install ProjectDiscovery suite + other Go security tools
RUN go install -v github.com/projectdiscovery/nuclei/v3/cmd/nuclei@latest && \
go install -v github.com/projectdiscovery/naabu/v2/cmd/naabu@latest && \
go install -v github.com/projectdiscovery/httpx/cmd/httpx@latest && \
go install -v github.com/projectdiscovery/subfinder/v2/cmd/subfinder@latest && \
go install -v github.com/projectdiscovery/katana/cmd/katana@latest && \
go install -v github.com/projectdiscovery/dnsx/cmd/dnsx@latest && \
go install -v github.com/projectdiscovery/uncover/cmd/uncover@latest && \
go install -v github.com/ffuf/ffuf/v2@latest && \
go install -v github.com/OJ/gobuster/v3@v3.7.0 && \
go install -v github.com/hahwul/dalfox/v2@latest && \
go install -v github.com/tomnomnom/waybackurls@latest
# Final runtime image - Debian-based for compatibility
FROM debian:bookworm-slim
LABEL maintainer="NeuroSploit Team"
LABEL description="NeuroSploit Security Sandbox - Isolated tool execution environment"
# Install runtime dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
bash \
curl \
wget \
nmap \
python3 \
python3-pip \
git \
jq \
dnsutils \
openssl \
libpcap-dev \
ca-certificates \
whois \
netcat-openbsd \
nikto \
masscan \
&& rm -rf /var/lib/apt/lists/*
# Install Python security tools
RUN pip3 install --no-cache-dir --break-system-packages \
sqlmap \
wfuzz \
dirsearch \
arjun \
wafw00f \
2>/dev/null || pip3 install --no-cache-dir --break-system-packages sqlmap
# Copy Go binaries from builder
COPY --from=go-builder /go/bin/nuclei /usr/local/bin/
COPY --from=go-builder /go/bin/naabu /usr/local/bin/
COPY --from=go-builder /go/bin/httpx /usr/local/bin/
COPY --from=go-builder /go/bin/subfinder /usr/local/bin/
COPY --from=go-builder /go/bin/katana /usr/local/bin/
COPY --from=go-builder /go/bin/dnsx /usr/local/bin/
COPY --from=go-builder /go/bin/uncover /usr/local/bin/
COPY --from=go-builder /go/bin/ffuf /usr/local/bin/
COPY --from=go-builder /go/bin/gobuster /usr/local/bin/
COPY --from=go-builder /go/bin/dalfox /usr/local/bin/
COPY --from=go-builder /go/bin/waybackurls /usr/local/bin/
# Create directories
RUN mkdir -p /opt/wordlists /opt/output /opt/templates /opt/nuclei-templates
# Download wordlists
RUN wget -q https://raw.githubusercontent.com/danielmiessler/SecLists/master/Discovery/Web-Content/common.txt \
-O /opt/wordlists/common.txt && \
wget -q https://raw.githubusercontent.com/danielmiessler/SecLists/master/Discovery/Web-Content/directory-list-2.3-medium.txt \
-O /opt/wordlists/directory-list-medium.txt && \
wget -q https://raw.githubusercontent.com/danielmiessler/SecLists/master/Discovery/DNS/subdomains-top1million-5000.txt \
-O /opt/wordlists/subdomains-5000.txt && \
wget -q https://raw.githubusercontent.com/danielmiessler/SecLists/master/Passwords/Common-Credentials/10-million-password-list-top-1000.txt \
-O /opt/wordlists/passwords-top1000.txt
# Update Nuclei templates (8000+ vulnerability checks)
RUN nuclei -update-templates -silent 2>/dev/null || true
# Health check script
RUN echo '#!/bin/bash\nnuclei -version > /dev/null 2>&1 && naabu -version > /dev/null 2>&1 && echo "OK"' > /opt/healthcheck.sh && \
chmod +x /opt/healthcheck.sh
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
CMD /opt/healthcheck.sh
WORKDIR /opt/output
ENTRYPOINT ["/bin/bash", "-c"]
+2
View File
@@ -12,8 +12,10 @@ RUN go install -v github.com/ffuf/ffuf/v2@latest && \
go install -v github.com/OJ/gobuster/v3@latest && \
go install -v github.com/projectdiscovery/httpx/cmd/httpx@latest && \
go install -v github.com/projectdiscovery/nuclei/v3/cmd/nuclei@latest && \
go install -v github.com/projectdiscovery/naabu/v2/cmd/naabu@latest && \
go install -v github.com/projectdiscovery/subfinder/v2/cmd/subfinder@latest && \
go install -v github.com/projectdiscovery/katana/cmd/katana@latest && \
go install -v github.com/projectdiscovery/dnsx/cmd/dnsx@latest && \
go install -v github.com/hahwul/dalfox/v2@latest && \
go install -v github.com/tomnomnom/waybackurls@latest
+38
View File
@@ -0,0 +1,38 @@
# NeuroSploit v3 - Kali Sandbox Build & Management
#
# Build image:
# docker compose -f docker/docker-compose.kali.yml build
#
# Build (no cache):
# docker compose -f docker/docker-compose.kali.yml build --no-cache
#
# Test container manually:
# docker compose -f docker/docker-compose.kali.yml run --rm kali-sandbox "nuclei -version"
#
# Note: In production, containers are managed by ContainerPool (core/container_pool.py).
# This compose file is for building the image and manual testing only.
services:
kali-sandbox:
build:
context: .
dockerfile: Dockerfile.kali
image: neurosploit-kali:latest
deploy:
resources:
limits:
memory: 2G
cpus: '2.0'
reservations:
memory: 512M
cpus: '0.5'
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- NET_RAW
- NET_ADMIN
labels:
neurosploit.type: "kali-sandbox"
neurosploit.version: "3.0"
+51
View File
@@ -0,0 +1,51 @@
# NeuroSploit v3 - Security Sandbox
# Isolated container for running real penetration testing tools
#
# Usage:
# docker compose -f docker-compose.sandbox.yml up -d
# docker compose -f docker-compose.sandbox.yml exec sandbox nuclei -u https://target.com
# docker compose -f docker-compose.sandbox.yml down
services:
sandbox:
build:
context: .
dockerfile: Dockerfile.sandbox
image: neurosploit-sandbox:latest
container_name: neurosploit-sandbox
command: ["sleep infinity"]
restart: unless-stopped
networks:
- sandbox-net
volumes:
- sandbox-output:/opt/output
- sandbox-templates:/opt/nuclei-templates
deploy:
resources:
limits:
memory: 2G
cpus: '2.0'
reservations:
memory: 512M
cpus: '0.5'
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- NET_RAW # Required for naabu/nmap raw sockets
- NET_ADMIN # Required for packet capture
healthcheck:
test: ["CMD", "/opt/healthcheck.sh"]
interval: 30s
timeout: 10s
retries: 3
networks:
sandbox-net:
driver: bridge
internal: false
volumes:
sandbox-output:
sandbox-templates:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="45" fill="#1a1a2e" stroke="#e94560" stroke-width="3"/>
<path d="M30 50 L45 35 L45 45 L70 45 L70 55 L45 55 L45 65 Z" fill="#e94560"/>
<circle cx="50" cy="50" r="8" fill="none" stroke="#e94560" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 314 B

+14
View File
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NeuroSploit v3 - AI-Powered Penetration Testing</title>
<script type="module" crossorigin src="/assets/index-DScaoRL2.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CjxVs3nK.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+3467
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -9,17 +9,27 @@ import RealtimeTaskPage from './pages/RealtimeTaskPage'
import ReportsPage from './pages/ReportsPage'
import ReportViewPage from './pages/ReportViewPage'
import SettingsPage from './pages/SettingsPage'
import SchedulerPage from './pages/SchedulerPage'
import AutoPentestPage from './pages/AutoPentestPage'
import VulnLabPage from './pages/VulnLabPage'
import TerminalAgentPage from './pages/TerminalAgentPage'
import SandboxDashboardPage from './pages/SandboxDashboardPage'
function App() {
return (
<Layout>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/auto" element={<AutoPentestPage />} />
<Route path="/vuln-lab" element={<VulnLabPage />} />
<Route path="/terminal" element={<TerminalAgentPage />} />
<Route path="/scan/new" element={<NewScanPage />} />
<Route path="/scan/:scanId" element={<ScanDetailsPage />} />
<Route path="/agent/:agentId" element={<AgentStatusPage />} />
<Route path="/tasks" element={<TaskLibraryPage />} />
<Route path="/realtime" element={<RealtimeTaskPage />} />
<Route path="/scheduler" element={<SchedulerPage />} />
<Route path="/sandboxes" element={<SandboxDashboardPage />} />
<Route path="/reports" element={<ReportsPage />} />
<Route path="/reports/:reportId" element={<ReportViewPage />} />
<Route path="/settings" element={<SettingsPage />} />
+11 -1
View File
@@ -7,14 +7,24 @@ import {
Settings,
Activity,
Shield,
Zap
Zap,
Clock,
Rocket,
FlaskConical,
Terminal,
Container
} from 'lucide-react'
const navItems = [
{ path: '/', icon: Home, label: 'Dashboard' },
{ path: '/auto', icon: Rocket, label: 'Auto Pentest' },
{ path: '/vuln-lab', icon: FlaskConical, label: 'Vuln Lab' },
{ path: '/terminal', icon: Terminal, label: 'Terminal Agent' },
{ path: '/sandboxes', icon: Container, label: 'Sandboxes' },
{ path: '/scan/new', icon: Bot, label: 'AI Agent' },
{ path: '/realtime', icon: Zap, label: 'Real-time Task' },
{ path: '/tasks', icon: BookOpen, label: 'Task Library' },
{ path: '/scheduler', icon: Clock, label: 'Scheduler' },
{ path: '/reports', icon: FileText, label: 'Reports' },
{ path: '/settings', icon: Settings, label: 'Settings' },
]
+151 -20
View File
@@ -3,7 +3,8 @@ import { useParams, useNavigate } from 'react-router-dom'
import {
Bot, RefreshCw, FileText, CheckCircle,
XCircle, Clock, Target, Shield, ChevronDown, ChevronRight, ExternalLink,
Copy, Download, StopCircle, Terminal, Brain, Send, Code, Globe, AlertTriangle
Copy, Download, StopCircle, Terminal, Brain, Send, Code, Globe, AlertTriangle,
SkipForward, MinusCircle, Pause, Play
} from 'lucide-react'
import Card from '../components/common/Card'
import Button from '../components/common/Button'
@@ -77,6 +78,11 @@ export default function AgentStatusPage() {
const [isSubmittingPrompt, setIsSubmittingPrompt] = useState(false)
const [promptSentMessage, setPromptSentMessage] = useState<string | null>(null)
// Phase skip state
const [skipConfirm, setSkipConfirm] = useState<string | null>(null)
const [isSkipping, setIsSkipping] = useState(false)
const [skippedPhases, setSkippedPhases] = useState<Set<string>>(new Set())
// Separate logs by source
const scriptLogs = logs.filter(l => l.source === 'script' || (!l.source && !l.message.includes('[LLM]') && !l.message.includes('[AI]')))
const llmLogs = logs.filter(l => l.source === 'llm' || l.message.includes('[LLM]') || l.message.includes('[AI]'))
@@ -107,12 +113,12 @@ export default function AgentStatusPage() {
fetchStatus()
// Poll every 2 seconds while running
// Poll every 5 seconds while running or paused
const interval = setInterval(() => {
if (status?.status === 'running') {
if (status?.status === 'running' || status?.status === 'paused') {
fetchStatus()
}
}, 2000)
}, 5000)
return () => clearInterval(interval)
}, [agentId, status?.status])
@@ -466,6 +472,28 @@ export default function AgentStatusPage() {
}
}
const handlePauseScan = async () => {
if (!agentId) return
try {
await agentApi.pause(agentId)
const statusData = await agentApi.getStatus(agentId)
setStatus(statusData)
} catch (err: any) {
console.error('Failed to pause agent:', err)
}
}
const handleResumeScan = async () => {
if (!agentId) return
try {
await agentApi.resume(agentId)
const statusData = await agentApi.getStatus(agentId)
setStatus(statusData)
} catch (err: any) {
console.error('Failed to resume agent:', err)
}
}
const handleSubmitPrompt = async () => {
if (!customPrompt.trim() || !agentId) return
setIsSubmittingPrompt(true)
@@ -496,6 +524,37 @@ export default function AgentStatusPage() {
}
}
const handleSkipToPhase = async (targetPhase: string) => {
if (!agentId) return
setIsSkipping(true)
try {
await agentApi.skipToPhase(agentId, targetPhase)
// Mark intermediate phases as skipped
const currentIndex = status ? getPhaseIndex(status.phase) : 0
const targetIndex = SCAN_PHASES.findIndex(p => p.key === targetPhase)
const newSkipped = new Set(skippedPhases)
for (let i = currentIndex; i < targetIndex; i++) {
newSkipped.add(SCAN_PHASES[i].key)
}
setSkippedPhases(newSkipped)
setSkipConfirm(null)
} catch (err: any) {
console.error('Failed to skip phase:', err)
} finally {
setIsSkipping(false)
}
}
// Track skipped phases from status updates
useEffect(() => {
if (!status) return
const phase = status.phase.toLowerCase()
if (phase.includes('_skipped')) {
const skippedKey = phase.replace('_skipped', '')
setSkippedPhases(prev => new Set(prev).add(skippedKey))
}
}, [status?.phase])
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text)
}
@@ -790,7 +849,8 @@ export default function AgentStatusPage() {
<span className={`px-3 py-1 rounded-full text-sm font-medium flex items-center gap-1 ${
status.status === 'running' ? 'bg-blue-500/20 text-blue-400' :
status.status === 'completed' ? 'bg-green-500/20 text-green-400' :
status.status === 'stopped' ? 'bg-yellow-500/20 text-yellow-400' :
status.status === 'paused' ? 'bg-yellow-500/20 text-yellow-400' :
status.status === 'stopped' ? 'bg-orange-500/20 text-orange-400' :
'bg-red-500/20 text-red-400'
}`}>
{PHASE_ICONS[status.status]}
@@ -802,10 +862,28 @@ export default function AgentStatusPage() {
</div>
<div className="flex gap-2">
{status.status === 'running' && (
<Button variant="danger" onClick={handleStopScan} isLoading={isStopping}>
<StopCircle className="w-4 h-4 mr-2" />
Stop Scan
</Button>
<>
<Button variant="secondary" onClick={handlePauseScan}>
<Pause className="w-4 h-4 mr-2" />
Pause
</Button>
<Button variant="danger" onClick={handleStopScan} isLoading={isStopping}>
<StopCircle className="w-4 h-4 mr-2" />
Stop
</Button>
</>
)}
{status.status === 'paused' && (
<>
<Button variant="primary" onClick={handleResumeScan}>
<Play className="w-4 h-4 mr-2" />
Resume
</Button>
<Button variant="danger" onClick={handleStopScan} isLoading={isStopping}>
<StopCircle className="w-4 h-4 mr-2" />
Stop
</Button>
</>
)}
{status.scan_id && (
<Button variant="secondary" onClick={() => navigate(`/scan/${status.scan_id}`)}>
@@ -833,31 +911,84 @@ export default function AgentStatusPage() {
{(status.status === 'running' || status.status === 'completed' || status.status === 'stopped') && (
<Card>
<div className="space-y-4">
{/* Phase Steps */}
{/* Phase Steps with Skip */}
<div className="flex items-center justify-between px-2">
{SCAN_PHASES.map((phase, index) => {
const currentIndex = status.status === 'completed' ? 4 : status.status === 'stopped' ? getPhaseIndex(status.phase) : getPhaseIndex(status.phase)
const isActive = index === currentIndex
const isCompleted = index < currentIndex || status.status === 'completed'
const isStopped = status.status === 'stopped' && index > currentIndex
const isSkipped = skippedPhases.has(phase.key)
const canSkipTo = (status.status === 'running' || status.status === 'paused') && index > currentIndex && phase.key !== 'completed'
return (
<div key={phase.key} className="flex flex-col items-center flex-1">
<div className={`w-8 h-8 rounded-full flex items-center justify-center mb-1 transition-all ${
isCompleted ? 'bg-green-500 text-white' :
isActive ? 'bg-primary-500 text-white animate-pulse' :
isStopped ? 'bg-yellow-500/20 text-yellow-500' :
'bg-dark-700 text-dark-400'
}`}>
{isCompleted ? <CheckCircle className="w-4 h-4" /> :
<div key={phase.key} className="flex flex-col items-center flex-1 relative group">
{/* Connector line */}
{index > 0 && (
<div className={`absolute top-4 right-1/2 w-full h-0.5 -translate-y-1/2 z-0 ${
isCompleted || isActive ? 'bg-green-500/50' :
isSkipped ? 'bg-yellow-500/30' :
'bg-dark-700'
}`} />
)}
{/* Phase node */}
<div
className={`relative z-10 w-8 h-8 rounded-full flex items-center justify-center mb-1 transition-all ${
isSkipped ? 'bg-yellow-500/20 text-yellow-500 ring-2 ring-yellow-500/30' :
isCompleted ? 'bg-green-500 text-white' :
isActive ? 'bg-primary-500 text-white animate-pulse ring-2 ring-primary-500/30' :
isStopped ? 'bg-yellow-500/20 text-yellow-500' :
canSkipTo ? 'bg-dark-700 text-dark-400 cursor-pointer hover:bg-primary-500/20 hover:text-primary-400 hover:ring-2 hover:ring-primary-500/30' :
'bg-dark-700 text-dark-400'
}`}
onClick={() => canSkipTo && setSkipConfirm(phase.key)}
>
{isSkipped ? <MinusCircle className="w-4 h-4" /> :
isCompleted ? <CheckCircle className="w-4 h-4" /> :
isActive ? (PHASE_ICONS[phase.key === 'recon' ? 'reconnaissance' : phase.key] || <span className="text-xs font-bold">{index + 1}</span>) :
isStopped ? <StopCircle className="w-4 h-4" /> :
canSkipTo ? <SkipForward className="w-3.5 h-3.5" /> :
<span className="text-xs font-bold">{index + 1}</span>}
</div>
<span className={`text-xs text-center ${
isCompleted || isActive ? 'text-white' : 'text-dark-500'
isSkipped ? 'text-yellow-500' :
isCompleted || isActive ? 'text-white' :
canSkipTo ? 'text-dark-400 group-hover:text-primary-400' :
'text-dark-500'
}`}>
{phase.label}
{isSkipped ? `${phase.label} (skipped)` : phase.label}
</span>
{/* Skip tooltip on hover */}
{canSkipTo && (
<div className="absolute -top-8 left-1/2 -translate-x-1/2 bg-dark-800 text-primary-400 text-[10px] px-2 py-0.5 rounded whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none border border-dark-600">
Skip to {phase.label}
</div>
)}
{/* Inline skip confirmation */}
{skipConfirm === phase.key && (
<div className="absolute top-10 left-1/2 -translate-x-1/2 z-20 bg-dark-800 border border-dark-600 rounded-lg p-3 shadow-xl whitespace-nowrap">
<p className="text-xs text-dark-300 mb-2">Skip to <span className="text-white font-medium">{phase.label}</span>?</p>
<div className="flex gap-2">
<button
onClick={() => handleSkipToPhase(phase.key)}
disabled={isSkipping}
className="px-3 py-1 bg-primary-500 text-white text-xs rounded hover:bg-primary-600 disabled:opacity-50"
>
{isSkipping ? 'Skipping...' : 'Confirm'}
</button>
<button
onClick={() => setSkipConfirm(null)}
className="px-3 py-1 bg-dark-700 text-dark-300 text-xs rounded hover:bg-dark-600"
>
Cancel
</button>
</div>
</div>
)}
</div>
)
})}
+929
View File
@@ -0,0 +1,929 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import {
Rocket, Shield, ChevronDown, ChevronUp, Loader2,
AlertTriangle, CheckCircle2, Globe, Lock, Bug, MessageSquare,
FileText, ScrollText, X, ExternalLink, Download, Sparkles, Trash2,
Brain, Wrench, Layers
} from 'lucide-react'
import { agentApi, reportsApi } from '../services/api'
import type { AgentStatus, AgentFinding, AgentLog } from '../types'
const PHASES = [
{ key: 'parallel', label: 'Parallel Streams', icon: Layers, range: [0, 50] as const },
{ key: 'deep', label: 'Deep Analysis', icon: Brain, range: [50, 75] as const },
{ key: 'final', label: 'Finalization', icon: Shield, range: [75, 100] as const },
]
const STREAMS = [
{ key: 'recon', label: 'Recon', icon: Globe, color: 'blue', activeUntil: 25 },
{ key: 'junior', label: 'Junior AI', icon: Brain, color: 'purple', activeUntil: 35 },
{ key: 'tools', label: 'Tools', icon: Wrench, color: 'orange', activeUntil: 50 },
] as const
const STREAM_COLORS: Record<string, { bg: string; text: string; border: string; pulse: string }> = {
blue: { bg: 'bg-blue-500/20', text: 'text-blue-400', border: 'border-blue-500/40', pulse: 'bg-blue-400' },
purple: { bg: 'bg-purple-500/20', text: 'text-purple-400', border: 'border-purple-500/40', pulse: 'bg-purple-400' },
orange: { bg: 'bg-orange-500/20', text: 'text-orange-400', border: 'border-orange-500/40', pulse: 'bg-orange-400' },
}
function phaseFromProgress(progress: number): number {
if (progress < 50) return 0
if (progress < 75) return 1
return 2
}
function StreamBadge({ stream, progress, isRunning }: {
stream: typeof STREAMS[number]
progress: number
isRunning: boolean
}) {
const active = isRunning && progress < stream.activeUntil
const done = progress >= stream.activeUntil
const colors = STREAM_COLORS[stream.color]
const Icon = stream.icon
return (
<div
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full border text-xs font-medium transition-all duration-300 ${
active
? `${colors.bg} ${colors.text} ${colors.border}`
: done
? 'bg-dark-700/50 text-dark-400 border-dark-600'
: 'bg-dark-900 text-dark-500 border-dark-700'
}`}
>
{active && (
<span className="relative flex h-2 w-2">
<span className={`animate-ping absolute inline-flex h-full w-full rounded-full ${colors.pulse} opacity-75`} />
<span className={`relative inline-flex rounded-full h-2 w-2 ${colors.pulse}`} />
</span>
)}
{done && <CheckCircle2 className="w-3 h-3 text-green-500" />}
{!active && !done && <Icon className="w-3 h-3" />}
<span>{stream.label}</span>
</div>
)
}
function logMessageColor(message: string): string {
if (message.startsWith('[STREAM 1]')) return 'text-blue-400'
if (message.startsWith('[STREAM 2]')) return 'text-purple-400'
if (message.startsWith('[STREAM 3]')) return 'text-orange-400'
if (message.startsWith('[TOOL]')) return 'text-orange-300'
if (message.startsWith('[DEEP]')) return 'text-cyan-400'
if (message.startsWith('[FINAL]')) return 'text-green-400'
return ''
}
const SEVERITY_COLORS: Record<string, string> = {
critical: 'bg-red-500',
high: 'bg-orange-500',
medium: 'bg-yellow-500',
low: 'bg-blue-500',
info: 'bg-gray-500',
}
const SEVERITY_BORDER: Record<string, string> = {
critical: 'border-red-500/40',
high: 'border-orange-500/40',
medium: 'border-yellow-500/40',
low: 'border-blue-500/40',
info: 'border-gray-500/40',
}
// Resolve a confidence score from the various possible sources on a finding.
function getConfidenceDisplay(finding: { confidence_score?: number; confidence?: string }): { score: number; color: string; label: string } | null {
let score: number | null = null
if (typeof finding.confidence_score === 'number') {
score = finding.confidence_score
} else if (finding.confidence) {
const parsed = Number(finding.confidence)
if (!isNaN(parsed)) {
score = parsed
} else {
const map: Record<string, number> = { high: 90, medium: 60, low: 30 }
score = map[finding.confidence.toLowerCase()] ?? null
}
}
if (score === null) return null
const color = score >= 90 ? 'green' : score >= 60 ? 'yellow' : 'red'
const label = score >= 90 ? 'Confirmed' : score >= 60 ? 'Likely' : 'Low'
return { score, color, label }
}
const CONFIDENCE_STYLES: Record<string, string> = {
green: 'bg-green-500/15 text-green-400 border-green-500/30',
yellow: 'bg-yellow-500/15 text-yellow-400 border-yellow-500/30',
red: 'bg-red-500/15 text-red-400 border-red-500/30',
}
const SESSION_KEY = 'neurosploit_autopentest_session'
interface SavedSession {
agentId: string
target: string
startedAt: string
}
export default function AutoPentestPage() {
const navigate = useNavigate()
const [target, setTarget] = useState('')
const [multiTarget, setMultiTarget] = useState(false)
const [targets, setTargets] = useState('')
const [subdomainDiscovery, setSubdomainDiscovery] = useState(false)
const [showAuth, setShowAuth] = useState(false)
const [authType, setAuthType] = useState<string>('')
const [authValue, setAuthValue] = useState('')
const [showPrompt, setShowPrompt] = useState(false)
const [customPrompt, setCustomPrompt] = useState('')
// Running state
const [isRunning, setIsRunning] = useState(false)
const [agentId, setAgentId] = useState<string | null>(null)
const [status, setStatus] = useState<AgentStatus | null>(null)
const [error, setError] = useState<string | null>(null)
// Live findings & logs
const [showFindings, setShowFindings] = useState(true)
const [showLogs, setShowLogs] = useState(false)
const [logs, setLogs] = useState<AgentLog[]>([])
const [expandedFinding, setExpandedFinding] = useState<string | null>(null)
// Report generation
const [generatingReport, setGeneratingReport] = useState(false)
const [reportId, setReportId] = useState<string | null>(null)
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const logsEndRef = useRef<HTMLDivElement>(null)
// Restore session on mount
useEffect(() => {
try {
const saved = localStorage.getItem(SESSION_KEY)
if (saved) {
const session: SavedSession = JSON.parse(saved)
setAgentId(session.agentId)
setTarget(session.target)
setIsRunning(true)
}
} catch {
// ignore parse errors
}
}, [])
// Save session when agentId changes
useEffect(() => {
if (agentId && isRunning) {
const session: SavedSession = {
agentId,
target: target || '',
startedAt: new Date().toISOString(),
}
localStorage.setItem(SESSION_KEY, JSON.stringify(session))
}
}, [agentId, isRunning, target])
// Poll agent status + logs
useEffect(() => {
if (!agentId) return
const poll = async () => {
try {
const s = await agentApi.getStatus(agentId)
setStatus(s)
if (s.status === 'completed' || s.status === 'error' || s.status === 'stopped') {
setIsRunning(false)
if (pollRef.current) clearInterval(pollRef.current)
}
} catch {
// ignore polling errors
}
// Fetch recent logs
try {
const logData = await agentApi.getLogs(agentId, 200)
setLogs(logData.logs || [])
} catch {
// ignore
}
}
poll()
pollRef.current = setInterval(poll, 3000)
return () => { if (pollRef.current) clearInterval(pollRef.current) }
}, [agentId])
// Auto-scroll logs
useEffect(() => {
if (showLogs && logsEndRef.current) {
logsEndRef.current.scrollIntoView({ behavior: 'smooth' })
}
}, [logs, showLogs])
const handleStart = async () => {
const primaryTarget = target.trim()
if (!primaryTarget) return
setError(null)
setIsRunning(true)
setStatus(null)
setLogs([])
setReportId(null)
try {
const targetList = multiTarget
? targets.split('\n').map(t => t.trim()).filter(Boolean)
: undefined
const resp = await agentApi.autoPentest(primaryTarget, {
subdomain_discovery: subdomainDiscovery,
targets: targetList,
auth_type: authType || undefined,
auth_value: authValue || undefined,
prompt: customPrompt.trim() || undefined,
})
setAgentId(resp.agent_id)
} catch (err: any) {
setError(err?.response?.data?.detail || err?.message || 'Failed to start pentest')
setIsRunning(false)
}
}
const handleStop = async () => {
if (!agentId) return
try {
await agentApi.stop(agentId)
setIsRunning(false)
} catch {
// ignore
}
}
const handleClearSession = () => {
localStorage.removeItem(SESSION_KEY)
setAgentId(null)
setStatus(null)
setLogs([])
setIsRunning(false)
setError(null)
setReportId(null)
setTarget('')
}
const handleGenerateAiReport = useCallback(async () => {
if (!status?.scan_id) return
setGeneratingReport(true)
try {
const report = await reportsApi.generateAiReport({
scan_id: status.scan_id,
title: `AI Pentest Report - ${target}`,
})
setReportId(report.id)
} catch (err: any) {
setError(err?.response?.data?.detail || 'Failed to generate AI report')
} finally {
setGeneratingReport(false)
}
}, [status?.scan_id, target])
const currentPhaseIdx = status ? phaseFromProgress(status.progress) : -1
const findings = status?.findings || []
const rejectedFindings = status?.rejected_findings || []
const allFindings = [...findings, ...rejectedFindings]
const [findingsFilter, setFindingsFilter] = useState<'confirmed' | 'rejected' | 'all'>('all')
const displayFindings = findingsFilter === 'confirmed' ? findings
: findingsFilter === 'rejected' ? rejectedFindings
: allFindings
const sevCounts = findings.reduce(
(acc, f) => {
acc[f.severity] = (acc[f.severity] || 0) + 1
return acc
},
{} as Record<string, number>
)
return (
<div className="min-h-screen flex flex-col items-center py-12 px-4">
{/* Header */}
<div className="text-center mb-10">
<div className="inline-flex items-center justify-center w-16 h-16 bg-green-500/20 rounded-2xl mb-4">
<Rocket className="w-8 h-8 text-green-400" />
</div>
<h1 className="text-3xl font-bold text-white mb-2">Auto Pentest</h1>
<p className="text-dark-400 max-w-md">
One-click comprehensive penetration test. 100 vulnerability types, AI-powered analysis, full report.
</p>
</div>
{/* Main Card - only show config when no session is active */}
{!agentId && (
<div className="w-full max-w-2xl bg-dark-800 border border-dark-700 rounded-2xl p-8">
{/* URL Input */}
<div className="mb-6">
<label className="block text-sm font-medium text-dark-300 mb-2">Target URL</label>
<input
type="url"
value={target}
onChange={e => setTarget(e.target.value)}
placeholder="https://example.com"
disabled={isRunning}
className="w-full px-4 py-4 bg-dark-900 border border-dark-600 rounded-xl text-white text-lg placeholder-dark-500 focus:outline-none focus:border-green-500 focus:ring-1 focus:ring-green-500 disabled:opacity-50"
/>
</div>
{/* Toggles */}
<div className="flex flex-wrap gap-4 mb-6">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={subdomainDiscovery}
onChange={e => setSubdomainDiscovery(e.target.checked)}
disabled={isRunning}
className="w-4 h-4 rounded bg-dark-900 border-dark-600 text-green-500 focus:ring-green-500"
/>
<span className="text-sm text-dark-300">Subdomain Discovery</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={multiTarget}
onChange={e => setMultiTarget(e.target.checked)}
disabled={isRunning}
className="w-4 h-4 rounded bg-dark-900 border-dark-600 text-green-500 focus:ring-green-500"
/>
<span className="text-sm text-dark-300">Multiple Targets</span>
</label>
</div>
{/* Multi-target textarea */}
{multiTarget && (
<div className="mb-6">
<label className="block text-sm font-medium text-dark-300 mb-2">Additional Targets (one per line)</label>
<textarea
value={targets}
onChange={e => setTargets(e.target.value)}
rows={4}
disabled={isRunning}
placeholder={"https://api.example.com\nhttps://admin.example.com"}
className="w-full px-4 py-3 bg-dark-900 border border-dark-600 rounded-xl text-white placeholder-dark-500 focus:outline-none focus:border-green-500 disabled:opacity-50"
/>
</div>
)}
{/* Auth Section (collapsible) */}
<div className="mb-6">
<button
onClick={() => setShowAuth(!showAuth)}
disabled={isRunning}
className="flex items-center gap-2 text-sm text-dark-400 hover:text-white transition-colors disabled:opacity-50"
>
<Lock className="w-4 h-4" />
<span>Authentication (Optional)</span>
{showAuth ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
{showAuth && (
<div className="mt-3 space-y-3 pl-6">
<select
value={authType}
onChange={e => setAuthType(e.target.value)}
disabled={isRunning}
className="w-full px-3 py-2 bg-dark-900 border border-dark-600 rounded-lg text-white text-sm focus:outline-none focus:border-green-500"
>
<option value="">No Authentication</option>
<option value="bearer">Bearer Token</option>
<option value="cookie">Cookie</option>
<option value="basic">Basic Auth (user:pass)</option>
<option value="header">Custom Header (Name:Value)</option>
</select>
{authType && (
<input
type="text"
value={authValue}
onChange={e => setAuthValue(e.target.value)}
disabled={isRunning}
placeholder={
authType === 'bearer' ? 'eyJhbGciOiJIUzI1NiIs...' :
authType === 'cookie' ? 'session=abc123; token=xyz' :
authType === 'basic' ? 'admin:password123' :
'X-API-Key:your-api-key'
}
className="w-full px-3 py-2 bg-dark-900 border border-dark-600 rounded-lg text-white text-sm placeholder-dark-500 focus:outline-none focus:border-green-500"
/>
)}
</div>
)}
</div>
{/* Custom AI Prompt (collapsible) */}
<div className="mb-6">
<button
onClick={() => setShowPrompt(!showPrompt)}
disabled={isRunning}
className="flex items-center gap-2 text-sm text-dark-400 hover:text-white transition-colors disabled:opacity-50"
>
<MessageSquare className="w-4 h-4" />
<span>Custom AI Prompt (Optional)</span>
{showPrompt ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
{showPrompt && (
<div className="mt-3 pl-6">
<textarea
value={customPrompt}
onChange={e => setCustomPrompt(e.target.value)}
rows={4}
disabled={isRunning}
placeholder={"Focus on authentication bypass and IDOR vulnerabilities.\nThe app uses JWT tokens in localStorage.\nAdmin panel at /admin requires role=admin cookie."}
className="w-full px-4 py-3 bg-dark-900 border border-dark-600 rounded-xl text-white text-sm placeholder-dark-500 focus:outline-none focus:border-green-500 disabled:opacity-50"
/>
<p className="mt-1 text-xs text-dark-500">
Guide the AI agent with additional context, focus areas, or specific instructions.
</p>
</div>
)}
</div>
{/* Error */}
{error && (
<div className="mb-6 p-3 bg-red-500/10 border border-red-500/20 rounded-lg flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-red-400" />
<span className="text-red-400 text-sm">{error}</span>
</div>
)}
{/* Start Button */}
<button
onClick={handleStart}
disabled={!target.trim()}
className="w-full py-4 bg-green-500 hover:bg-green-600 disabled:bg-dark-600 disabled:text-dark-400 text-white font-bold text-lg rounded-xl transition-colors flex items-center justify-center gap-3"
>
<Rocket className="w-6 h-6" />
START PENTEST
</button>
</div>
)}
{/* Active Session View */}
{agentId && (
<div className="w-full max-w-4xl">
{/* Session Header */}
<div className="bg-dark-800 border border-dark-700 rounded-2xl p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className={`w-3 h-3 rounded-full ${isRunning ? 'bg-green-500 animate-pulse' : status?.status === 'completed' ? 'bg-green-500' : status?.status === 'error' ? 'bg-red-500' : 'bg-gray-500'}`} />
<h3 className="text-white font-semibold">
{isRunning ? 'Pentest Running' : status?.status === 'completed' ? 'Pentest Complete' : status?.status === 'error' ? 'Pentest Failed' : 'Pentest Stopped'}
</h3>
<span className="text-dark-400 text-sm">{target}</span>
</div>
<div className="flex items-center gap-2">
{isRunning && (
<button
onClick={handleStop}
className="px-4 py-1.5 bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded-lg text-sm transition-colors flex items-center gap-1.5"
>
<X className="w-4 h-4" /> Stop
</button>
)}
{!isRunning && (
<button
onClick={handleClearSession}
className="px-4 py-1.5 bg-dark-700 hover:bg-dark-600 text-dark-300 rounded-lg text-sm transition-colors flex items-center gap-1.5"
>
<Trash2 className="w-4 h-4" /> Clear Session
</button>
)}
</div>
</div>
{/* Progress Bar */}
{status && (
<>
<div className="flex items-center justify-between text-sm mb-2">
<span className="text-dark-400">{status.phase || 'Initializing...'}</span>
<span className="text-dark-400">{status.progress}%</span>
</div>
<div className="w-full bg-dark-900 rounded-full h-2 mb-4">
<div
className="bg-green-500 h-2 rounded-full transition-all duration-500"
style={{ width: `${status.progress}%` }}
/>
</div>
{/* Phase Indicators */}
<div className="grid grid-cols-3 gap-3">
{PHASES.map((phase, idx) => {
const Icon = phase.icon
const isActive = idx === currentPhaseIdx && isRunning
const isDone = idx < currentPhaseIdx || status.status === 'completed'
return (
<div
key={phase.key}
className={`rounded-xl p-3 border transition-all duration-300 ${
isActive ? 'bg-green-500/10 border-green-500/30' :
isDone ? 'bg-dark-700/50 border-dark-600' :
'bg-dark-900 border-dark-700'
}`}
>
<div className="flex items-center gap-2 mb-1">
{isActive ? (
<Loader2 className="w-4 h-4 animate-spin text-green-400 flex-shrink-0" />
) : isDone ? (
<CheckCircle2 className="w-4 h-4 text-green-500 flex-shrink-0" />
) : (
<Icon className={`w-4 h-4 flex-shrink-0 ${isActive ? 'text-green-400' : 'text-dark-500'}`} />
)}
<span className={`text-xs font-medium ${
isActive ? 'text-green-400' :
isDone ? 'text-dark-300' :
'text-dark-500'
}`}>
{phase.label}
</span>
<span className={`ml-auto text-[10px] ${
isActive ? 'text-green-500/60' :
isDone ? 'text-dark-500' :
'text-dark-600'
}`}>
{phase.range[0]}-{phase.range[1]}%
</span>
</div>
{/* Stream badges for the parallel phase */}
{idx === 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{STREAMS.map(stream => (
<StreamBadge
key={stream.key}
stream={stream}
progress={status.progress}
isRunning={isRunning}
/>
))}
</div>
)}
</div>
)
})}
</div>
</>
)}
</div>
{/* Severity Summary + Tabs */}
{findings.length > 0 && (
<div className="bg-dark-800 border border-dark-700 rounded-2xl p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<h3 className="text-white font-semibold">Findings ({findings.length})</h3>
<div className="flex gap-2">
{['critical', 'high', 'medium', 'low', 'info'].map(sev => {
const count = sevCounts[sev] || 0
if (count === 0) return null
return (
<span key={sev} className={`${SEVERITY_COLORS[sev]} text-white px-2 py-0.5 rounded-full text-xs font-bold`}>
{count}
</span>
)
})}
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => { setShowFindings(true); setShowLogs(false) }}
className={`px-3 py-1 rounded-lg text-xs transition-colors ${showFindings ? 'bg-primary-500 text-white' : 'bg-dark-700 text-dark-400 hover:text-white'}`}
>
<Bug className="w-3 h-3 inline mr-1" />Findings ({findings.length})
</button>
{rejectedFindings.length > 0 && (
<button
onClick={() => { setShowFindings(true); setShowLogs(false); setFindingsFilter(findingsFilter === 'rejected' ? 'all' : 'rejected') }}
className={`px-3 py-1 rounded-lg text-xs transition-colors ${findingsFilter === 'rejected' && showFindings ? 'bg-orange-500/30 text-orange-400' : 'bg-dark-700 text-orange-400/60 hover:text-orange-400'}`}
>
<AlertTriangle className="w-3 h-3 inline mr-1" />Rejected ({rejectedFindings.length})
</button>
)}
<button
onClick={() => { setShowLogs(true); setShowFindings(false) }}
className={`px-3 py-1 rounded-lg text-xs transition-colors ${showLogs ? 'bg-primary-500 text-white' : 'bg-dark-700 text-dark-400 hover:text-white'}`}
>
<ScrollText className="w-3 h-3 inline mr-1" />Activity Log
</button>
</div>
</div>
{/* Findings Filter Tabs */}
{showFindings && allFindings.length > 0 && rejectedFindings.length > 0 && (
<div className="flex gap-1 mb-2">
{(['all', 'confirmed', 'rejected'] as const).map(f => (
<button
key={f}
onClick={() => setFindingsFilter(f)}
className={`px-2 py-0.5 rounded-full text-xs transition-colors ${
findingsFilter === f ? 'bg-primary-500/20 text-primary-400' : 'text-dark-500 hover:text-dark-300'
}`}
>
{f === 'all' ? `All (${allFindings.length})` : f === 'confirmed' ? `Confirmed (${findings.length})` : `Rejected (${rejectedFindings.length})`}
</button>
))}
</div>
)}
{/* Findings List */}
{showFindings && (
<div className="space-y-2 max-h-[500px] overflow-y-auto pr-1">
{displayFindings.map((f: AgentFinding) => (
<div
key={f.id}
className={`border ${f.ai_status === 'rejected' ? 'border-orange-500/30 opacity-70' : (SEVERITY_BORDER[f.severity] || 'border-dark-600')} rounded-xl bg-dark-900 overflow-hidden`}
>
<button
onClick={() => setExpandedFinding(expandedFinding === f.id ? null : f.id)}
className="w-full flex items-center gap-3 p-3 text-left hover:bg-dark-800/50 transition-colors"
>
<span className={`${SEVERITY_COLORS[f.severity]} text-white px-2 py-0.5 rounded text-xs font-bold uppercase flex-shrink-0`}>
{f.severity}
</span>
<span className={`text-sm flex-1 truncate ${f.ai_status === 'rejected' ? 'text-dark-400' : 'text-white'}`}>{f.title}</span>
{f.ai_status === 'rejected' && (
<span className="text-xs px-1.5 py-0.5 rounded-full bg-orange-500/20 text-orange-400 flex-shrink-0">Rejected</span>
)}
{(() => {
const conf = getConfidenceDisplay(f)
if (!conf) return null
return (
<span className={`text-xs font-semibold px-1.5 py-0.5 rounded-full border flex-shrink-0 ${CONFIDENCE_STYLES[conf.color]}`}>
{conf.score}/100
</span>
)
})()}
<span className="text-dark-500 text-xs flex-shrink-0">{f.vulnerability_type}</span>
{expandedFinding === f.id ? <ChevronUp className="w-4 h-4 text-dark-400" /> : <ChevronDown className="w-4 h-4 text-dark-400" />}
</button>
{expandedFinding === f.id && (
<div className="px-3 pb-3 space-y-2 border-t border-dark-700 pt-2">
<div className="grid grid-cols-2 gap-2 text-xs">
{f.affected_endpoint && (
<div>
<span className="text-dark-500">Endpoint: </span>
<span className="text-dark-300 break-all">{f.affected_endpoint}</span>
</div>
)}
{f.parameter && (
<div>
<span className="text-dark-500">Parameter: </span>
<span className="text-dark-300">{f.parameter}</span>
</div>
)}
{f.cwe_id && (
<div>
<span className="text-dark-500">CWE: </span>
<span className="text-dark-300">{f.cwe_id}</span>
</div>
)}
{f.cvss_score > 0 && (
<div>
<span className="text-dark-500">CVSS: </span>
<span className="text-dark-300">{f.cvss_score}</span>
</div>
)}
</div>
{f.description && (
<p className="text-dark-400 text-xs">{f.description.substring(0, 300)}{f.description.length > 300 ? '...' : ''}</p>
)}
{f.payload && (
<div className="bg-dark-800 rounded-lg p-2">
<span className="text-dark-500 text-xs">Payload: </span>
<code className="text-green-400 text-xs break-all">{f.payload.substring(0, 200)}</code>
</div>
)}
{f.evidence && (
<div className="bg-dark-800 rounded-lg p-2">
<span className="text-dark-500 text-xs">Evidence: </span>
<span className="text-dark-300 text-xs">{f.evidence.substring(0, 300)}</span>
</div>
)}
{f.poc_code && (
<div className="mt-2">
<p className="text-xs font-medium text-dark-400 mb-1">PoC Code</p>
<pre className="p-2 bg-dark-950 rounded text-xs text-green-400 overflow-x-auto max-h-[300px] overflow-y-auto whitespace-pre-wrap">{f.poc_code}</pre>
</div>
)}
{f.ai_status === 'rejected' && f.rejection_reason && (
<div className="bg-orange-500/10 border border-orange-500/20 rounded-lg p-2">
<span className="text-orange-400 text-xs font-medium">Rejection: </span>
<span className="text-orange-300/80 text-xs">{f.rejection_reason}</span>
</div>
)}
<div className="flex items-center gap-2 flex-wrap">
<span className={`text-xs ${f.ai_status === 'rejected' ? 'text-orange-400' : f.ai_verified ? 'text-green-400' : 'text-dark-500'}`}>
{f.ai_status === 'rejected' ? 'AI Rejected' : f.ai_verified ? 'AI Verified' : 'Tool Detected'}
</span>
{(() => {
const conf = getConfidenceDisplay(f)
if (!conf) return null
return (
<span className={`text-xs font-semibold px-1.5 py-0.5 rounded-full border ${CONFIDENCE_STYLES[conf.color]}`}>
Confidence: {conf.score}/100 ({conf.label})
</span>
)
})()}
</div>
{(() => {
const hasBreakdown = f.confidence_breakdown && Object.keys(f.confidence_breakdown).length > 0
const hasProof = !!f.proof_of_execution
const hasControls = !!f.negative_controls
if (!hasBreakdown && !hasProof && !hasControls) return null
return (
<div className="bg-dark-800 rounded-lg p-2 space-y-1">
{hasBreakdown && (
<div className="grid grid-cols-2 gap-x-4 gap-y-0.5 text-xs text-dark-400">
{Object.entries(f.confidence_breakdown!).map(([key, val]) => (
<div key={key} className="flex justify-between">
<span className="capitalize">{key.replace(/_/g, ' ')}</span>
<span className={`font-mono font-medium ${
Number(val) > 0 ? 'text-green-400' : Number(val) < 0 ? 'text-red-400' : 'text-dark-500'
}`}>
{Number(val) > 0 ? '+' : ''}{val}
</span>
</div>
))}
</div>
)}
{hasProof && (
<p className="text-xs text-dark-400 flex items-start gap-1">
<span className="text-green-400">&#10003;</span>
<span>Proof: <span className="text-dark-300">{f.proof_of_execution}</span></span>
</p>
)}
{hasControls && (
<p className="text-xs text-dark-400 flex items-start gap-1">
<span className="text-blue-400">&#9632;</span>
<span>Controls: <span className="text-dark-300">{f.negative_controls}</span></span>
</p>
)}
</div>
)
})()}
</div>
)}
</div>
))}
</div>
)}
{/* Activity Log */}
{showLogs && (
<div className="bg-dark-900 rounded-xl p-3 max-h-[500px] overflow-y-auto font-mono text-xs">
{logs.length === 0 ? (
<p className="text-dark-500 text-center py-4">No logs yet...</p>
) : (
logs.map((log, i) => (
<div key={i} className="flex gap-2 py-0.5">
<span className="text-dark-600 flex-shrink-0">{log.time || ''}</span>
<span className={`flex-shrink-0 uppercase w-12 ${
log.level === 'error' ? 'text-red-400' :
log.level === 'warning' ? 'text-yellow-400' :
log.level === 'info' ? 'text-blue-400' :
'text-dark-500'
}`}>{log.level}</span>
<span className={logMessageColor(log.message) || (log.source === 'llm' ? 'text-purple-400' : 'text-dark-300')}>
{log.message}
</span>
</div>
))
)}
<div ref={logsEndRef} />
</div>
)}
</div>
)}
{/* No findings yet but running */}
{findings.length === 0 && isRunning && (
<div className="bg-dark-800 border border-dark-700 rounded-2xl p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-white font-semibold">Activity</h3>
<button
onClick={() => setShowLogs(!showLogs)}
className={`px-3 py-1 rounded-lg text-xs transition-colors ${showLogs ? 'bg-primary-500 text-white' : 'bg-dark-700 text-dark-400 hover:text-white'}`}
>
<ScrollText className="w-3 h-3 inline mr-1" />{showLogs ? 'Hide' : 'Show'} Logs
</button>
</div>
{showLogs ? (
<div className="bg-dark-900 rounded-xl p-3 max-h-[400px] overflow-y-auto font-mono text-xs">
{logs.length === 0 ? (
<div className="flex items-center justify-center py-4 text-dark-500">
<Loader2 className="w-4 h-4 animate-spin mr-2" /> Waiting for logs...
</div>
) : (
logs.map((log, i) => (
<div key={i} className="flex gap-2 py-0.5">
<span className="text-dark-600 flex-shrink-0">{log.time || ''}</span>
<span className={`flex-shrink-0 uppercase w-12 ${
log.level === 'error' ? 'text-red-400' :
log.level === 'warning' ? 'text-yellow-400' :
log.level === 'info' ? 'text-blue-400' :
'text-dark-500'
}`}>{log.level}</span>
<span className={logMessageColor(log.message) || (log.source === 'llm' ? 'text-purple-400' : 'text-dark-300')}>
{log.message}
</span>
</div>
))
)}
<div ref={logsEndRef} />
</div>
) : (
<div className="flex items-center justify-center py-6 text-dark-500">
<Loader2 className="w-5 h-5 animate-spin mr-2" />
Scanning in progress... Findings will appear here as they are discovered.
</div>
)}
</div>
)}
{/* Completion Actions */}
{status?.status === 'completed' && (
<div className="bg-dark-800 border border-green-500/30 rounded-2xl p-6 mb-6">
<div className="flex items-center gap-3 mb-4">
<CheckCircle2 className="w-6 h-6 text-green-500" />
<h3 className="text-green-400 font-semibold text-lg">Pentest Complete</h3>
</div>
<p className="text-dark-400 mb-4">
Found {findings.length} vulnerabilities across {target}.
</p>
{/* Report Generation */}
{generatingReport && (
<div className="mb-4 p-4 bg-purple-500/10 border border-purple-500/20 rounded-xl flex items-center gap-3">
<Loader2 className="w-5 h-5 text-purple-400 animate-spin" />
<div>
<p className="text-purple-400 font-medium text-sm">Generating AI Report...</p>
<p className="text-dark-400 text-xs">The AI is analyzing findings and writing the executive summary.</p>
</div>
</div>
)}
<div className="flex flex-wrap gap-3">
<button
onClick={() => navigate(`/agent/${agentId}`)}
className="px-5 py-2 bg-primary-500 hover:bg-primary-600 text-white rounded-lg transition-colors flex items-center gap-2 text-sm"
>
<ExternalLink className="w-4 h-4" /> View Full Results
</button>
{!reportId ? (
<button
onClick={handleGenerateAiReport}
disabled={generatingReport || !status.scan_id}
className="px-5 py-2 bg-purple-500 hover:bg-purple-600 disabled:opacity-50 text-white rounded-lg transition-colors flex items-center gap-2 text-sm"
>
<Sparkles className="w-4 h-4" /> Generate AI Report
</button>
) : (
<>
<a
href={reportsApi.getViewUrl(reportId)}
target="_blank"
rel="noopener noreferrer"
className="px-5 py-2 bg-green-500 hover:bg-green-600 text-white rounded-lg transition-colors flex items-center gap-2 text-sm"
>
<FileText className="w-4 h-4" /> View Report
</a>
<a
href={reportsApi.getDownloadZipUrl(reportId)}
className="px-5 py-2 bg-dark-700 hover:bg-dark-600 text-white rounded-lg transition-colors flex items-center gap-2 text-sm"
>
<Download className="w-4 h-4" /> Download ZIP
</a>
</>
)}
</div>
</div>
)}
{/* Error state */}
{status?.status === 'error' && (
<div className="bg-dark-800 border border-red-500/30 rounded-2xl p-6">
<div className="flex items-center gap-3 mb-2">
<AlertTriangle className="w-6 h-6 text-red-400" />
<h3 className="text-red-400 font-semibold">Pentest Failed</h3>
</div>
<p className="text-dark-400">{status.error || 'An unexpected error occurred.'}</p>
</div>
)}
</div>
)}
</div>
)
}
+16 -2
View File
@@ -249,13 +249,27 @@ export default function HomePage() {
recentVulnerabilities.slice(0, 5).map((vuln) => (
<div
key={vuln.id}
className="flex items-center justify-between p-3 bg-dark-900/50 rounded-lg"
className={`flex items-center justify-between p-3 bg-dark-900/50 rounded-lg ${
(vuln as any).validation_status === 'ai_rejected' ? 'opacity-60 border-l-2 border-orange-500/40' :
(vuln as any).validation_status === 'false_positive' ? 'opacity-40' : ''
}`}
>
<div className="flex-1 min-w-0">
<p className="font-medium text-white truncate">{vuln.title}</p>
<p className="text-xs text-dark-400 truncate">{vuln.affected_endpoint}</p>
</div>
<SeverityBadge severity={vuln.severity} />
<div className="flex items-center gap-1.5">
{(vuln as any).validation_status === 'ai_rejected' && (
<span className="text-xs px-1.5 py-0.5 rounded-full bg-orange-500/20 text-orange-400">Rejected</span>
)}
{(vuln as any).validation_status === 'validated' && (
<span className="text-xs px-1.5 py-0.5 rounded-full bg-green-500/20 text-green-400">Validated</span>
)}
{(vuln as any).validation_status === 'false_positive' && (
<span className="text-xs px-1.5 py-0.5 rounded-full bg-dark-600 text-dark-400">FP</span>
)}
<SeverityBadge severity={vuln.severity} />
</div>
</div>
))
)}
+474
View File
@@ -0,0 +1,474 @@
import { useState, useEffect, useCallback } from 'react'
import { Link } from 'react-router-dom'
import {
Box, RefreshCw, Trash2, Heart, Clock, Cpu,
HardDrive, Timer, AlertTriangle, CheckCircle2,
XCircle, Wrench, Container
} from 'lucide-react'
import Card from '../components/common/Card'
import Button from '../components/common/Button'
import { sandboxApi } from '../services/api'
import type { SandboxPoolStatus, SandboxContainer } from '../types'
function formatUptime(seconds: number): string {
if (seconds < 60) return `${Math.floor(seconds)}s`
if (seconds < 3600) {
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
return `${m}m ${s}s`
}
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
return `${h}h ${m}m`
}
function timeAgo(isoDate: string | null): string {
if (!isoDate) return 'Unknown'
const diff = (Date.now() - new Date(isoDate).getTime()) / 1000
if (diff < 60) return 'Just now'
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
return `${Math.floor(diff / 86400)}d ago`
}
export default function SandboxDashboardPage() {
const [data, setData] = useState<SandboxPoolStatus | null>(null)
const [loading, setLoading] = useState(true)
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
const [destroyConfirm, setDestroyConfirm] = useState<string | null>(null)
const [healthResults, setHealthResults] = useState<Record<string, { status: string; tools: string[] } | null>>({})
const [healthLoading, setHealthLoading] = useState<Record<string, boolean>>({})
const [actionLoading, setActionLoading] = useState(false)
const fetchData = useCallback(async (showSpinner = false) => {
if (showSpinner) setLoading(true)
try {
const result = await sandboxApi.list()
setData(result)
} catch (error) {
console.error('Failed to fetch sandbox data:', error)
if (!data) {
setData({
pool: { active: 0, max_concurrent: 0, image: 'N/A', container_ttl_minutes: 0, docker_available: false },
containers: [],
error: 'Failed to connect to backend',
})
}
} finally {
setLoading(false)
}
}, [])
// Initial fetch + 5-second polling
useEffect(() => {
fetchData(true)
const interval = setInterval(() => fetchData(false), 5000)
return () => clearInterval(interval)
}, [fetchData])
// Auto-dismiss messages
useEffect(() => {
if (message) {
const timer = setTimeout(() => setMessage(null), 4000)
return () => clearTimeout(timer)
}
}, [message])
const handleDestroy = async (scanId: string) => {
if (destroyConfirm !== scanId) {
setDestroyConfirm(scanId)
setTimeout(() => setDestroyConfirm(null), 5000)
return
}
setDestroyConfirm(null)
setActionLoading(true)
try {
await sandboxApi.destroy(scanId)
setMessage({ type: 'success', text: `Container for scan ${scanId.slice(0, 8)}... destroyed` })
fetchData(false)
} catch (error: any) {
setMessage({ type: 'error', text: error?.response?.data?.detail || 'Failed to destroy container' })
} finally {
setActionLoading(false)
}
}
const handleHealthCheck = async (scanId: string) => {
setHealthLoading(prev => ({ ...prev, [scanId]: true }))
try {
const result = await sandboxApi.healthCheck(scanId)
setHealthResults(prev => ({ ...prev, [scanId]: result }))
setTimeout(() => {
setHealthResults(prev => ({ ...prev, [scanId]: null }))
}, 8000)
} catch {
setHealthResults(prev => ({ ...prev, [scanId]: { status: 'error', tools: [] } }))
} finally {
setHealthLoading(prev => ({ ...prev, [scanId]: false }))
}
}
const handleCleanup = async (type: 'expired' | 'orphans') => {
setActionLoading(true)
try {
if (type === 'expired') {
await sandboxApi.cleanup()
} else {
await sandboxApi.cleanupOrphans()
}
setMessage({ type: 'success', text: `${type === 'expired' ? 'Expired' : 'Orphan'} containers cleaned up` })
fetchData(false)
} catch (error: any) {
setMessage({ type: 'error', text: error?.response?.data?.detail || `Cleanup failed` })
} finally {
setActionLoading(false)
}
}
if (loading && !data) {
return (
<div className="animate-pulse space-y-6">
<div className="h-8 bg-dark-800 rounded w-64" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[1, 2, 3, 4].map(i => (
<div key={i} className="h-24 bg-dark-800 rounded-lg" />
))}
</div>
<div className="space-y-4">
{[1, 2].map(i => (
<div key={i} className="h-40 bg-dark-800 rounded-lg" />
))}
</div>
</div>
)
}
const pool = data?.pool
const containers = data?.containers || []
const utilizationPct = pool ? (pool.active / pool.max_concurrent) * 100 : 0
return (
<div className="space-y-6 animate-fadeIn">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
<div className="w-10 h-10 bg-blue-500/20 rounded-lg flex items-center justify-center">
<Container className="w-6 h-6 text-blue-400" />
</div>
Sandbox Containers
</h1>
<p className="text-dark-400 mt-1">Real-time monitoring of per-scan Kali Linux containers</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleCleanup('expired')}
isLoading={actionLoading}
>
<Timer className="w-4 h-4 mr-1" />
Cleanup Expired
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleCleanup('orphans')}
isLoading={actionLoading}
>
<Trash2 className="w-4 h-4 mr-1" />
Cleanup Orphans
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => fetchData(false)}
>
<RefreshCw className="w-4 h-4 mr-1" />
Refresh
</Button>
</div>
</div>
{/* Status message */}
{message && (
<div className={`flex items-center gap-2 px-4 py-3 rounded-lg text-sm animate-fadeIn ${
message.type === 'success'
? 'bg-green-500/10 border border-green-500/30 text-green-400'
: 'bg-red-500/10 border border-red-500/30 text-red-400'
}`}>
{message.type === 'success' ? (
<CheckCircle2 className="w-4 h-4 flex-shrink-0" />
) : (
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
)}
{message.text}
</div>
)}
{/* Pool Stats Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{/* Active Containers */}
<Card>
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
utilizationPct >= 100 ? 'bg-red-500/20' :
utilizationPct >= 80 ? 'bg-yellow-500/20' :
'bg-green-500/20'
}`}>
<Box className={`w-5 h-5 ${
utilizationPct >= 100 ? 'text-red-400' :
utilizationPct >= 80 ? 'text-yellow-400' :
'text-green-400'
}`} />
</div>
<div>
<p className="text-2xl font-bold text-white">
{pool?.active || 0}<span className="text-dark-400 text-lg">/{pool?.max_concurrent || 0}</span>
</p>
<p className="text-xs text-dark-400">Active Containers</p>
</div>
</div>
</Card>
{/* Docker Status */}
<Card>
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
pool?.docker_available ? 'bg-green-500/20' : 'bg-red-500/20'
}`}>
<HardDrive className={`w-5 h-5 ${
pool?.docker_available ? 'text-green-400' : 'text-red-400'
}`} />
</div>
<div>
<p className="text-lg font-bold text-white">
{pool?.docker_available ? 'Online' : 'Offline'}
</p>
<p className="text-xs text-dark-400">Docker Engine</p>
</div>
</div>
</Card>
{/* Container Image */}
<Card>
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-purple-500/20 rounded-lg flex items-center justify-center">
<Cpu className="w-5 h-5 text-purple-400" />
</div>
<div>
<p className="text-sm font-bold text-white truncate max-w-[140px]" title={pool?.image}>
{pool?.image?.split(':')[0]?.split('/').pop() || 'N/A'}
</p>
<p className="text-xs text-dark-400">
{pool?.image?.includes(':') ? pool.image.split(':')[1] : 'latest'}
</p>
</div>
</div>
</Card>
{/* TTL */}
<Card>
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-orange-500/20 rounded-lg flex items-center justify-center">
<Clock className="w-5 h-5 text-orange-400" />
</div>
<div>
<p className="text-2xl font-bold text-white">
{pool?.container_ttl_minutes || 0}<span className="text-dark-400 text-lg"> min</span>
</p>
<p className="text-xs text-dark-400">Container TTL</p>
</div>
</div>
</Card>
</div>
{/* Capacity Bar */}
{pool && pool.max_concurrent > 0 && (
<div className="bg-dark-800 rounded-lg p-4 border border-dark-700">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-dark-300">Pool Capacity</span>
<span className={`text-sm font-medium ${
utilizationPct >= 100 ? 'text-red-400' :
utilizationPct >= 80 ? 'text-yellow-400' :
'text-green-400'
}`}>
{Math.round(utilizationPct)}%
</span>
</div>
<div className="w-full bg-dark-900 rounded-full h-2.5">
<div
className={`h-2.5 rounded-full transition-all duration-500 ${
utilizationPct >= 100 ? 'bg-red-500' :
utilizationPct >= 80 ? 'bg-yellow-500' :
'bg-green-500'
}`}
style={{ width: `${Math.min(utilizationPct, 100)}%` }}
/>
</div>
</div>
)}
{/* Container List */}
{containers.length === 0 ? (
<div className="bg-dark-800 rounded-lg border border-dark-700 p-12 text-center">
<Box className="w-16 h-16 text-dark-600 mx-auto mb-4" />
<h3 className="text-lg font-medium text-dark-300 mb-2">No Sandbox Containers Running</h3>
<p className="text-dark-400 text-sm max-w-md mx-auto">
Containers are automatically created when scans start and destroyed when they complete.
Start a scan to see containers here.
</p>
</div>
) : (
<div className="space-y-3">
<h2 className="text-lg font-semibold text-white">
Running Containers ({containers.length})
</h2>
{containers.map((container: SandboxContainer) => {
const health = healthResults[container.scan_id]
const isHealthLoading = healthLoading[container.scan_id]
const isConfirming = destroyConfirm === container.scan_id
return (
<div
key={container.scan_id}
className="bg-dark-800 rounded-lg border border-dark-700 p-5 hover:border-dark-600 transition-colors"
>
{/* Container Header */}
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className={`w-3 h-3 rounded-full ${
container.available ? 'bg-green-500 animate-pulse' : 'bg-red-500'
}`} />
<div>
<h3 className="text-white font-medium font-mono text-sm">
{container.container_name}
</h3>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-dark-400">Scan:</span>
<Link
to={`/scan/${container.scan_id}`}
className="text-xs text-primary-400 hover:text-primary-300 font-mono"
>
{container.scan_id.slice(0, 12)}...
</Link>
</div>
</div>
</div>
<div className="flex items-center gap-2">
{/* Status badge */}
<span className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium ${
container.available
? 'bg-green-500/10 text-green-400 border border-green-500/30'
: 'bg-red-500/10 text-red-400 border border-red-500/30'
}`}>
{container.available ? (
<><CheckCircle2 className="w-3 h-3" /> Running</>
) : (
<><XCircle className="w-3 h-3" /> Stopped</>
)}
</span>
</div>
</div>
{/* Container Info Grid */}
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 mb-4">
{/* Uptime */}
<div>
<p className="text-xs text-dark-400 mb-1">Uptime</p>
<p className="text-sm text-white font-medium">
{formatUptime(container.uptime_seconds)}
</p>
</div>
{/* Created */}
<div>
<p className="text-xs text-dark-400 mb-1">Created</p>
<p className="text-sm text-dark-300">
{timeAgo(container.created_at)}
</p>
</div>
{/* Tools count */}
<div>
<p className="text-xs text-dark-400 mb-1">Installed Tools</p>
<p className="text-sm text-white font-medium">
{container.installed_tools.length}
</p>
</div>
</div>
{/* Installed Tools */}
{container.installed_tools.length > 0 && (
<div className="mb-4">
<p className="text-xs text-dark-400 mb-2">Tools</p>
<div className="flex flex-wrap gap-1.5">
{container.installed_tools.map(tool => (
<span
key={tool}
className="inline-flex items-center gap-1 px-2 py-0.5 bg-dark-900 border border-dark-600 rounded text-xs text-dark-300"
>
<Wrench className="w-3 h-3 text-dark-500" />
{tool}
</span>
))}
</div>
</div>
)}
{/* Health Check Result */}
{health && (
<div className={`mb-4 px-3 py-2 rounded-lg text-xs animate-fadeIn ${
health.status === 'healthy'
? 'bg-green-500/10 border border-green-500/20 text-green-400'
: health.status === 'degraded'
? 'bg-yellow-500/10 border border-yellow-500/20 text-yellow-400'
: 'bg-red-500/10 border border-red-500/20 text-red-400'
}`}>
<span className="font-medium">Health: {health.status}</span>
{health.tools.length > 0 && (
<span className="ml-2">
Verified: {health.tools.join(', ')}
</span>
)}
</div>
)}
{/* Actions */}
<div className="flex items-center gap-2 pt-3 border-t border-dark-700">
<Button
variant="ghost"
size="sm"
onClick={() => handleHealthCheck(container.scan_id)}
isLoading={isHealthLoading}
>
<Heart className="w-4 h-4 mr-1" />
Health Check
</Button>
<Button
variant={isConfirming ? 'danger' : 'ghost'}
size="sm"
onClick={() => handleDestroy(container.scan_id)}
isLoading={actionLoading}
>
<Trash2 className="w-4 h-4 mr-1" />
{isConfirming ? 'Confirm Destroy' : 'Destroy'}
</Button>
</div>
</div>
)
})}
</div>
)}
{/* Auto-refresh indicator */}
<div className="text-center text-xs text-dark-500">
Auto-refreshing every 5 seconds
</div>
</div>
)
}
+565 -38
View File
@@ -2,15 +2,47 @@ import { useEffect, useMemo, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import {
Globe, FileText, StopCircle, RefreshCw, ChevronDown, ChevronRight,
ExternalLink, Copy, Shield, AlertTriangle, Cpu, CheckCircle, XCircle, Clock
ExternalLink, Copy, Shield, AlertTriangle, Cpu, CheckCircle, XCircle, Clock,
SkipForward, Check, Minus, Pause, Play, Download
} from 'lucide-react'
import Card from '../components/common/Card'
import Button from '../components/common/Button'
import { SeverityBadge } from '../components/common/Badge'
import { scansApi, reportsApi, agentTasksApi } from '../services/api'
import { scansApi, reportsApi, agentTasksApi, agentApi, vulnerabilitiesApi } from '../services/api'
import { wsService } from '../services/websocket'
import { useScanStore } from '../store'
import type { Endpoint, Vulnerability, WSMessage, ScanAgentTask, Report } from '../types'
import type { Endpoint, Vulnerability, WSMessage, ScanAgentTask, Report, AgentStatus, AgentFinding } from '../types'
// Resolve a confidence score from the various possible sources on a finding.
// Handles: numeric confidence_score field, legacy string "high"/"medium"/"low", or numeric strings.
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 {
// Legacy text values
const map: Record<string, number> = { high: 90, medium: 60, low: 30 }
score = map[finding.confidence.toLowerCase()] ?? null
}
}
if (score === null) return null
const color = score >= 90 ? 'green' : score >= 60 ? 'yellow' : 'red'
const label = score >= 90 ? 'Confirmed' : score >= 60 ? 'Likely' : 'Low'
return { score, color, label }
}
const CONFIDENCE_STYLES: Record<string, string> = {
green: 'bg-green-500/15 text-green-400 border-green-500/30',
yellow: 'bg-yellow-500/15 text-yellow-400 border-yellow-500/30',
red: 'bg-red-500/15 text-red-400 border-red-500/30',
}
export default function ScanDetailsPage() {
const { scanId } = useParams<{ scanId: string }>()
@@ -29,6 +61,10 @@ export default function ScanDetailsPage() {
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [autoGeneratedReport, setAutoGeneratedReport] = useState<Report | null>(null)
const [agentData, setAgentData] = useState<AgentStatus | null>(null)
const [skipConfirm, setSkipConfirm] = useState<string | null>(null)
const [skippedPhases, setSkippedPhases] = useState<Set<string>>(new Set())
const [validationFilter, setValidationFilter] = useState<'all' | 'confirmed' | 'rejected' | 'validated'>('all')
// Calculate vulnerability counts from actual data
const vulnCounts = useMemo(() => getVulnCounts(), [vulnerabilities])
@@ -68,6 +104,57 @@ export default function ScanDetailsPage() {
if (reportsData.reports?.length > 0) {
setAutoGeneratedReport(reportsData.reports[0])
}
// If scan has no vulns from DB, try to get agent data (in-memory findings)
if ((!vulnsData.vulnerabilities || vulnsData.vulnerabilities.length === 0)) {
const agentStatus = await agentApi.getByScan(scanId)
if (agentStatus) {
setAgentData(agentStatus)
// Convert agent findings to vulnerability format for display
if (agentStatus.findings && agentStatus.findings.length > 0) {
const mapFinding = (f: AgentFinding): Vulnerability => ({
id: f.id,
scan_id: scanId,
title: f.title,
vulnerability_type: f.vulnerability_type,
severity: f.severity,
cvss_score: f.cvss_score || null,
cvss_vector: f.cvss_vector || null,
cwe_id: f.cwe_id || null,
description: f.description || null,
affected_endpoint: f.affected_endpoint || null,
poc_request: f.request || null,
poc_response: f.response || null,
poc_payload: f.payload || null,
poc_parameter: f.parameter || null,
poc_evidence: f.evidence || null,
poc_code: f.poc_code || null,
impact: f.impact || null,
remediation: f.remediation || null,
references: f.references || [],
ai_analysis: f.evidence || null,
validation_status: f.ai_status === 'rejected' ? 'ai_rejected' : 'ai_confirmed',
ai_rejection_reason: f.rejection_reason || null,
confidence_score: f.confidence_score,
confidence_breakdown: f.confidence_breakdown,
proof_of_execution: f.proof_of_execution,
negative_controls: f.negative_controls,
created_at: new Date().toISOString()
})
const confirmed = agentStatus.findings.map(mapFinding)
const rejected = (agentStatus.rejected_findings || []).map(mapFinding)
const mappedVulns: Vulnerability[] = [...confirmed, ...rejected]
setVulnerabilities(mappedVulns)
}
// Update scan progress from agent
if (agentStatus.progress !== undefined) {
updateScan(scanId, {
progress: agentStatus.progress,
current_phase: agentStatus.phase
})
}
}
}
} catch (err: any) {
console.error('Failed to fetch scan:', err)
setError(err?.response?.data?.detail || 'Failed to load scan')
@@ -77,9 +164,9 @@ export default function ScanDetailsPage() {
}
fetchData()
// Poll for updates while scan is running
// Poll for updates while scan is running or paused (8s interval, WebSocket handles real-time)
const pollInterval = setInterval(async () => {
if (currentScan?.status === 'running' || !currentScan) {
if (currentScan?.status === 'running' || currentScan?.status === 'paused' || !currentScan) {
try {
const scan = await scansApi.get(scanId)
setCurrentScan(scan)
@@ -99,11 +186,60 @@ export default function ScanDetailsPage() {
if (tasksData.tasks?.length > 0) {
setAgentTasks(tasksData.tasks)
}
// Also poll agent data for real-time findings
if (!vulnsData.vulnerabilities || vulnsData.vulnerabilities.length === 0) {
const agentStatus = await agentApi.getByScan(scanId)
if (agentStatus) {
setAgentData(agentStatus)
if (agentStatus.findings && agentStatus.findings.length > 0) {
const mapFinding = (f: AgentFinding): Vulnerability => ({
id: f.id,
scan_id: scanId,
title: f.title,
vulnerability_type: f.vulnerability_type,
severity: f.severity,
cvss_score: f.cvss_score || null,
cvss_vector: f.cvss_vector || null,
cwe_id: f.cwe_id || null,
description: f.description || null,
affected_endpoint: f.affected_endpoint || null,
poc_request: f.request || null,
poc_response: f.response || null,
poc_payload: f.payload || null,
poc_parameter: f.parameter || null,
poc_evidence: f.evidence || null,
poc_code: f.poc_code || null,
impact: f.impact || null,
remediation: f.remediation || null,
references: f.references || [],
ai_analysis: f.evidence || null,
validation_status: f.ai_status === 'rejected' ? 'ai_rejected' : 'ai_confirmed',
ai_rejection_reason: f.rejection_reason || null,
confidence_score: f.confidence_score,
confidence_breakdown: f.confidence_breakdown,
proof_of_execution: f.proof_of_execution,
negative_controls: f.negative_controls,
created_at: new Date().toISOString()
})
const confirmed = agentStatus.findings.map(mapFinding)
const rejected = (agentStatus.rejected_findings || []).map(mapFinding)
const mappedVulns: Vulnerability[] = [...confirmed, ...rejected]
setVulnerabilities(mappedVulns)
}
if (agentStatus.progress !== undefined) {
updateScan(scanId, {
progress: agentStatus.progress,
current_phase: agentStatus.phase
})
}
}
}
} catch (err) {
console.error('Poll error:', err)
}
}
}, 3000)
}, 8000)
// Connect WebSocket for running scans
wsService.connect(scanId)
@@ -117,10 +253,16 @@ export default function ScanDetailsPage() {
current_phase: message.message as string
})
break
case 'phase_change':
updateScan(scanId, { current_phase: message.phase as string })
addLog('info', `Phase: ${message.phase}`)
case 'phase_change': {
const phase = message.phase as string
updateScan(scanId, { current_phase: phase })
addLog('info', `Phase: ${phase}`)
// Track skipped phases
if (phase.endsWith('_skipped')) {
setSkippedPhases(prev => new Set([...prev, phase.replace('_skipped', '')]))
}
break
}
case 'endpoint_found':
addEndpoint(message.endpoint as Endpoint)
break
@@ -245,6 +387,36 @@ export default function ScanDetailsPage() {
}
}
const handlePauseScan = async () => {
if (!scanId) return
try {
await scansApi.pause(scanId)
updateScan(scanId, { status: 'paused' })
} catch (error) {
console.error('Failed to pause scan:', error)
}
}
const handleResumeScan = async () => {
if (!scanId) return
try {
await scansApi.resume(scanId)
updateScan(scanId, { status: 'running' })
} catch (error) {
console.error('Failed to resume scan:', error)
}
}
const handleSkipToPhase = async (phase: string) => {
if (!scanId) return
try {
await scansApi.skipToPhase(scanId, phase)
setSkipConfirm(null)
} catch (error: any) {
console.error('Failed to skip phase:', error)
}
}
const handleGenerateReport = async () => {
if (!scanId) return
setIsGeneratingReport(true)
@@ -327,20 +499,53 @@ export default function ScanDetailsPage() {
</div>
</div>
<div className="flex gap-2">
{currentScan.status === 'running' && (
<Button variant="danger" onClick={handleStopScan}>
<StopCircle className="w-4 h-4 mr-2" />
Stop Scan
{agentData?.agent_id && (
<Button variant="secondary" onClick={() => navigate(`/agent/${agentData.agent_id}`)}>
<Cpu className="w-4 h-4 mr-2" />
Agent View
</Button>
)}
{currentScan.status === 'running' && (
<>
<Button variant="secondary" onClick={handlePauseScan}>
<Pause className="w-4 h-4 mr-2" />
Pause
</Button>
<Button variant="danger" onClick={handleStopScan}>
<StopCircle className="w-4 h-4 mr-2" />
Stop
</Button>
</>
)}
{currentScan.status === 'paused' && (
<>
<Button variant="primary" onClick={handleResumeScan}>
<Play className="w-4 h-4 mr-2" />
Resume
</Button>
<Button variant="danger" onClick={handleStopScan}>
<StopCircle className="w-4 h-4 mr-2" />
Stop
</Button>
</>
)}
{autoGeneratedReport && (
<Button
variant="secondary"
onClick={() => window.open(reportsApi.getViewUrl(autoGeneratedReport.id), '_blank')}
>
<FileText className="w-4 h-4 mr-2" />
View Report
</Button>
<>
<Button
variant="secondary"
onClick={() => window.open(reportsApi.getViewUrl(autoGeneratedReport.id), '_blank')}
>
<FileText className="w-4 h-4 mr-2" />
View Report
</Button>
<Button
variant="secondary"
onClick={() => window.open(reportsApi.getDownloadZipUrl(autoGeneratedReport.id), '_blank')}
>
<Download className="w-4 h-4 mr-2" />
Download ZIP
</Button>
</>
)}
{(currentScan.status === 'completed' || currentScan.status === 'stopped') && (
<Button onClick={handleGenerateReport} isLoading={isGeneratingReport}>
@@ -351,23 +556,131 @@ export default function ScanDetailsPage() {
</div>
</div>
{/* Progress */}
{currentScan.status === 'running' && (
<Card>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-dark-300">{currentScan.current_phase || 'Initializing...'}</span>
<span className="text-white font-medium">{currentScan.progress}%</span>
{/* Phase Stepper */}
{(currentScan.status === 'running' || currentScan.status === 'paused' || currentScan.status === 'completed' || currentScan.status === 'stopped') && (() => {
const PHASES = [
{ id: 'initializing', label: 'Init', fullLabel: 'Initialization' },
{ id: 'recon', label: 'Recon', fullLabel: 'Reconnaissance' },
{ id: 'analyzing', label: 'Analysis', fullLabel: 'AI Analysis' },
{ id: 'testing', label: 'Testing', fullLabel: 'Vulnerability Testing' },
{ id: 'completed', label: 'Done', fullLabel: 'Completed' },
]
const phaseOrder = PHASES.map(p => p.id)
const rawPhase = currentScan.current_phase || 'initializing'
// Normalize: "skipping_to_testing" -> current is between phases
const currentPhase = rawPhase.startsWith('skipping_to_') ? rawPhase.replace('skipping_to_', '') : rawPhase.replace('_skipped', '')
const currentIdx = phaseOrder.indexOf(currentPhase)
const isRunning = currentScan.status === 'running' || currentScan.status === 'paused'
return (
<Card>
<div className="space-y-4">
{/* Phase nodes */}
<div className="flex items-center justify-between relative">
{PHASES.map((phase, idx) => {
const isCompleted = idx < currentIdx || currentScan.status === 'completed'
const isActive = idx === currentIdx && isRunning
const isSkipped = skippedPhases.has(phase.id)
const isFuture = idx > currentIdx && isRunning
const canSkipTo = isFuture && phase.id !== 'initializing'
return (
<div key={phase.id} className="flex items-center flex-1 last:flex-none">
{/* Node */}
<div className="flex flex-col items-center relative z-10">
{/* Circle */}
{canSkipTo ? (
skipConfirm === phase.id ? (
<div className="flex items-center gap-1">
<button
onClick={() => handleSkipToPhase(phase.id)}
className="w-9 h-9 rounded-full bg-brand-500 text-white flex items-center justify-center hover:bg-brand-400 transition-colors"
title={`Skip to ${phase.fullLabel}`}
>
<Check className="w-4 h-4" />
</button>
<button
onClick={() => setSkipConfirm(null)}
className="w-7 h-7 rounded-full bg-dark-600 text-dark-300 flex items-center justify-center hover:bg-dark-500 transition-colors"
>
<XCircle className="w-3.5 h-3.5" />
</button>
</div>
) : (
<button
onClick={() => setSkipConfirm(phase.id)}
className="w-9 h-9 rounded-full border-2 border-dark-500 bg-dark-800 text-dark-400 flex items-center justify-center hover:border-brand-400 hover:text-brand-400 hover:bg-brand-500/10 transition-all group"
title={`Skip to ${phase.fullLabel}`}
>
<SkipForward className="w-4 h-4 opacity-0 group-hover:opacity-100 transition-opacity" />
<span className="absolute text-[10px] group-hover:hidden">{idx + 1}</span>
</button>
)
) : isCompleted ? (
<div className={`w-9 h-9 rounded-full flex items-center justify-center ${
isSkipped
? 'bg-yellow-500/20 border-2 border-yellow-500/50'
: 'bg-green-500/20 border-2 border-green-500/50'
}`}>
{isSkipped
? <Minus className="w-4 h-4 text-yellow-400" />
: <Check className="w-4 h-4 text-green-400" />
}
</div>
) : isActive ? (
<div className="w-9 h-9 rounded-full bg-brand-500/20 border-2 border-brand-500 flex items-center justify-center animate-pulse">
<div className="w-3 h-3 rounded-full bg-brand-400" />
</div>
) : (
<div className="w-9 h-9 rounded-full border-2 border-dark-600 bg-dark-800 flex items-center justify-center">
<span className="text-xs text-dark-500">{idx + 1}</span>
</div>
)}
{/* Label */}
<span className={`text-xs mt-2 font-medium ${
isActive ? 'text-brand-400' : isCompleted ? (isSkipped ? 'text-yellow-400' : 'text-green-400') : 'text-dark-500'
}`}>
{isSkipped ? `${phase.label} (skipped)` : phase.label}
</span>
{/* Skip hint */}
{canSkipTo && skipConfirm === phase.id && (
<span className="text-[10px] text-brand-400 mt-0.5">Skip here?</span>
)}
</div>
{/* Connector line */}
{idx < PHASES.length - 1 && (
<div className={`flex-1 h-0.5 mx-2 mt-[-20px] ${
idx < currentIdx ? 'bg-green-500/50' : 'bg-dark-600'
}`} />
)}
</div>
)
})}
</div>
{/* Progress bar + info */}
<div className="flex items-center justify-between text-sm">
<span className="text-dark-300">
{rawPhase.startsWith('skipping_to_')
? `Skipping to ${rawPhase.replace('skipping_to_', '')}...`
: currentScan.current_phase || 'Initializing...'
}
</span>
<span className="text-white font-medium">{currentScan.progress}%</span>
</div>
<div className="h-1.5 bg-dark-900 rounded-full overflow-hidden">
<div
className="h-full bg-brand-500 rounded-full transition-all duration-300"
style={{ width: `${currentScan.progress}%` }}
/>
</div>
</div>
<div className="h-2 bg-dark-900 rounded-full overflow-hidden">
<div
className="h-full bg-primary-500 rounded-full transition-all duration-300"
style={{ width: `${currentScan.progress}%` }}
/>
</div>
</div>
</Card>
)}
</Card>
)
})()}
{/* Auto-generated Report Notification */}
{autoGeneratedReport && (
@@ -472,6 +785,31 @@ export default function ScanDetailsPage() {
{/* Vulnerabilities Tab */}
{activeTab === 'vulns' && (
<div className="space-y-3">
{/* Validation Filter Tabs */}
{vulnerabilities.length > 0 && (
<div className="flex gap-2 mb-2">
{(['all', 'confirmed', 'rejected', 'validated'] as const).map((filter) => {
const count = filter === 'all' ? vulnerabilities.length
: filter === 'confirmed' ? vulnerabilities.filter(v => !v.validation_status || v.validation_status === 'ai_confirmed' || v.validation_status === 'validated').length
: filter === 'rejected' ? vulnerabilities.filter(v => v.validation_status === 'ai_rejected' || v.validation_status === 'false_positive').length
: vulnerabilities.filter(v => v.validation_status === 'validated').length
return (
<button
key={filter}
onClick={() => setValidationFilter(filter)}
className={`px-3 py-1 text-xs rounded-full transition-colors ${
validationFilter === filter
? 'bg-primary-500/20 text-primary-400 border border-primary-500/30'
: 'bg-dark-700 text-dark-400 border border-dark-600 hover:text-dark-300'
}`}
>
{filter.charAt(0).toUpperCase() + filter.slice(1)} ({count})
</button>
)
})}
</div>
)}
{vulnerabilities.length === 0 ? (
<Card>
<p className="text-dark-400 text-center py-8">
@@ -479,10 +817,23 @@ export default function ScanDetailsPage() {
</p>
</Card>
) : (
vulnerabilities.map((vuln, idx) => (
vulnerabilities
.filter((vuln) => {
if (validationFilter === 'all') return true
if (validationFilter === 'confirmed') return !vuln.validation_status || vuln.validation_status === 'ai_confirmed' || vuln.validation_status === 'validated'
if (validationFilter === 'rejected') return vuln.validation_status === 'ai_rejected' || vuln.validation_status === 'false_positive'
if (validationFilter === 'validated') return vuln.validation_status === 'validated'
return true
})
.map((vuln, idx) => (
<div
key={vuln.id || `vuln-${idx}`}
className="bg-dark-800 rounded-lg border border-dark-700 overflow-hidden"
className={`bg-dark-800 rounded-lg border overflow-hidden ${
vuln.validation_status === 'ai_rejected' ? 'border-orange-500/40 opacity-70' :
vuln.validation_status === 'false_positive' ? 'border-dark-600 opacity-50' :
vuln.validation_status === 'validated' ? 'border-green-500/40' :
'border-dark-700'
}`}
>
{/* Vulnerability Header */}
<div
@@ -513,6 +864,37 @@ export default function ScanDetailsPage() {
</span>
)}
<SeverityBadge severity={vuln.severity} />
{/* Confidence Score */}
{(() => {
const conf = getConfidenceDisplay(vuln as any)
if (!conf) return null
return (
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full border ${CONFIDENCE_STYLES[conf.color]}`}>
{conf.score}/100
</span>
)
})()}
{/* Validation Status Badge */}
{vuln.validation_status === 'ai_rejected' && (
<span className="text-xs px-2 py-0.5 rounded-full bg-orange-500/20 text-orange-400 border border-orange-500/30 flex items-center gap-1">
<AlertTriangle className="w-3 h-3" /> AI Rejected
</span>
)}
{vuln.validation_status === 'validated' && (
<span className="text-xs px-2 py-0.5 rounded-full bg-green-500/20 text-green-400 border border-green-500/30 flex items-center gap-1">
<CheckCircle className="w-3 h-3" /> Validated
</span>
)}
{vuln.validation_status === 'false_positive' && (
<span className="text-xs px-2 py-0.5 rounded-full bg-dark-600 text-dark-400 border border-dark-500 flex items-center gap-1">
<XCircle className="w-3 h-3" /> False Positive
</span>
)}
{(!vuln.validation_status || vuln.validation_status === 'ai_confirmed') && (
<span className="text-xs px-2 py-0.5 rounded-full bg-emerald-500/15 text-emerald-400 border border-emerald-500/30">
AI Confirmed
</span>
)}
</div>
</div>
</div>
@@ -545,6 +927,59 @@ export default function ScanDetailsPage() {
)}
</div>
{/* Validation Pipeline Details */}
{(() => {
const conf = getConfidenceDisplay(vuln as any)
if (!conf) return null
return (
<div className={`rounded-lg p-3 border ${CONFIDENCE_STYLES[conf.color]}`}>
<div className="flex items-center gap-2 mb-2">
<Shield className="w-4 h-4" />
<span className="text-sm font-semibold">Validation Pipeline</span>
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${
conf.score >= 90 ? 'bg-green-500/20 text-green-400' :
conf.score >= 60 ? 'bg-yellow-500/20 text-yellow-400' :
'bg-red-500/20 text-red-400'
}`}>
{conf.score}/100 {conf.label}
</span>
</div>
{/* Scoring Breakdown */}
{vuln.confidence_breakdown && Object.keys(vuln.confidence_breakdown).length > 0 && (
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs mt-1 mb-2">
{Object.entries(vuln.confidence_breakdown).map(([key, val]) => (
<div key={key} className="flex justify-between">
<span className="opacity-70 capitalize">{key.replace(/_/g, ' ')}</span>
<span className={`font-mono font-medium ${
Number(val) > 0 ? 'text-green-400' : Number(val) < 0 ? 'text-red-400' : 'opacity-50'
}`}>
{Number(val) > 0 ? '+' : ''}{val}
</span>
</div>
))}
</div>
)}
{/* Proof of Execution */}
{vuln.proof_of_execution && (
<div className="text-xs mt-1 flex items-start gap-1">
<CheckCircle className="w-3 h-3 mt-0.5 flex-shrink-0 text-green-400" />
<span className="opacity-80">{vuln.proof_of_execution}</span>
</div>
)}
{/* Negative Controls */}
{vuln.negative_controls && (
<div className="text-xs mt-1 flex items-start gap-1">
<Shield className="w-3 h-3 mt-0.5 flex-shrink-0 text-blue-400" />
<span className="opacity-80">{vuln.negative_controls}</span>
</div>
)}
</div>
)
})()}
{/* Description */}
{vuln.description && (
<div>
@@ -602,6 +1037,14 @@ export default function ScanDetailsPage() {
</div>
)}
{/* Exploitation Code */}
{vuln.poc_code && (
<div className="mt-3">
<p className="text-xs font-medium text-dark-400 mb-1">Exploitation Code</p>
<pre className="p-3 bg-dark-950 rounded text-xs text-green-400 overflow-x-auto max-h-[400px] overflow-y-auto whitespace-pre-wrap">{vuln.poc_code}</pre>
</div>
)}
{/* Remediation */}
{vuln.remediation && (
<div>
@@ -618,6 +1061,90 @@ export default function ScanDetailsPage() {
</div>
)}
{/* AI Rejection Reason */}
{vuln.validation_status === 'ai_rejected' && vuln.ai_rejection_reason && (
<div className="bg-orange-500/10 border border-orange-500/20 rounded-lg p-3">
<p className="text-sm font-medium text-orange-400 mb-1 flex items-center gap-1">
<AlertTriangle className="w-4 h-4" /> AI Rejection Reason
</p>
<p className="text-sm text-orange-300/80">{vuln.ai_rejection_reason}</p>
</div>
)}
{/* Manual Validation Actions */}
{vuln.validation_status !== 'validated' && vuln.validation_status !== 'false_positive' && (
<div className="flex items-center gap-2 pt-2 border-t border-dark-700">
<span className="text-xs text-dark-500 mr-2">Manual Review:</span>
<Button
variant="ghost"
size="sm"
className="text-green-400 hover:bg-green-500/10 border border-green-500/30"
onClick={async (e) => {
e.stopPropagation()
try {
await vulnerabilitiesApi.validate(vuln.id, 'validated')
// Update local state
const updated = vulnerabilities.map(v =>
v.id === vuln.id ? { ...v, validation_status: 'validated' as const } : v
)
setVulnerabilities(updated)
} catch (err) { console.error('Validate error:', err) }
}}
>
<CheckCircle className="w-3 h-3 mr-1" />
Validate
</Button>
<Button
variant="ghost"
size="sm"
className="text-dark-400 hover:bg-red-500/10 border border-dark-600"
onClick={async (e) => {
e.stopPropagation()
try {
await vulnerabilitiesApi.validate(vuln.id, 'false_positive')
const updated = vulnerabilities.map(v =>
v.id === vuln.id ? { ...v, validation_status: 'false_positive' as const } : v
)
setVulnerabilities(updated)
} catch (err) { console.error('Mark FP error:', err) }
}}
>
<XCircle className="w-3 h-3 mr-1" />
False Positive
</Button>
{vuln.validation_status === 'ai_rejected' && (
<span className="text-xs text-orange-400/60 ml-2">
AI rejected this finding - review the evidence above
</span>
)}
</div>
)}
{(vuln.validation_status === 'validated' || vuln.validation_status === 'false_positive') && (
<div className="flex items-center gap-2 pt-2 border-t border-dark-700">
<span className="text-xs text-dark-500">
{vuln.validation_status === 'validated' ? 'Manually validated by pentester' : 'Marked as false positive'}
</span>
<Button
variant="ghost"
size="sm"
className="text-dark-500 hover:text-dark-300 text-xs"
onClick={async (e) => {
e.stopPropagation()
try {
const revertTo = vuln.ai_rejection_reason ? 'ai_rejected' : 'ai_confirmed'
await vulnerabilitiesApi.validate(vuln.id, revertTo)
const updated = vulnerabilities.map(v =>
v.id === vuln.id ? { ...v, validation_status: revertTo as any } : v
)
setVulnerabilities(updated)
} catch (err) { console.error('Revert error:', err) }
}}
>
Undo
</Button>
</div>
)}
{/* References */}
{vuln.references?.length > 0 && (
<div>
+734
View File
@@ -0,0 +1,734 @@
import { useState, useEffect, useCallback } from 'react'
import { Plus, Trash2, Play, Pause, Clock, RefreshCw, Target, Calendar, ChevronDown, Shield, Zap, Search, Settings2, AlertTriangle, CheckCircle2 } from 'lucide-react'
import Card from '../components/common/Card'
import Button from '../components/common/Button'
import Input from '../components/common/Input'
import { schedulerApi } from '../services/api'
import type { ScheduleJob, AgentRole } from '../types'
// Cron presets for quick selection
const CRON_PRESETS = [
{ label: 'Every Hour', value: '0 * * * *', desc: 'Runs at the start of every hour' },
{ label: 'Every 6 Hours', value: '0 */6 * * *', desc: 'Runs every 6 hours' },
{ label: 'Daily at 2 AM', value: '0 2 * * *', desc: 'Runs once a day at 2:00 AM' },
{ label: 'Daily at Midnight', value: '0 0 * * *', desc: 'Runs once a day at midnight' },
{ label: 'Weekdays at 9 AM', value: '0 9 * * 1-5', desc: 'Monday to Friday at 9:00 AM' },
{ label: 'Weekly (Monday)', value: '0 0 * * 1', desc: 'Every Monday at midnight' },
{ label: 'Weekly (Friday)', value: '0 18 * * 5', desc: 'Every Friday at 6:00 PM' },
{ label: 'Monthly (1st)', value: '0 0 1 * *', desc: 'First day of each month' },
{ label: 'Custom', value: 'custom', desc: 'Enter a custom cron expression' },
]
const SCAN_TYPES = [
{ id: 'quick', label: 'Quick', icon: Zap, desc: 'Fast surface scan' },
{ id: 'full', label: 'Full', icon: Search, desc: 'Comprehensive analysis' },
{ id: 'custom', label: 'Custom', icon: Settings2, desc: 'Custom configuration' },
]
const DAYS_OF_WEEK = [
{ id: 0, short: 'Sun', full: 'Sunday' },
{ id: 1, short: 'Mon', full: 'Monday' },
{ id: 2, short: 'Tue', full: 'Tuesday' },
{ id: 3, short: 'Wed', full: 'Wednesday' },
{ id: 4, short: 'Thu', full: 'Thursday' },
{ id: 5, short: 'Fri', full: 'Friday' },
{ id: 6, short: 'Sat', full: 'Saturday' },
]
export default function SchedulerPage() {
const [jobs, setJobs] = useState<ScheduleJob[]>([])
const [agentRoles, setAgentRoles] = useState<AgentRole[]>([])
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
// Form state
const [jobId, setJobId] = useState('')
const [target, setTarget] = useState('')
const [scanType, setScanType] = useState('quick')
const [scheduleMode, setScheduleMode] = useState<'interval' | 'preset' | 'days'>('preset')
const [cronPreset, setCronPreset] = useState('0 2 * * *')
const [customCron, setCustomCron] = useState('')
const [intervalMinutes, setIntervalMinutes] = useState('60')
const [selectedDays, setSelectedDays] = useState<number[]>([1, 2, 3, 4, 5])
const [executionHour, setExecutionHour] = useState('02')
const [executionMinute, setExecutionMinute] = useState('00')
const [agentRole, setAgentRole] = useState('')
const [showRoleDropdown, setShowRoleDropdown] = useState(false)
const [isCreating, setIsCreating] = useState(false)
const fetchData = useCallback(async () => {
setLoading(true)
try {
const [jobsData, rolesData] = await Promise.all([
schedulerApi.list(),
schedulerApi.getAgentRoles(),
])
setJobs(jobsData)
setAgentRoles(rolesData)
} catch (error) {
console.error('Failed to fetch scheduler data:', error)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData()
}, [fetchData])
// Auto-dismiss messages after 4 seconds
useEffect(() => {
if (message) {
const timer = setTimeout(() => setMessage(null), 4000)
return () => clearTimeout(timer)
}
}, [message])
const buildCronExpression = (): string | undefined => {
if (scheduleMode === 'interval') return undefined
if (scheduleMode === 'preset') {
return cronPreset === 'custom' ? customCron : cronPreset
}
// days mode: build cron from selected days + time
if (selectedDays.length === 0) return undefined
const daysStr = selectedDays.sort((a, b) => a - b).join(',')
return `${executionMinute} ${executionHour} * * ${daysStr}`
}
const getIntervalMinutes = (): number | undefined => {
if (scheduleMode !== 'interval') return undefined
return parseInt(intervalMinutes) || 60
}
const handleCreate = async () => {
if (!jobId.trim()) {
setMessage({ type: 'error', text: 'Job ID is required' })
return
}
if (!target.trim()) {
setMessage({ type: 'error', text: 'Target URL is required' })
return
}
const cron = buildCronExpression()
const interval = getIntervalMinutes()
if (!cron && !interval) {
setMessage({ type: 'error', text: 'Please configure a schedule (select days or set interval)' })
return
}
setIsCreating(true)
setMessage(null)
try {
await schedulerApi.create({
job_id: jobId.trim(),
target: target.trim(),
scan_type: scanType,
cron_expression: cron,
interval_minutes: interval,
agent_role: agentRole || undefined,
})
setMessage({ type: 'success', text: `Schedule "${jobId}" created successfully` })
setShowForm(false)
resetForm()
fetchData()
} catch (error: any) {
const detail = error?.response?.data?.detail || 'Failed to create schedule'
setMessage({ type: 'error', text: detail })
} finally {
setIsCreating(false)
}
}
const handleDelete = async (id: string) => {
try {
await schedulerApi.delete(id)
setMessage({ type: 'success', text: `Schedule "${id}" deleted` })
setDeleteConfirm(null)
fetchData()
} catch (error) {
setMessage({ type: 'error', text: `Failed to delete "${id}"` })
}
}
const handlePause = async (id: string) => {
try {
await schedulerApi.pause(id)
fetchData()
} catch (error) {
setMessage({ type: 'error', text: `Failed to pause "${id}"` })
}
}
const handleResume = async (id: string) => {
try {
await schedulerApi.resume(id)
fetchData()
} catch (error) {
setMessage({ type: 'error', text: `Failed to resume "${id}"` })
}
}
const toggleDay = (dayId: number) => {
setSelectedDays(prev =>
prev.includes(dayId) ? prev.filter(d => d !== dayId) : [...prev, dayId]
)
}
const resetForm = () => {
setJobId('')
setTarget('')
setScanType('quick')
setScheduleMode('preset')
setCronPreset('0 2 * * *')
setCustomCron('')
setIntervalMinutes('60')
setSelectedDays([1, 2, 3, 4, 5])
setExecutionHour('02')
setExecutionMinute('00')
setAgentRole('')
setShowRoleDropdown(false)
}
const selectedRole = agentRoles.find(r => r.id === agentRole)
return (
<div className="max-w-5xl mx-auto space-y-6 animate-fadeIn">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold text-white flex items-center gap-3">
<div className="p-2 bg-brand-500/20 rounded-lg">
<Calendar className="w-6 h-6 text-brand-400" />
</div>
Scan Scheduler
</h2>
<p className="text-dark-400 mt-1 ml-14">Schedule automated recurring scans with agent specialization</p>
</div>
<div className="flex gap-2">
<Button variant="secondary" onClick={fetchData}>
<RefreshCw className="w-4 h-4 mr-2" />
Refresh
</Button>
<Button onClick={() => { setShowForm(!showForm); if (showForm) resetForm() }}>
<Plus className="w-4 h-4 mr-2" />
New Schedule
</Button>
</div>
</div>
{/* Status Message */}
{message && (
<div className={`flex items-center gap-3 p-4 rounded-lg border transition-all ${
message.type === 'success'
? 'bg-green-500/10 border-green-500/30 text-green-400'
: 'bg-red-500/10 border-red-500/30 text-red-400'
}`}>
{message.type === 'success'
? <CheckCircle2 className="w-5 h-5 flex-shrink-0" />
: <AlertTriangle className="w-5 h-5 flex-shrink-0" />
}
<span>{message.text}</span>
</div>
)}
{/* Create Form */}
{showForm && (
<div className="bg-dark-800 border border-dark-700 rounded-xl overflow-hidden">
<div className="p-5 border-b border-dark-700">
<h3 className="text-lg font-semibold text-white">Create New Schedule</h3>
<p className="text-dark-400 text-sm mt-1">Configure a recurring scan with specialized agent roles</p>
</div>
<div className="p-5 space-y-6">
{/* Row 1: Job ID + Target */}
<div className="grid grid-cols-2 gap-4">
<Input
label="Job ID"
placeholder="daily-scan-prod"
value={jobId}
onChange={(e) => setJobId(e.target.value)}
helperText="Unique identifier for this schedule"
/>
<Input
label="Target URL"
placeholder="https://example.com"
value={target}
onChange={(e) => setTarget(e.target.value)}
helperText="URL to scan on each execution"
/>
</div>
{/* Row 2: Scan Type */}
<div>
<label className="block text-sm font-medium text-dark-200 mb-3">Scan Type</label>
<div className="grid grid-cols-3 gap-3">
{SCAN_TYPES.map(({ id, label, icon: Icon, desc }) => (
<button
key={id}
onClick={() => setScanType(id)}
className={`p-4 rounded-lg border-2 text-left transition-all ${
scanType === id
? 'border-brand-500 bg-brand-500/10'
: 'border-dark-600 bg-dark-900/50 hover:border-dark-500'
}`}
>
<div className="flex items-center gap-2 mb-1">
<Icon className={`w-4 h-4 ${scanType === id ? 'text-brand-400' : 'text-dark-400'}`} />
<span className={`font-medium ${scanType === id ? 'text-white' : 'text-dark-300'}`}>{label}</span>
</div>
<p className="text-xs text-dark-500">{desc}</p>
</button>
))}
</div>
</div>
{/* Row 3: Agent Role Dropdown */}
<div>
<label className="block text-sm font-medium text-dark-200 mb-3">
<Shield className="w-4 h-4 inline mr-1 -mt-0.5" />
Agent Role
</label>
<div className="relative">
<button
onClick={() => setShowRoleDropdown(!showRoleDropdown)}
className="w-full flex items-center justify-between p-3 rounded-lg border border-dark-600 bg-dark-900/50 hover:border-dark-500 transition-colors text-left"
>
<div>
{selectedRole ? (
<>
<span className="text-white font-medium">{selectedRole.name}</span>
<span className="text-dark-500 text-sm ml-2">- {selectedRole.description}</span>
</>
) : (
<span className="text-dark-500">Select an agent role (optional)</span>
)}
</div>
<ChevronDown className={`w-4 h-4 text-dark-400 transition-transform ${showRoleDropdown ? 'rotate-180' : ''}`} />
</button>
{showRoleDropdown && (
<div className="absolute z-20 w-full mt-1 bg-dark-800 border border-dark-600 rounded-lg shadow-xl max-h-72 overflow-y-auto">
{/* None option */}
<button
onClick={() => { setAgentRole(''); setShowRoleDropdown(false) }}
className={`w-full flex items-start gap-3 p-3 text-left hover:bg-dark-700/50 transition-colors border-b border-dark-700/50 ${
!agentRole ? 'bg-dark-700/30' : ''
}`}
>
<div className="w-8 h-8 rounded-lg bg-dark-600 flex items-center justify-center flex-shrink-0 mt-0.5">
<Target className="w-4 h-4 text-dark-400" />
</div>
<div>
<p className="text-dark-300 font-medium">Default Agent</p>
<p className="text-dark-500 text-xs">No specialization - uses general pentest agent</p>
</div>
</button>
{agentRoles.map((role) => (
<button
key={role.id}
onClick={() => { setAgentRole(role.id); setShowRoleDropdown(false) }}
className={`w-full flex items-start gap-3 p-3 text-left hover:bg-dark-700/50 transition-colors ${
agentRole === role.id ? 'bg-brand-500/10 border-l-2 border-brand-500' : ''
}`}
>
<div className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5 ${
agentRole === role.id ? 'bg-brand-500/20' : 'bg-dark-600'
}`}>
<Shield className={`w-4 h-4 ${agentRole === role.id ? 'text-brand-400' : 'text-dark-400'}`} />
</div>
<div className="min-w-0">
<p className={`font-medium ${agentRole === role.id ? 'text-brand-400' : 'text-white'}`}>
{role.name}
</p>
<p className="text-dark-500 text-xs mt-0.5">{role.description}</p>
{role.tools.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1.5">
{role.tools.slice(0, 4).map(tool => (
<span key={tool} className="px-1.5 py-0.5 text-[10px] bg-dark-600 text-dark-300 rounded">
{tool}
</span>
))}
{role.tools.length > 4 && (
<span className="px-1.5 py-0.5 text-[10px] bg-dark-600 text-dark-400 rounded">
+{role.tools.length - 4}
</span>
)}
</div>
)}
</div>
</button>
))}
</div>
)}
</div>
</div>
{/* Row 4: Schedule Configuration */}
<div>
<label className="block text-sm font-medium text-dark-200 mb-3">
<Clock className="w-4 h-4 inline mr-1 -mt-0.5" />
Schedule
</label>
{/* Schedule mode tabs */}
<div className="flex gap-1 p-1 bg-dark-900/50 rounded-lg mb-4">
{[
{ id: 'preset' as const, label: 'Presets' },
{ id: 'days' as const, label: 'Days & Time' },
{ id: 'interval' as const, label: 'Interval' },
].map(tab => (
<button
key={tab.id}
onClick={() => setScheduleMode(tab.id)}
className={`flex-1 py-2 px-3 rounded-md text-sm font-medium transition-all ${
scheduleMode === tab.id
? 'bg-brand-500 text-white shadow-sm'
: 'text-dark-400 hover:text-dark-200'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Preset mode */}
{scheduleMode === 'preset' && (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-2">
{CRON_PRESETS.map(preset => (
<button
key={preset.value}
onClick={() => setCronPreset(preset.value)}
className={`p-3 rounded-lg border text-left transition-all ${
cronPreset === preset.value
? 'border-brand-500 bg-brand-500/10'
: 'border-dark-600 bg-dark-900/30 hover:border-dark-500'
}`}
>
<p className={`text-sm font-medium ${cronPreset === preset.value ? 'text-brand-400' : 'text-dark-200'}`}>
{preset.label}
</p>
<p className="text-xs text-dark-500 mt-0.5">{preset.desc}</p>
</button>
))}
</div>
{cronPreset === 'custom' && (
<Input
label="Custom Cron Expression"
placeholder="*/30 * * * *"
value={customCron}
onChange={(e) => setCustomCron(e.target.value)}
helperText="Format: minute hour day-of-month month day-of-week"
/>
)}
</div>
)}
{/* Days & Time mode */}
{scheduleMode === 'days' && (
<div className="space-y-4">
<div>
<p className="text-sm text-dark-400 mb-2">Select days of the week</p>
<div className="flex gap-2">
{DAYS_OF_WEEK.map(day => (
<button
key={day.id}
onClick={() => toggleDay(day.id)}
className={`flex-1 py-3 rounded-lg border-2 text-center text-sm font-medium transition-all ${
selectedDays.includes(day.id)
? 'border-brand-500 bg-brand-500/15 text-brand-400'
: 'border-dark-600 bg-dark-900/30 text-dark-400 hover:border-dark-500'
}`}
title={day.full}
>
{day.short}
</button>
))}
</div>
<div className="flex gap-2 mt-2">
<button
onClick={() => setSelectedDays([1, 2, 3, 4, 5])}
className="text-xs text-brand-400 hover:text-brand-300 transition-colors"
>
Weekdays
</button>
<span className="text-dark-600">|</span>
<button
onClick={() => setSelectedDays([0, 6])}
className="text-xs text-brand-400 hover:text-brand-300 transition-colors"
>
Weekends
</button>
<span className="text-dark-600">|</span>
<button
onClick={() => setSelectedDays([0, 1, 2, 3, 4, 5, 6])}
className="text-xs text-brand-400 hover:text-brand-300 transition-colors"
>
Every Day
</button>
</div>
</div>
<div>
<p className="text-sm text-dark-400 mb-2">Execution Time</p>
<div className="flex items-center gap-2">
<select
value={executionHour}
onChange={(e) => setExecutionHour(e.target.value)}
className="bg-dark-900 border border-dark-600 rounded-lg px-3 py-2.5 text-white text-sm focus:border-brand-500 focus:outline-none"
>
{Array.from({ length: 24 }, (_, i) => (
<option key={i} value={String(i).padStart(2, '0')}>
{String(i).padStart(2, '0')}
</option>
))}
</select>
<span className="text-dark-400 text-lg font-bold">:</span>
<select
value={executionMinute}
onChange={(e) => setExecutionMinute(e.target.value)}
className="bg-dark-900 border border-dark-600 rounded-lg px-3 py-2.5 text-white text-sm focus:border-brand-500 focus:outline-none"
>
{['00', '15', '30', '45'].map(m => (
<option key={m} value={m}>{m}</option>
))}
</select>
<span className="text-dark-500 text-sm ml-2">UTC</span>
</div>
</div>
{selectedDays.length > 0 && (
<div className="p-3 bg-dark-900/50 rounded-lg border border-dark-700/50">
<p className="text-xs text-dark-400">
Cron: <code className="text-brand-400 bg-dark-700 px-1.5 py-0.5 rounded">
{`${executionMinute} ${executionHour} * * ${selectedDays.sort((a, b) => a - b).join(',')}`}
</code>
</p>
</div>
)}
</div>
)}
{/* Interval mode */}
{scheduleMode === 'interval' && (
<div className="space-y-3">
<div className="grid grid-cols-4 gap-2">
{[
{ label: '15 min', value: '15' },
{ label: '30 min', value: '30' },
{ label: '1 hour', value: '60' },
{ label: '2 hours', value: '120' },
{ label: '4 hours', value: '240' },
{ label: '6 hours', value: '360' },
{ label: '12 hours', value: '720' },
{ label: '24 hours', value: '1440' },
].map(opt => (
<button
key={opt.value}
onClick={() => setIntervalMinutes(opt.value)}
className={`py-2.5 px-3 rounded-lg border text-sm font-medium transition-all ${
intervalMinutes === opt.value
? 'border-brand-500 bg-brand-500/10 text-brand-400'
: 'border-dark-600 bg-dark-900/30 text-dark-400 hover:border-dark-500'
}`}
>
{opt.label}
</button>
))}
</div>
<Input
label="Custom interval (minutes)"
type="number"
min="1"
value={intervalMinutes}
onChange={(e) => setIntervalMinutes(e.target.value)}
helperText={`Scan runs every ${parseInt(intervalMinutes) >= 60 ? `${Math.floor(parseInt(intervalMinutes) / 60)}h ${parseInt(intervalMinutes) % 60}m` : `${intervalMinutes} minutes`}`}
/>
</div>
)}
</div>
{/* Actions */}
<div className="flex items-center justify-between pt-2 border-t border-dark-700">
<p className="text-xs text-dark-500">
{scheduleMode === 'interval'
? `Runs every ${parseInt(intervalMinutes) >= 60 ? `${Math.floor(parseInt(intervalMinutes) / 60)} hour(s)` : `${intervalMinutes} min`}`
: scheduleMode === 'days' && selectedDays.length > 0
? `Runs on ${selectedDays.sort((a,b)=>a-b).map(d => DAYS_OF_WEEK[d].short).join(', ')} at ${executionHour}:${executionMinute}`
: scheduleMode === 'preset' && cronPreset !== 'custom'
? CRON_PRESETS.find(p => p.value === cronPreset)?.desc || ''
: ''
}
</p>
<div className="flex gap-3">
<Button variant="secondary" onClick={() => { setShowForm(false); resetForm() }}>
Cancel
</Button>
<Button onClick={handleCreate} isLoading={isCreating}>
<Plus className="w-4 h-4 mr-2" />
Create Schedule
</Button>
</div>
</div>
</div>
</div>
)}
{/* Summary Stats */}
{jobs.length > 0 && (
<div className="grid grid-cols-3 gap-4">
<div className="bg-dark-800/50 border border-dark-700/50 rounded-lg p-4">
<p className="text-dark-400 text-sm">Total Schedules</p>
<p className="text-2xl font-bold text-white mt-1">{jobs.length}</p>
</div>
<div className="bg-dark-800/50 border border-dark-700/50 rounded-lg p-4">
<p className="text-dark-400 text-sm">Active</p>
<p className="text-2xl font-bold text-green-400 mt-1">{jobs.filter(j => j.status === 'active').length}</p>
</div>
<div className="bg-dark-800/50 border border-dark-700/50 rounded-lg p-4">
<p className="text-dark-400 text-sm">Total Runs</p>
<p className="text-2xl font-bold text-brand-400 mt-1">{jobs.reduce((sum, j) => sum + j.run_count, 0)}</p>
</div>
</div>
)}
{/* Jobs List */}
<div>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white">
Scheduled Jobs
<span className="text-dark-500 text-sm font-normal ml-2">
{jobs.length} job{jobs.length !== 1 ? 's' : ''}
</span>
</h3>
</div>
{loading ? (
<div className="flex items-center justify-center py-16">
<RefreshCw className="w-6 h-6 text-dark-400 animate-spin" />
</div>
) : jobs.length === 0 ? (
<Card>
<div className="text-center py-16">
<div className="w-16 h-16 bg-dark-700/50 rounded-full flex items-center justify-center mx-auto mb-4">
<Calendar className="w-8 h-8 text-dark-500" />
</div>
<p className="text-dark-300 font-medium">No scheduled jobs yet</p>
<p className="text-dark-500 text-sm mt-1 mb-4">Create a schedule to run automated recurring scans</p>
<Button onClick={() => setShowForm(true)}>
<Plus className="w-4 h-4 mr-2" />
Create First Schedule
</Button>
</div>
</Card>
) : (
<div className="space-y-3">
{jobs.map((job) => (
<div
key={job.id}
className="bg-dark-800 border border-dark-700/50 rounded-xl p-5 hover:border-dark-600 transition-colors"
>
<div className="flex items-start justify-between">
<div className="flex items-start gap-4">
{/* Status indicator */}
<div className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 ${
job.status === 'active' ? 'bg-green-500/15' : 'bg-yellow-500/15'
}`}>
{job.status === 'active'
? <Play className="w-5 h-5 text-green-400" />
: <Pause className="w-5 h-5 text-yellow-400" />
}
</div>
<div>
<div className="flex items-center gap-3">
<p className="font-semibold text-white text-lg">{job.id}</p>
<span className={`px-2.5 py-0.5 text-xs rounded-full font-medium ${
job.status === 'active'
? 'bg-green-500/15 text-green-400 border border-green-500/30'
: 'bg-yellow-500/15 text-yellow-400 border border-yellow-500/30'
}`}>
{job.status}
</span>
<span className="px-2 py-0.5 text-xs rounded bg-dark-700 text-dark-300">
{job.scan_type}
</span>
{job.agent_role && (
<span className="px-2 py-0.5 text-xs rounded bg-brand-500/15 text-brand-400 border border-brand-500/30">
{job.agent_role.replace(/_/g, ' ')}
</span>
)}
</div>
<div className="flex items-center gap-4 mt-2 text-sm text-dark-400">
<span className="flex items-center gap-1.5">
<Target className="w-3.5 h-3.5" />
{job.target}
</span>
<span className="flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5" />
{job.schedule}
</span>
{job.run_count > 0 && (
<span className="flex items-center gap-1.5">
<RefreshCw className="w-3.5 h-3.5" />
{job.run_count} run{job.run_count !== 1 ? 's' : ''}
</span>
)}
</div>
{(job.next_run || job.last_run) && (
<div className="flex items-center gap-4 mt-1.5 text-xs text-dark-500">
{job.next_run && (
<span>Next: {new Date(job.next_run).toLocaleString()}</span>
)}
{job.last_run && (
<span>Last: {new Date(job.last_run).toLocaleString()}</span>
)}
</div>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-1">
{job.status === 'active' ? (
<Button variant="ghost" size="sm" onClick={() => handlePause(job.id)} title="Pause schedule">
<Pause className="w-4 h-4 text-yellow-400" />
</Button>
) : (
<Button variant="ghost" size="sm" onClick={() => handleResume(job.id)} title="Resume schedule">
<Play className="w-4 h-4 text-green-400" />
</Button>
)}
{deleteConfirm === job.id ? (
<div className="flex items-center gap-1 ml-2">
<Button variant="ghost" size="sm" onClick={() => handleDelete(job.id)}>
<span className="text-red-400 text-xs font-medium">Confirm</span>
</Button>
<Button variant="ghost" size="sm" onClick={() => setDeleteConfirm(null)}>
<span className="text-dark-400 text-xs">Cancel</span>
</Button>
</div>
) : (
<Button variant="ghost" size="sm" onClick={() => setDeleteConfirm(job.id)} title="Delete schedule">
<Trash2 className="w-4 h-4 text-red-400" />
</Button>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
)
}
+106 -13
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'
import { Save, Shield, Trash2, RefreshCw, AlertTriangle } from 'lucide-react'
import { Save, Shield, Trash2, RefreshCw, AlertTriangle, Brain, Router, Eye } from 'lucide-react'
import Card from '../components/common/Card'
import Button from '../components/common/Button'
import Input from '../components/common/Input'
@@ -8,10 +8,15 @@ interface Settings {
llm_provider: string
has_anthropic_key: boolean
has_openai_key: boolean
has_openrouter_key: boolean
max_concurrent_scans: number
aggressive_mode: boolean
default_scan_type: string
recon_enabled_by_default: boolean
enable_model_routing: boolean
enable_knowledge_augmentation: boolean
enable_browser_validation: boolean
max_output_tokens: number | null
}
interface DbStats {
@@ -26,9 +31,14 @@ export default function SettingsPage() {
const [dbStats, setDbStats] = useState<DbStats | null>(null)
const [apiKey, setApiKey] = useState('')
const [openaiKey, setOpenaiKey] = useState('')
const [openrouterKey, setOpenrouterKey] = useState('')
const [llmProvider, setLlmProvider] = useState('claude')
const [maxConcurrentScans, setMaxConcurrentScans] = useState('3')
const [maxOutputTokens, setMaxOutputTokens] = useState('')
const [aggressiveMode, setAggressiveMode] = useState(false)
const [enableModelRouting, setEnableModelRouting] = useState(false)
const [enableKnowledgeAugmentation, setEnableKnowledgeAugmentation] = useState(false)
const [enableBrowserValidation, setEnableBrowserValidation] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [isClearing, setIsClearing] = useState(false)
const [showClearConfirm, setShowClearConfirm] = useState(false)
@@ -48,6 +58,10 @@ export default function SettingsPage() {
setLlmProvider(data.llm_provider)
setMaxConcurrentScans(String(data.max_concurrent_scans))
setAggressiveMode(data.aggressive_mode)
setEnableModelRouting(data.enable_model_routing ?? false)
setEnableKnowledgeAugmentation(data.enable_knowledge_augmentation ?? false)
setEnableBrowserValidation(data.enable_browser_validation ?? false)
setMaxOutputTokens(data.max_output_tokens ? String(data.max_output_tokens) : '')
}
} catch (error) {
console.error('Failed to fetch settings:', error)
@@ -78,8 +92,13 @@ export default function SettingsPage() {
llm_provider: llmProvider,
anthropic_api_key: apiKey || undefined,
openai_api_key: openaiKey || undefined,
openrouter_api_key: openrouterKey || undefined,
max_concurrent_scans: parseInt(maxConcurrentScans),
aggressive_mode: aggressiveMode
aggressive_mode: aggressiveMode,
enable_model_routing: enableModelRouting,
enable_knowledge_augmentation: enableKnowledgeAugmentation,
enable_browser_validation: enableBrowserValidation,
max_output_tokens: maxOutputTokens ? parseInt(maxOutputTokens) : null
})
})
@@ -88,6 +107,7 @@ export default function SettingsPage() {
setSettings(data)
setApiKey('')
setOpenaiKey('')
setOpenrouterKey('')
setMessage({ type: 'success', text: 'Settings saved successfully!' })
} else {
setMessage({ type: 'error', text: 'Failed to save settings' })
@@ -123,6 +143,15 @@ export default function SettingsPage() {
}
}
const ToggleSwitch = ({ enabled, onToggle }: { enabled: boolean; onToggle: () => void }) => (
<button
onClick={onToggle}
className={`w-12 h-6 rounded-full transition-colors ${enabled ? 'bg-primary-500' : 'bg-dark-700'}`}
>
<div className={`w-5 h-5 bg-white rounded-full shadow-md transform transition-transform ${enabled ? 'translate-x-6' : 'translate-x-0.5'}`} />
</button>
)
return (
<div className="max-w-2xl mx-auto space-y-6 animate-fadeIn">
{/* Status Message */}
@@ -139,14 +168,14 @@ export default function SettingsPage() {
<label className="block text-sm font-medium text-dark-200 mb-2">
LLM Provider
</label>
<div className="flex gap-2">
{['claude', 'openai', 'ollama'].map((provider) => (
<div className="flex gap-2 flex-wrap">
{['claude', 'openai', 'openrouter', 'ollama'].map((provider) => (
<Button
key={provider}
variant={llmProvider === provider ? 'primary' : 'secondary'}
onClick={() => setLlmProvider(provider)}
>
{provider.charAt(0).toUpperCase() + provider.slice(1)}
{provider === 'openrouter' ? 'OpenRouter' : provider.charAt(0).toUpperCase() + provider.slice(1)}
</Button>
))}
</div>
@@ -173,6 +202,72 @@ export default function SettingsPage() {
helperText={settings?.has_openai_key ? 'API key is configured. Enter a new key to update.' : 'Required for OpenAI-powered analysis'}
/>
)}
{llmProvider === 'openrouter' && (
<Input
label="OpenRouter API Key"
type="password"
placeholder={settings?.has_openrouter_key ? '••••••••••••••••' : 'sk-or-...'}
value={openrouterKey}
onChange={(e) => setOpenrouterKey(e.target.value)}
helperText={settings?.has_openrouter_key ? 'API key is configured. Enter a new key to update.' : 'Required for OpenRouter model access'}
/>
)}
<Input
label="Max Output Tokens"
type="number"
min="1024"
max="64000"
placeholder="Default (profile-based)"
value={maxOutputTokens}
onChange={(e) => setMaxOutputTokens(e.target.value)}
helperText="Override max output tokens (up to 64000 for Claude). Leave empty for profile defaults."
/>
</div>
</Card>
{/* Advanced Features */}
<Card title="Advanced Features" subtitle="Optional AI enhancement modules">
<div className="space-y-3">
<div className="flex items-center justify-between p-4 bg-dark-900/50 rounded-lg">
<div className="flex items-center gap-3">
<Router className="w-5 h-5 text-blue-400" />
<div>
<p className="font-medium text-white">Model Routing</p>
<p className="text-sm text-dark-400">
Route tasks to specialized LLM profiles by type (reasoning, analysis, generation)
</p>
</div>
</div>
<ToggleSwitch enabled={enableModelRouting} onToggle={() => setEnableModelRouting(!enableModelRouting)} />
</div>
<div className="flex items-center justify-between p-4 bg-dark-900/50 rounded-lg">
<div className="flex items-center gap-3">
<Brain className="w-5 h-5 text-purple-400" />
<div>
<p className="font-medium text-white">Knowledge Augmentation</p>
<p className="text-sm text-dark-400">
Enrich AI context with bug bounty pattern datasets (19 vuln types)
</p>
</div>
</div>
<ToggleSwitch enabled={enableKnowledgeAugmentation} onToggle={() => setEnableKnowledgeAugmentation(!enableKnowledgeAugmentation)} />
</div>
<div className="flex items-center justify-between p-4 bg-dark-900/50 rounded-lg">
<div className="flex items-center gap-3">
<Eye className="w-5 h-5 text-green-400" />
<div>
<p className="font-medium text-white">Browser Validation</p>
<p className="text-sm text-dark-400">
Playwright-based browser validation with screenshot capture
</p>
</div>
</div>
<ToggleSwitch enabled={enableBrowserValidation} onToggle={() => setEnableBrowserValidation(!enableBrowserValidation)} />
</div>
</div>
</Card>
@@ -196,12 +291,7 @@ export default function SettingsPage() {
Use more payloads and bypass techniques (may be slower)
</p>
</div>
<button
onClick={() => setAggressiveMode(!aggressiveMode)}
className={`w-12 h-6 rounded-full transition-colors ${aggressiveMode ? 'bg-primary-500' : 'bg-dark-700'}`}
>
<div className={`w-5 h-5 bg-white rounded-full shadow-md transform transition-transform ${aggressiveMode ? 'translate-x-6' : 'translate-x-0.5'}`} />
</button>
<ToggleSwitch enabled={aggressiveMode} onToggle={() => setAggressiveMode(!aggressiveMode)} />
</div>
</div>
</Card>
@@ -290,8 +380,11 @@ export default function SettingsPage() {
<div className="text-sm text-dark-400 space-y-1">
<p>Dynamic vulnerability testing driven by AI prompts</p>
<p>50+ vulnerability types across 10 categories</p>
<p>Real-time dashboard with WebSocket updates</p>
<p>Professional HTML/PDF/JSON reports</p>
<p>Multi-provider LLM support (Claude, GPT, OpenRouter, Ollama)</p>
<p>Task-type model routing and knowledge augmentation</p>
<p>Playwright browser validation with screenshot capture</p>
<p>OHVR-structured PoC reporting</p>
<p>Scheduled recurring scans with cron/interval triggers</p>
</div>
</div>
</Card>
File diff suppressed because it is too large Load Diff
+995
View File
@@ -0,0 +1,995 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import {
FlaskConical, ChevronDown, ChevronUp, Loader2, Lock,
AlertTriangle, CheckCircle2, XCircle, Play, Square,
Trash2, Eye, Search, BarChart3, Clock, Target,
Terminal, Shield, Globe, FileText, ChevronRight
} from 'lucide-react'
import { vulnLabApi } from '../services/api'
import type { VulnTypeCategory, VulnLabChallenge, VulnLabStats, VulnLabLogEntry } from '../types'
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 RESULT_BADGE: Record<string, { bg: string; text: string; label: string }> = {
detected: { bg: 'bg-green-500/20', text: 'text-green-400', label: 'Detected' },
not_detected: { bg: 'bg-red-500/20', text: 'text-red-400', label: 'Not Detected' },
error: { bg: 'bg-yellow-500/20', text: 'text-yellow-400', label: 'Error' },
}
const STATUS_BADGE: Record<string, { bg: string; text: string }> = {
running: { bg: 'bg-blue-500/20', text: 'text-blue-400' },
completed: { bg: 'bg-green-500/20', text: 'text-green-400' },
failed: { bg: 'bg-red-500/20', text: 'text-red-400' },
stopped: { bg: 'bg-orange-500/20', text: 'text-orange-400' },
pending: { bg: 'bg-gray-500/20', text: 'text-gray-400' },
}
const LOG_LEVEL_COLORS: Record<string, string> = {
error: 'text-red-400',
warning: 'text-yellow-400',
info: 'text-blue-300',
debug: 'text-dark-500',
critical: 'text-red-500 font-bold',
}
function LogLine({ log }: { log: VulnLabLogEntry }) {
const color = LOG_LEVEL_COLORS[log.level] || 'text-dark-400'
const time = log.time ? new Date(log.time).toLocaleTimeString() : ''
const isLlm = log.source === 'llm'
return (
<div className={`flex gap-2 text-xs font-mono leading-relaxed ${color}`}>
<span className="text-dark-600 shrink-0 w-16">{time}</span>
<span className={`shrink-0 w-12 uppercase ${
log.level === 'error' ? 'text-red-500' :
log.level === 'warning' ? 'text-yellow-500' :
'text-dark-600'
}`}>{log.level}</span>
{isLlm && <span className="text-purple-500 shrink-0">[AI]</span>}
<span className="break-all">{log.message}</span>
</div>
)
}
export default function VulnLabPage() {
const navigate = useNavigate()
// Form state
const [targetUrl, setTargetUrl] = useState('')
const [challengeName, setChallengeName] = useState('')
const [selectedVulnType, setSelectedVulnType] = useState('')
const [showAuth, setShowAuth] = useState(false)
const [authType, setAuthType] = useState('')
const [authValue, setAuthValue] = useState('')
const [notes, setNotes] = useState('')
const [searchFilter, setSearchFilter] = useState('')
// Data state
const [categories, setCategories] = useState<Record<string, VulnTypeCategory>>({})
const [expandedCat, setExpandedCat] = useState<string | null>(null)
const [challenges, setChallenges] = useState<VulnLabChallenge[]>([])
const [stats, setStats] = useState<VulnLabStats | null>(null)
// Running state
const [isRunning, setIsRunning] = useState(false)
const [runningChallengeId, setRunningChallengeId] = useState<string | null>(null)
const [runningStatus, setRunningStatus] = useState<any>(null)
const [runningLogs, setRunningLogs] = useState<VulnLabLogEntry[]>([])
const [error, setError] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<'test' | 'history' | 'stats'>('test')
const [showLogs, setShowLogs] = useState(true)
const [logFilter, setLogFilter] = useState<'all' | 'info' | 'warning' | 'error'>('all')
// History expansion state
const [expandedChallenge, setExpandedChallenge] = useState<string | null>(null)
const [expandedChallengeData, setExpandedChallengeData] = useState<any>(null)
const [loadingChallenge, setLoadingChallenge] = useState(false)
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const logsEndRef = useRef<HTMLDivElement>(null)
const autoScrollRef = useRef(true)
// Load vuln types on mount
useEffect(() => {
vulnLabApi.getTypes().then(data => {
setCategories(data.categories)
}).catch(() => {})
loadChallenges()
loadStats()
}, [])
// Auto-scroll logs
useEffect(() => {
if (autoScrollRef.current && logsEndRef.current) {
logsEndRef.current.scrollIntoView({ behavior: 'smooth' })
}
}, [runningLogs])
// Poll running challenge (3s for faster updates)
useEffect(() => {
if (!runningChallengeId || !isRunning) return
const poll = async () => {
try {
const s = await vulnLabApi.getChallenge(runningChallengeId)
setRunningStatus(s)
if (s.logs) setRunningLogs(s.logs)
if (['completed', 'failed', 'stopped', 'error'].includes(s.status)) {
setIsRunning(false)
if (pollRef.current) clearInterval(pollRef.current)
loadChallenges()
loadStats()
}
} catch {}
}
poll()
pollRef.current = setInterval(poll, 3000)
return () => { if (pollRef.current) clearInterval(pollRef.current) }
}, [runningChallengeId, isRunning])
const loadChallenges = async () => {
try {
const data = await vulnLabApi.listChallenges({ limit: 50 })
setChallenges(data.challenges)
} catch {}
}
const loadStats = async () => {
try {
const data = await vulnLabApi.getStats()
setStats(data)
} catch {}
}
const handleStart = async () => {
if (!targetUrl.trim() || !selectedVulnType) return
setError(null)
setIsRunning(true)
setRunningStatus(null)
setRunningLogs([])
setShowLogs(true)
try {
const resp = await vulnLabApi.run({
target_url: targetUrl.trim(),
vuln_type: selectedVulnType,
challenge_name: challengeName || undefined,
auth_type: authType || undefined,
auth_value: authValue || undefined,
notes: notes || undefined,
})
setRunningChallengeId(resp.challenge_id)
} catch (err: any) {
setError(err?.response?.data?.detail || err?.message || 'Failed to start test')
setIsRunning(false)
}
}
const handleStop = async () => {
if (!runningChallengeId) return
try {
await vulnLabApi.stopChallenge(runningChallengeId)
setIsRunning(false)
} catch {}
}
const handleDelete = async (id: string) => {
try {
await vulnLabApi.deleteChallenge(id)
if (expandedChallenge === id) {
setExpandedChallenge(null)
setExpandedChallengeData(null)
}
loadChallenges()
loadStats()
} catch {}
}
const toggleChallengeExpand = useCallback(async (challengeId: string) => {
if (expandedChallenge === challengeId) {
setExpandedChallenge(null)
setExpandedChallengeData(null)
return
}
setExpandedChallenge(challengeId)
setLoadingChallenge(true)
try {
const data = await vulnLabApi.getChallenge(challengeId)
setExpandedChallengeData(data)
} catch {
setExpandedChallengeData(null)
} finally {
setLoadingChallenge(false)
}
}, [expandedChallenge])
// Get selected vuln type info
const getSelectedVulnInfo = () => {
for (const cat of Object.values(categories)) {
const found = cat.types.find(t => t.key === selectedVulnType)
if (found) return found
}
return null
}
const selectedInfo = getSelectedVulnInfo()
// Filter vuln types by search
const filteredCategories = Object.entries(categories).map(([key, cat]) => {
const filtered = searchFilter
? cat.types.filter(t =>
t.key.includes(searchFilter.toLowerCase()) ||
t.title.toLowerCase().includes(searchFilter.toLowerCase())
)
: cat.types
return { key, ...cat, types: filtered }
}).filter(c => c.types.length > 0)
const formatDuration = (seconds: number | null | undefined) => {
if (!seconds) return '-'
if (seconds < 60) return `${seconds}s`
const m = Math.floor(seconds / 60)
const s = seconds % 60
return `${m}m ${s}s`
}
// Filter running logs
const filteredLogs = logFilter === 'all'
? runningLogs
: runningLogs.filter(l => l.level === logFilter)
return (
<div className="min-h-screen flex flex-col items-center py-8 px-4">
{/* Header */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-16 h-16 bg-purple-500/20 rounded-2xl mb-4">
<FlaskConical className="w-8 h-8 text-purple-400" />
</div>
<h1 className="text-3xl font-bold text-white mb-2">Vulnerability Lab</h1>
<p className="text-dark-400 max-w-lg">
Test individual vulnerability types against labs, CTFs, and PortSwigger challenges.
Track detection performance per vuln type.
</p>
</div>
{/* Tab Bar */}
<div className="flex gap-2 mb-6">
{[
{ key: 'test' as const, label: 'New Test', icon: Play },
{ key: 'history' as const, label: 'History', icon: Clock },
{ key: 'stats' as const, label: 'Stats', icon: BarChart3 },
].map(tab => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium transition-colors ${
activeTab === tab.key
? 'bg-purple-500/20 text-purple-400 border border-purple-500/30'
: 'bg-dark-800 text-dark-400 border border-dark-700 hover:text-white'
}`}
>
<tab.icon className="w-4 h-4" />
{tab.label}
</button>
))}
</div>
{/* ========== NEW TEST TAB ========== */}
{activeTab === 'test' && (
<div className="w-full max-w-3xl">
<div className="bg-dark-800 border border-dark-700 rounded-2xl p-8">
{/* Target URL */}
<div className="mb-6">
<label className="block text-sm font-medium text-dark-300 mb-2">Target URL</label>
<input
type="url"
value={targetUrl}
onChange={e => setTargetUrl(e.target.value)}
placeholder="https://lab.example.com/vuln-page"
disabled={isRunning}
className="w-full px-4 py-4 bg-dark-900 border border-dark-600 rounded-xl text-white text-lg placeholder-dark-500 focus:outline-none focus:border-purple-500 focus:ring-1 focus:ring-purple-500 disabled:opacity-50"
/>
</div>
{/* Challenge Name (optional) */}
<div className="mb-6">
<label className="block text-sm font-medium text-dark-300 mb-2">Challenge Name (optional)</label>
<input
type="text"
value={challengeName}
onChange={e => setChallengeName(e.target.value)}
placeholder={selectedVulnType?.startsWith('xss')
? 'e.g. "Reflected XSS with most tags and attributes blocked"'
: "e.g. PortSwigger Lab: Reflected XSS into HTML context"}
disabled={isRunning}
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-purple-500 disabled:opacity-50"
/>
</div>
{/* Vulnerability Type Selector */}
<div className="mb-6">
<label className="block text-sm font-medium text-dark-300 mb-2">
Vulnerability Type {selectedInfo && (
<span className={`ml-2 px-2 py-0.5 rounded text-xs ${SEVERITY_COLORS[selectedInfo.severity]} text-white`}>
{selectedInfo.severity}
</span>
)}
</label>
{/* Search */}
<div className="relative mb-3">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-dark-500" />
<input
type="text"
value={searchFilter}
onChange={e => setSearchFilter(e.target.value)}
placeholder="Search vuln types..."
disabled={isRunning}
className="w-full pl-10 pr-4 py-2.5 bg-dark-900 border border-dark-600 rounded-lg text-white text-sm placeholder-dark-500 focus:outline-none focus:border-purple-500 disabled:opacity-50"
/>
</div>
{/* Selected indicator */}
{selectedInfo && (
<div className="mb-3 p-3 bg-purple-500/10 border border-purple-500/20 rounded-lg flex items-center justify-between">
<div>
<span className="text-purple-400 font-medium">{selectedInfo.title}</span>
{selectedInfo.cwe_id && (
<span className="ml-2 text-dark-500 text-xs">{selectedInfo.cwe_id}</span>
)}
</div>
<button
onClick={() => setSelectedVulnType('')}
disabled={isRunning}
className="text-dark-500 hover:text-white text-xs"
>
Clear
</button>
</div>
)}
{/* Category accordion */}
<div className="max-h-80 overflow-y-auto border border-dark-600 rounded-xl bg-dark-900">
{filteredCategories.map(cat => (
<div key={cat.key} className="border-b border-dark-700 last:border-b-0">
<button
onClick={() => setExpandedCat(expandedCat === cat.key ? null : cat.key)}
disabled={isRunning}
className="w-full flex items-center justify-between px-4 py-3 text-sm font-medium text-dark-300 hover:text-white hover:bg-dark-800 transition-colors disabled:opacity-50"
>
<span>{cat.label} ({cat.types.length})</span>
{expandedCat === cat.key ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
{expandedCat === cat.key && (
<div className="px-2 pb-2">
{cat.types.map(vtype => (
<button
key={vtype.key}
onClick={() => setSelectedVulnType(vtype.key)}
disabled={isRunning}
className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-colors disabled:opacity-50 ${
selectedVulnType === vtype.key
? 'bg-purple-500/20 text-purple-400'
: 'text-dark-400 hover:bg-dark-800 hover:text-white'
}`}
>
<span className="text-left">{vtype.title}</span>
<div className="flex items-center gap-2">
{vtype.cwe_id && <span className="text-dark-600 text-xs">{vtype.cwe_id}</span>}
<span className={`w-2 h-2 rounded-full ${SEVERITY_COLORS[vtype.severity]}`} />
</div>
</button>
))}
</div>
)}
</div>
))}
</div>
</div>
{/* Auth Section */}
<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-purple-500"
>
<option value="">No Authentication</option>
<option value="bearer">Bearer Token</option>
<option value="cookie">Cookie</option>
<option value="basic">Basic Auth (user:pass)</option>
<option value="header">Custom Header (Name:Value)</option>
</select>
{authType && (
<input
type="text"
value={authValue}
onChange={e => setAuthValue(e.target.value)}
disabled={isRunning}
placeholder={
authType === 'bearer' ? 'eyJhbGciOiJIUzI1NiIs...' :
authType === 'cookie' ? 'session=abc123; token=xyz' :
authType === 'basic' ? 'admin:password123' :
'X-API-Key:your-api-key'
}
className="w-full px-3 py-2 bg-dark-900 border border-dark-600 rounded-lg text-white text-sm placeholder-dark-500 focus:outline-none focus:border-purple-500"
/>
)}
</div>
)}
</div>
{/* Notes */}
<div className="mb-6">
<label className="block text-sm font-medium text-dark-300 mb-2">Notes (optional)</label>
<textarea
value={notes}
onChange={e => setNotes(e.target.value)}
rows={2}
disabled={isRunning}
placeholder={selectedVulnType?.startsWith('xss')
? "Hints: blocked chars/tags, encoding observed, context (e.g. 'angle brackets HTML-encoded, input in onclick attribute')"
: "e.g. PortSwigger Apprentice level, no WAF"}
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-purple-500 disabled:opacity-50"
/>
</div>
{/* Error */}
{error && (
<div className="mb-6 p-3 bg-red-500/10 border border-red-500/20 rounded-lg flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-red-400" />
<span className="text-red-400 text-sm">{error}</span>
</div>
)}
{/* Start/Stop */}
{!isRunning ? (
<button
onClick={handleStart}
disabled={!targetUrl.trim() || !selectedVulnType}
className="w-full py-4 bg-purple-500 hover:bg-purple-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"
>
<FlaskConical className="w-6 h-6" />
START TEST
</button>
) : (
<button
onClick={handleStop}
className="w-full py-4 bg-red-500 hover:bg-red-600 text-white font-bold text-lg rounded-xl transition-colors flex items-center justify-center gap-3"
>
<Square className="w-6 h-6" />
STOP TEST
</button>
)}
</div>
{/* Running Progress + Live Logs */}
{runningStatus && (
<div className="mt-6 space-y-4">
{/* Status Card */}
<div className="bg-dark-800 border border-dark-700 rounded-2xl p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-white font-semibold flex items-center gap-2">
{runningStatus.status === 'running' && <Loader2 className="w-5 h-5 animate-spin text-purple-400" />}
{runningStatus.status === 'completed' && <CheckCircle2 className="w-5 h-5 text-green-400" />}
{['failed', 'error'].includes(runningStatus.status) && <XCircle className="w-5 h-5 text-red-400" />}
{runningStatus.status === 'stopped' && <Square className="w-5 h-5 text-orange-400" />}
{selectedInfo?.title || selectedVulnType}
</h3>
<span className="text-sm text-dark-400">{runningStatus.progress || 0}%</span>
</div>
{/* Progress bar */}
<div className="w-full bg-dark-900 rounded-full h-2 mb-4">
<div
className={`h-2 rounded-full transition-all duration-500 ${
runningStatus.status === 'completed' ? 'bg-green-500' :
runningStatus.status === 'error' || runningStatus.status === 'failed' ? 'bg-red-500' :
'bg-purple-500'
}`}
style={{ width: `${runningStatus.progress || 0}%` }}
/>
</div>
{/* Info row */}
<div className="flex items-center gap-4 text-sm text-dark-400 mb-3">
{runningStatus.phase && (
<span className="flex items-center gap-1">
<Shield className="w-3.5 h-3.5" />
{runningStatus.phase}
</span>
)}
<span className="flex items-center gap-1">
<Terminal className="w-3.5 h-3.5" />
{runningLogs.length} log entries
</span>
{runningStatus.findings_count > 0 && (
<span className="flex items-center gap-1 text-green-400">
<AlertTriangle className="w-3.5 h-3.5" />
{runningStatus.findings_count} finding(s)
</span>
)}
</div>
{/* Findings preview */}
{runningStatus.findings && runningStatus.findings.length > 0 && (
<div className="mb-3 space-y-2">
{runningStatus.findings.slice(-3).map((f: any, i: number) => (
<div key={i} className="p-2 bg-green-500/5 border border-green-500/20 rounded-lg">
<div className="flex items-center gap-2">
<span className={`px-1.5 py-0.5 rounded text-xs font-bold ${SEVERITY_COLORS[f.severity || 'medium']} text-white`}>
{(f.severity || 'medium').toUpperCase()}
</span>
<span className="text-sm text-green-300">{f.title || f.vulnerability_type || 'Finding'}</span>
</div>
{f.affected_endpoint && (
<p className="text-xs text-dark-500 mt-1 truncate">{f.affected_endpoint}</p>
)}
</div>
))}
</div>
)}
{/* Result badge on completion */}
{runningStatus.result && (
<div className="mt-4 flex items-center gap-3">
<span className={`px-3 py-1 rounded-full text-sm font-medium ${
RESULT_BADGE[runningStatus.result]?.bg || 'bg-gray-500/20'
} ${RESULT_BADGE[runningStatus.result]?.text || 'text-gray-400'}`}>
{RESULT_BADGE[runningStatus.result]?.label || runningStatus.result}
</span>
{runningStatus.scan_id && (
<button
onClick={() => navigate(`/scan/${runningStatus.scan_id}`)}
className="text-sm text-purple-400 hover:text-purple-300 flex items-center gap-1"
>
<Eye className="w-4 h-4" /> View Scan Details
</button>
)}
</div>
)}
{/* Error */}
{runningStatus.error && (
<div className="mt-3 p-2 bg-red-500/10 border border-red-500/20 rounded text-red-400 text-sm">
{runningStatus.error}
</div>
)}
</div>
{/* Live Logs Panel */}
<div className="bg-dark-800 border border-dark-700 rounded-2xl overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700">
<button
onClick={() => setShowLogs(!showLogs)}
className="flex items-center gap-2 text-sm font-medium text-dark-300 hover:text-white transition-colors"
>
<Terminal className="w-4 h-4 text-purple-400" />
Live Agent Logs
<span className="text-dark-600 text-xs">({runningLogs.length})</span>
{showLogs ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
{showLogs && (
<div className="flex items-center gap-1">
{(['all', 'info', 'warning', 'error'] as const).map(level => (
<button
key={level}
onClick={() => setLogFilter(level)}
className={`px-2 py-1 rounded text-xs transition-colors ${
logFilter === level
? 'bg-purple-500/20 text-purple-400'
: 'text-dark-500 hover:text-white'
}`}
>
{level}
</button>
))}
</div>
)}
</div>
{showLogs && (
<div
className="p-3 bg-dark-900 max-h-80 overflow-y-auto space-y-0.5"
onScroll={(e) => {
const el = e.currentTarget
autoScrollRef.current = el.scrollTop + el.clientHeight >= el.scrollHeight - 50
}}
>
{filteredLogs.length === 0 ? (
<p className="text-dark-600 text-xs font-mono">Waiting for logs...</p>
) : (
filteredLogs.map((log, i) => <LogLine key={i} log={log} />)
)}
<div ref={logsEndRef} />
</div>
)}
</div>
</div>
)}
</div>
)}
{/* ========== HISTORY TAB ========== */}
{activeTab === 'history' && (
<div className="w-full max-w-4xl">
<div className="bg-dark-800 border border-dark-700 rounded-2xl overflow-hidden">
<div className="p-4 border-b border-dark-700 flex items-center justify-between">
<h3 className="text-white font-semibold">Challenge History ({challenges.length})</h3>
<button
onClick={loadChallenges}
className="text-sm text-dark-400 hover:text-white"
>
Refresh
</button>
</div>
{challenges.length === 0 ? (
<div className="p-8 text-center text-dark-500">
No challenges yet. Start your first test!
</div>
) : (
<div className="divide-y divide-dark-700">
{challenges.map(ch => {
const statusBadge = STATUS_BADGE[ch.status] || STATUS_BADGE.pending
const resultBadge = ch.result ? RESULT_BADGE[ch.result] : null
const isExpanded = expandedChallenge === ch.id
return (
<div key={ch.id}>
{/* Challenge row */}
<div
className={`p-4 cursor-pointer transition-colors ${
isExpanded ? 'bg-dark-900/80' : 'hover:bg-dark-900/50'
}`}
onClick={() => toggleChallengeExpand(ch.id)}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<ChevronRight className={`w-4 h-4 text-dark-500 transition-transform ${isExpanded ? 'rotate-90' : ''}`} />
<span className="text-white font-medium">
{ch.challenge_name || ch.vuln_type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())}
</span>
<span className={`px-2 py-0.5 rounded text-xs ${statusBadge.bg} ${statusBadge.text}`}>
{ch.status}
</span>
{resultBadge && (
<span className={`px-2 py-0.5 rounded text-xs ${resultBadge.bg} ${resultBadge.text}`}>
{resultBadge.label}
</span>
)}
</div>
<div className="flex items-center gap-2" onClick={e => e.stopPropagation()}>
{ch.scan_id && (
<button
onClick={() => navigate(`/scan/${ch.scan_id}`)}
className="p-1.5 text-dark-400 hover:text-white rounded"
title="View scan details"
>
<Eye className="w-4 h-4" />
</button>
)}
<button
onClick={() => handleDelete(ch.id)}
className="p-1.5 text-dark-400 hover:text-red-400 rounded"
title="Delete"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
<div className="flex items-center gap-4 text-xs text-dark-500 ml-7">
<span className="flex items-center gap-1">
<Target className="w-3 h-3" />
{ch.target_url.length > 50 ? ch.target_url.slice(0, 50) + '...' : ch.target_url}
</span>
<span className="text-dark-600">|</span>
<span>{ch.vuln_type}</span>
{ch.vuln_category && (
<>
<span className="text-dark-600">|</span>
<span>{ch.vuln_category}</span>
</>
)}
<span className="text-dark-600">|</span>
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{formatDuration(ch.duration)}
</span>
{(ch.endpoints_count ?? 0) > 0 && (
<>
<span className="text-dark-600">|</span>
<span className="flex items-center gap-1">
<Globe className="w-3 h-3" />
{ch.endpoints_count} endpoints
</span>
</>
)}
{(ch.logs_count ?? 0) > 0 && (
<>
<span className="text-dark-600">|</span>
<span className="flex items-center gap-1">
<Terminal className="w-3 h-3" />
{ch.logs_count} logs
</span>
</>
)}
</div>
{/* Findings summary */}
{ch.findings_count > 0 && (
<div className="flex gap-2 mt-2 ml-7">
{(['critical', 'high', 'medium', 'low', 'info'] as const).map(sev => {
const count = ch[`${sev}_count` as keyof VulnLabChallenge] as number
if (!count) return null
return (
<span key={sev} className={`${SEVERITY_COLORS[sev]} text-white px-2 py-0.5 rounded text-xs font-bold`}>
{count} {sev}
</span>
)
})}
</div>
)}
</div>
{/* Expanded detail section */}
{isExpanded && (
<div className="border-t border-dark-700 bg-dark-900/50">
{loadingChallenge ? (
<div className="p-6 flex items-center justify-center gap-2 text-dark-400">
<Loader2 className="w-5 h-5 animate-spin" />
Loading details...
</div>
) : expandedChallengeData ? (
<div className="p-4 space-y-4">
{/* Findings Detail */}
{(expandedChallengeData.findings_detail || expandedChallengeData.findings || []).length > 0 && (
<div>
<h4 className="text-sm font-medium text-dark-300 mb-2 flex items-center gap-2">
<Shield className="w-4 h-4 text-green-400" />
Findings ({(expandedChallengeData.findings_detail || expandedChallengeData.findings || []).length})
</h4>
<div className="space-y-2">
{(expandedChallengeData.findings_detail || expandedChallengeData.findings || []).map((f: any, i: number) => (
<div key={i} className="p-3 bg-dark-800 border border-dark-700 rounded-lg">
<div className="flex items-center gap-2 mb-1">
<span className={`px-1.5 py-0.5 rounded text-xs font-bold ${SEVERITY_COLORS[f.severity || 'medium']} text-white`}>
{(f.severity || 'medium').toUpperCase()}
</span>
<span className="text-sm text-white font-medium">
{f.title || f.vulnerability_type || 'Finding'}
</span>
</div>
{f.vulnerability_type && (
<p className="text-xs text-dark-500 mb-1">Type: {f.vulnerability_type}</p>
)}
{f.affected_endpoint && (
<p className="text-xs text-dark-400 mb-1 flex items-center gap-1">
<Globe className="w-3 h-3" />
{f.affected_endpoint}
</p>
)}
{f.payload && (
<div className="mt-1">
<span className="text-xs text-dark-600">Payload: </span>
<code className="text-xs text-purple-400 bg-dark-900 px-1.5 py-0.5 rounded break-all">
{f.payload}
</code>
</div>
)}
{f.evidence && (
<div className="mt-1">
<span className="text-xs text-dark-600">Evidence: </span>
<span className="text-xs text-dark-400">{f.evidence.slice(0, 300)}</span>
</div>
)}
</div>
))}
</div>
</div>
)}
{/* No findings message */}
{(expandedChallengeData.findings_detail || expandedChallengeData.findings || []).length === 0 &&
expandedChallengeData.status !== 'running' && (
<div className="p-3 bg-dark-800 border border-dark-700 rounded-lg text-center text-dark-500 text-sm">
No findings detected for this challenge.
</div>
)}
{/* Agent Logs */}
{(expandedChallengeData.logs || []).length > 0 && (
<ChallengeLogsViewer logs={expandedChallengeData.logs} />
)}
{/* Notes */}
{expandedChallengeData.notes && (
<div className="p-3 bg-dark-800 border border-dark-700 rounded-lg">
<h4 className="text-xs font-medium text-dark-500 mb-1 flex items-center gap-1">
<FileText className="w-3 h-3" /> Notes
</h4>
<p className="text-sm text-dark-300">{expandedChallengeData.notes}</p>
</div>
)}
</div>
) : (
<div className="p-6 text-center text-dark-500 text-sm">
Failed to load challenge details.
</div>
)}
</div>
)}
</div>
)
})}
</div>
)}
</div>
</div>
)}
{/* ========== STATS TAB ========== */}
{activeTab === 'stats' && (
<div className="w-full max-w-4xl">
{!stats ? (
<div className="text-center text-dark-500 py-12">Loading stats...</div>
) : (
<div className="space-y-6">
{/* Overview cards */}
<div className="grid grid-cols-4 gap-4">
{[
{ label: 'Total Tests', value: stats.total, color: 'text-white' },
{ label: 'Running', value: stats.running, color: 'text-blue-400' },
{ label: 'Detection Rate', value: `${stats.detection_rate}%`, color: 'text-green-400' },
{ label: 'Detected', value: stats.result_counts?.detected || 0, color: 'text-green-400' },
].map((card, i) => (
<div key={i} className="bg-dark-800 border border-dark-700 rounded-xl p-4 text-center">
<div className={`text-2xl font-bold ${card.color}`}>{card.value}</div>
<div className="text-xs text-dark-500 mt-1">{card.label}</div>
</div>
))}
</div>
{/* Per-category breakdown */}
{Object.keys(stats.by_category).length > 0 && (
<div className="bg-dark-800 border border-dark-700 rounded-2xl p-6">
<h3 className="text-white font-semibold mb-4">Detection by Category</h3>
<div className="space-y-3">
{Object.entries(stats.by_category).map(([cat, data]) => {
const rate = data.total > 0 ? Math.round(data.detected / data.total * 100) : 0
const catLabel = categories[cat]?.label || cat
return (
<div key={cat}>
<div className="flex items-center justify-between mb-1">
<span className="text-sm text-dark-300">{catLabel}</span>
<span className="text-sm text-dark-400">
{data.detected}/{data.total} ({rate}%)
</span>
</div>
<div className="w-full bg-dark-900 rounded-full h-2">
<div
className={`h-2 rounded-full transition-all ${rate >= 70 ? 'bg-green-500' : rate >= 40 ? 'bg-yellow-500' : 'bg-red-500'}`}
style={{ width: `${rate}%` }}
/>
</div>
</div>
)
})}
</div>
</div>
)}
{/* Per-type breakdown */}
{Object.keys(stats.by_type).length > 0 && (
<div className="bg-dark-800 border border-dark-700 rounded-2xl p-6">
<h3 className="text-white font-semibold mb-4">Detection by Vulnerability Type</h3>
<div className="grid grid-cols-2 gap-3">
{Object.entries(stats.by_type).map(([vtype, data]) => {
const rate = data.total > 0 ? Math.round(data.detected / data.total * 100) : 0
return (
<div key={vtype} className="flex items-center justify-between p-2 bg-dark-900 rounded-lg">
<span className="text-sm text-dark-300">{vtype.replace(/_/g, ' ')}</span>
<div className="flex items-center gap-2">
<span className={`text-xs font-bold ${rate >= 70 ? 'text-green-400' : rate >= 40 ? 'text-yellow-400' : 'text-red-400'}`}>
{rate}%
</span>
<span className="text-xs text-dark-600">({data.detected}/{data.total})</span>
</div>
</div>
)
})}
</div>
</div>
)}
{stats.total === 0 && (
<div className="text-center text-dark-500 py-8">
No test data yet. Run some vulnerability tests to see stats!
</div>
)}
</div>
)}
</div>
)}
</div>
)
}
/* ===== Challenge Logs Viewer Component ===== */
function ChallengeLogsViewer({ logs }: { logs: VulnLabLogEntry[] }) {
const [expanded, setExpanded] = useState(false)
const [filter, setFilter] = useState<'all' | 'info' | 'warning' | 'error'>('all')
const filtered = filter === 'all' ? logs : logs.filter(l => l.level === filter)
const displayed = expanded ? filtered : filtered.slice(-30)
const errorCount = logs.filter(l => l.level === 'error').length
const warnCount = logs.filter(l => l.level === 'warning').length
return (
<div className="border border-dark-700 rounded-lg overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 bg-dark-800 border-b border-dark-700">
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-2 text-sm font-medium text-dark-300 hover:text-white transition-colors"
>
<Terminal className="w-4 h-4 text-purple-400" />
Agent Logs ({logs.length})
{errorCount > 0 && <span className="text-red-400 text-xs">({errorCount} errors)</span>}
{warnCount > 0 && <span className="text-yellow-400 text-xs">({warnCount} warnings)</span>}
{expanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
</button>
<div className="flex items-center gap-1">
{(['all', 'info', 'warning', 'error'] as const).map(level => (
<button
key={level}
onClick={() => setFilter(level)}
className={`px-2 py-0.5 rounded text-xs transition-colors ${
filter === level
? 'bg-purple-500/20 text-purple-400'
: 'text-dark-600 hover:text-white'
}`}
>
{level}
</button>
))}
</div>
</div>
<div className={`p-2 bg-dark-900 overflow-y-auto space-y-0.5 ${expanded ? 'max-h-96' : 'max-h-48'}`}>
{!expanded && filtered.length > 30 && (
<p className="text-dark-600 text-xs font-mono mb-1">
... {filtered.length - 30} older entries hidden (click to expand)
</p>
)}
{displayed.map((log, i) => <LogLine key={i} log={log} />)}
{displayed.length === 0 && (
<p className="text-dark-600 text-xs font-mono">No logs matching filter.</p>
)}
</div>
</div>
)
}
+248 -1
View File
@@ -2,7 +2,9 @@ import axios from 'axios'
import type {
Scan, Vulnerability, Prompt, PromptPreset, Report, DashboardStats,
AgentTask, AgentRequest, AgentResponse, AgentStatus, AgentLog, AgentMode,
ScanAgentTask, ActivityFeedItem
ScanAgentTask, ActivityFeedItem, ScheduleJob, ScheduleJobRequest, AgentRole,
VulnLabChallenge, VulnLabRunRequest, VulnLabRunResponse, VulnLabRealtimeStatus,
VulnTypeCategory, VulnLabStats, SandboxPoolStatus
} from '../types'
const api = axios.create({
@@ -48,11 +50,26 @@ export const scansApi = {
return response.data
},
pause: async (scanId: string) => {
const response = await api.post(`/scans/${scanId}/pause`)
return response.data
},
resume: async (scanId: string) => {
const response = await api.post(`/scans/${scanId}/resume`)
return response.data
},
delete: async (scanId: string) => {
const response = await api.delete(`/scans/${scanId}`)
return response.data
},
skipToPhase: async (scanId: string, phase: string) => {
const response = await api.post(`/scans/${scanId}/skip-to/${phase}`)
return response.data
},
getEndpoints: async (scanId: string, page = 1, perPage = 50) => {
const response = await api.get(`/scans/${scanId}/endpoints?page=${page}&per_page=${perPage}`)
return response.data
@@ -154,10 +171,20 @@ export const reportsApi = {
return response.data
},
generateAiReport: async (data: {
scan_id: string
title?: string
}): Promise<Report> => {
const response = await api.post('/reports/ai-generate', data)
return response.data
},
getViewUrl: (reportId: string) => `/api/v1/reports/${reportId}/view`,
getDownloadUrl: (reportId: string, format: string) => `/api/v1/reports/${reportId}/download/${format}`,
getDownloadZipUrl: (reportId: string) => `/api/v1/reports/${reportId}/download-zip`,
delete: async (reportId: string) => {
const response = await api.delete(`/reports/${reportId}`)
return response.data
@@ -210,6 +237,14 @@ export const vulnerabilitiesApi = {
const response = await api.get(`/vulnerabilities/${vulnId}`)
return response.data
},
validate: async (vulnId: string, validationStatus: string, notes?: string) => {
const response = await api.patch(`/scans/vulnerabilities/${vulnId}/validate`, {
validation_status: validationStatus,
notes,
})
return response.data
},
}
// Scan Agent Tasks API (for tracking scan-specific tasks)
@@ -261,6 +296,16 @@ export const agentApi = {
return response.data
},
// Get agent status by scan_id (reverse lookup)
getByScan: async (scanId: string): Promise<AgentStatus | null> => {
try {
const response = await api.get(`/agent/by-scan/${scanId}`)
return response.data
} catch {
return null
}
},
// Get agent logs
getLogs: async (agentId: string, limit = 100): Promise<{ agent_id: string; total_logs: number; logs: AgentLog[] }> => {
const response = await api.get(`/agent/logs/${agentId}?limit=${limit}`)
@@ -285,12 +330,44 @@ export const agentApi = {
return response.data
},
// Pause a running agent
pause: async (agentId: string) => {
const response = await api.post(`/agent/pause/${agentId}`)
return response.data
},
// Resume a paused agent
resume: async (agentId: string) => {
const response = await api.post(`/agent/resume/${agentId}`)
return response.data
},
// Skip to a specific phase
skipToPhase: async (agentId: string, phase: string) => {
const response = await api.post(`/agent/skip-to/${agentId}/${phase}`)
return response.data
},
// Send custom prompt to agent
sendPrompt: async (agentId: string, prompt: string) => {
const response = await api.post(`/agent/prompt/${agentId}`, { prompt })
return response.data
},
// One-click auto pentest
autoPentest: async (target: string, options?: { subdomain_discovery?: boolean; targets?: string[]; auth_type?: string; auth_value?: string; prompt?: string }): Promise<AgentResponse> => {
const response = await api.post('/agent/run', {
target,
mode: 'auto_pentest',
subdomain_discovery: options?.subdomain_discovery || false,
targets: options?.targets,
auth_type: options?.auth_type,
auth_value: options?.auth_value,
prompt: options?.prompt,
})
return response.data
},
// Quick synchronous run (for small targets)
quickRun: async (target: string, mode: AgentMode = 'full_auto') => {
const response = await api.post(`/agent/quick?target=${encodeURIComponent(target)}&mode=${mode}`)
@@ -393,4 +470,174 @@ export const agentApi = {
},
}
// Vulnerability Lab API
export const vulnLabApi = {
getTypes: async (): Promise<{ categories: Record<string, VulnTypeCategory>; total_types: number }> => {
const response = await api.get('/vuln-lab/types')
return response.data
},
run: async (request: VulnLabRunRequest): Promise<VulnLabRunResponse> => {
const response = await api.post('/vuln-lab/run', request)
return response.data
},
listChallenges: async (filters?: {
vuln_type?: string
vuln_category?: string
status?: string
result?: string
limit?: number
}): Promise<{ challenges: VulnLabChallenge[]; total: number }> => {
const params = new URLSearchParams()
if (filters?.vuln_type) params.append('vuln_type', filters.vuln_type)
if (filters?.vuln_category) params.append('vuln_category', filters.vuln_category)
if (filters?.status) params.append('status', filters.status)
if (filters?.result) params.append('result', filters.result)
if (filters?.limit) params.append('limit', String(filters.limit))
const qs = params.toString()
const response = await api.get(`/vuln-lab/challenges${qs ? `?${qs}` : ''}`)
return response.data
},
getChallenge: async (challengeId: string): Promise<VulnLabRealtimeStatus | VulnLabChallenge> => {
const response = await api.get(`/vuln-lab/challenges/${challengeId}`)
return response.data
},
getStats: async (): Promise<VulnLabStats> => {
const response = await api.get('/vuln-lab/stats')
return response.data
},
stopChallenge: async (challengeId: string) => {
const response = await api.post(`/vuln-lab/challenges/${challengeId}/stop`)
return response.data
},
deleteChallenge: async (challengeId: string) => {
const response = await api.delete(`/vuln-lab/challenges/${challengeId}`)
return response.data
},
getLogs: async (challengeId: string, limit = 100) => {
const response = await api.get(`/vuln-lab/logs/${challengeId}?limit=${limit}`)
return response.data
},
}
// Scheduler API
export const schedulerApi = {
list: async (): Promise<ScheduleJob[]> => {
const response = await api.get('/scheduler/')
return response.data
},
create: async (data: ScheduleJobRequest): Promise<ScheduleJob> => {
const response = await api.post('/scheduler/', data)
return response.data
},
delete: async (jobId: string) => {
const response = await api.delete(`/scheduler/${jobId}`)
return response.data
},
pause: async (jobId: string) => {
const response = await api.post(`/scheduler/${jobId}/pause`)
return response.data
},
resume: async (jobId: string) => {
const response = await api.post(`/scheduler/${jobId}/resume`)
return response.data
},
getAgentRoles: async (): Promise<AgentRole[]> => {
const response = await api.get('/scheduler/agent-roles')
return response.data
},
}
// Terminal Agent API
export const terminalApi = {
createSession: async (target: string, name?: string, template_id?: string) => {
const response = await api.post('/terminal/session', { target, name, template_id })
return response.data
},
listSessions: async () => {
const response = await api.get('/terminal/sessions')
return response.data
},
getSession: async (sessionId: string) => {
const response = await api.get(`/terminal/sessions/${sessionId}`)
return response.data
},
deleteSession: async (sessionId: string) => {
const response = await api.delete(`/terminal/sessions/${sessionId}`)
return response.data
},
sendMessage: async (sessionId: string, message: string) => {
const response = await api.post(`/terminal/sessions/${sessionId}/message`, { message })
return response.data
},
executeCommand: async (sessionId: string, command: string, execution_method: string) => {
const response = await api.post(`/terminal/sessions/${sessionId}/execute`, { command, execution_method })
return response.data
},
addExploitationStep: async (sessionId: string, step: { description: string; command: string; result: string; step_type: string }) => {
const response = await api.post(`/terminal/sessions/${sessionId}/exploitation-path`, step)
return response.data
},
getExploitationPath: async (sessionId: string) => {
const response = await api.get(`/terminal/sessions/${sessionId}/exploitation-path`)
return response.data
},
getVpnStatus: async (sessionId: string) => {
const response = await api.get(`/terminal/sessions/${sessionId}/vpn-status`)
return response.data
},
listTemplates: async () => {
const response = await api.get('/terminal/templates')
return response.data
},
}
// Sandbox API
export const sandboxApi = {
list: async (): Promise<SandboxPoolStatus> => {
const response = await api.get('/sandbox/')
return response.data
},
healthCheck: async (scanId: string) => {
const response = await api.get(`/sandbox/${scanId}`)
return response.data
},
destroy: async (scanId: string) => {
const response = await api.delete(`/sandbox/${scanId}`)
return response.data
},
cleanup: async () => {
const response = await api.post('/sandbox/cleanup')
return response.data
},
cleanupOrphans: async () => {
const response = await api.post('/sandbox/cleanup-orphans')
return response.data
},
}
export default api
+176 -3
View File
@@ -2,7 +2,7 @@
export interface Scan {
id: string
name: string | null
status: 'pending' | 'running' | 'completed' | 'failed' | 'stopped'
status: 'pending' | 'running' | 'paused' | 'completed' | 'failed' | 'stopped'
scan_type: 'quick' | 'full' | 'custom'
recon_enabled: boolean
progress: number
@@ -52,10 +52,19 @@ export interface Vulnerability {
poc_request: string | null
poc_response: string | null
poc_payload: string | null
poc_parameter: string | null
poc_evidence: string | null
impact: string | null
remediation: string | null
references: string[]
ai_analysis: string | null
poc_code?: string | null
validation_status?: 'ai_confirmed' | 'ai_rejected' | 'validated' | 'false_positive' | 'pending_review'
ai_rejection_reason?: string | null
confidence_score?: number // 0-100 numeric (from agent findings)
confidence_breakdown?: Record<string, number>
proof_of_execution?: string
negative_controls?: string
created_at: string
}
@@ -160,7 +169,7 @@ export interface ScanAgentTask {
}
// Agent types
export type AgentMode = 'full_auto' | 'recon_only' | 'prompt_only' | 'analyze_only'
export type AgentMode = 'full_auto' | 'recon_only' | 'prompt_only' | 'analyze_only' | 'auto_pentest'
export interface AgentTask {
id: string
@@ -186,6 +195,8 @@ export interface AgentRequest {
auth_value?: string
custom_headers?: Record<string, string>
max_depth?: number
subdomain_discovery?: boolean
targets?: string[]
}
export interface AgentResponse {
@@ -198,7 +209,7 @@ export interface AgentResponse {
export interface AgentStatus {
agent_id: string
scan_id?: string // Link to database scan
status: 'running' | 'completed' | 'error' | 'stopped'
status: 'running' | 'paused' | 'completed' | 'error' | 'stopped'
mode: string
target: string
task?: string
@@ -209,6 +220,8 @@ export interface AgentStatus {
logs_count: number
findings_count: number
findings: AgentFinding[]
rejected_findings_count?: number
rejected_findings?: AgentFinding[]
report?: AgentReport
error?: string
}
@@ -234,6 +247,12 @@ export interface AgentFinding {
references: string[]
ai_verified: boolean
confidence?: string
confidence_score?: number // 0-100 numeric
confidence_breakdown?: Record<string, number> // Scoring breakdown
proof_of_execution?: string // What proof was found
negative_controls?: string // Control test results
ai_status?: 'confirmed' | 'rejected' | 'pending'
rejection_reason?: string
}
export interface AgentReport {
@@ -316,6 +335,160 @@ export interface RealtimeSessionSummary {
messages_count: number
}
// Agent Role type (from config.json)
export interface AgentRole {
id: string
name: string
description: string
tools: string[]
}
// Scheduler types
export interface ScheduleJob {
id: string
target: string
scan_type: string
schedule: string
status: 'active' | 'paused'
next_run: string | null
last_run: string | null
run_count: number
agent_role: string | null
llm_profile: string | null
}
export interface ScheduleJobRequest {
job_id: string
target: string
scan_type: string
cron_expression?: string
interval_minutes?: number
agent_role?: string
llm_profile?: string
}
// Vulnerability Lab types
export interface VulnLabLogEntry {
level: string
message: string
time: string
source: string
}
export interface VulnLabChallenge {
id: string
target_url: string
challenge_name: string | null
vuln_type: string
vuln_category: string | null
auth_type: string | null
status: 'pending' | 'running' | 'paused' | 'completed' | 'failed' | 'stopped'
result: 'detected' | 'not_detected' | 'error' | null
agent_id: string | null
scan_id: string | null
findings_count: number
critical_count: number
high_count: number
medium_count: number
low_count: number
info_count: number
findings_detail: Array<{
title: string
vulnerability_type: string
severity: string
affected_endpoint: string
evidence: string
payload?: string
}>
started_at: string | null
completed_at: string | null
duration: number | null
notes: string | null
logs?: VulnLabLogEntry[]
logs_count?: number
endpoints_count?: number
created_at: string | null
}
export interface VulnLabRunRequest {
target_url: string
vuln_type: string
challenge_name?: string
auth_type?: string
auth_value?: string
custom_headers?: Record<string, string>
notes?: string
}
export interface VulnLabRunResponse {
challenge_id: string
agent_id: string
status: string
message: string
}
export interface VulnLabRealtimeStatus {
challenge_id: string
status: string
progress: number
phase: string
findings_count: number
findings: any[]
logs_count: number
logs?: VulnLabLogEntry[]
error: string | null
result: string | null
scan_id: string | null
agent_id: string | null
vuln_type?: string
target?: string
source: string
}
export interface VulnTypeCategory {
label: string
types: Array<{
key: string
title: string
severity: string
cwe_id: string
description: string
}>
count: number
}
export interface VulnLabStats {
total: number
running: number
status_counts: Record<string, number>
result_counts: Record<string, number>
detection_rate: number
by_type: Record<string, { detected: number; not_detected: number; error: number; total: number }>
by_category: Record<string, { detected: number; not_detected: number; error: number; total: number }>
}
// Sandbox Container types
export interface SandboxContainer {
scan_id: string
container_name: string
available: boolean
installed_tools: string[]
created_at: string | null
uptime_seconds: number
}
export interface SandboxPoolStatus {
pool: {
active: number
max_concurrent: number
image: string
container_ttl_minutes: number
docker_available: boolean
}
containers: SandboxContainer[]
error?: string
}
// Activity Feed types
export interface ActivityFeedItem {
type: 'scan' | 'vulnerability' | 'agent_task' | 'report'