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
docker/Dockerfile.kali Normal file
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
docker/Dockerfile.sandbox Normal file
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"]

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

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"

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

641
frontend/dist/assets/index-DScaoRL2.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

5
frontend/dist/favicon.svg vendored Normal file
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
frontend/dist/index.html vendored Normal file
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
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

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 />} />

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' },
]

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>
)
})}

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>
)
}

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>
))
)}

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>
)
}

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>

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>
)
}

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

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>
)
}

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

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'