mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-02-12 14:02:45 +00:00
Add files via upload
This commit is contained in:
123
docker/Dockerfile.kali
Normal file
123
docker/Dockerfile.kali
Normal 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
98
docker/Dockerfile.sandbox
Normal 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"]
|
||||
@@ -12,8 +12,10 @@ RUN go install -v github.com/ffuf/ffuf/v2@latest && \
|
||||
go install -v github.com/OJ/gobuster/v3@latest && \
|
||||
go install -v github.com/projectdiscovery/httpx/cmd/httpx@latest && \
|
||||
go install -v github.com/projectdiscovery/nuclei/v3/cmd/nuclei@latest && \
|
||||
go install -v github.com/projectdiscovery/naabu/v2/cmd/naabu@latest && \
|
||||
go install -v github.com/projectdiscovery/subfinder/v2/cmd/subfinder@latest && \
|
||||
go install -v github.com/projectdiscovery/katana/cmd/katana@latest && \
|
||||
go install -v github.com/projectdiscovery/dnsx/cmd/dnsx@latest && \
|
||||
go install -v github.com/hahwul/dalfox/v2@latest && \
|
||||
go install -v github.com/tomnomnom/waybackurls@latest
|
||||
|
||||
|
||||
38
docker/docker-compose.kali.yml
Normal file
38
docker/docker-compose.kali.yml
Normal file
@@ -0,0 +1,38 @@
|
||||
# NeuroSploit v3 - Kali Sandbox Build & Management
|
||||
#
|
||||
# Build image:
|
||||
# docker compose -f docker/docker-compose.kali.yml build
|
||||
#
|
||||
# Build (no cache):
|
||||
# docker compose -f docker/docker-compose.kali.yml build --no-cache
|
||||
#
|
||||
# Test container manually:
|
||||
# docker compose -f docker/docker-compose.kali.yml run --rm kali-sandbox "nuclei -version"
|
||||
#
|
||||
# Note: In production, containers are managed by ContainerPool (core/container_pool.py).
|
||||
# This compose file is for building the image and manual testing only.
|
||||
|
||||
services:
|
||||
kali-sandbox:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.kali
|
||||
image: neurosploit-kali:latest
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 2G
|
||||
cpus: '2.0'
|
||||
reservations:
|
||||
memory: 512M
|
||||
cpus: '0.5'
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
cap_add:
|
||||
- NET_RAW
|
||||
- NET_ADMIN
|
||||
labels:
|
||||
neurosploit.type: "kali-sandbox"
|
||||
neurosploit.version: "3.0"
|
||||
51
docker/docker-compose.sandbox.yml
Normal file
51
docker/docker-compose.sandbox.yml
Normal 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:
|
||||
1
frontend/dist/assets/index-CjxVs3nK.css
vendored
Normal file
1
frontend/dist/assets/index-CjxVs3nK.css
vendored
Normal file
File diff suppressed because one or more lines are too long
641
frontend/dist/assets/index-DScaoRL2.js
vendored
Normal file
641
frontend/dist/assets/index-DScaoRL2.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend/dist/assets/index-DScaoRL2.js.map
vendored
Normal file
1
frontend/dist/assets/index-DScaoRL2.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
5
frontend/dist/favicon.svg
vendored
Normal file
5
frontend/dist/favicon.svg
vendored
Normal 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
14
frontend/dist/index.html
vendored
Normal 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
3467
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 />} />
|
||||
|
||||
@@ -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' },
|
||||
]
|
||||
|
||||
@@ -3,7 +3,8 @@ import { useParams, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Bot, RefreshCw, FileText, CheckCircle,
|
||||
XCircle, Clock, Target, Shield, ChevronDown, ChevronRight, ExternalLink,
|
||||
Copy, Download, StopCircle, Terminal, Brain, Send, Code, Globe, AlertTriangle
|
||||
Copy, Download, StopCircle, Terminal, Brain, Send, Code, Globe, AlertTriangle,
|
||||
SkipForward, MinusCircle, Pause, Play
|
||||
} from 'lucide-react'
|
||||
import Card from '../components/common/Card'
|
||||
import Button from '../components/common/Button'
|
||||
@@ -77,6 +78,11 @@ export default function AgentStatusPage() {
|
||||
const [isSubmittingPrompt, setIsSubmittingPrompt] = useState(false)
|
||||
const [promptSentMessage, setPromptSentMessage] = useState<string | null>(null)
|
||||
|
||||
// Phase skip state
|
||||
const [skipConfirm, setSkipConfirm] = useState<string | null>(null)
|
||||
const [isSkipping, setIsSkipping] = useState(false)
|
||||
const [skippedPhases, setSkippedPhases] = useState<Set<string>>(new Set())
|
||||
|
||||
// Separate logs by source
|
||||
const scriptLogs = logs.filter(l => l.source === 'script' || (!l.source && !l.message.includes('[LLM]') && !l.message.includes('[AI]')))
|
||||
const llmLogs = logs.filter(l => l.source === 'llm' || l.message.includes('[LLM]') || l.message.includes('[AI]'))
|
||||
@@ -107,12 +113,12 @@ export default function AgentStatusPage() {
|
||||
|
||||
fetchStatus()
|
||||
|
||||
// Poll every 2 seconds while running
|
||||
// Poll every 5 seconds while running or paused
|
||||
const interval = setInterval(() => {
|
||||
if (status?.status === 'running') {
|
||||
if (status?.status === 'running' || status?.status === 'paused') {
|
||||
fetchStatus()
|
||||
}
|
||||
}, 2000)
|
||||
}, 5000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [agentId, status?.status])
|
||||
@@ -466,6 +472,28 @@ export default function AgentStatusPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handlePauseScan = async () => {
|
||||
if (!agentId) return
|
||||
try {
|
||||
await agentApi.pause(agentId)
|
||||
const statusData = await agentApi.getStatus(agentId)
|
||||
setStatus(statusData)
|
||||
} catch (err: any) {
|
||||
console.error('Failed to pause agent:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleResumeScan = async () => {
|
||||
if (!agentId) return
|
||||
try {
|
||||
await agentApi.resume(agentId)
|
||||
const statusData = await agentApi.getStatus(agentId)
|
||||
setStatus(statusData)
|
||||
} catch (err: any) {
|
||||
console.error('Failed to resume agent:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmitPrompt = async () => {
|
||||
if (!customPrompt.trim() || !agentId) return
|
||||
setIsSubmittingPrompt(true)
|
||||
@@ -496,6 +524,37 @@ export default function AgentStatusPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSkipToPhase = async (targetPhase: string) => {
|
||||
if (!agentId) return
|
||||
setIsSkipping(true)
|
||||
try {
|
||||
await agentApi.skipToPhase(agentId, targetPhase)
|
||||
// Mark intermediate phases as skipped
|
||||
const currentIndex = status ? getPhaseIndex(status.phase) : 0
|
||||
const targetIndex = SCAN_PHASES.findIndex(p => p.key === targetPhase)
|
||||
const newSkipped = new Set(skippedPhases)
|
||||
for (let i = currentIndex; i < targetIndex; i++) {
|
||||
newSkipped.add(SCAN_PHASES[i].key)
|
||||
}
|
||||
setSkippedPhases(newSkipped)
|
||||
setSkipConfirm(null)
|
||||
} catch (err: any) {
|
||||
console.error('Failed to skip phase:', err)
|
||||
} finally {
|
||||
setIsSkipping(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Track skipped phases from status updates
|
||||
useEffect(() => {
|
||||
if (!status) return
|
||||
const phase = status.phase.toLowerCase()
|
||||
if (phase.includes('_skipped')) {
|
||||
const skippedKey = phase.replace('_skipped', '')
|
||||
setSkippedPhases(prev => new Set(prev).add(skippedKey))
|
||||
}
|
||||
}, [status?.phase])
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
}
|
||||
@@ -790,7 +849,8 @@ export default function AgentStatusPage() {
|
||||
<span className={`px-3 py-1 rounded-full text-sm font-medium flex items-center gap-1 ${
|
||||
status.status === 'running' ? 'bg-blue-500/20 text-blue-400' :
|
||||
status.status === 'completed' ? 'bg-green-500/20 text-green-400' :
|
||||
status.status === 'stopped' ? 'bg-yellow-500/20 text-yellow-400' :
|
||||
status.status === 'paused' ? 'bg-yellow-500/20 text-yellow-400' :
|
||||
status.status === 'stopped' ? 'bg-orange-500/20 text-orange-400' :
|
||||
'bg-red-500/20 text-red-400'
|
||||
}`}>
|
||||
{PHASE_ICONS[status.status]}
|
||||
@@ -802,10 +862,28 @@ export default function AgentStatusPage() {
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{status.status === 'running' && (
|
||||
<Button variant="danger" onClick={handleStopScan} isLoading={isStopping}>
|
||||
<StopCircle className="w-4 h-4 mr-2" />
|
||||
Stop Scan
|
||||
</Button>
|
||||
<>
|
||||
<Button variant="secondary" onClick={handlePauseScan}>
|
||||
<Pause className="w-4 h-4 mr-2" />
|
||||
Pause
|
||||
</Button>
|
||||
<Button variant="danger" onClick={handleStopScan} isLoading={isStopping}>
|
||||
<StopCircle className="w-4 h-4 mr-2" />
|
||||
Stop
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{status.status === 'paused' && (
|
||||
<>
|
||||
<Button variant="primary" onClick={handleResumeScan}>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Resume
|
||||
</Button>
|
||||
<Button variant="danger" onClick={handleStopScan} isLoading={isStopping}>
|
||||
<StopCircle className="w-4 h-4 mr-2" />
|
||||
Stop
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{status.scan_id && (
|
||||
<Button variant="secondary" onClick={() => navigate(`/scan/${status.scan_id}`)}>
|
||||
@@ -833,31 +911,84 @@ export default function AgentStatusPage() {
|
||||
{(status.status === 'running' || status.status === 'completed' || status.status === 'stopped') && (
|
||||
<Card>
|
||||
<div className="space-y-4">
|
||||
{/* Phase Steps */}
|
||||
{/* Phase Steps with Skip */}
|
||||
<div className="flex items-center justify-between px-2">
|
||||
{SCAN_PHASES.map((phase, index) => {
|
||||
const currentIndex = status.status === 'completed' ? 4 : status.status === 'stopped' ? getPhaseIndex(status.phase) : getPhaseIndex(status.phase)
|
||||
const isActive = index === currentIndex
|
||||
const isCompleted = index < currentIndex || status.status === 'completed'
|
||||
const isStopped = status.status === 'stopped' && index > currentIndex
|
||||
const isSkipped = skippedPhases.has(phase.key)
|
||||
const canSkipTo = (status.status === 'running' || status.status === 'paused') && index > currentIndex && phase.key !== 'completed'
|
||||
|
||||
return (
|
||||
<div key={phase.key} className="flex flex-col items-center flex-1">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center mb-1 transition-all ${
|
||||
isCompleted ? 'bg-green-500 text-white' :
|
||||
isActive ? 'bg-primary-500 text-white animate-pulse' :
|
||||
isStopped ? 'bg-yellow-500/20 text-yellow-500' :
|
||||
'bg-dark-700 text-dark-400'
|
||||
}`}>
|
||||
{isCompleted ? <CheckCircle className="w-4 h-4" /> :
|
||||
<div key={phase.key} className="flex flex-col items-center flex-1 relative group">
|
||||
{/* Connector line */}
|
||||
{index > 0 && (
|
||||
<div className={`absolute top-4 right-1/2 w-full h-0.5 -translate-y-1/2 z-0 ${
|
||||
isCompleted || isActive ? 'bg-green-500/50' :
|
||||
isSkipped ? 'bg-yellow-500/30' :
|
||||
'bg-dark-700'
|
||||
}`} />
|
||||
)}
|
||||
|
||||
{/* Phase node */}
|
||||
<div
|
||||
className={`relative z-10 w-8 h-8 rounded-full flex items-center justify-center mb-1 transition-all ${
|
||||
isSkipped ? 'bg-yellow-500/20 text-yellow-500 ring-2 ring-yellow-500/30' :
|
||||
isCompleted ? 'bg-green-500 text-white' :
|
||||
isActive ? 'bg-primary-500 text-white animate-pulse ring-2 ring-primary-500/30' :
|
||||
isStopped ? 'bg-yellow-500/20 text-yellow-500' :
|
||||
canSkipTo ? 'bg-dark-700 text-dark-400 cursor-pointer hover:bg-primary-500/20 hover:text-primary-400 hover:ring-2 hover:ring-primary-500/30' :
|
||||
'bg-dark-700 text-dark-400'
|
||||
}`}
|
||||
onClick={() => canSkipTo && setSkipConfirm(phase.key)}
|
||||
>
|
||||
{isSkipped ? <MinusCircle className="w-4 h-4" /> :
|
||||
isCompleted ? <CheckCircle className="w-4 h-4" /> :
|
||||
isActive ? (PHASE_ICONS[phase.key === 'recon' ? 'reconnaissance' : phase.key] || <span className="text-xs font-bold">{index + 1}</span>) :
|
||||
isStopped ? <StopCircle className="w-4 h-4" /> :
|
||||
canSkipTo ? <SkipForward className="w-3.5 h-3.5" /> :
|
||||
<span className="text-xs font-bold">{index + 1}</span>}
|
||||
</div>
|
||||
|
||||
<span className={`text-xs text-center ${
|
||||
isCompleted || isActive ? 'text-white' : 'text-dark-500'
|
||||
isSkipped ? 'text-yellow-500' :
|
||||
isCompleted || isActive ? 'text-white' :
|
||||
canSkipTo ? 'text-dark-400 group-hover:text-primary-400' :
|
||||
'text-dark-500'
|
||||
}`}>
|
||||
{phase.label}
|
||||
{isSkipped ? `${phase.label} (skipped)` : phase.label}
|
||||
</span>
|
||||
|
||||
{/* Skip tooltip on hover */}
|
||||
{canSkipTo && (
|
||||
<div className="absolute -top-8 left-1/2 -translate-x-1/2 bg-dark-800 text-primary-400 text-[10px] px-2 py-0.5 rounded whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none border border-dark-600">
|
||||
Skip to {phase.label}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inline skip confirmation */}
|
||||
{skipConfirm === phase.key && (
|
||||
<div className="absolute top-10 left-1/2 -translate-x-1/2 z-20 bg-dark-800 border border-dark-600 rounded-lg p-3 shadow-xl whitespace-nowrap">
|
||||
<p className="text-xs text-dark-300 mb-2">Skip to <span className="text-white font-medium">{phase.label}</span>?</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleSkipToPhase(phase.key)}
|
||||
disabled={isSkipping}
|
||||
className="px-3 py-1 bg-primary-500 text-white text-xs rounded hover:bg-primary-600 disabled:opacity-50"
|
||||
>
|
||||
{isSkipping ? 'Skipping...' : 'Confirm'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSkipConfirm(null)}
|
||||
className="px-3 py-1 bg-dark-700 text-dark-300 text-xs rounded hover:bg-dark-600"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
929
frontend/src/pages/AutoPentestPage.tsx
Normal file
929
frontend/src/pages/AutoPentestPage.tsx
Normal 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">✓</span>
|
||||
<span>Proof: <span className="text-dark-300">{f.proof_of_execution}</span></span>
|
||||
</p>
|
||||
)}
|
||||
{hasControls && (
|
||||
<p className="text-xs text-dark-400 flex items-start gap-1">
|
||||
<span className="text-blue-400">■</span>
|
||||
<span>Controls: <span className="text-dark-300">{f.negative_controls}</span></span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Activity Log */}
|
||||
{showLogs && (
|
||||
<div className="bg-dark-900 rounded-xl p-3 max-h-[500px] overflow-y-auto font-mono text-xs">
|
||||
{logs.length === 0 ? (
|
||||
<p className="text-dark-500 text-center py-4">No logs yet...</p>
|
||||
) : (
|
||||
logs.map((log, i) => (
|
||||
<div key={i} className="flex gap-2 py-0.5">
|
||||
<span className="text-dark-600 flex-shrink-0">{log.time || ''}</span>
|
||||
<span className={`flex-shrink-0 uppercase w-12 ${
|
||||
log.level === 'error' ? 'text-red-400' :
|
||||
log.level === 'warning' ? 'text-yellow-400' :
|
||||
log.level === 'info' ? 'text-blue-400' :
|
||||
'text-dark-500'
|
||||
}`}>{log.level}</span>
|
||||
<span className={logMessageColor(log.message) || (log.source === 'llm' ? 'text-purple-400' : 'text-dark-300')}>
|
||||
{log.message}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={logsEndRef} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No findings yet but running */}
|
||||
{findings.length === 0 && isRunning && (
|
||||
<div className="bg-dark-800 border border-dark-700 rounded-2xl p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-white font-semibold">Activity</h3>
|
||||
<button
|
||||
onClick={() => setShowLogs(!showLogs)}
|
||||
className={`px-3 py-1 rounded-lg text-xs transition-colors ${showLogs ? 'bg-primary-500 text-white' : 'bg-dark-700 text-dark-400 hover:text-white'}`}
|
||||
>
|
||||
<ScrollText className="w-3 h-3 inline mr-1" />{showLogs ? 'Hide' : 'Show'} Logs
|
||||
</button>
|
||||
</div>
|
||||
{showLogs ? (
|
||||
<div className="bg-dark-900 rounded-xl p-3 max-h-[400px] overflow-y-auto font-mono text-xs">
|
||||
{logs.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-4 text-dark-500">
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" /> Waiting for logs...
|
||||
</div>
|
||||
) : (
|
||||
logs.map((log, i) => (
|
||||
<div key={i} className="flex gap-2 py-0.5">
|
||||
<span className="text-dark-600 flex-shrink-0">{log.time || ''}</span>
|
||||
<span className={`flex-shrink-0 uppercase w-12 ${
|
||||
log.level === 'error' ? 'text-red-400' :
|
||||
log.level === 'warning' ? 'text-yellow-400' :
|
||||
log.level === 'info' ? 'text-blue-400' :
|
||||
'text-dark-500'
|
||||
}`}>{log.level}</span>
|
||||
<span className={logMessageColor(log.message) || (log.source === 'llm' ? 'text-purple-400' : 'text-dark-300')}>
|
||||
{log.message}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={logsEndRef} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-6 text-dark-500">
|
||||
<Loader2 className="w-5 h-5 animate-spin mr-2" />
|
||||
Scanning in progress... Findings will appear here as they are discovered.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Completion Actions */}
|
||||
{status?.status === 'completed' && (
|
||||
<div className="bg-dark-800 border border-green-500/30 rounded-2xl p-6 mb-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<CheckCircle2 className="w-6 h-6 text-green-500" />
|
||||
<h3 className="text-green-400 font-semibold text-lg">Pentest Complete</h3>
|
||||
</div>
|
||||
<p className="text-dark-400 mb-4">
|
||||
Found {findings.length} vulnerabilities across {target}.
|
||||
</p>
|
||||
|
||||
{/* Report Generation */}
|
||||
{generatingReport && (
|
||||
<div className="mb-4 p-4 bg-purple-500/10 border border-purple-500/20 rounded-xl flex items-center gap-3">
|
||||
<Loader2 className="w-5 h-5 text-purple-400 animate-spin" />
|
||||
<div>
|
||||
<p className="text-purple-400 font-medium text-sm">Generating AI Report...</p>
|
||||
<p className="text-dark-400 text-xs">The AI is analyzing findings and writing the executive summary.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
onClick={() => navigate(`/agent/${agentId}`)}
|
||||
className="px-5 py-2 bg-primary-500 hover:bg-primary-600 text-white rounded-lg transition-colors flex items-center gap-2 text-sm"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" /> View Full Results
|
||||
</button>
|
||||
|
||||
{!reportId ? (
|
||||
<button
|
||||
onClick={handleGenerateAiReport}
|
||||
disabled={generatingReport || !status.scan_id}
|
||||
className="px-5 py-2 bg-purple-500 hover:bg-purple-600 disabled:opacity-50 text-white rounded-lg transition-colors flex items-center gap-2 text-sm"
|
||||
>
|
||||
<Sparkles className="w-4 h-4" /> Generate AI Report
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<a
|
||||
href={reportsApi.getViewUrl(reportId)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-5 py-2 bg-green-500 hover:bg-green-600 text-white rounded-lg transition-colors flex items-center gap-2 text-sm"
|
||||
>
|
||||
<FileText className="w-4 h-4" /> View Report
|
||||
</a>
|
||||
<a
|
||||
href={reportsApi.getDownloadZipUrl(reportId)}
|
||||
className="px-5 py-2 bg-dark-700 hover:bg-dark-600 text-white rounded-lg transition-colors flex items-center gap-2 text-sm"
|
||||
>
|
||||
<Download className="w-4 h-4" /> Download ZIP
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{status?.status === 'error' && (
|
||||
<div className="bg-dark-800 border border-red-500/30 rounded-2xl p-6">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<AlertTriangle className="w-6 h-6 text-red-400" />
|
||||
<h3 className="text-red-400 font-semibold">Pentest Failed</h3>
|
||||
</div>
|
||||
<p className="text-dark-400">{status.error || 'An unexpected error occurred.'}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -249,13 +249,27 @@ export default function HomePage() {
|
||||
recentVulnerabilities.slice(0, 5).map((vuln) => (
|
||||
<div
|
||||
key={vuln.id}
|
||||
className="flex items-center justify-between p-3 bg-dark-900/50 rounded-lg"
|
||||
className={`flex items-center justify-between p-3 bg-dark-900/50 rounded-lg ${
|
||||
(vuln as any).validation_status === 'ai_rejected' ? 'opacity-60 border-l-2 border-orange-500/40' :
|
||||
(vuln as any).validation_status === 'false_positive' ? 'opacity-40' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-white truncate">{vuln.title}</p>
|
||||
<p className="text-xs text-dark-400 truncate">{vuln.affected_endpoint}</p>
|
||||
</div>
|
||||
<SeverityBadge severity={vuln.severity} />
|
||||
<div className="flex items-center gap-1.5">
|
||||
{(vuln as any).validation_status === 'ai_rejected' && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded-full bg-orange-500/20 text-orange-400">Rejected</span>
|
||||
)}
|
||||
{(vuln as any).validation_status === 'validated' && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded-full bg-green-500/20 text-green-400">Validated</span>
|
||||
)}
|
||||
{(vuln as any).validation_status === 'false_positive' && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded-full bg-dark-600 text-dark-400">FP</span>
|
||||
)}
|
||||
<SeverityBadge severity={vuln.severity} />
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
474
frontend/src/pages/SandboxDashboardPage.tsx
Normal file
474
frontend/src/pages/SandboxDashboardPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -2,15 +2,47 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Globe, FileText, StopCircle, RefreshCw, ChevronDown, ChevronRight,
|
||||
ExternalLink, Copy, Shield, AlertTriangle, Cpu, CheckCircle, XCircle, Clock
|
||||
ExternalLink, Copy, Shield, AlertTriangle, Cpu, CheckCircle, XCircle, Clock,
|
||||
SkipForward, Check, Minus, Pause, Play, Download
|
||||
} from 'lucide-react'
|
||||
import Card from '../components/common/Card'
|
||||
import Button from '../components/common/Button'
|
||||
import { SeverityBadge } from '../components/common/Badge'
|
||||
import { scansApi, reportsApi, agentTasksApi } from '../services/api'
|
||||
import { scansApi, reportsApi, agentTasksApi, agentApi, vulnerabilitiesApi } from '../services/api'
|
||||
import { wsService } from '../services/websocket'
|
||||
import { useScanStore } from '../store'
|
||||
import type { Endpoint, Vulnerability, WSMessage, ScanAgentTask, Report } from '../types'
|
||||
import type { Endpoint, Vulnerability, WSMessage, ScanAgentTask, Report, AgentStatus, AgentFinding } from '../types'
|
||||
|
||||
// Resolve a confidence score from the various possible sources on a finding.
|
||||
// Handles: numeric confidence_score field, legacy string "high"/"medium"/"low", or numeric strings.
|
||||
function getConfidenceDisplay(finding: { confidence_score?: number; confidence?: string }): { score: number; color: string; label: string } | null {
|
||||
let score: number | null = null
|
||||
|
||||
if (typeof finding.confidence_score === 'number') {
|
||||
score = finding.confidence_score
|
||||
} else if (finding.confidence) {
|
||||
const parsed = Number(finding.confidence)
|
||||
if (!isNaN(parsed)) {
|
||||
score = parsed
|
||||
} else {
|
||||
// Legacy text values
|
||||
const map: Record<string, number> = { high: 90, medium: 60, low: 30 }
|
||||
score = map[finding.confidence.toLowerCase()] ?? null
|
||||
}
|
||||
}
|
||||
|
||||
if (score === null) return null
|
||||
|
||||
const color = score >= 90 ? 'green' : score >= 60 ? 'yellow' : 'red'
|
||||
const label = score >= 90 ? 'Confirmed' : score >= 60 ? 'Likely' : 'Low'
|
||||
return { score, color, label }
|
||||
}
|
||||
|
||||
const CONFIDENCE_STYLES: Record<string, string> = {
|
||||
green: 'bg-green-500/15 text-green-400 border-green-500/30',
|
||||
yellow: 'bg-yellow-500/15 text-yellow-400 border-yellow-500/30',
|
||||
red: 'bg-red-500/15 text-red-400 border-red-500/30',
|
||||
}
|
||||
|
||||
export default function ScanDetailsPage() {
|
||||
const { scanId } = useParams<{ scanId: string }>()
|
||||
@@ -29,6 +61,10 @@ export default function ScanDetailsPage() {
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [autoGeneratedReport, setAutoGeneratedReport] = useState<Report | null>(null)
|
||||
const [agentData, setAgentData] = useState<AgentStatus | null>(null)
|
||||
const [skipConfirm, setSkipConfirm] = useState<string | null>(null)
|
||||
const [skippedPhases, setSkippedPhases] = useState<Set<string>>(new Set())
|
||||
const [validationFilter, setValidationFilter] = useState<'all' | 'confirmed' | 'rejected' | 'validated'>('all')
|
||||
|
||||
// Calculate vulnerability counts from actual data
|
||||
const vulnCounts = useMemo(() => getVulnCounts(), [vulnerabilities])
|
||||
@@ -68,6 +104,57 @@ export default function ScanDetailsPage() {
|
||||
if (reportsData.reports?.length > 0) {
|
||||
setAutoGeneratedReport(reportsData.reports[0])
|
||||
}
|
||||
|
||||
// If scan has no vulns from DB, try to get agent data (in-memory findings)
|
||||
if ((!vulnsData.vulnerabilities || vulnsData.vulnerabilities.length === 0)) {
|
||||
const agentStatus = await agentApi.getByScan(scanId)
|
||||
if (agentStatus) {
|
||||
setAgentData(agentStatus)
|
||||
// Convert agent findings to vulnerability format for display
|
||||
if (agentStatus.findings && agentStatus.findings.length > 0) {
|
||||
const mapFinding = (f: AgentFinding): Vulnerability => ({
|
||||
id: f.id,
|
||||
scan_id: scanId,
|
||||
title: f.title,
|
||||
vulnerability_type: f.vulnerability_type,
|
||||
severity: f.severity,
|
||||
cvss_score: f.cvss_score || null,
|
||||
cvss_vector: f.cvss_vector || null,
|
||||
cwe_id: f.cwe_id || null,
|
||||
description: f.description || null,
|
||||
affected_endpoint: f.affected_endpoint || null,
|
||||
poc_request: f.request || null,
|
||||
poc_response: f.response || null,
|
||||
poc_payload: f.payload || null,
|
||||
poc_parameter: f.parameter || null,
|
||||
poc_evidence: f.evidence || null,
|
||||
poc_code: f.poc_code || null,
|
||||
impact: f.impact || null,
|
||||
remediation: f.remediation || null,
|
||||
references: f.references || [],
|
||||
ai_analysis: f.evidence || null,
|
||||
validation_status: f.ai_status === 'rejected' ? 'ai_rejected' : 'ai_confirmed',
|
||||
ai_rejection_reason: f.rejection_reason || null,
|
||||
confidence_score: f.confidence_score,
|
||||
confidence_breakdown: f.confidence_breakdown,
|
||||
proof_of_execution: f.proof_of_execution,
|
||||
negative_controls: f.negative_controls,
|
||||
created_at: new Date().toISOString()
|
||||
})
|
||||
const confirmed = agentStatus.findings.map(mapFinding)
|
||||
const rejected = (agentStatus.rejected_findings || []).map(mapFinding)
|
||||
const mappedVulns: Vulnerability[] = [...confirmed, ...rejected]
|
||||
setVulnerabilities(mappedVulns)
|
||||
}
|
||||
// Update scan progress from agent
|
||||
if (agentStatus.progress !== undefined) {
|
||||
updateScan(scanId, {
|
||||
progress: agentStatus.progress,
|
||||
current_phase: agentStatus.phase
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Failed to fetch scan:', err)
|
||||
setError(err?.response?.data?.detail || 'Failed to load scan')
|
||||
@@ -77,9 +164,9 @@ export default function ScanDetailsPage() {
|
||||
}
|
||||
fetchData()
|
||||
|
||||
// Poll for updates while scan is running
|
||||
// Poll for updates while scan is running or paused (8s interval, WebSocket handles real-time)
|
||||
const pollInterval = setInterval(async () => {
|
||||
if (currentScan?.status === 'running' || !currentScan) {
|
||||
if (currentScan?.status === 'running' || currentScan?.status === 'paused' || !currentScan) {
|
||||
try {
|
||||
const scan = await scansApi.get(scanId)
|
||||
setCurrentScan(scan)
|
||||
@@ -99,11 +186,60 @@ export default function ScanDetailsPage() {
|
||||
if (tasksData.tasks?.length > 0) {
|
||||
setAgentTasks(tasksData.tasks)
|
||||
}
|
||||
|
||||
// Also poll agent data for real-time findings
|
||||
if (!vulnsData.vulnerabilities || vulnsData.vulnerabilities.length === 0) {
|
||||
const agentStatus = await agentApi.getByScan(scanId)
|
||||
if (agentStatus) {
|
||||
setAgentData(agentStatus)
|
||||
if (agentStatus.findings && agentStatus.findings.length > 0) {
|
||||
const mapFinding = (f: AgentFinding): Vulnerability => ({
|
||||
id: f.id,
|
||||
scan_id: scanId,
|
||||
title: f.title,
|
||||
vulnerability_type: f.vulnerability_type,
|
||||
severity: f.severity,
|
||||
cvss_score: f.cvss_score || null,
|
||||
cvss_vector: f.cvss_vector || null,
|
||||
cwe_id: f.cwe_id || null,
|
||||
description: f.description || null,
|
||||
affected_endpoint: f.affected_endpoint || null,
|
||||
poc_request: f.request || null,
|
||||
poc_response: f.response || null,
|
||||
poc_payload: f.payload || null,
|
||||
poc_parameter: f.parameter || null,
|
||||
poc_evidence: f.evidence || null,
|
||||
poc_code: f.poc_code || null,
|
||||
impact: f.impact || null,
|
||||
remediation: f.remediation || null,
|
||||
references: f.references || [],
|
||||
ai_analysis: f.evidence || null,
|
||||
validation_status: f.ai_status === 'rejected' ? 'ai_rejected' : 'ai_confirmed',
|
||||
ai_rejection_reason: f.rejection_reason || null,
|
||||
confidence_score: f.confidence_score,
|
||||
confidence_breakdown: f.confidence_breakdown,
|
||||
proof_of_execution: f.proof_of_execution,
|
||||
negative_controls: f.negative_controls,
|
||||
created_at: new Date().toISOString()
|
||||
})
|
||||
const confirmed = agentStatus.findings.map(mapFinding)
|
||||
const rejected = (agentStatus.rejected_findings || []).map(mapFinding)
|
||||
const mappedVulns: Vulnerability[] = [...confirmed, ...rejected]
|
||||
setVulnerabilities(mappedVulns)
|
||||
}
|
||||
if (agentStatus.progress !== undefined) {
|
||||
updateScan(scanId, {
|
||||
progress: agentStatus.progress,
|
||||
current_phase: agentStatus.phase
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Poll error:', err)
|
||||
}
|
||||
}
|
||||
}, 3000)
|
||||
}, 8000)
|
||||
|
||||
// Connect WebSocket for running scans
|
||||
wsService.connect(scanId)
|
||||
@@ -117,10 +253,16 @@ export default function ScanDetailsPage() {
|
||||
current_phase: message.message as string
|
||||
})
|
||||
break
|
||||
case 'phase_change':
|
||||
updateScan(scanId, { current_phase: message.phase as string })
|
||||
addLog('info', `Phase: ${message.phase}`)
|
||||
case 'phase_change': {
|
||||
const phase = message.phase as string
|
||||
updateScan(scanId, { current_phase: phase })
|
||||
addLog('info', `Phase: ${phase}`)
|
||||
// Track skipped phases
|
||||
if (phase.endsWith('_skipped')) {
|
||||
setSkippedPhases(prev => new Set([...prev, phase.replace('_skipped', '')]))
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'endpoint_found':
|
||||
addEndpoint(message.endpoint as Endpoint)
|
||||
break
|
||||
@@ -245,6 +387,36 @@ export default function ScanDetailsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handlePauseScan = async () => {
|
||||
if (!scanId) return
|
||||
try {
|
||||
await scansApi.pause(scanId)
|
||||
updateScan(scanId, { status: 'paused' })
|
||||
} catch (error) {
|
||||
console.error('Failed to pause scan:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleResumeScan = async () => {
|
||||
if (!scanId) return
|
||||
try {
|
||||
await scansApi.resume(scanId)
|
||||
updateScan(scanId, { status: 'running' })
|
||||
} catch (error) {
|
||||
console.error('Failed to resume scan:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSkipToPhase = async (phase: string) => {
|
||||
if (!scanId) return
|
||||
try {
|
||||
await scansApi.skipToPhase(scanId, phase)
|
||||
setSkipConfirm(null)
|
||||
} catch (error: any) {
|
||||
console.error('Failed to skip phase:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleGenerateReport = async () => {
|
||||
if (!scanId) return
|
||||
setIsGeneratingReport(true)
|
||||
@@ -327,20 +499,53 @@ export default function ScanDetailsPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{currentScan.status === 'running' && (
|
||||
<Button variant="danger" onClick={handleStopScan}>
|
||||
<StopCircle className="w-4 h-4 mr-2" />
|
||||
Stop Scan
|
||||
{agentData?.agent_id && (
|
||||
<Button variant="secondary" onClick={() => navigate(`/agent/${agentData.agent_id}`)}>
|
||||
<Cpu className="w-4 h-4 mr-2" />
|
||||
Agent View
|
||||
</Button>
|
||||
)}
|
||||
{currentScan.status === 'running' && (
|
||||
<>
|
||||
<Button variant="secondary" onClick={handlePauseScan}>
|
||||
<Pause className="w-4 h-4 mr-2" />
|
||||
Pause
|
||||
</Button>
|
||||
<Button variant="danger" onClick={handleStopScan}>
|
||||
<StopCircle className="w-4 h-4 mr-2" />
|
||||
Stop
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{currentScan.status === 'paused' && (
|
||||
<>
|
||||
<Button variant="primary" onClick={handleResumeScan}>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Resume
|
||||
</Button>
|
||||
<Button variant="danger" onClick={handleStopScan}>
|
||||
<StopCircle className="w-4 h-4 mr-2" />
|
||||
Stop
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{autoGeneratedReport && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => window.open(reportsApi.getViewUrl(autoGeneratedReport.id), '_blank')}
|
||||
>
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
View Report
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => window.open(reportsApi.getViewUrl(autoGeneratedReport.id), '_blank')}
|
||||
>
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
View Report
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => window.open(reportsApi.getDownloadZipUrl(autoGeneratedReport.id), '_blank')}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Download ZIP
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{(currentScan.status === 'completed' || currentScan.status === 'stopped') && (
|
||||
<Button onClick={handleGenerateReport} isLoading={isGeneratingReport}>
|
||||
@@ -351,23 +556,131 @@ export default function ScanDetailsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
{currentScan.status === 'running' && (
|
||||
<Card>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-dark-300">{currentScan.current_phase || 'Initializing...'}</span>
|
||||
<span className="text-white font-medium">{currentScan.progress}%</span>
|
||||
{/* Phase Stepper */}
|
||||
{(currentScan.status === 'running' || currentScan.status === 'paused' || currentScan.status === 'completed' || currentScan.status === 'stopped') && (() => {
|
||||
const PHASES = [
|
||||
{ id: 'initializing', label: 'Init', fullLabel: 'Initialization' },
|
||||
{ id: 'recon', label: 'Recon', fullLabel: 'Reconnaissance' },
|
||||
{ id: 'analyzing', label: 'Analysis', fullLabel: 'AI Analysis' },
|
||||
{ id: 'testing', label: 'Testing', fullLabel: 'Vulnerability Testing' },
|
||||
{ id: 'completed', label: 'Done', fullLabel: 'Completed' },
|
||||
]
|
||||
const phaseOrder = PHASES.map(p => p.id)
|
||||
const rawPhase = currentScan.current_phase || 'initializing'
|
||||
// Normalize: "skipping_to_testing" -> current is between phases
|
||||
const currentPhase = rawPhase.startsWith('skipping_to_') ? rawPhase.replace('skipping_to_', '') : rawPhase.replace('_skipped', '')
|
||||
const currentIdx = phaseOrder.indexOf(currentPhase)
|
||||
const isRunning = currentScan.status === 'running' || currentScan.status === 'paused'
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="space-y-4">
|
||||
{/* Phase nodes */}
|
||||
<div className="flex items-center justify-between relative">
|
||||
{PHASES.map((phase, idx) => {
|
||||
const isCompleted = idx < currentIdx || currentScan.status === 'completed'
|
||||
const isActive = idx === currentIdx && isRunning
|
||||
const isSkipped = skippedPhases.has(phase.id)
|
||||
const isFuture = idx > currentIdx && isRunning
|
||||
const canSkipTo = isFuture && phase.id !== 'initializing'
|
||||
|
||||
return (
|
||||
<div key={phase.id} className="flex items-center flex-1 last:flex-none">
|
||||
{/* Node */}
|
||||
<div className="flex flex-col items-center relative z-10">
|
||||
{/* Circle */}
|
||||
{canSkipTo ? (
|
||||
skipConfirm === phase.id ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => handleSkipToPhase(phase.id)}
|
||||
className="w-9 h-9 rounded-full bg-brand-500 text-white flex items-center justify-center hover:bg-brand-400 transition-colors"
|
||||
title={`Skip to ${phase.fullLabel}`}
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSkipConfirm(null)}
|
||||
className="w-7 h-7 rounded-full bg-dark-600 text-dark-300 flex items-center justify-center hover:bg-dark-500 transition-colors"
|
||||
>
|
||||
<XCircle className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setSkipConfirm(phase.id)}
|
||||
className="w-9 h-9 rounded-full border-2 border-dark-500 bg-dark-800 text-dark-400 flex items-center justify-center hover:border-brand-400 hover:text-brand-400 hover:bg-brand-500/10 transition-all group"
|
||||
title={`Skip to ${phase.fullLabel}`}
|
||||
>
|
||||
<SkipForward className="w-4 h-4 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<span className="absolute text-[10px] group-hover:hidden">{idx + 1}</span>
|
||||
</button>
|
||||
)
|
||||
) : isCompleted ? (
|
||||
<div className={`w-9 h-9 rounded-full flex items-center justify-center ${
|
||||
isSkipped
|
||||
? 'bg-yellow-500/20 border-2 border-yellow-500/50'
|
||||
: 'bg-green-500/20 border-2 border-green-500/50'
|
||||
}`}>
|
||||
{isSkipped
|
||||
? <Minus className="w-4 h-4 text-yellow-400" />
|
||||
: <Check className="w-4 h-4 text-green-400" />
|
||||
}
|
||||
</div>
|
||||
) : isActive ? (
|
||||
<div className="w-9 h-9 rounded-full bg-brand-500/20 border-2 border-brand-500 flex items-center justify-center animate-pulse">
|
||||
<div className="w-3 h-3 rounded-full bg-brand-400" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-9 h-9 rounded-full border-2 border-dark-600 bg-dark-800 flex items-center justify-center">
|
||||
<span className="text-xs text-dark-500">{idx + 1}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Label */}
|
||||
<span className={`text-xs mt-2 font-medium ${
|
||||
isActive ? 'text-brand-400' : isCompleted ? (isSkipped ? 'text-yellow-400' : 'text-green-400') : 'text-dark-500'
|
||||
}`}>
|
||||
{isSkipped ? `${phase.label} (skipped)` : phase.label}
|
||||
</span>
|
||||
|
||||
{/* Skip hint */}
|
||||
{canSkipTo && skipConfirm === phase.id && (
|
||||
<span className="text-[10px] text-brand-400 mt-0.5">Skip here?</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Connector line */}
|
||||
{idx < PHASES.length - 1 && (
|
||||
<div className={`flex-1 h-0.5 mx-2 mt-[-20px] ${
|
||||
idx < currentIdx ? 'bg-green-500/50' : 'bg-dark-600'
|
||||
}`} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Progress bar + info */}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-dark-300">
|
||||
{rawPhase.startsWith('skipping_to_')
|
||||
? `Skipping to ${rawPhase.replace('skipping_to_', '')}...`
|
||||
: currentScan.current_phase || 'Initializing...'
|
||||
}
|
||||
</span>
|
||||
<span className="text-white font-medium">{currentScan.progress}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-dark-900 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-brand-500 rounded-full transition-all duration-300"
|
||||
style={{ width: `${currentScan.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-2 bg-dark-900 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary-500 rounded-full transition-all duration-300"
|
||||
style={{ width: `${currentScan.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Auto-generated Report Notification */}
|
||||
{autoGeneratedReport && (
|
||||
@@ -472,6 +785,31 @@ export default function ScanDetailsPage() {
|
||||
{/* Vulnerabilities Tab */}
|
||||
{activeTab === 'vulns' && (
|
||||
<div className="space-y-3">
|
||||
{/* Validation Filter Tabs */}
|
||||
{vulnerabilities.length > 0 && (
|
||||
<div className="flex gap-2 mb-2">
|
||||
{(['all', 'confirmed', 'rejected', 'validated'] as const).map((filter) => {
|
||||
const count = filter === 'all' ? vulnerabilities.length
|
||||
: filter === 'confirmed' ? vulnerabilities.filter(v => !v.validation_status || v.validation_status === 'ai_confirmed' || v.validation_status === 'validated').length
|
||||
: filter === 'rejected' ? vulnerabilities.filter(v => v.validation_status === 'ai_rejected' || v.validation_status === 'false_positive').length
|
||||
: vulnerabilities.filter(v => v.validation_status === 'validated').length
|
||||
return (
|
||||
<button
|
||||
key={filter}
|
||||
onClick={() => setValidationFilter(filter)}
|
||||
className={`px-3 py-1 text-xs rounded-full transition-colors ${
|
||||
validationFilter === filter
|
||||
? 'bg-primary-500/20 text-primary-400 border border-primary-500/30'
|
||||
: 'bg-dark-700 text-dark-400 border border-dark-600 hover:text-dark-300'
|
||||
}`}
|
||||
>
|
||||
{filter.charAt(0).toUpperCase() + filter.slice(1)} ({count})
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{vulnerabilities.length === 0 ? (
|
||||
<Card>
|
||||
<p className="text-dark-400 text-center py-8">
|
||||
@@ -479,10 +817,23 @@ export default function ScanDetailsPage() {
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
vulnerabilities.map((vuln, idx) => (
|
||||
vulnerabilities
|
||||
.filter((vuln) => {
|
||||
if (validationFilter === 'all') return true
|
||||
if (validationFilter === 'confirmed') return !vuln.validation_status || vuln.validation_status === 'ai_confirmed' || vuln.validation_status === 'validated'
|
||||
if (validationFilter === 'rejected') return vuln.validation_status === 'ai_rejected' || vuln.validation_status === 'false_positive'
|
||||
if (validationFilter === 'validated') return vuln.validation_status === 'validated'
|
||||
return true
|
||||
})
|
||||
.map((vuln, idx) => (
|
||||
<div
|
||||
key={vuln.id || `vuln-${idx}`}
|
||||
className="bg-dark-800 rounded-lg border border-dark-700 overflow-hidden"
|
||||
className={`bg-dark-800 rounded-lg border overflow-hidden ${
|
||||
vuln.validation_status === 'ai_rejected' ? 'border-orange-500/40 opacity-70' :
|
||||
vuln.validation_status === 'false_positive' ? 'border-dark-600 opacity-50' :
|
||||
vuln.validation_status === 'validated' ? 'border-green-500/40' :
|
||||
'border-dark-700'
|
||||
}`}
|
||||
>
|
||||
{/* Vulnerability Header */}
|
||||
<div
|
||||
@@ -513,6 +864,37 @@ export default function ScanDetailsPage() {
|
||||
</span>
|
||||
)}
|
||||
<SeverityBadge severity={vuln.severity} />
|
||||
{/* Confidence Score */}
|
||||
{(() => {
|
||||
const conf = getConfidenceDisplay(vuln as any)
|
||||
if (!conf) return null
|
||||
return (
|
||||
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full border ${CONFIDENCE_STYLES[conf.color]}`}>
|
||||
{conf.score}/100
|
||||
</span>
|
||||
)
|
||||
})()}
|
||||
{/* Validation Status Badge */}
|
||||
{vuln.validation_status === 'ai_rejected' && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-orange-500/20 text-orange-400 border border-orange-500/30 flex items-center gap-1">
|
||||
<AlertTriangle className="w-3 h-3" /> AI Rejected
|
||||
</span>
|
||||
)}
|
||||
{vuln.validation_status === 'validated' && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-green-500/20 text-green-400 border border-green-500/30 flex items-center gap-1">
|
||||
<CheckCircle className="w-3 h-3" /> Validated
|
||||
</span>
|
||||
)}
|
||||
{vuln.validation_status === 'false_positive' && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-dark-600 text-dark-400 border border-dark-500 flex items-center gap-1">
|
||||
<XCircle className="w-3 h-3" /> False Positive
|
||||
</span>
|
||||
)}
|
||||
{(!vuln.validation_status || vuln.validation_status === 'ai_confirmed') && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-emerald-500/15 text-emerald-400 border border-emerald-500/30">
|
||||
AI Confirmed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -545,6 +927,59 @@ export default function ScanDetailsPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Validation Pipeline Details */}
|
||||
{(() => {
|
||||
const conf = getConfidenceDisplay(vuln as any)
|
||||
if (!conf) return null
|
||||
return (
|
||||
<div className={`rounded-lg p-3 border ${CONFIDENCE_STYLES[conf.color]}`}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Shield className="w-4 h-4" />
|
||||
<span className="text-sm font-semibold">Validation Pipeline</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${
|
||||
conf.score >= 90 ? 'bg-green-500/20 text-green-400' :
|
||||
conf.score >= 60 ? 'bg-yellow-500/20 text-yellow-400' :
|
||||
'bg-red-500/20 text-red-400'
|
||||
}`}>
|
||||
{conf.score}/100 {conf.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Scoring Breakdown */}
|
||||
{vuln.confidence_breakdown && Object.keys(vuln.confidence_breakdown).length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs mt-1 mb-2">
|
||||
{Object.entries(vuln.confidence_breakdown).map(([key, val]) => (
|
||||
<div key={key} className="flex justify-between">
|
||||
<span className="opacity-70 capitalize">{key.replace(/_/g, ' ')}</span>
|
||||
<span className={`font-mono font-medium ${
|
||||
Number(val) > 0 ? 'text-green-400' : Number(val) < 0 ? 'text-red-400' : 'opacity-50'
|
||||
}`}>
|
||||
{Number(val) > 0 ? '+' : ''}{val}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Proof of Execution */}
|
||||
{vuln.proof_of_execution && (
|
||||
<div className="text-xs mt-1 flex items-start gap-1">
|
||||
<CheckCircle className="w-3 h-3 mt-0.5 flex-shrink-0 text-green-400" />
|
||||
<span className="opacity-80">{vuln.proof_of_execution}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Negative Controls */}
|
||||
{vuln.negative_controls && (
|
||||
<div className="text-xs mt-1 flex items-start gap-1">
|
||||
<Shield className="w-3 h-3 mt-0.5 flex-shrink-0 text-blue-400" />
|
||||
<span className="opacity-80">{vuln.negative_controls}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Description */}
|
||||
{vuln.description && (
|
||||
<div>
|
||||
@@ -602,6 +1037,14 @@ export default function ScanDetailsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Exploitation Code */}
|
||||
{vuln.poc_code && (
|
||||
<div className="mt-3">
|
||||
<p className="text-xs font-medium text-dark-400 mb-1">Exploitation Code</p>
|
||||
<pre className="p-3 bg-dark-950 rounded text-xs text-green-400 overflow-x-auto max-h-[400px] overflow-y-auto whitespace-pre-wrap">{vuln.poc_code}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Remediation */}
|
||||
{vuln.remediation && (
|
||||
<div>
|
||||
@@ -618,6 +1061,90 @@ export default function ScanDetailsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Rejection Reason */}
|
||||
{vuln.validation_status === 'ai_rejected' && vuln.ai_rejection_reason && (
|
||||
<div className="bg-orange-500/10 border border-orange-500/20 rounded-lg p-3">
|
||||
<p className="text-sm font-medium text-orange-400 mb-1 flex items-center gap-1">
|
||||
<AlertTriangle className="w-4 h-4" /> AI Rejection Reason
|
||||
</p>
|
||||
<p className="text-sm text-orange-300/80">{vuln.ai_rejection_reason}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Manual Validation Actions */}
|
||||
{vuln.validation_status !== 'validated' && vuln.validation_status !== 'false_positive' && (
|
||||
<div className="flex items-center gap-2 pt-2 border-t border-dark-700">
|
||||
<span className="text-xs text-dark-500 mr-2">Manual Review:</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-green-400 hover:bg-green-500/10 border border-green-500/30"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation()
|
||||
try {
|
||||
await vulnerabilitiesApi.validate(vuln.id, 'validated')
|
||||
// Update local state
|
||||
const updated = vulnerabilities.map(v =>
|
||||
v.id === vuln.id ? { ...v, validation_status: 'validated' as const } : v
|
||||
)
|
||||
setVulnerabilities(updated)
|
||||
} catch (err) { console.error('Validate error:', err) }
|
||||
}}
|
||||
>
|
||||
<CheckCircle className="w-3 h-3 mr-1" />
|
||||
Validate
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-dark-400 hover:bg-red-500/10 border border-dark-600"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation()
|
||||
try {
|
||||
await vulnerabilitiesApi.validate(vuln.id, 'false_positive')
|
||||
const updated = vulnerabilities.map(v =>
|
||||
v.id === vuln.id ? { ...v, validation_status: 'false_positive' as const } : v
|
||||
)
|
||||
setVulnerabilities(updated)
|
||||
} catch (err) { console.error('Mark FP error:', err) }
|
||||
}}
|
||||
>
|
||||
<XCircle className="w-3 h-3 mr-1" />
|
||||
False Positive
|
||||
</Button>
|
||||
{vuln.validation_status === 'ai_rejected' && (
|
||||
<span className="text-xs text-orange-400/60 ml-2">
|
||||
AI rejected this finding - review the evidence above
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(vuln.validation_status === 'validated' || vuln.validation_status === 'false_positive') && (
|
||||
<div className="flex items-center gap-2 pt-2 border-t border-dark-700">
|
||||
<span className="text-xs text-dark-500">
|
||||
{vuln.validation_status === 'validated' ? 'Manually validated by pentester' : 'Marked as false positive'}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-dark-500 hover:text-dark-300 text-xs"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation()
|
||||
try {
|
||||
const revertTo = vuln.ai_rejection_reason ? 'ai_rejected' : 'ai_confirmed'
|
||||
await vulnerabilitiesApi.validate(vuln.id, revertTo)
|
||||
const updated = vulnerabilities.map(v =>
|
||||
v.id === vuln.id ? { ...v, validation_status: revertTo as any } : v
|
||||
)
|
||||
setVulnerabilities(updated)
|
||||
} catch (err) { console.error('Revert error:', err) }
|
||||
}}
|
||||
>
|
||||
Undo
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* References */}
|
||||
{vuln.references?.length > 0 && (
|
||||
<div>
|
||||
|
||||
734
frontend/src/pages/SchedulerPage.tsx
Normal file
734
frontend/src/pages/SchedulerPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
1071
frontend/src/pages/TerminalAgentPage.tsx
Normal file
1071
frontend/src/pages/TerminalAgentPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
995
frontend/src/pages/VulnLabPage.tsx
Normal file
995
frontend/src/pages/VulnLabPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user